Files
zyt/server/app/adminapi/logic/stats/YejiStatsLogic.php
T
2026-05-08 14:13:19 +08:00

4784 lines
183 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\tcm\PrescriptionOrder;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\MediaChannelService;
use think\db\Query;
use think\facade\Db;
/**
* 业绩看板(部门 × 时间区间 × 渠道来源)
*
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心展示部门在选定渠道下计 0**
* - 被指派数 = 区间内 tcm_diagnosis_assign_log 成功指派次数(to_assistant_id>0),按**被指派医助**归属展示部门;选定渠道时收窄与「{渠道}业绩」同源(channels EXISTS 或 tag 诊单/医助)
* · **二中心展示部门在选定渠道下计 0**(与进线一致)
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按诊单医助在组织树内归属 rollup(含下级),与 dept 模块 ErCenter 复诊算法一致
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
* - 接诊诊单 = 列名对应**业务订单条数**(非取消、create_time 区间),与处方列表按 **assistant_dept_id** 筛选时一致:诊单 **assistant_id** 或订单 **creator_id** 任一人事部门落在该行子树即计入;多行命中时取**最深的展示部门行**,与「业绩归属医助」分组可能不同。
* - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag 口径均按订单计(tag 时按诊单医助收窄),非挂号条数。
* - 合计业绩 = 区间内业务订单 amount 求和(排除已取消(4) 与软删),**部门列**与「接诊诊单」一致:诊单医助或订单创建人的人事部门落在该展示行子树则计入,多行命中取最深展示行;与处方列表按 assistant_dept_id 筛选后的金额合计一致。
* 无法归入任一展示部门的金额单列「未归属中心」行;**全表合计行**仍为同时间/业绩条件的全表 SUM(不限部门)。
* · 时间窗与 stats_order_amount_performance 一致;列表若另有患者筛选或账号数据域收窄,金额可能仍不同
* · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**名称含「二中心」的展示部门不参与该渠道进线/业绩/成交单/ROI 分子**
* - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按**业绩归属医助**归部门(与处方列表医助筛选一致);
* 渠道判定仅用 EXISTS:"患者有任一已完成挂号 (status=3) 且 channels 命中字典",**不限挂号时点**;无字典映射时回退 tag/creator 口径
* - 成交单数 = 区间内非取消业务订单 **条数**;按**业绩归属医助**映射到「中心」(与处方订单列表医助条件一致);无法归属计入「未归属中心」;合计=全表 COUNT。
* - 投放成本 = zyt_account_cost 在日期区间内 **amount** 合计(选定渠道时 `media_channel_code = channel_code` 过滤)。
* · **各部门行投放成本** = 总投放成本 × (该部门进线 ÷ 各展示部门进线合计)。相当于先算 **单进线成本** = 总成本 ÷ 总进线,再乘部门进线。
* · 选定渠道下二中心部门进线为 0,故该行分摊成本为 0;合计行成本 = 账务汇总总额(部门行分摊之和与之对齐,尾差归入进线最多的部门)。
* · ROI = 渠道业绩合计 ÷ 总投放成本(选定渠道时分子不含二中心渠道业绩)。
* - ROI = 选定渠道时为 **{渠道}业绩合计** ÷ 投放成本(与表格「渠道业绩」列合计一致;未选渠道时分子为全表业绩合计;成本为 0 时返回 null)
*
* 「业绩/接诊标签匹配」:
* 优先 tcm_diagnosis.external_userid = 客户 external_userid
* 若为空则 patient_id → zyt_user → zyt_user_auth.unionid = qywx_external_contact.unionid(再验 contact_tag),
* 与列表客户标签一致,避免因未回填 external_userid 导致业绩/接诊恒为 0。
*
* 「部门归属」算法:
* 配置 $primaryDeptIds(前端可指定;默认所有「含『中心』字样」的二级 / 三级部门),
* 每个 admin 经 admin_dept → dept 沿 pid 上溯,命中第一个 $primaryDeptIds 即归属之;都不命中归到 0=未分配。
*
* 「渠道」 = 「企微标签」:
* `zyt_qywx_media_channel.channel_code` 形如 tag_<tagid> 或 tagname_<hex>
* ① tag_ 前缀:剥前缀得到企业 tag_id,参与 zyt_qywx_external_contact_tag 过滤;
* ② tagname_ 前缀(个人标签):当前不参与进线/业绩/诊单过滤(contact_tag 表只跟踪企业标签),仅按成本过滤。
*/
class YejiStatsLogic
{
/**
* overview / assistantLeaderboards 共用的筛选与时间上下文。
*
* @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params
*
* @return array{
* startDate:string,
* endDate:string,
* startTs:int,
* endTs:int,
* channelCode:string,
* tagFilterId:string,
* channelInfo:array|null,
* primaryDeptIds:int[],
* deptNameMap:array<int,string>,
* deptToDescendants:array<int,int[]>,
* adminToPrimary:array<int,int>,
* tagDiagIds:int[]|null,
* tagAssistantIds:array<int,int>|null,
* tagFallback:bool,
* appointmentChannelValues:int[],
* channelFilterActive:bool
* }
*/
private static function resolveYejiContext(array $params): array
{
$startDate = self::normalizeDate($params['start_date'] ?? date('Y-m-d'));
$endDate = self::normalizeDate($params['end_date'] ?? $startDate);
$startTs = (int) strtotime($startDate . ' 00:00:00');
$endTs = (int) strtotime($endDate . ' 23:59:59');
$channelCode = trim((string) ($params['channel_code'] ?? ''));
if ($channelCode === '' && isset($params['tag_id']) && $params['tag_id'] !== '') {
$rawTagId = trim((string) $params['tag_id']);
$channelCode = str_starts_with($rawTagId, 'tag_') || str_starts_with($rawTagId, 'tagname_')
? $rawTagId
: ('tag_' . $rawTagId);
}
$tagFilterId = '';
$channelInfo = null;
if ($channelCode !== '') {
$channelInfo = Db::name('qywx_media_channel')
->where('channel_code', $channelCode)
->find();
if ($channelInfo && str_starts_with($channelCode, 'tag_')) {
$tagFilterId = (string) substr($channelCode, 4);
}
}
$explicitDeptFilter = self::hasExplicitDeptIdsParam($params['dept_ids'] ?? null);
[$primaryDeptIds, $deptNameMap, $tableRowDeptIds, $deptById] = self::resolvePrimaryDepts($params['dept_ids'] ?? null);
$deptToDescendants = self::buildDeptDescendants($primaryDeptIds);
$adminToPrimary = self::buildAdminToPrimaryDept($primaryDeptIds, $deptToDescendants);
$tagDiagIds = null;
$tagAssistantIds = null;
$tagFallback = false;
if ($tagFilterId !== '') {
[$tagDiagIds, $tagAssistantIds] = self::resolveTagScopedDiagnosisAndAssistants($tagFilterId);
if ($tagDiagIds === [] && $tagAssistantIds !== []) {
$tagDiagIds = null;
$tagFallback = true;
}
}
$appointmentChannelValues = [];
if ($channelInfo !== null) {
$appointmentChannelValues = MediaChannelService::getLegacyAppointmentChannelValues($channelInfo);
}
return [
'startDate' => $startDate,
'endDate' => $endDate,
'startTs' => $startTs,
'endTs' => $endTs,
'channelCode' => $channelCode,
'tagFilterId' => $tagFilterId,
'channelInfo' => $channelInfo,
'primaryDeptIds' => $primaryDeptIds,
'tableRowDeptIds' => $tableRowDeptIds,
'deptNameMap' => $deptNameMap,
'deptById' => $deptById,
'deptToDescendants' => $deptToDescendants,
'adminToPrimary' => $adminToPrimary,
'tagDiagIds' => $tagDiagIds,
'tagAssistantIds' => $tagAssistantIds,
'tagFallback' => $tagFallback,
'appointmentChannelValues' => $appointmentChannelValues,
'channelFilterActive' => ($channelCode !== ''),
'explicitDeptFilter' => $explicitDeptFilter,
'dataScopeRestricted' => false,
];
}
/**
* 按角色「数据范围」收窄业绩看板上下文(adminToPrimary、tableRowDeptIds)。
* 与 DataScopeService / 列表 HasDataScopeFilter 一致:ALL 不处理;SELF/本部门/本部门及下级 仅保留可见 admin_id。
*/
private static function applyYejiDataScope(array &$c, int $adminId, array $adminInfo): void
{
if ($adminId <= 0 || !DataScopeService::isEnabled()) {
return;
}
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === null) {
return;
}
$c['dataScopeRestricted'] = true;
$visibleIds = array_values(array_unique(array_filter(array_map('intval', $visibleIds), static function (int $v): bool {
return $v > 0;
})));
$c['dataScopeVisibleAdminIds'] = $visibleIds;
if ($visibleIds === []) {
$c['adminToPrimary'] = [];
$c['tableRowDeptIds'] = [];
return;
}
$flip = array_flip($visibleIds);
$filtered = [];
foreach ($c['adminToPrimary'] as $aid => $pid) {
if (isset($flip[$aid])) {
$filtered[$aid] = $pid;
}
}
$c['adminToPrimary'] = $filtered;
$allowedPrimaries = array_values(array_unique(array_values($filtered)));
if ($allowedPrimaries === []) {
$c['tableRowDeptIds'] = [];
return;
}
$allowFlip = array_flip($allowedPrimaries);
$c['tableRowDeptIds'] = array_values(array_filter(
$c['tableRowDeptIds'],
static function (int $did) use ($allowFlip): bool {
return $did > 0 && isset($allowFlip[$did]);
}
));
}
/**
* 与业绩看板相同的日期、部门树、渠道、标签与数据范围上下文,供医生日统计等复用。
*
* @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params
*
* @return array{
* startDate:string,endDate:string,startTs:int,endTs:int,channelCode:string,
* adminToPrimary:array<int,int>,tableRowDeptIds:int[],
* appointmentChannelValues:int[],channelFilterActive:bool,
* tagDiagIds:int[]|null,tagAssistantIds:array<int,int>|null,tagFallback:bool
* }
*/
public static function resolveSharedYejiFilterContext(
array $params,
int $viewerAdminId = 0,
array $viewerAdminInfo = []
): array {
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
return [
'startDate' => $c['startDate'],
'endDate' => $c['endDate'],
'startTs' => $c['startTs'],
'endTs' => $c['endTs'],
'channelCode' => $c['channelCode'],
'adminToPrimary' => $c['adminToPrimary'],
'tableRowDeptIds' => $c['tableRowDeptIds'],
'appointmentChannelValues' => $c['appointmentChannelValues'],
'channelFilterActive' => $c['channelFilterActive'],
'tagDiagIds' => $c['tagDiagIds'],
'tagAssistantIds' => $c['tagAssistantIds'],
'tagFallback' => $c['tagFallback'],
];
}
/**
* @param array{
* start_date?: string,
* end_date?: string,
* dept_ids?: int[]|string,
* channel_code?: string,
* tag_id?: string,
* } $params
*
* @return array<string, mixed>
*/
public static function overview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$startDate = $c['startDate'];
$endDate = $c['endDate'];
$startTs = $c['startTs'];
$endTs = $c['endTs'];
$channelCode = $c['channelCode'];
$tagFilterId = $c['tagFilterId'];
$channelInfo = $c['channelInfo'];
$primaryDeptIds = $c['primaryDeptIds'];
$tableRowDeptIds = $c['tableRowDeptIds'];
$deptToDescendants = $c['deptToDescendants'];
$deptNameMap = $c['deptNameMap'];
$deptById = $c['deptById'];
$adminToPrimary = $c['adminToPrimary'];
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
$tagFallback = $c['tagFallback'];
$appointmentChannelValues = $c['appointmentChannelValues'];
$channelFilterActive = $c['channelFilterActive'];
$explicitDeptFilter = $c['explicitDeptFilter'];
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$narrowTotalsToTable = $explicitDeptFilter || $dataScopeRestricted;
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
$leads = self::countLeads($startTs, $endTs, $adminToPrimary, $tagFilterId);
$assignsByDept = self::countDiagnosisAssignsByDept(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$appointmentChannelValues,
$channelFilterActive,
$dataScopeAdminIds,
$channelInfo
);
// 接诊:
// - consults(全量) → "接诊诊单" 列;不限渠道
// - channelConsults(渠道) → 「{渠道}成交单」列;未选渠道时与 consults 相同
$consultsBoth = self::countConsults(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds,
$appointmentChannelValues,
$channelFilterActive,
$dataScopeAdminIds,
$tableRowDeptIds,
$deptById,
$channelInfo
);
$consults = $consultsBoth['all'];
$channelConsults = $consultsBoth['scoped'];
$bookedByDept = self::aggregateBookedAppointmentsByDept(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds,
$dataScopeAdminIds
);
$listAlignedPack = ($tableRowDeptIds !== [] && $deptById !== [])
? self::aggregateListAlignedOrderMetricsByDept(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById
)
: null;
// 业绩:部门行「合计业绩」与列表部门摊行一致;「{渠道}业绩」同列表摊行 + 渠道 EXISTS(与抽屉一致)
$perfBoth = self::sumPerformance(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$appointmentChannelValues,
$channelFilterActive,
$tableRowDeptIds,
$deptById,
$listAlignedPack !== null ? $listAlignedPack['amount'] : null,
$channelInfo
);
$performance = $perfBoth['all'];
$channelPerformance = $perfBoth['scoped'];
// 成交单数(全量):与合计业绩列同列表部门口径
$dealOrderByDept = $listAlignedPack !== null
? $listAlignedPack['count']
: self::aggregateDealOrderCount(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById
);
$globalDealOrderCount = self::countGlobalDealOrders($startTs, $endTs);
/** 选定渠道时从全量渠道 SUM 与部门行中同步扣减「二中心」展示部门上的渠道业绩 */
$erCenterChannelStrip = 0.0;
if ($channelCode !== '') {
$erCenterChannelStrip = self::excludeErCenterFromChannelMetrics(
$primaryDeptIds,
$deptNameMap,
$channelPerformance,
$channelConsults
);
self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $leads);
self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $assignsByDept);
}
$globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1);
$leadsForCost = self::mergeHiddenPrimaryLeadsForCostAllocation(
$primaryDeptIds,
$tableRowDeptIds,
$leads,
$deptToDescendants
);
$costs = self::allocateCostByLeads($globalCost, $tableRowDeptIds, $leadsForCost);
@set_time_limit(120);
$revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs);
$revisitTotals = $revisitPack['totals'];
$revisitSlotsByDept = $revisitPack['slots'];
// ── 5) 组装每行 ──────────────────────────────────────────────
$rows = [];
$totalLead = 0;
$totalAssign = 0;
$totalRevisit = 0;
/** @var array<int, int> $totalRevisitSlots */
$totalRevisitSlots = [];
$totalChannelConsult = 0;
$totalPerf = 0.0;
$totalChannelPerf = 0.0;
$totalCost = 0.0;
$unmappedConsult = (int) ($consults[0] ?? 0);
$consultSumInTable = 0;
$bookedSumInTable = 0;
foreach ($tableRowDeptIds as $deptId) {
$lead = (int) ($leads[$deptId] ?? 0);
$cnt = (int) ($consults[$deptId] ?? 0);
$consultSumInTable += $cnt;
$bookedSumInTable += (int) ($bookedByDept[$deptId] ?? 0);
$cCnt = (int) ($channelConsults[$deptId] ?? 0);
// 合计业绩:部门下所有订单业绩(≠4),不限渠道
$perf = round((float) ($performance[$deptId] ?? 0), 1);
// {渠道}业绩:部门 × 选定渠道下业绩(≠4)
$cperf = round((float) ($channelPerformance[$deptId] ?? 0), 1);
$cost = round((float) ($costs[$deptId] ?? 0), 1);
$rowCost = $cost;
$rowRoi = $rowCost > 0 ? round($cperf / $rowCost, 1) : null;
$dDeal = (int) ($dealOrderByDept[$deptId] ?? 0);
$assignCnt = (int) ($assignsByDept[$deptId] ?? 0);
$rv = (int) ($revisitTotals[$deptId] ?? 0);
$rvSlots = $revisitSlotsByDept[$deptId] ?? [];
$rows[] = [
'dept_id' => $deptId,
'dept_name' => self::formatYejiDeptRowDisplayName($deptId, $deptById),
'lead_count' => $lead,
'assign_count' => $assignCnt,
'revisit_count' => $rv,
'revisit_slots' => $rvSlots,
'appointment_booked_count' => (int) ($bookedByDept[$deptId] ?? 0),
'consult_count' => $cnt,
'deal_order_count' => $dDeal,
// {渠道}成交单:选定渠道时与 completed_performance 同源;未选渠道时与 consult_count 相同
'channel_consult_count' => $cCnt,
'performance_amount' => $perf,
'completed_performance_amount' => $cperf,
'avg_price' => $cnt > 0 ? round($perf / $cnt, 1) : null,
// {渠道}客单价 = {渠道}业绩 / {渠道}成交单
'channel_avg_price' => $cCnt > 0 ? round($cperf / $cCnt, 1) : null,
'cost_amount' => round($rowCost, 1),
'roi' => $rowRoi,
];
$totalLead += $lead;
$totalAssign += $assignCnt;
$totalRevisit += $rv;
foreach ($rvSlots as $slot => $cntRv) {
$slot = (int) $slot;
$totalRevisitSlots[$slot] = ($totalRevisitSlots[$slot] ?? 0) + (int) $cntRv;
}
$totalChannelConsult += $cCnt;
$totalPerf += $perf;
$totalChannelPerf += $cperf;
// 不再用各行成本叠加(避免父级未在展示列时丢失),合计直接用 $globalCost
}
$allocatedPerf = round($totalPerf, 1);
$allocatedChannelPerf = round($totalChannelPerf, 1);
$globalPerfAll = self::sumGlobalPerformanceAmount($startTs, $endTs);
$globalPerfChannel = $globalPerfAll;
if ($channelFilterActive) {
$globalPerfChannel = self::sumGlobalChannelScopedPerformance(
$startTs,
$endTs,
$appointmentChannelValues,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$channelInfo
);
$globalPerfChannel = round(max(0.0, $globalPerfChannel - $erCenterChannelStrip), 1);
}
$allocatedDealOrderSum = 0;
foreach ($tableRowDeptIds as $rid) {
$allocatedDealOrderSum += (int) ($dealOrderByDept[$rid] ?? 0);
}
$totalBooked = $narrowTotalsToTable ? $bookedSumInTable : array_sum($bookedByDept);
$listAlignedTotalPerf = null;
$listAlignedDealCnt = null;
if ($narrowTotalsToTable) {
$deptForListAlign = $explicitDeptFilter && $primaryDeptIds !== [] ? $primaryDeptIds : null;
$visForListAlign = $dataScopeRestricted && $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] ? $dataScopeAdminIds : null;
if ($deptForListAlign !== null || $visForListAlign !== null) {
$listAlignedTotalPerf = self::sumOrderListAlignedPerformanceAmount(
$startTs,
$endTs,
$deptForListAlign,
$visForListAlign
);
$listAlignedDealCnt = self::countOrderListAlignedPerformanceOrders(
$startTs,
$endTs,
$deptForListAlign,
$visForListAlign
);
}
}
if ($narrowTotalsToTable) {
$totalConsult = $consultSumInTable;
$unmappedDealOrder = 0;
$unassignedPerf = 0.0;
$unassignedChannelPerf = 0.0;
$showUnassignedRow = false;
} else {
$totalConsult = array_sum($consults);
$unmappedDealOrder = max(0, $globalDealOrderCount - $allocatedDealOrderSum);
$unassignedPerf = round($globalPerfAll - $allocatedPerf, 1);
$unassignedChannelPerf = round($globalPerfChannel - $allocatedChannelPerf, 1);
$showUnassignedRow = abs($unassignedPerf) >= 0.01 || abs($unassignedChannelPerf) >= 0.01 || $unmappedConsult > 0 || $unmappedDealOrder > 0;
}
if ($showUnassignedRow) {
$uPerf = $unassignedPerf;
$uCperf = $channelFilterActive ? $unassignedChannelPerf : $uPerf;
$rows[] = [
'dept_id' => 0,
'dept_name' => '未归属中心',
'lead_count' => 0,
'assign_count' => 0,
'revisit_count' => 0,
'revisit_slots' => [],
'appointment_booked_count' => (int) ($bookedByDept[0] ?? 0),
'consult_count' => $unmappedConsult,
'deal_order_count' => $unmappedDealOrder,
'channel_consult_count' => 0,
'performance_amount' => $uPerf,
'completed_performance_amount' => $uCperf,
'avg_price' => null,
'channel_avg_price' => null,
'cost_amount' => 0.0,
'roi' => null,
];
}
if ($narrowTotalsToTable) {
$totalPerf = $listAlignedTotalPerf !== null ? $listAlignedTotalPerf : $allocatedPerf;
$totalChannelPerf = $allocatedChannelPerf;
$totalDealOrderCount = $listAlignedDealCnt !== null ? $listAlignedDealCnt : $allocatedDealOrderSum;
} else {
$totalPerf = $globalPerfAll;
$totalChannelPerf = $globalPerfChannel;
$totalDealOrderCount = $globalDealOrderCount;
}
$totalCost = round($globalCost, 1);
// ROI 合计 = 渠道业绩合计(与「completed」列一致,**不含二中心**)÷ 投放成本合计
$totalRoiNumerator = round($totalChannelPerf, 1);
if ($totalRevisitSlots !== []) {
ksort($totalRevisitSlots, SORT_NUMERIC);
}
$scopedTableNote = ($explicitDeptFilter
? ' 已筛选展示部门时,表格「合计业绩」「接诊诊单」各行为同一套列表部门口径(诊单医助或创建人∪子树、最深展示行);底栏合计业绩/成交单与处方订单列表 extend(所选部门树+数据域)一致,可能与各行相加不同。'
: '')
. ($dataScopeRestricted
? ' 当前账号受数据范围限制时,底栏合计业绩与列表同域。'
: '');
$baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」与「接诊诊单」均为列表 assistant_dept_id 口径(诊单医助或订单创建人人事部门∪子树,多行命中取最深展示行)。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊。';
return [
'start_date' => $startDate,
'end_date' => $endDate,
'dept_ids' => array_values($tableRowDeptIds),
'channel_code' => $channelCode,
'channel_name' => $channelInfo ? (string) $channelInfo['channel_name'] : '',
'channel_group' => $channelInfo ? (string) $channelInfo['source_group_name'] : '',
'channel_filter_note' => $channelCode !== ''
? ('{渠道}业绩:与「处方订单列表」业绩同口径,按订单 create_time 归日、仅剔除履约已取消(4)(NULL 与其它状态均计入);渠道判定用"该患者是否有挂号 channels 命中 dict + status=3"(不限挂号时点)。'
. ' {渠道}成交单数:与{渠道}业绩同源,仅剔除已取消(4)的订单条数(非挂号条数)。'
. ' 「已完成挂号」列同 admin 挂号已完成列表;「接诊诊单」列为业务订单条数(非取消),与列表按 assistant_dept_id 筛选时一致(诊单医助或创建人∪子树)。名称含「二中心」的部门**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例,从账务总投放金额中分摊(总成本÷总进线=单进线成本)。ROI = 渠道业绩(不含二中心)÷ 总投放成本。进线按所选标签客户精确过滤。'
. ($tagFallback
? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。'
: '')
. $scopedTableNote)
: ($baseNoteNoChannel . $scopedTableNote),
'rows' => $rows,
'total' => [
'lead_count' => $totalLead,
'assign_count' => $totalAssign,
'revisit_count' => $totalRevisit,
'revisit_slots' => $totalRevisitSlots,
'appointment_booked_count' => $totalBooked,
'consult_count' => $totalConsult,
'deal_order_count' => $totalDealOrderCount,
'channel_consult_count' => $totalChannelConsult,
'performance_amount' => $totalPerf,
'completed_performance_amount' => $totalChannelPerf,
'avg_price' => $totalConsult > 0 ? round($totalPerf / $totalConsult, 1) : null,
'channel_avg_price' => $totalChannelConsult > 0 ? round($totalChannelPerf / $totalChannelConsult, 1) : null,
'cost_amount' => $totalCost,
'cost_per_lead' => $totalLead > 0 && $totalCost > 0 ? round($totalCost / $totalLead, 1) : null,
'roi' => $totalCost > 0 ? round($totalRoiNumerator / $totalCost, 1) : null,
],
];
}
/**
* 多组时间区间一次性返回(前端"月/周/日"四张表共用一次请求)。
*
* @param array<int, array{label?:string, start_date?:string, end_date?:string}> $ranges
* @return array<int, array<string, mixed>>
*/
public static function overviewBatch(array $ranges, array $base, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$out = [];
foreach ($ranges as $r) {
$params = array_merge($base, [
'start_date' => (string) ($r['start_date'] ?? date('Y-m-d')),
'end_date' => (string) ($r['end_date'] ?? date('Y-m-d')),
]);
$stat = self::overview($params, $viewerAdminId, $viewerAdminInfo);
$stat['label'] = (string) ($r['label'] ?? ($stat['start_date'] === $stat['end_date'] ? $stat['start_date'] : ($stat['start_date'] . '~' . $stat['end_date'])));
$out[] = $stat;
}
return $out;
}
/**
* 医助排行榜:按展示部门分表,每表内按诊金降序排名。
* 诊金口径:无渠道=业绩归属医助(与列表医助筛选一致);有渠道={渠道}业绩/dict;二中心在选渠道下注 0。
* 被指派数 = 区间内诊单指派日志(to_assistant_id>0)按**被指派医助**计数,与部门表「被指派数」同收窄/二中心规则。
* 接诊率 = 排行用诊金 ÷ 进线条数(元/进线,1 位小数;进线为 0 时为 0)。
* 复诊(属二中心组织架构的展示部门):同一诊单下业务单按 create_time 排序第 2 笔起分项,与部门业绩表「二中心复诊」同源;前端点击数字可下钻对应业务订单。
*
* @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params
*
* @return array<string, mixed>
*/
public static function assistantLeaderboards(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$startDate = $c['startDate'];
$endDate = $c['endDate'];
$startTs = $c['startTs'];
$endTs = $c['endTs'];
$channelCode = $c['channelCode'];
$channelInfo = $c['channelInfo'];
$tagFilterId = $c['tagFilterId'];
$primaryDeptIds = $c['primaryDeptIds'];
$tableRowDeptIds = $c['tableRowDeptIds'];
$deptNameMap = $c['deptNameMap'];
$deptById = $c['deptById'];
$adminToPrimary = $c['adminToPrimary'];
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
$tagFallback = $c['tagFallback'];
$appointmentChannelValues = $c['appointmentChannelValues'];
$channelFilterActive = $c['channelFilterActive'];
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
$channelName = $channelInfo ? (string) $channelInfo['channel_name'] : '';
if ($adminToPrimary === []) {
return [
'start_date' => $startDate,
'end_date' => $endDate,
'channel_code' => $channelCode,
'channel_name' => $channelName,
'channel_filter_note' => '',
'range_note' => '',
'leaderboards' => [],
];
}
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
$perfBoth = self::sumPerformanceByAdmin(
$startTs,
$endTs,
$tagDiagIds,
$scopedAssistants,
$appointmentChannelValues,
$channelFilterActive,
$channelInfo
);
$feeByAdmin = $channelFilterActive ? $perfBoth['scoped'] : $perfBoth['all'];
$dealBoth = self::sumDealOrderByAdmin(
$startTs,
$endTs,
$tagDiagIds,
$scopedAssistants,
$appointmentChannelValues,
$channelFilterActive,
$channelInfo
);
$dealOrderByAdmin = $channelFilterActive ? $dealBoth['scoped'] : $dealBoth['all'];
$leadsByAdmin = self::countLeadsByAdmin($startTs, $endTs, $tagFilterId);
/** 与部门表接诊同口径;标签/医助穿透时与业绩同源收窄,避免榜上证务与接诊脱节 */
$consultsByAdmin = self::aggregateConsultsByAdmin($startTs, $endTs, $tagDiagIds, $scopedAssistants);
$assignsByAdmin = self::countDiagnosisAssignsByAdmin(
$startTs,
$endTs,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$appointmentChannelValues,
$channelFilterActive,
$dataScopeAdminIds,
$channelInfo
);
$appointmentByAdmin = self::countBookedAppointmentsByAdminForLeaderboard(
$startTs,
$endTs,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$dataScopeAdminIds
);
if ($channelFilterActive) {
self::excludeErCenterFromAdminLeaderboard(
$adminToPrimary,
$deptNameMap,
$feeByAdmin,
$leadsByAdmin,
$dealOrderByAdmin,
$assignsByAdmin
);
}
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
$revisitByAssistant = DeptLogic::getErCenterRevisitCountsByAssistant($startTs, $endTs);
$assistantIdRows = Db::name('admin_role')->where('role_id', 2)->column('admin_id');
$assistantSet = [];
foreach ($assistantIdRows as $rid) {
$assistantSet[(int) $rid] = true;
}
$nameIds = [];
foreach (array_keys($assistantSet) as $aid) {
if (isset($adminToPrimary[$aid])) {
$nameIds[] = $aid;
}
}
$adminNames = [];
if ($nameIds !== []) {
$nameRows = Db::name('admin')
->whereIn('id', $nameIds)
->whereNull('delete_time')
->column('name', 'id');
foreach ($nameRows as $id => $name) {
$adminNames[(int) $id] = (string) $name;
}
}
$rangeNote = $channelFilterActive
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
. '「二中心」医助在该渠道下诊金、进线与被指派计 0。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
: '排行诊金 = 处方订单列表医助口径(诊单医助或代建创建人与列表一致)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
$leaderboards = [];
foreach ($tableRowDeptIds as $deptId) {
$leafName = $deptNameMap[$deptId] ?? ('部门#' . $deptId);
$deptName = self::formatYejiDeptRowDisplayName($deptId, $deptById);
$deptAbbr = self::deptDisplayAbbr($leafName);
$isErCenterLb = isset($erCenterDeptSet[$deptId]);
$rows = [];
foreach (array_keys($assistantSet) as $aid) {
if (($adminToPrimary[$aid] ?? 0) !== $deptId) {
continue;
}
$fee = round((float) ($feeByAdmin[$aid] ?? 0.0), 1);
$lead = (int) ($leadsByAdmin[$aid] ?? 0);
$consult = (int) ($consultsByAdmin[$aid] ?? 0);
$dealOrders = (int) ($dealOrderByAdmin[$aid] ?? 0);
$assignCnt = (int) ($assignsByAdmin[$aid] ?? 0);
$apptCnt = (int) ($appointmentByAdmin[$aid] ?? 0);
$rv = $isErCenterLb ? (int) ($revisitByAssistant['totals'][$aid] ?? 0) : 0;
$rvSlots = $isErCenterLb ? ($revisitByAssistant['slots'][$aid] ?? []) : [];
$rawFee = (float) ($feeByAdmin[$aid] ?? 0.0);
$rate = $lead > 0 ? round($rawFee / $lead, 1) : 0.0;
$row = [
'admin_id' => $aid,
'name' => $adminNames[$aid] ?? ('#' . $aid),
'fee_amount' => $fee,
'lead_count' => $lead,
'assign_count' => $assignCnt,
'consult_count' => $consult,
'deal_order_count' => $dealOrders,
'appointment_count' => $apptCnt,
'consult_rate' => $rate,
'dept_abbr' => $deptAbbr,
];
if ($isErCenterLb) {
$row['revisit_count'] = $rv;
$row['revisit_slots'] = $rvSlots;
}
$rows[] = $row;
}
usort($rows, static function (array $a, array $b): int {
if ($a['fee_amount'] != $b['fee_amount']) {
return $b['fee_amount'] <=> $a['fee_amount'];
}
return $b['consult_rate'] <=> $a['consult_rate'];
});
foreach ($rows as $i => $row) {
$rows[$i]['rank'] = $i + 1;
}
$leaderboards[] = [
'dept_id' => $deptId,
'dept_name' => $deptName,
'dept_abbr' => $deptAbbr,
'er_center_subtree' => $isErCenterLb,
'rows' => $rows,
];
}
return [
'start_date' => $startDate,
'end_date' => $endDate,
'channel_code' => $channelCode,
'channel_name' => $channelName,
'channel_filter_note' => $channelCode !== ''
? '医助榜:名称含「二中心」的部门在选定渠道下诊金、进线、被指派数计 0,与部门看板一致。'
: '',
'range_note' => $rangeNote,
'leaderboards' => $leaderboards,
];
}
/**
* 数据范围下:可见成员所在部门及其下级部门 id(用于部门下拉收窄)。
*
* @return array<int, true>|null null 表示不限制,[] 表示无可选
*/
private static function buildYejiDataScopeAllowedDeptIdMap(int $adminId, array $adminInfo): ?array
{
return DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
}
/**
* 列出所有可选部门
*
* @return array<int, array{id:int, name:string, pid:int, path:string}>
*/
public static function deptOptions(int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$rows = Db::name('dept')->whereNull('delete_time')->order('pid asc, id asc')->select()->toArray();
if ($rows === []) {
return [];
}
$byId = [];
foreach ($rows as $r) {
$byId[(int) $r['id']] = $r;
}
$buildPath = function (int $id) use ($byId): string {
$names = [];
$cur = $id;
for ($i = 0; $i < 50; $i++) {
if (!isset($byId[$cur])) {
break;
}
array_unshift($names, (string) $byId[$cur]['name']);
$pid = (int) $byId[$cur]['pid'];
if ($pid <= 0) {
break;
}
$cur = $pid;
}
return implode(' / ', array_filter($names, static fn($s): bool => $s !== ''));
};
$out = [];
foreach ($rows as $r) {
$id = (int) $r['id'];
$out[] = [
'id' => $id,
'name' => (string) $r['name'],
'pid' => (int) $r['pid'],
'path' => $buildPath($id),
];
}
$allowed = self::buildYejiDataScopeAllowedDeptIdMap($viewerAdminId, $viewerAdminInfo);
if ($allowed !== null) {
if ($allowed === []) {
return [];
}
$out = array_values(array_filter(
$out,
static function (array $item) use ($allowed): bool {
return isset($allowed[(int) ($item['id'] ?? 0)]);
}
));
}
return $out;
}
/**
* 渠道下拉(按 source_group_name 分组),同时附带客户/成本统计便于排序展示
*
* @return array{
* groups: array<int, array{
* group_name: string,
* channels: array<int, array{
* channel_code: string, channel_name: string,
* source_tag_id: string, source_group_name: string,
* customer_count: int
* }>
* }>,
* total: int
* }
*/
public static function channelOptions(): array
{
$rows = Db::name('qywx_media_channel')
->where('status', 1)
->order('source_group_name asc, id asc')
->select()
->toArray();
if ($rows === []) {
return ['groups' => [], 'total' => 0];
}
// 顺路给 tag_* 渠道带上「打了该标签的客户数」(直接用 contact_tag 表)
$tagIds = [];
foreach ($rows as $r) {
$code = (string) $r['channel_code'];
if (str_starts_with($code, 'tag_')) {
$tagIds[] = substr($code, 4);
}
}
$tagToCount = [];
if ($tagIds !== []) {
$cntRows = Db::name('qywx_external_contact_tag')
->whereIn('tag_id', $tagIds)
->fieldRaw('tag_id, COUNT(DISTINCT external_userid) AS c')
->group('tag_id')
->select()
->toArray();
foreach ($cntRows as $cr) {
$tagToCount[(string) $cr['tag_id']] = (int) $cr['c'];
}
}
$groups = [];
foreach ($rows as $r) {
$g = (string) ($r['source_group_name'] ?: '其它');
$groups[$g] ??= ['group_name' => $g, 'channels' => []];
$code = (string) $r['channel_code'];
$custCount = 0;
if (str_starts_with($code, 'tag_')) {
$tid = substr($code, 4);
$custCount = (int) ($tagToCount[$tid] ?? 0);
}
$groups[$g]['channels'][] = [
'channel_code' => $code,
'channel_name' => (string) $r['channel_name'],
'source_tag_id' => (string) $r['source_tag_id'],
'source_group_name' => $g,
'customer_count' => $custCount,
];
}
// 「自媒体」类组排前
$sortedGroups = array_values($groups);
usort($sortedGroups, static function (array $a, array $b): int {
$aMedia = mb_strpos($a['group_name'], '自媒体') !== false ? 0 : 1;
$bMedia = mb_strpos($b['group_name'], '自媒体') !== false ? 0 : 1;
if ($aMedia !== $bMedia) {
return $aMedia <=> $bMedia;
}
return strcmp($a['group_name'], $b['group_name']);
});
return ['groups' => $sortedGroups, 'total' => count($rows)];
}
// ─────────────────────────────────────────────────────────────────
// 内部
// ─────────────────────────────────────────────────────────────────
private static function normalizeDate(string $s): string
{
$s = trim($s);
$ts = strtotime($s);
return $ts === false ? date('Y-m-d') : date('Y-m-d', $ts);
}
/**
* 请求是否显式带了部门筛选(非空 dept_ids),用于与「默认全部中心」区分。
*/
private static function hasExplicitDeptIdsParam($raw): bool
{
if (\is_string($raw) && trim($raw) !== '') {
foreach (explode(',', $raw) as $p) {
if ((int) trim($p) > 0) {
return true;
}
}
}
if (\is_array($raw)) {
foreach ($raw as $v) {
if ((int) $v > 0) {
return true;
}
}
}
return false;
}
/**
* 用户传入 dept_ids 时:展开为「所选部门 + 全子孙」作为 primary(用于归属与聚合);表格行序同序。
* 若用户勾选的是含下级部门的父节点(如「一中心」),则表格**仅展示其下级部门行**,不重复展示该中心父级行;其它中心不会出现在展开集合中。
* 默认(未传):primary 与表格行均为所有 name 含「中心」且 pid>0 的节点(父子同存时去掉父)。
*
* @return array{0: int[], 1: array<int,string>, 2: int[], 3: array<int, array<string, mixed>>}
*/
private static function resolvePrimaryDepts($input): array
{
$rows = Db::name('dept')->whereNull('delete_time')->field(['id', 'name', 'pid'])->order('pid asc, id asc')->select()->toArray();
$byId = [];
foreach ($rows as $r) {
$byId[(int) $r['id']] = $r;
}
$childrenOf = [];
foreach ($rows as $r) {
$pid = (int) $r['pid'];
$childrenOf[$pid][] = (int) $r['id'];
}
$userRoots = [];
if (\is_string($input) && $input !== '') {
$input = explode(',', $input);
}
if (\is_array($input)) {
foreach ($input as $v) {
$iv = (int) $v;
if ($iv > 0) {
$userRoots[] = $iv;
}
}
$userRoots = array_values(array_unique($userRoots));
}
if ($userRoots !== []) {
$deptIds = self::expandDeptRootIdsWithDescendants($userRoots, $childrenOf);
$deptIds = self::sortYejiDeptIdsAncestorsBeforeDescendants($deptIds, $byId);
$inExpanded = array_flip($deptIds);
$tableRowDeptIds = [];
foreach ($deptIds as $id) {
$isUserRoot = \in_array($id, $userRoots, true);
$hasChildInExpanded = false;
foreach (($childrenOf[$id] ?? []) as $cid) {
if (isset($inExpanded[$cid])) {
$hasChildInExpanded = true;
break;
}
}
if ($isUserRoot && $hasChildInExpanded) {
continue;
}
$tableRowDeptIds[] = $id;
}
} else {
$candidates = [];
foreach ($rows as $r) {
if (mb_strpos((string) $r['name'], '中心') !== false && (int) $r['pid'] > 0) {
$candidates[(int) $r['id']] = true;
}
}
// 父子都在 → 去掉父
foreach (array_keys($candidates) as $id) {
$cur = (int) $byId[$id]['pid'];
while ($cur > 0) {
if (isset($candidates[$cur])) {
unset($candidates[$cur]);
}
$cur = (int) ($byId[$cur]['pid'] ?? 0);
}
}
$deptIds = array_keys($candidates);
sort($deptIds);
$tableRowDeptIds = $deptIds;
}
$names = [];
foreach ($deptIds as $id) {
$names[$id] = (string) ($byId[$id]['name'] ?? ('部门#' . $id));
}
return [$deptIds, $names, $tableRowDeptIds, $byId];
}
/**
* 表格展示用部门名:自根到本级的全路径「父 / … / 子」;顶级「甄养堂互联网医院」不展示(与业务筛选里常见根节点重复冗长)。
*
* @param array<int, array<string, mixed>> $byId
*/
private static function formatYejiDeptRowDisplayName(int $deptId, array $byId): string
{
if ($deptId <= 0 || !isset($byId[$deptId])) {
return '部门#' . $deptId;
}
$leafLabel = (string) $byId[$deptId]['name'];
$names = [];
$cur = $deptId;
for ($i = 0; $i < 50; $i++) {
if (!isset($byId[$cur])) {
break;
}
array_unshift($names, (string) $byId[$cur]['name']);
$pid = (int) $byId[$cur]['pid'];
if ($pid <= 0) {
break;
}
$cur = $pid;
}
$names = array_values(array_filter($names, static fn (string $s): bool => $s !== ''));
if ($names !== [] && $names[0] === '甄养堂互联网医院') {
array_shift($names);
}
if ($names === []) {
return $leafLabel !== '' ? $leafLabel : ('部门#' . $deptId);
}
return implode(' / ', $names);
}
/**
* @param array<int, int[]> $childrenOf 父 id => 子部门 id 列表(来自未删除部门表)
*
* @return int[]
*/
private static function expandDeptRootIdsWithDescendants(array $rootIds, array $childrenOf): array
{
$set = [];
$stack = array_values(array_filter(array_map('intval', $rootIds), static fn (int $id): bool => $id > 0));
while ($stack !== []) {
$id = (int) array_pop($stack);
if ($id <= 0 || isset($set[$id])) {
continue;
}
$set[$id] = true;
foreach (($childrenOf[$id] ?? []) as $cid) {
$cid = (int) $cid;
if ($cid > 0 && !isset($set[$cid])) {
$stack[] = $cid;
}
}
}
return array_keys($set);
}
/**
* 同一展开集合内:深度小的 id 在前,保证其后 buildDeptDescendants 写入 deptToPrimary 时子部门覆盖父部门。
*
* @param array<int, array<string, mixed>> $byId
*
* @return int[]
*/
private static function sortYejiDeptIdsAncestorsBeforeDescendants(array $deptIds, array $byId): array
{
$deptIds = array_values(array_unique(array_map('intval', $deptIds)));
if ($deptIds === []) {
return [];
}
$idSet = array_flip($deptIds);
$memo = [];
$depthFn = static function (int $id) use (&$depthFn, &$memo, $byId, $idSet): int {
if (isset($memo[$id])) {
return $memo[$id];
}
if (!isset($idSet[$id])) {
return $memo[$id] = -1;
}
$pid = (int) ($byId[$id]['pid'] ?? 0);
if ($pid <= 0 || !isset($idSet[$pid])) {
return $memo[$id] = 0;
}
return $memo[$id] = $depthFn($pid) + 1;
};
usort($deptIds, static function (int $a, int $b) use ($depthFn): int {
$da = $depthFn($a);
$db = $depthFn($b);
if ($da !== $db) {
return $da <=> $db;
}
return $a <=> $b;
});
return $deptIds;
}
/**
* 每个 primary dept_id 的所有子孙(含自身)id 集合
*
* @param int[] $primaryDeptIds
* @return array<int, int[]>
*/
private static function buildDeptDescendants(array $primaryDeptIds): array
{
$rows = Db::name('dept')->whereNull('delete_time')->field(['id', 'pid'])->select()->toArray();
$childrenOf = [];
foreach ($rows as $r) {
$pid = (int) $r['pid'];
$childrenOf[$pid][] = (int) $r['id'];
}
$result = [];
foreach ($primaryDeptIds as $rootId) {
$stack = [$rootId];
$set = [];
while ($stack !== []) {
$cur = (int) array_pop($stack);
if (isset($set[$cur])) {
continue;
}
$set[$cur] = true;
foreach (($childrenOf[$cur] ?? []) as $child) {
if (!isset($set[$child])) {
$stack[] = $child;
}
}
}
$result[$rootId] = array_keys($set);
}
return $result;
}
/**
* admin_id → primary dept_id 归属
*
* @param int[] $primaryDeptIds
* @param array<int, int[]> $deptToDescendants
* @return array<int, int>
*/
private static function buildAdminToPrimaryDept(array $primaryDeptIds, array $deptToDescendants): array
{
if ($primaryDeptIds === []) {
return [];
}
$deptToPrimary = [];
foreach ($deptToDescendants as $primary => $descs) {
foreach ($descs as $d) {
$deptToPrimary[$d] = $primary;
}
}
if ($deptToPrimary === []) {
return [];
}
$links = Db::name('admin_dept')
->whereIn('dept_id', array_keys($deptToPrimary))
->field(['admin_id', 'dept_id'])
->select()
->toArray();
$adminToPrimary = [];
foreach ($links as $l) {
$adminId = (int) $l['admin_id'];
$deptId = (int) $l['dept_id'];
if (!isset($deptToPrimary[$deptId])) {
continue;
}
if (!isset($adminToPrimary[$adminId])) {
$adminToPrimary[$adminId] = $deptToPrimary[$deptId];
}
}
return $adminToPrimary;
}
/**
* 业绩/成交单按「表格展示部门行」归类:先自 admin 的部门链向上命中第一个 $tableRowDeptIds
* 否则再向下取「位于该部门子树内的首个表格行」(与勾选父部门时隐藏父行、业绩需落到可见子行一致)。
*
* @param int[] $adminIds
* @param int[] $tableRowDeptIds
* @param array<int, array<string, mixed>> $deptById
*
* @return array<int, int> admin_id => table_row_dept_id
*/
private static function batchMapPerformanceAdminsToTableDept(array $adminIds, array $tableRowDeptIds, array $deptById): array
{
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static fn (int $id): bool => $id > 0)));
if ($adminIds === [] || $tableRowDeptIds === []) {
return [];
}
$tableRowSet = array_flip(array_map('intval', $tableRowDeptIds));
$links = Db::name('admin_dept')
->whereIn('admin_id', $adminIds)
->field(['admin_id', 'dept_id'])
->select()
->toArray();
$byAdmin = [];
foreach ($links as $l) {
$aid = (int) $l['admin_id'];
$did = (int) $l['dept_id'];
if ($aid > 0 && $did > 0) {
$byAdmin[$aid][$did] = true;
}
}
$out = [];
foreach ($adminIds as $aid) {
$deptIds = array_keys($byAdmin[$aid] ?? []);
if ($deptIds === []) {
continue;
}
$mapped = self::mapAdminDeptLinksToTableRow($deptIds, $tableRowDeptIds, $tableRowSet, $deptById);
if ($mapped > 0) {
$out[$aid] = $mapped;
}
}
return $out;
}
/**
* @param int[] $deptIdsForAdmin
* @param array<int, true> $tableRowSet
* @param array<int, array<string, mixed>> $deptById
*/
private static function mapAdminDeptLinksToTableRow(array $deptIdsForAdmin, array $tableRowDeptIds, array $tableRowSet, array $deptById): int
{
$best = 0;
$bestDepth = -1;
foreach ($deptIdsForAdmin as $d) {
$d = (int) $d;
if ($d <= 0) {
continue;
}
$hit = self::resolveDeptToNearestTableRowUpward($d, $tableRowSet, $deptById);
if ($hit > 0) {
$depth = self::deptDepthFromRoot($hit, $deptById);
if ($depth > $bestDepth || ($depth === $bestDepth && ($best === 0 || $hit < $best))) {
$best = $hit;
$bestDepth = $depth;
}
}
}
if ($best > 0) {
return $best;
}
foreach ($deptIdsForAdmin as $d) {
$d = (int) $d;
if ($d <= 0) {
continue;
}
$hit = self::resolveFirstTableRowUnderDept($d, $tableRowDeptIds, $deptById);
if ($hit > 0) {
return $hit;
}
}
return 0;
}
private static function deptDepthFromRoot(int $deptId, array $deptById): int
{
$n = 0;
$cur = $deptId;
for ($i = 0; $i < 50 && $cur > 0; $i++) {
$n++;
$cur = (int) ($deptById[$cur]['pid'] ?? 0);
}
return $n;
}
/**
* @param array<int, true> $tableRowSet
* @param array<int, array<string, mixed>> $deptById
*/
private static function resolveDeptToNearestTableRowUpward(int $deptId, array $tableRowSet, array $deptById): int
{
$cur = $deptId;
for ($i = 0; $i < 50 && $cur > 0; $i++) {
if (isset($tableRowSet[$cur])) {
return $cur;
}
$cur = (int) ($deptById[$cur]['pid'] ?? 0);
}
return 0;
}
/**
* $ancestorId 是否为 $deptId 的祖先(含自身相等)。
*
* @param array<int, array<string, mixed>> $deptById
*/
private static function deptIsAncestorOf(int $ancestorId, int $deptId, array $deptById): bool
{
$cur = $deptId;
for ($i = 0; $i < 50 && $cur > 0; $i++) {
if ($cur === $ancestorId) {
return true;
}
$cur = (int) ($deptById[$cur]['pid'] ?? 0);
}
return false;
}
/**
* @param int[] $tableRowDeptIds
* @param array<int, array<string, mixed>> $deptById
*/
private static function resolveFirstTableRowUnderDept(int $adminDeptId, array $tableRowDeptIds, array $deptById): int
{
foreach ($tableRowDeptIds as $tid) {
$tid = (int) $tid;
if ($tid > 0 && self::deptIsAncestorOf($adminDeptId, $tid, $deptById)) {
return $tid;
}
}
return 0;
}
/**
* 将聚合结果行映射到展示部门:优先 batchMap(父部门仅挂在表格外时仍能落到子行),否则退回 adminToPrimary。
*
* @param array<int, array<string, mixed>> $rows query 结果,含 assistant_id
* @param array<int, int> $adminToPrimary
* @param int[] $tableRowDeptIds
* @param array<int, array<string, mixed>> $deptById
* @param string $amtKey 数值字段名(金额 amt 或条数 cnt)
*
* @return array<int, float|int>
*/
private static function foldPerformanceRowsToDeptByTable(
array $rows,
array $adminToPrimary,
array $tableRowDeptIds,
array $deptById,
string $amtKey
): array {
if ($rows === []) {
return [];
}
$aids = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid > 0) {
$aids[] = $aid;
}
}
$adminToTable = ($tableRowDeptIds !== [] && $deptById !== [])
? self::batchMapPerformanceAdminsToTableDept($aids, $tableRowDeptIds, $deptById)
: [];
$byDept = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$primary = (int) ($adminToTable[$aid] ?? 0);
if ($primary <= 0) {
$primary = (int) ($adminToPrimary[$aid] ?? 0);
}
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + ($amtKey === 'cnt' ? (int) ($r['cnt'] ?? 0) : (float) ($r['amt'] ?? 0));
}
return $byDept;
}
/**
* 接诊挂号 aggregateConsults 结果行 → 表格展示部门:规则同业绩 fold(优先 batchMap 到 tableRow,再 adminToPrimary;先有效医助再接诊医生)。
*
* @param array<int, array<string, mixed>> $rows eff_a, doctor_id, cnt
*
* @return array<int, int>
*/
private static function foldConsultCountRowsToDeptByTable(
array $rows,
array $adminToPrimary,
array $tableRowDeptIds,
array $deptById,
?array $dataScopeAdminIds
): array {
if ($rows === []) {
return [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$aids = [];
foreach ($rows as $r) {
$e = (int) ($r['eff_a'] ?? 0);
$d = (int) ($r['doctor_id'] ?? 0);
if ($e > 0) {
$aids[] = $e;
}
if ($d > 0) {
$aids[] = $d;
}
}
$adminToTable = ($tableRowDeptIds !== [] && $deptById !== [])
? self::batchMapPerformanceAdminsToTableDept($aids, $tableRowDeptIds, $deptById)
: [];
$byDept = [];
foreach ($rows as $r) {
$effId = (int) ($r['eff_a'] ?? 0);
$docId = (int) ($r['doctor_id'] ?? 0);
$cnt = (int) ($r['cnt'] ?? 0);
if ($cnt <= 0) {
continue;
}
if ($visFlip !== null) {
if ($effId > 0) {
if (!isset($visFlip[$effId])) {
continue;
}
} elseif ($docId > 0) {
if (!isset($visFlip[$docId])) {
continue;
}
} else {
continue;
}
}
$primary = 0;
foreach ([$effId, $docId] as $aid) {
if ($aid <= 0) {
continue;
}
$p = (int) ($adminToTable[$aid] ?? 0);
if ($p <= 0) {
$p = (int) ($adminToPrimary[$aid] ?? 0);
}
if ($p > 0) {
$primary = $p;
break;
}
}
if ($primary > 0) {
$byDept[$primary] = ($byDept[$primary] ?? 0) + $cnt;
} else {
$byDept[0] = ($byDept[0] ?? 0) + $cnt;
}
}
return $byDept;
}
/**
* 进线数据:add_external_contact 事件按 user_id(work_wechat_userid) → admin → primary dept
*
* @param array<int, int> $adminToPrimary
* @return array<int, int>
*/
private static function countLeads(int $startTs, int $endTs, array $adminToPrimary, string $tagFilterId): array
{
if ($adminToPrimary === []) {
return [];
}
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTs, $endTs])
->where('e.user_id', '<>', '')
->field(['e.user_id', 'COUNT(*) AS cnt'])
->group('e.user_id');
if ($tagFilterId !== '') {
// 客户身上有该标签(带前缀以避免 raw SQL 不被框架 prefix 替换)
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)",
[$tagFilterId]
);
}
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$userIds = array_column($rows, 'user_id');
$adminIdByWx = Db::name('admin')
->whereIn('work_wechat_userid', $userIds)
->whereNull('delete_time')
->column('id', 'work_wechat_userid');
$byDept = [];
foreach ($rows as $r) {
$wx = (string) $r['user_id'];
$adminId = (int) ($adminIdByWx[$wx] ?? 0);
if ($adminId <= 0) {
continue;
}
$primary = (int) ($adminToPrimary[$adminId] ?? 0);
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + (int) $r['cnt'];
}
return $byDept;
}
/**
* 被指派数:诊单指派日志(to_assistant_id>0)按被指派医助归属展示部门;选定渠道时收窄与「{渠道}业绩」同源。
*
* @param array<int, int> $adminToPrimary
* @param int[]|null $tagDiagIds
* @param array<int, int>|null $tagAssistantIds tag 退化口径下按 to_assistant_id 过滤(与 aggregatePerformance 一致)
*
* @return array<int, int>
*/
private static function countDiagnosisAssignsByDept(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $dataScopeAdminIds = null,
?array $channelInfo = null
): array {
if ($adminToPrimary === []) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0);
if ($channelFilterActive) {
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
return [];
}
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$query->whereRaw($pack[0], $pack[1]);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
if ($tagDiagIds !== null) {
$query->whereIn('lg.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds)));
}
} else {
return [];
}
}
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
->group('lg.to_assistant_id');
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$byDept = [];
foreach ($rows as $r) {
$aid = (int) ($r['to_assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
if ($visFlip !== null && !isset($visFlip[$aid])) {
continue;
}
$primary = (int) ($adminToPrimary[$aid] ?? 0);
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + (int) ($r['cnt'] ?? 0);
}
return $byDept;
}
/**
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间,按 to_assistant_id 汇总(供排行榜)。
*
* @return array<int, int>
*/
private static function countDiagnosisAssignsByAdmin(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $dataScopeAdminIds = null,
?array $channelInfo = null
): array {
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0);
if ($channelFilterActive) {
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
return [];
}
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$query->whereRaw($pack[0], $pack[1]);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
if ($tagDiagIds !== null) {
$query->whereIn('lg.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds)));
}
} else {
return [];
}
}
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
->group('lg.to_assistant_id');
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['to_assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
if ($visFlip !== null && !isset($visFlip[$aid])) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) ($r['cnt'] ?? 0);
}
return $byAdmin;
}
/**
* 接诊诊单聚合:返回两组按部门归属的接诊计数。
* - all: 区间内 status=3 挂号条数按部门归属(与列表计数一致),不含「仅 role_id=2」等额外限制。
* - scoped: 选定渠道时「{渠道}成交单」= **非取消业务订单条数**(与 {渠道}业绩同源):dict 下 EXISTS 与侧栏一致(channels channel_source);有展示行时按列表部门摊行;否则走归因折叠。
* 未选渠道时 scoped == all(不再额外查一次)。
*
* @param int[] $appointmentChannelValues dict_data value 整型列表(通常 1=自媒体1)
* @param int $startTs 区间起点日 00:00:00 对应日期(用于取 start_date
* @param int $endTs 区间终点日 23:59:59 对应日期(用于取 end_date
*
* @return array{
* all: array<int, int>,
* scoped: array<int, int>
* }
*/
private static function countConsults(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
array $appointmentChannelValues = [],
bool $channelFilterActive = false,
?array $dataScopeAdminIds = null,
array $tableRowDeptIds = [],
array $deptById = [],
?array $channelInfo = null
): array {
if ($adminToPrimary === []) {
return ['all' => [], 'scoped' => []];
}
$allByDept = self::aggregateConsults(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$dataScopeAdminIds,
$tableRowDeptIds,
$deptById
);
if (!$channelFilterActive) {
return ['all' => $allByDept, 'scoped' => $allByDept];
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$scopedByDept = [];
} else {
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
$scopedByDept = [];
} elseif ($tableRowDeptIds !== [] && $deptById !== []) {
$scopedAligned = self::aggregateListAlignedOrderMetricsByDept(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById,
$pack[0],
$pack[1]
);
$scopedByDept = $scopedAligned['count'];
} else {
$scopedByDept = self::aggregateDealOrderCountByAppointmentChannels(
$startTs,
$endTs,
$adminToPrimary,
$norm,
$tableRowDeptIds,
$deptById,
$channelInfo
);
}
}
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
$scopedByDept = self::aggregateDealOrderCount(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds,
$tableRowDeptIds,
$deptById
);
} else {
$scopedByDept = [];
}
return ['all' => $allByDept, 'scoped' => $scopedByDept];
}
/**
* 单次接诊聚合查询:status=3 + 可选 tag;与 admin 挂号列表计数条件一致(不限医助角色、不要求 assistant_id)。
* 按 (有效医助 id, doctor_id) 分组后,再映射到展示「中心」:先医助、再接诊医生;无法归属则 dept_key=0。
*
* @return array<int, int>
*/
private static function aggregateConsults(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
?array $dataScopeAdminIds = null,
array $tableRowDeptIds = [],
array $deptById = []
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$eff = self::consultEffectiveAssistantSql();
$query = Db::name('doctor_appointment')->alias('a');
self::applyConsultFiltersAlignedWithAppointmentLists($query, $startTs, $endTs);
if ($tagDiagIds !== null) {
$query->whereIn('a.patient_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')');
}
$query
->field([
Db::raw("({$eff}) AS eff_a"),
'a.doctor_id',
Db::raw('COUNT(*) AS cnt'),
])
->group([$eff, 'a.doctor_id']);
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
if ($tableRowDeptIds !== [] && $deptById !== []) {
return self::foldConsultCountRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, $dataScopeAdminIds);
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$byDept = [];
foreach ($rows as $r) {
$effId = (int) ($r['eff_a'] ?? 0);
$docId = (int) ($r['doctor_id'] ?? 0);
$cnt = (int) ($r['cnt'] ?? 0);
if ($cnt <= 0) {
continue;
}
// 数据范围:仅计有效医助/接诊医生在可见名单内的挂号,避免 invisible 医助挂到 visible 医生上造成跨范围泄露
if ($visFlip !== null) {
if ($effId > 0) {
if (!isset($visFlip[$effId])) {
continue;
}
} elseif ($docId > 0) {
if (!isset($visFlip[$docId])) {
continue;
}
} else {
continue;
}
}
$primary = 0;
if ($effId > 0) {
$primary = (int) ($adminToPrimary[$effId] ?? 0);
}
if ($primary <= 0 && $docId > 0) {
$primary = (int) ($adminToPrimary[$docId] ?? 0);
}
if ($primary > 0) {
$byDept[$primary] = ($byDept[$primary] ?? 0) + $cnt;
} else {
$byDept[0] = ($byDept[0] ?? 0) + $cnt;
}
}
return $byDept;
}
/**
* 业绩表「预约诊单」列:status∈{1,3,4};部门归属规则与 {@see aggregateConsults} 一致。
*
* @return array<int, int> 展示部门 id => 条数(键 0 为无法归入展示「中心」)
*/
private static function aggregateBookedAppointmentsByDept(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
?array $dataScopeAdminIds = null
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$startDate = date('Y-m-d', $startTs);
$endDate = date('Y-m-d', $endTs);
$eff = self::consultEffectiveAssistantSql();
$query = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereIn('a.status', [1, 3, 4])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
if ($tagDiagIds !== null) {
$query->whereIn('a.patient_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')');
}
$query
->field([
Db::raw("({$eff}) AS eff_a"),
'a.doctor_id',
Db::raw('COUNT(*) AS cnt'),
])
->group([$eff, 'a.doctor_id']);
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$byDept = [];
foreach ($rows as $r) {
$effId = (int) ($r['eff_a'] ?? 0);
$docId = (int) ($r['doctor_id'] ?? 0);
$cnt = (int) ($r['cnt'] ?? 0);
if ($cnt <= 0) {
continue;
}
if ($visFlip !== null) {
if ($effId > 0) {
if (!isset($visFlip[$effId])) {
continue;
}
} elseif ($docId > 0) {
if (!isset($visFlip[$docId])) {
continue;
}
} else {
continue;
}
}
$primary = 0;
if ($effId > 0) {
$primary = (int) ($adminToPrimary[$effId] ?? 0);
}
if ($primary <= 0 && $docId > 0) {
$primary = (int) ($adminToPrimary[$docId] ?? 0);
}
if ($primary > 0) {
$byDept[$primary] = ($byDept[$primary] ?? 0) + $cnt;
} else {
$byDept[0] = ($byDept[0] ?? 0) + $cnt;
}
}
return $byDept;
}
/**
* 接诊按医助汇总(不按部门归并)。
*
* @return array<int, int>
*/
private static function aggregateConsultsByAdmin(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$eff = self::consultEffectiveAssistantSql();
$query = Db::name('doctor_appointment')->alias('a');
self::applyConsultFiltersAlignedWithAppointmentLists($query, $startTs, $endTs);
if ($tagDiagIds !== null) {
$query->whereIn('a.patient_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')');
}
$query->field([Db::raw("({$eff}) AS eff_a"), Db::raw('COUNT(*) AS cnt')])
->group($eff);
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['eff_a'] ?? 0);
if ($aid <= 0) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) $r['cnt'];
}
return $byAdmin;
}
/**
* 与挂号列表一致的有效医助:优先挂号表创建人 assistant_id,为 0 时用诊单医助(列表 join 展示字段)。
*/
private static function consultEffectiveAssistantSql(): string
{
return 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
}
/**
* 医助排行榜「预约诊单」:`doctor_appointment.appointment_date` 落在统计区间内;
* 状态为已预约(1)、已完成(3)、已过号(4),不含已取消(2);
* 有效医助与 {@see consultEffectiveAssistantSql()} 一致。
*
* @return array<int, int>
*/
private static function countBookedAppointmentsByAdminForLeaderboard(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds,
?array $dataScopeAdminIds
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$startDate = date('Y-m-d', $startTs);
$endDate = date('Y-m-d', $endTs);
$eff = self::consultEffectiveAssistantSql();
$query = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereIn('a.status', [1, 3, 4])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
if ($tagDiagIds !== null) {
$query->whereIn('a.patient_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')');
}
$query->field([Db::raw("({$eff}) AS eff_a"), Db::raw('COUNT(*) AS cnt')])
->group($eff);
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['eff_a'] ?? 0);
if ($aid <= 0) {
continue;
}
if ($visFlip !== null && !isset($visFlip[$aid])) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) ($r['cnt'] ?? 0);
}
return $byAdmin;
}
/**
* doctor_appointment 别名 a:与 AppointmentLists 已完成挂号 **计数** 对齐(诊单软删、appointment_date、status=3)。
* 不按医助角色或 assistant_id 再收窄(与列表 tab 总数一致)。
*
* @param \think\db\Query $query
*/
private static function applyConsultFiltersAlignedWithAppointmentLists($query, int $startTs, int $endTs): void
{
$startDate = date('Y-m-d', $startTs);
$endDate = date('Y-m-d', $endTs);
$query->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.status', 3)
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
}
/**
* 进线按接待人(admin)汇总;不按展示部门截断,后续按 adminToPrimary 归属。
*
* @return array<int, int>
*/
private static function countLeadsByAdmin(int $startTs, int $endTs, string $tagFilterId): array
{
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTs, $endTs])
->where('e.user_id', '<>', '')
->field(['e.user_id', 'COUNT(*) AS cnt'])
->group('e.user_id');
if ($tagFilterId !== '') {
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)",
[$tagFilterId]
);
}
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$userIds = array_column($rows, 'user_id');
$adminIdByWx = Db::name('admin')
->whereIn('work_wechat_userid', $userIds)
->whereNull('delete_time')
->column('id', 'work_wechat_userid');
$byAdmin = [];
foreach ($rows as $r) {
$wx = (string) $r['user_id'];
$adminId = (int) ($adminIdByWx[$wx] ?? 0);
if ($adminId <= 0) {
continue;
}
$byAdmin[$adminId] = ($byAdmin[$adminId] ?? 0) + (int) $r['cnt'];
}
return $byAdmin;
}
/**
* 进线事件查询公共条件(与 countLeads / countLeadsByAdmin 一致)。
*
* @param \think\db\BaseQuery $query
*/
private static function applyLeadEventBaseFilter($query, int $startTs, int $endTs, string $tagFilterId): void
{
$query->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTs, $endTs])
->where('e.user_id', '<>', '');
if ($tagFilterId !== '') {
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)",
[$tagFilterId]
);
}
}
/**
* 归属到指定展示「中心」行部门的接待成员企微 userid 列表。
*
* @param array<int, int> $adminToPrimary
*
* @return list<string>
*/
private static function collectWorkWxUserIdsForYejiDeptRow(int $deptRowId, array $adminToPrimary): array
{
$adminIds = [];
foreach ($adminToPrimary as $aid => $pid) {
if ((int) $pid === $deptRowId) {
$adminIds[] = (int) $aid;
}
}
if ($adminIds === []) {
return [];
}
$wids = Db::name('admin')
->whereIn('id', $adminIds)
->whereNull('delete_time')
->column('work_wechat_userid');
$out = [];
foreach ($wids as $w) {
$w = trim((string) $w);
if ($w !== '') {
$out[] = $w;
}
}
return array_values(array_unique($out));
}
/**
* 进线明细:与看板「进线数据」同口径的 add_external_contact 逐条列表。
*
* @param array{
* start_date?: string,
* end_date?: string,
* dept_ids?: int[]|string,
* channel_code?: string,
* tag_id?: string,
* dept_id?: int|string,
* page?: int|string,
* page_size?: int|string
* } $params
*
* @return array{
* start_date: string,
* end_date: string,
* dept_id: int,
* dept_name: string,
* channel_code: string,
* count: int,
* lists: list<array<string, mixed>>,
* note: string
* }
*/
public static function leadLineList(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$deptId = (int) ($params['dept_id'] ?? 0);
$empty = static function (array $c, int $deptId, string $deptName, string $note) {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => (string) $c['channelCode'],
'count' => 0,
'lists' => [],
'note' => $note,
];
};
if ($deptId <= 0) {
return $empty($c, 0, '', '未归属中心无进线明细(看板该行进线为 0)。');
}
if (!in_array($deptId, $c['tableRowDeptIds'], true)) {
return $empty(
$c,
$deptId,
self::formatYejiDeptRowDisplayName($deptId, $c['deptById']),
'该部门不在当前展示部门筛选或数据权限范围内。'
);
}
$deptNameMap = $c['deptNameMap'];
$channelCode = (string) $c['channelCode'];
if ($channelCode !== '' && self::isErCenterDisplayPrimaryName((string) ($deptNameMap[$deptId] ?? ''))) {
return $empty(
$c,
$deptId,
self::formatYejiDeptRowDisplayName($deptId, $c['deptById']),
'与看板一致:名称含「二中心」的展示部门在选定渠道下进线计 0,无明细。'
);
}
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$tagFilterId = (string) $c['tagFilterId'];
$page = max(1, (int) ($params['page'] ?? 1));
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$offset = ($page - 1) * $limit;
$wxUserIds = self::collectWorkWxUserIdsForYejiDeptRow($deptId, $c['adminToPrimary']);
$deptName = self::formatYejiDeptRowDisplayName($deptId, $c['deptById']);
$ecTable = self::tableWithPrefix('qywx_external_contact');
if ($wxUserIds === []) {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => 0,
'lists' => [],
'note' => '当前部门下没有可映射到接待企微成员的管理员,无进线流水。',
];
}
$countQuery = Db::name('qywx_external_contact_event')->alias('e');
self::applyLeadEventBaseFilter($countQuery, $startTs, $endTs, $tagFilterId);
$countQuery->whereIn('e.user_id', $wxUserIds);
$count = (int) $countQuery->count();
$listQuery = Db::name('qywx_external_contact_event')->alias('e')
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
->leftJoin("{$ecTable} ec", 'ec.external_userid = e.external_userid AND ec.delete_time IS NULL');
self::applyLeadEventBaseFilter($listQuery, $startTs, $endTs, $tagFilterId);
$listQuery->whereIn('e.user_id', $wxUserIds);
$rawList = $listQuery
->field(
'e.id,e.event_time,e.external_userid,e.user_id,e.state,'
. 'a.name AS reception_admin_name,ec.name AS external_contact_name'
)
->order('e.event_time', 'desc')
->order('e.id', 'desc')
->limit($offset, $limit)
->select()
->toArray();
$lists = [];
foreach ($rawList as $rw) {
$ts = (int) ($rw['event_time'] ?? 0);
$lists[] = [
'id' => (int) ($rw['id'] ?? 0),
'event_time' => $ts,
'event_time_text' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '',
'external_userid' => (string) ($rw['external_userid'] ?? ''),
'user_id' => (string) ($rw['user_id'] ?? ''),
'state' => (string) ($rw['state'] ?? ''),
'reception_admin_name' => (string) ($rw['reception_admin_name'] ?? ''),
'external_contact_name' => (string) ($rw['external_contact_name'] ?? ''),
];
}
$note = '与看板「进线数据」同口径:企微 change_type=add_external_contact,按接待成员归属当前部门行;'
. ($tagFilterId !== '' ? '已按所选渠道标签过滤客户。' : '未选渠道时不限制客户标签。');
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => $count,
'lists' => $lists,
'note' => $note,
];
}
/**
* admin → 展示「中心」映射:用于挂号明细按部门筛选(与 aggregateConsults 映射同源)。
*/
private static function buildYejiAdminPrimaryMapUnionSql(array $adminToPrimary): string
{
if ($adminToPrimary === []) {
return 'SELECT 0 AS map_aid, 0 AS map_pid WHERE 0 = 1';
}
$parts = [];
foreach ($adminToPrimary as $aid => $pid) {
$parts[] = 'SELECT ' . (int) $aid . ' AS map_aid,' . (int) $pid . ' AS map_pid';
}
return implode(' UNION ALL ', $parts);
}
/**
* @return list<array<string, mixed>>
*/
private static function formatYejiAppointmentLinePayloadRows(array $rawList, bool $hasChSrc): array
{
$statusMap = [
1 => '已预约',
2 => '已取消',
3 => '已完成',
4 => '已过号',
];
$typeMap = [
'video' => '视频问诊',
'text' => '图文问诊',
'phone' => '电话问诊',
];
$lists = [];
foreach ($rawList as $rw) {
$st = (int) ($rw['status'] ?? 0);
$tp = (string) ($rw['appointment_type'] ?? '');
$lists[] = [
'id' => (int) ($rw['id'] ?? 0),
'appointment_date' => (string) ($rw['appointment_date'] ?? ''),
'appointment_time' => (string) ($rw['appointment_time'] ?? ''),
'status' => $st,
'status_desc' => $statusMap[$st] ?? '未知',
'appointment_type' => $tp,
'appointment_type_desc' => $typeMap[$tp] ?? '未知',
'diagnosis_id' => (int) ($rw['patient_id'] ?? 0),
'patient_name' => (string) ($rw['patient_name'] ?? ''),
'patient_phone' => (string) ($rw['patient_phone'] ?? ''),
'doctor_name' => (string) ($rw['doctor_name'] ?? ''),
'channel_source' => $hasChSrc ? (string) ($rw['channel_source'] ?? '') : '',
];
}
return $lists;
}
/**
* 业绩表部门行「预约诊单」挂号明细(与 {@see aggregateBookedAppointmentsByDept} 同口径)。
*
* @param array<string, mixed> $c resolveYejiContext + applyYejiDataScope 后的上下文
*
* @return array<string, mixed>
*/
private static function yejiDeptAppointmentLinesInner(array $params, array $c): array
{
$deptIdParam = (int) ($params['dept_id'] ?? 0);
$channelCode = (string) $c['channelCode'];
$deptName = $deptIdParam === 0
? '未归属中心'
: self::formatYejiDeptRowDisplayName($deptIdParam, $c['deptById']);
$empty = static function (string $note) use ($c, $channelCode, $deptIdParam, $deptName): array {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => 0,
'assistant_name' => '',
'dept_id' => $deptIdParam,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => 0,
'lists' => [],
'note' => $note,
];
};
if ($deptIdParam < 0) {
return $empty('请指定有效部门。');
}
if ($deptIdParam > 0 && !in_array($deptIdParam, $c['tableRowDeptIds'], true)) {
return $empty('该部门不在当前展示部门筛选或数据权限范围内。');
}
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
if ($tagDiagIds !== null && $tagDiagIds === []) {
return $empty('当前渠道标签下无关联诊单,无挂号明细。');
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return $empty('当前渠道标签下无关联医助,无挂号明细。');
}
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$startDate = date('Y-m-d', $startTs);
$endDate = date('Y-m-d', $endTs);
$eff = self::consultEffectiveAssistantSql();
$page = max(1, (int) ($params['page'] ?? 1));
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$offset = ($page - 1) * $limit;
$hasChSrc = self::doctorAppointmentHasChannelSourceColumn();
$mapWrapped = '(' . self::buildYejiAdminPrimaryMapUnionSql($c['adminToPrimary']) . ')';
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
$buildBaseQuery = static function () use (
$eff,
$startDate,
$endDate,
$tagDiagIds,
$tagAssistantIds,
$mapWrapped,
$deptIdParam,
$dataScopeAdminIds
) {
$q = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->leftJoin('admin doc', 'a.doctor_id = doc.id AND doc.delete_time IS NULL')
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereIn('a.status', [1, 3, 4])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
if ($tagDiagIds !== null) {
$q->whereIn('a.patient_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
if ($ids !== []) {
$q->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')');
}
}
$primaryPred = 'COALESCE('
. "IF(({$eff}) > 0, (SELECT m.map_pid FROM {$mapWrapped} m WHERE m.map_aid = ({$eff}) LIMIT 1), NULL),"
. " IF(a.doctor_id > 0, (SELECT m2.map_pid FROM {$mapWrapped} m2 WHERE m2.map_aid = a.doctor_id LIMIT 1), NULL),"
. ' 0)';
$q->whereRaw("{$primaryPred} = ?", [$deptIdParam]);
if ($dataScopeAdminIds !== null && $dataScopeAdminIds !== []) {
$visIn = implode(',', array_map('intval', $dataScopeAdminIds));
$q->whereRaw(
"(({$eff}) > 0 AND ({$eff}) IN ({$visIn})) OR (({$eff}) <= 0 AND a.doctor_id > 0 AND a.doctor_id IN ({$visIn}))"
);
}
return $q;
};
$count = (int) $buildBaseQuery()->count();
$fieldParts = [
'a.id',
'a.appointment_date',
'a.appointment_time',
'a.status',
'a.appointment_type',
'a.patient_id',
'u.patient_name',
'u.phone AS patient_phone',
'doc.name AS doctor_name',
];
if ($hasChSrc) {
$fieldParts[] = 'a.channel_source';
}
$rawList = $buildBaseQuery()
->field(implode(',', $fieldParts))
->order('a.appointment_date', 'asc')
->order('a.appointment_time', 'asc')
->order('a.id', 'asc')
->limit($offset, $limit)
->select()
->toArray();
$lists = self::formatYejiAppointmentLinePayloadRows($rawList, $hasChSrc);
$note = '与业绩表「预约诊单」列同口径:预约日期在统计区间内,状态为已预约 / 已完成 / 已过号(不含已取消);部门归属医助优先,否则接诊医生。';
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => 0,
'assistant_name' => '',
'dept_id' => $deptIdParam,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => $count,
'lists' => $lists,
'note' => $note,
];
}
/**
* 医助排行榜「预约诊单」逐条挂号明细(与 {@see countBookedAppointmentsByAdminForLeaderboard} 同口径)。
*
* @param array{
* start_date?: string,
* end_date?: string,
* dept_ids?: int[]|string,
* channel_code?: string,
* tag_id?: string,
* assistant_id?: int|string,
* admin_id?: int|string,
* dept_id?: int|string,
* page?: int|string,
* page_size?: int|string
* } $params
*
* @return array{
* start_date: string,
* end_date: string,
* assistant_id: int,
* assistant_name: string,
* dept_id: int,
* dept_name: string,
* channel_code: string,
* count: int,
* lists: list<array<string, mixed>>,
* note: string
* }
*/
public static function leaderboardAppointmentLines(
array $params,
int $viewerAdminId = 0,
array $viewerAdminInfo = []
): array {
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$assistantId = (int) ($params['assistant_id'] ?? $params['admin_id'] ?? 0);
if ($assistantId <= 0 && array_key_exists('dept_id', $params)) {
return self::yejiDeptAppointmentLinesInner($params, $c);
}
$channelCode = (string) $c['channelCode'];
$assistantName = '';
if ($assistantId > 0) {
$assistantName = (string) (Db::name('admin')->where('id', $assistantId)->whereNull('delete_time')->value('name') ?: '');
}
$baseOut = static function (array $c, string $note) use ($assistantId, $assistantName, $channelCode): array {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => $assistantId,
'assistant_name' => $assistantName,
'dept_id' => 0,
'dept_name' => '',
'channel_code' => $channelCode,
'count' => 0,
'lists' => [],
'note' => $note,
];
};
if ($assistantId <= 0) {
return $baseOut($c, '请指定有效医助,或传入 dept_id 查看部门汇总明细。');
}
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
if ($dataScopeRestricted && $dataScopeAdminIds !== null) {
$flip = array_flip($dataScopeAdminIds);
if (!isset($flip[$assistantId])) {
return $baseOut($c, '当前账号数据权限下不可查看该医助挂号明细。');
}
}
if (!isset($c['adminToPrimary'][$assistantId])) {
return $baseOut($c, '该医助不在当前业绩看板展示范围内。');
}
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
$tagFallback = $c['tagFallback'];
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
if ($tagDiagIds !== null && $tagDiagIds === []) {
return $baseOut($c, '当前渠道标签下无关联诊单,无挂号明细。');
}
if ($scopedAssistants !== null && $scopedAssistants === []) {
return $baseOut($c, '当前渠道标签下无关联医助,无挂号明细。');
}
if ($scopedAssistants !== null && !isset($scopedAssistants[$assistantId])) {
return $baseOut($c, '当前渠道标签口径下该医助无归属挂号(与排行榜「预约诊单」一致)。');
}
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$startDate = date('Y-m-d', $startTs);
$endDate = date('Y-m-d', $endTs);
$eff = self::consultEffectiveAssistantSql();
$page = max(1, (int) ($params['page'] ?? 1));
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$offset = ($page - 1) * $limit;
$hasChSrc = self::doctorAppointmentHasChannelSourceColumn();
$buildBaseQuery = static function () use (
$eff,
$assistantId,
$startDate,
$endDate,
$tagDiagIds,
$scopedAssistants
) {
$q = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->leftJoin('admin doc', 'a.doctor_id = doc.id AND doc.delete_time IS NULL')
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereIn('a.status', [1, 3, 4])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)')
->whereRaw("({$eff}) = ?", [$assistantId]);
if ($tagDiagIds !== null) {
$q->whereIn('a.patient_id', $tagDiagIds);
}
if ($scopedAssistants !== null) {
$ids = array_map('intval', array_keys($scopedAssistants));
if ($ids !== []) {
$q->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')');
}
}
return $q;
};
$count = (int) $buildBaseQuery()->count();
$fieldParts = [
'a.id',
'a.appointment_date',
'a.appointment_time',
'a.status',
'a.appointment_type',
'a.patient_id',
'u.patient_name',
'u.phone AS patient_phone',
'doc.name AS doctor_name',
];
if ($hasChSrc) {
$fieldParts[] = 'a.channel_source';
}
$rawList = $buildBaseQuery()
->field(implode(',', $fieldParts))
->order('a.appointment_date', 'asc')
->order('a.appointment_time', 'asc')
->order('a.id', 'asc')
->limit($offset, $limit)
->select()
->toArray();
$lists = self::formatYejiAppointmentLinePayloadRows($rawList, $hasChSrc);
$note = '与排行榜「预约诊单」同口径:预约日期在统计区间内,状态为已预约 / 已完成 / 已过号(不含已取消);'
. '有效医助优先取挂号表创建医助,否则取诊单医助。';
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => $assistantId,
'assistant_name' => $assistantName,
'dept_id' => 0,
'dept_name' => '',
'channel_code' => $channelCode,
'count' => $count,
'lists' => $lists,
'note' => $note,
];
}
/**
* 业绩看板:二中心复诊按部门行下钻 —— 医助姓名与业务订单笔数(与表格「复诊」列同口径)。
*
* @param array{
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
* dept_id?:int|string,revisit_slot?:int|string
* } $params
* revisit_slot0=复诊合计(全部复诊笔),≥2=对应分项
*
* @return array{
* start_date:string,end_date:string,dept_id:int,dept_name:string,revisit_slot:int,revisit_slot_label:string,
* rows:list<array{admin_id:int,name:string,order_count:int}>,note:string
* }
*/
public static function revisitDeptAssistantBreakdown(
array $params,
int $viewerAdminId = 0,
array $viewerAdminInfo = []
): array {
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$deptId = (int) ($params['dept_id'] ?? 0);
$revisitSlot = (int) ($params['revisit_slot'] ?? 0);
$deptName = $deptId > 0 ? self::formatYejiDeptRowDisplayName($deptId, $c['deptById']) : '';
$slotLabel = '复诊合计';
if ($revisitSlot >= 2) {
if ($revisitSlot === 2) {
$slotLabel = '复诊2';
} elseif ($revisitSlot >= 3 && $revisitSlot <= 10) {
$cn = ['三', '四', '五', '六', '七', '八', '九', '十'];
$slotLabel = '复诊' . ($cn[$revisitSlot - 3] ?? (string) $revisitSlot);
} else {
$slotLabel = '复诊' . (string) $revisitSlot;
}
}
$out = [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'revisit_slot' => $revisitSlot,
'revisit_slot_label' => $slotLabel,
'rows' => [],
'note' => '',
];
if ($deptId <= 0) {
$out['note'] = '请选择有效部门行。';
return $out;
}
if (!in_array($deptId, $c['tableRowDeptIds'], true)) {
$out['note'] = '该部门不在当前展示部门或数据权限范围内。';
return $out;
}
if ($revisitSlot < 0 || $revisitSlot === 1) {
$out['note'] = '无效的复诊分项。';
return $out;
}
$pack = DeptLogic::getErCenterRevisitAssistantBreakdownForDept(
$deptId,
$revisitSlot,
(int) $c['startTs'],
(int) $c['endTs']
);
$rows = $pack['rows'];
if (!empty($c['dataScopeRestricted']) && isset($c['dataScopeVisibleAdminIds'])) {
$flip = array_flip($c['dataScopeVisibleAdminIds']);
$filtered = [];
foreach ($rows as $rw) {
if (isset($flip[(int) ($rw['admin_id'] ?? 0)])) {
$filtered[] = $rw;
}
}
$rows = $filtered;
}
$out['rows'] = $rows;
if ($rows === []) {
$out['note'] = '当前部门与区间下无符合条件的复诊业务订单(口径与看板「二中心复诊」列一致)。';
}
return $out;
}
/**
* 与 sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。
*
* @param int[] $appointmentChannelValues
*
* @return array{all: array<int, float>, scoped: array<int, float>}
*/
private static function sumPerformanceByAdmin(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIdsForScoped,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $channelInfo = null
): array {
$all = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, null, null);
if (!$channelFilterActive) {
return ['all' => $all, 'scoped' => $all];
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$scoped = [];
} else {
$scoped = self::aggregatePerformanceAmountsByAssistantChannel($startTs, $endTs, $norm, $channelInfo);
}
} elseif ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) {
$scoped = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped);
} else {
$scoped = [];
}
return ['all' => $all, 'scoped' => $scoped];
}
/**
* 与 sumPerformanceByAdmin 对应:按诊单医助统计成交单数(非取消业务订单条数),全量/渠道分支一致。
*
* @param int[] $appointmentChannelValues
*
* @return array{all: array<int, int>, scoped: array<int, int>}
*/
private static function sumDealOrderByAdmin(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIdsForScoped,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $channelInfo = null
): array {
$all = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, null, null);
if (!$channelFilterActive) {
return ['all' => $all, 'scoped' => $all];
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$scoped = [];
} else {
$scoped = self::aggregateDealOrderCountByAssistantChannel($startTs, $endTs, $norm, $channelInfo);
}
} elseif ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) {
$scoped = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped);
} else {
$scoped = [];
}
return ['all' => $all, 'scoped' => $scoped];
}
/**
* 成交单数按业绩归属医助聚合(与 aggregatePerformanceAmountsByAdmin 同一 WHERE,计条数非金额)。
*
* @return array<int, int>
*/
private static function aggregateDealOrderCountGroupedByAssistant(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs])
->where('o.diagnosis_id', '>', 0);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw("({$att}) > 0", []);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')');
}
$query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')])
->group($att);
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) $r['cnt'];
}
return $byAdmin;
}
/**
* dict(channels) 渠道成交单数按诊单医助聚合(与 aggregatePerformanceAmountsByAssistantChannel 同源)。
*
* @param int[] $appointmentChannelValues
*
* @return array<int, int>
*/
private static function aggregateDealOrderCountByAssistantChannel(
int $startTs,
int $endTs,
array $appointmentChannelValues,
?array $channelInfo = null
): array {
if ($appointmentChannelValues === []) {
return [];
}
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$existsSql = $pack[0];
$existsBindings = $pack[1];
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$att = self::sqlPerformanceAttributionAdminExpr();
$query->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 0", [])
->whereRaw($existsSql, $existsBindings)
->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')])
->group($att);
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) $r['cnt'];
}
return $byAdmin;
}
/**
* 业绩按归属医助聚合到 admin(与处方订单列表医助筛选口径一致:诊单医助或代建创建人)。
*
* @return array<int, float>
*/
private static function aggregatePerformanceAmountsByAdmin(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs])
->where('o.diagnosis_id', '>', 0);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw("({$att}) > 0", []);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')');
}
$query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')])
->group($att);
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (float) $r['amt'];
}
return $byAdmin;
}
/**
* dict(channels) 渠道业绩按业绩归属医助聚合(与 aggregatePerformanceByAppointmentChannels 同源)。
*
* @param int[] $appointmentChannelValues
*
* @return array<int, float>
*/
private static function aggregatePerformanceAmountsByAssistantChannel(
int $startTs,
int $endTs,
array $appointmentChannelValues,
?array $channelInfo = null
): array {
if ($appointmentChannelValues === []) {
return [];
}
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$existsSql = $pack[0];
$existsBindings = $pack[1];
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$att = self::sqlPerformanceAttributionAdminExpr();
$query->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 0", [])
->whereRaw($existsSql, $existsBindings)
->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')])
->group($att);
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (float) $r['amt'];
}
return $byAdmin;
}
/**
* 选定渠道时:二中心展示部门下属医助的渠道诊金、进线清零(与部门行一致)。
*
* @param array<int, float> $feeByAdmin
* @param array<int, int> $leadsByAdmin
* @param array<int, int>|null $assignsByAdmin
*/
private static function excludeErCenterFromAdminLeaderboard(
array $adminToPrimary,
array $deptNameMap,
array &$feeByAdmin,
array &$leadsByAdmin,
?array &$dealOrderByAdmin = null,
?array &$assignsByAdmin = null
): void {
foreach ($adminToPrimary as $aid => $pid) {
if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) {
continue;
}
$feeByAdmin[$aid] = 0.0;
$leadsByAdmin[$aid] = 0;
if ($dealOrderByAdmin !== null) {
$dealOrderByAdmin[$aid] = 0;
}
if ($assignsByAdmin !== null) {
$assignsByAdmin[$aid] = 0;
}
}
}
/** 排行榜部门列短名:取名称首字符 */
private static function deptDisplayAbbr(string $deptName): string
{
$t = trim($deptName);
if ($t === '') {
return '—';
}
$ch = mb_substr($t, 0, 1, 'UTF-8');
return $ch !== '' ? $ch : substr($t, 0, 1);
}
/**
* 业绩/成交单统计:仅排除履约已取消 fulfillment_status = 4,其余(含 NULL 与任意其它状态)全部计入。
* 与看板「合计业绩」、处方列表业绩 extend 口径一致;勿用 `<> 4`,以免误排除 NULL。
*
* @param \think\db\BaseQuery|\think\Model $query Db::name / Model::where 等查询实例
* @param string $tableAlias 主表别名(如 Db 侧 `alias('o')` 传 `o`);无别名时传空字符串
*/
public static function applyPrescriptionOrderNotCancelledForPerformanceQuery($query, string $tableAlias = ''): void
{
if ($tableAlias !== '') {
$query->whereRaw("NOT ({$tableAlias}.fulfillment_status <=> ?)", [4]);
} else {
$query->whereRaw('NOT (fulfillment_status <=> ?)', [4]);
}
}
/**
* @param \think\db\BaseQuery|\think\Model $query
*/
private static function applyPrescriptionOrderNotCancelledForPerformance($query, string $tableAlias = 'o'): void
{
self::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, $tableAlias);
}
/**
* 业绩按医助归属:须 alias 订单 o、诊单 dg,并调用本方法 left join 处方 rxrx.id = o.prescription_id
*/
private static function joinPrescriptionForPerformanceAttribution($query): void
{
$rxTable = self::tableWithPrefix('tcm_prescription');
$query->leftJoin("{$rxTable} rx", 'rx.id = o.prescription_id AND rx.delete_time IS NULL');
}
/**
* 业绩归属医助 admin_id SQL 表达式(表别名固定为 o / dg / rx)。
* 与 PrescriptionOrderLists 医助筛选一致:诊单 assistant_id 优先;否则订单创建人且非关联处方开方人。
*/
private static function sqlPerformanceAttributionAdminExpr(): string
{
return 'CASE WHEN dg.assistant_id > 0 THEN dg.assistant_id '
. 'WHEN o.creator_id > 0 AND (rx.id IS NULL OR NOT (o.creator_id <=> rx.creator_id)) THEN o.creator_id '
. 'ELSE 0 END';
}
/**
* 处方订单业绩全量合计(不按部门归属截断),与订单列表在**同一 create_time 区间、同一业绩条件**下的 SUM(amount) 对齐。
*/
private static function sumGlobalPerformanceAmount(int $startTs, int $endTs): float
{
$query = Db::name('tcm_prescription_order')
->alias('o')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
return round((float) $query->sum('o.amount'), 1);
}
/**
* 看板「合计业绩」某部门行对应的业绩归属医助 id(与 overview 中 foldPerformanceRowsToDeptByTable 摊行一致)。
* $yejiDeptIdsParam 与看板本次请求的 dept_ids 一致(未选则为 null)。
*
* @param int|string|int[]|string[]|null $yejiDeptIdsParam
*
* @return int[]
*/
public static function collectAdminIdsAttributedToYejiTableRow(int $tableRowDeptId, $yejiDeptIdsParam): array
{
if ($tableRowDeptId <= 0) {
return [];
}
[$primaryDeptIds, , $tableRowDeptIds, $deptById] = self::resolvePrimaryDepts($yejiDeptIdsParam);
if ($tableRowDeptIds === []) {
return [];
}
$deptToDescendants = self::buildDeptDescendants($primaryDeptIds);
$adminToPrimary = self::buildAdminToPrimaryDept($primaryDeptIds, $deptToDescendants);
$links = Db::name('admin_dept')->field(['admin_id', 'dept_id'])->select()->toArray();
$byAdmin = [];
foreach ($links as $l) {
$aid = (int) $l['admin_id'];
$did = (int) $l['dept_id'];
if ($aid > 0 && $did > 0) {
$byAdmin[$aid][$did] = true;
}
}
$candidateIds = array_values(array_unique(array_merge(array_keys($byAdmin), array_keys($adminToPrimary))));
$tableRowSet = array_flip(array_map('intval', $tableRowDeptIds));
$out = [];
foreach ($candidateIds as $aid) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
$deptIdsForAdmin = array_keys($byAdmin[$aid] ?? []);
$mapped = 0;
if ($deptIdsForAdmin !== []) {
$mapped = self::mapAdminDeptLinksToTableRow($deptIdsForAdmin, $tableRowDeptIds, $tableRowSet, $deptById);
}
if ($mapped <= 0) {
$mapped = (int) ($adminToPrimary[$aid] ?? 0);
}
if ($mapped === $tableRowDeptId) {
$out[] = $aid;
}
}
return array_values(array_unique($out));
}
/**
* 与处方订单列表 buildBaseQueryForStats(仅人事筛选维度)对齐的订单查询 WHERE。
*
* @param int[]|null $expandedDeptIds
* @param int[]|null $visibleAdminIds
*/
private static function applyOrderListStatsVisibilityToPrescriptionQuery(
$query,
?array $expandedDeptIds,
?array $visibleAdminIds
): void {
$hasDept = $expandedDeptIds !== null && $expandedDeptIds !== [];
$hasVis = $visibleAdminIds !== null && $visibleAdminIds !== [];
if (!$hasDept && !$hasVis) {
return;
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$adTable = self::tableWithPrefix('admin_dept');
if ($hasDept) {
$ids = array_values(array_unique(array_filter(array_map('intval', $expandedDeptIds), static function (int $x): bool {
return $x > 0;
})));
if ($ids === []) {
$query->whereRaw('0 = 1');
return;
}
$inList = implode(',', $ids);
$query->where(function ($q) use ($diagTable, $adTable, $inList) {
$q->whereExists(
"SELECT 1 FROM {$adTable} ad INNER JOIN {$diagTable} dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL "
. "WHERE dg.id = o.diagnosis_id AND ad.dept_id IN ({$inList})"
);
$q->whereExists(
"SELECT 1 FROM {$adTable} adc WHERE adc.admin_id = o.creator_id AND adc.dept_id IN ({$inList})",
'OR'
);
});
}
if ($hasVis) {
$ids = array_values(array_unique(array_filter(array_map('intval', $visibleAdminIds), static function (int $x): bool {
return $x > 0;
})));
if ($ids === []) {
$query->whereRaw('0 = 1');
return;
}
$inList = implode(',', $ids);
$query->where(function ($q) use ($diagTable, $inList, $ids) {
$q->whereIn('o.creator_id', $ids);
$q->whereOrRaw(
"EXISTS (SELECT 1 FROM {$diagTable} dg WHERE dg.id = o.diagnosis_id "
. "AND dg.delete_time IS NULL AND dg.assistant_id IN ({$inList}))"
);
});
}
}
/**
* 收窄合计时与订单列表 extend「业绩金额」一致(create_time、NOT(fulfillment<=>4)、人事筛选)。
*
* @param int[]|null $expandedDeptIds
* @param int[]|null $visibleAdminIds
*/
private static function sumOrderListAlignedPerformanceAmount(
int $startTs,
int $endTs,
?array $expandedDeptIds,
?array $visibleAdminIds
): float {
$query = Db::name('tcm_prescription_order')
->alias('o')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
self::applyOrderListStatsVisibilityToPrescriptionQuery($query, $expandedDeptIds, $visibleAdminIds);
return round((float) $query->sum('o.amount'), 1);
}
/**
* 与 sumOrderListAlignedPerformanceAmount 同源,计订单条数。
*/
private static function countOrderListAlignedPerformanceOrders(
int $startTs,
int $endTs,
?array $expandedDeptIds,
?array $visibleAdminIds
): int {
$query = Db::name('tcm_prescription_order')
->alias('o')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
self::applyOrderListStatsVisibilityToPrescriptionQuery($query, $expandedDeptIds, $visibleAdminIds);
return (int) $query->count();
}
/**
* 标签/医助兜底口径下的渠道向业绩全量 SUM(与 sumPerformance 的 scoped 分支同一组 WHERE,无部门归属过滤)。
*/
private static function sumGlobalPerformanceTagScoped(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds
): float {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return 0.0;
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return 0.0;
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs])
->where('o.diagnosis_id', '>', 0);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw("({$att}) > 0", []);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')');
}
return round((float) $query->sum('o.amount'), 1);
}
/**
* dict(channels) 映射下渠道业绩全量 SUM(与 aggregatePerformanceByAppointmentChannels 同源,不按 primary dept 汇总)。
*/
private static function sumGlobalPerformanceAppointmentChannels(
int $startTs,
int $endTs,
array $appointmentChannelValues,
?array $channelInfo = null
): float {
if ($appointmentChannelValues === []) {
return 0.0;
}
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
return 0.0;
}
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
return 0.0;
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$existsSql = $pack[0];
$existsBindings = $pack[1];
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$att = self::sqlPerformanceAttributionAdminExpr();
$query->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 0", [])
->whereRaw($existsSql, $existsBindings);
return round((float) $query->sum('o.amount'), 1);
}
/**
* 选定渠道时:与 overview 中 sumPerformance「scoped」同规则的全局业绩金额(用于未归属补差)。
*/
private static function sumGlobalChannelScopedPerformance(
int $startTs,
int $endTs,
array $appointmentChannelValues,
?array $tagDiagIds,
?array $tagAssistantIdsForScoped,
?array $channelInfo = null
): float {
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
return 0.0;
}
return self::sumGlobalPerformanceAppointmentChannels($startTs, $endTs, $norm, $channelInfo);
}
if ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) {
return self::sumGlobalPerformanceTagScoped($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped);
}
return 0.0;
}
/** @param int[] $raw */
private static function normalizeAppointmentChannelValues(array $raw): array
{
$out = [];
foreach ($raw as $v) {
$i = (int) $v;
if ($i > 0) {
$out[$i] = true;
}
}
return array_keys($out);
}
/**
* 业绩聚合:返回两组按部门归属的业绩金额。
* 口径与 /tcm.prescriptionOrder/lists 的 stats_*_performance 完全一致:
* 业绩 = SUM(amount) WHERE NOT (fulfillment_status <=> 4) AND delete_time IS NULL
*
* - all: 部门全量业绩(不限渠道)。用于「合计业绩」列展示。
* - scoped: 「{渠道}业绩」:患者渠道 EXISTS 与侧栏/抽屉一致(channels channel_source);有展示行时部门列与「合计业绩」同为列表部门摊行。
*
* @return array{
* all: array<int, float>,
* scoped: array<int, float>
* }
*/
private static function sumPerformance(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds = null,
array $appointmentChannelValues = [],
bool $channelFilterActive = false,
array $tableRowDeptIds = [],
array $deptById = [],
?array $precomputedListAlignedAmountByDept = null,
?array $channelInfo = null
): array {
if ($adminToPrimary === []) {
return ['all' => [], 'scoped' => []];
}
// 1) 部门「合计业绩」:有展示行时与「接诊诊单」同列表部门口径(可由 overview 预计算避免重复查单)
if ($precomputedListAlignedAmountByDept !== null) {
$allByDept = [];
foreach ($precomputedListAlignedAmountByDept as $deptId => $v) {
$allByDept[(int) $deptId] = round((float) $v, 1);
}
} elseif ($tableRowDeptIds !== [] && $deptById !== []) {
$aligned = self::aggregateListAlignedOrderMetricsByDept(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById
);
$allByDept = [];
foreach ($aligned['amount'] as $deptId => $v) {
$allByDept[(int) $deptId] = round((float) $v, 1);
}
} else {
$allByDept = self::aggregatePerformance(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById
);
}
if (!$channelFilterActive) {
return ['all' => $allByDept, 'scoped' => $allByDept];
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$scopedByDept = [];
} else {
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
$scopedByDept = [];
} elseif ($tableRowDeptIds !== [] && $deptById !== []) {
$scopedAligned = self::aggregateListAlignedOrderMetricsByDept(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById,
$pack[0],
$pack[1]
);
$scopedByDept = [];
foreach ($scopedAligned['amount'] as $deptId => $v) {
$scopedByDept[(int) $deptId] = round((float) $v, 1);
}
} else {
$scopedByDept = self::aggregatePerformanceByAppointmentChannels(
$startTs,
$endTs,
$adminToPrimary,
$norm,
$tableRowDeptIds,
$deptById,
$channelInfo
);
}
}
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
$scopedByDept = self::aggregatePerformance(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds,
$tableRowDeptIds,
$deptById
);
} else {
$scopedByDept = [];
}
return ['all' => $allByDept, 'scoped' => $scopedByDept];
}
/**
* 渠道业绩:与「处方订单列表」业绩完全同口径 —— 按 o.create_time 归日、仅剔除已取消(4)(含 NULL);
* EXISTS 仅用于"该患者属于此渠道"的判定(有任一挂号 channels 命中 + status=3**不限挂号时点**);
* 部门归属按业绩归属医助(与处方列表医助条件一致:诊单医助或代建创建人)。
*/
private static function aggregatePerformanceByAppointmentChannels(
int $startTs,
int $endTs,
array $adminToPrimary,
array $appointmentChannelValues,
array $tableRowDeptIds = [],
array $deptById = [],
?array $channelInfo = null
): array {
if ($appointmentChannelValues === [] || $adminToPrimary === []) {
return [];
}
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 0", [])
->whereRaw($pack[0], $pack[1])
->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')])
->group($att);
$rows = $query->select()->toArray();
return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'amt');
}
/**
* 「{渠道}接诊」在字典映射到 channels 时:按**成交单数**(非取消业务订单条数)计,
* 与 aggregatePerformanceByAppointmentChannels 同一套 JOIN / EXISTS / 订单 create_time 窗口。
*
* @return array<int, int>
*/
private static function aggregateDealOrderCountByAppointmentChannels(
int $startTs,
int $endTs,
array $adminToPrimary,
array $appointmentChannelValues,
array $tableRowDeptIds = [],
array $deptById = [],
?array $channelInfo = null
): array {
if ($appointmentChannelValues === [] || $adminToPrimary === []) {
return [];
}
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 0", [])
->whereRaw($pack[0], $pack[1])
->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(o.id) AS cnt')])
->group($att);
$rows = $query->select()->toArray();
return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
}
/**
* 单次业绩聚合:与 aggregatePerformanceAmountsByAdmin / aggregateDealOrderCount 同源 ——
* INNER 诊单,按业绩归属医助 SUM(amount) 后映射到展示「中心」。
* WHERE: delete_time IS NULL、NOT(fulfillment <=> 4)、create_time 区间、diagnosis_id>0、归属医助>0
* 可选 tagdiagnosis_id IN、归属医助 IN。
*
* @return array<int, float>
*/
private static function aggregatePerformance(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
array $tableRowDeptIds = [],
array $deptById = []
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs])
->where('o.diagnosis_id', '>', 0);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw("({$att}) > 0", []);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')');
}
$query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')])
->group($att);
$rows = $query->select()->toArray();
return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'amt');
}
/**
* 全量成交单数:与 sumGlobalPerformanceAmount 同一 WHERE/COUNT(非取消订单条数)。
*/
private static function countGlobalDealOrders(int $startTs, int $endTs): int
{
$query = Db::name('tcm_prescription_order')
->alias('o')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
return (int) $query->count();
}
/**
* 全量部门 org 树:pid => 子部门 id 列表(缓存一次请求内多次 aggregate 复用)。
*
* @return array<int, int[]>
*/
private static function yejiFullDeptChildrenOf(): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$rows = Db::name('dept')->whereNull('delete_time')->field(['id', 'pid'])->select()->toArray();
$childrenOf = [];
foreach ($rows as $r) {
$pid = (int) $r['pid'];
$childrenOf[$pid][] = (int) $r['id'];
}
return $cache = $childrenOf;
}
/**
* @return array<int, true>
*/
private static function yejiDeptSubtreeIdFlip(int $rootDeptId, array $childrenOf): array
{
$flip = [];
$stack = [$rootDeptId];
while ($stack !== []) {
$cur = (int) array_pop($stack);
if ($cur <= 0 || isset($flip[$cur])) {
continue;
}
$flip[$cur] = true;
foreach ($childrenOf[$cur] ?? [] as $cid) {
$stack[] = (int) $cid;
}
}
return $flip;
}
/**
* 与 {@see PrescriptionOrderLists::applyDoctorAssistantFilters} 中 assistant_dept_id(未开抽屉对齐开关)一致:
* 诊单医助或订单创建人的人事部门落在该行子树即命中;命中多行时取组织深度最大的展示行。
*
* @param array<int, true> $candidates admin_id => true
* @param array<int, int[]> $adminDeptIdList
* @param array<int, array<int, true>> $rowSubtreeFlips
* @param int[] $tableRowDeptIds
* @param array<int, array<string, mixed>> $deptById
*
* @return int 表格展示部门 id,无法归属则为 0
*/
private static function resolveDeepestYejiTableRowForListDeptOr(
array $candidates,
array $adminDeptIdList,
array $rowSubtreeFlips,
array $tableRowDeptIds,
array $deptById,
array $adminToPrimary
): int {
if ($candidates === []) {
return 0;
}
/** @var int[] $matching */
$matching = [];
foreach ($tableRowDeptIds as $rid) {
$rid = (int) $rid;
if ($rid <= 0) {
continue;
}
$flip = $rowSubtreeFlips[$rid] ?? [];
if ($flip === []) {
continue;
}
foreach ($candidates as $aid => $_) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
foreach ($adminDeptIdList[$aid] ?? [] as $d) {
$d = (int) $d;
if ($d > 0 && isset($flip[$d])) {
$matching[] = $rid;
break 2;
}
}
}
}
if ($matching === []) {
foreach ($candidates as $aid => $_) {
$p = (int) ($adminToPrimary[(int) $aid] ?? 0);
if ($p > 0) {
foreach ($tableRowDeptIds as $rid) {
if ((int) $rid === $p) {
return $p;
}
}
}
}
return 0;
}
$best = 0;
$bestDepth = -1;
foreach ($matching as $rid) {
$depth = self::deptDepthFromRoot($rid, $deptById);
if ($depth > $bestDepth || ($depth === $bestDepth && ($best === 0 || $rid < $best))) {
$best = $rid;
$bestDepth = $depth;
}
}
return $best;
}
/**
* 列表部门口径(诊单医助 ∪ 创建人 ⇄ 展示行子树,最深行):同时聚单笔数与金额,供「接诊诊单」与「合计业绩」列共用。
*
* @return array{count: array<int, int>, amount: array<int, float>}
*/
private static function aggregateListAlignedOrderMetricsByDept(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
array $tableRowDeptIds,
array $deptById,
?string $channelScopedExistsSql = null,
array $channelScopedExistsBindings = []
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return ['count' => [], 'amount' => []];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return ['count' => [], 'amount' => []];
}
if ($tableRowDeptIds === [] || $deptById === []) {
return ['count' => [], 'amount' => []];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs])
->where('o.diagnosis_id', '>', 0);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw('(dg.assistant_id > 0 OR o.creator_id > 0)');
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$in = implode(',', $ids);
$query->whereRaw("(({$att}) IN ({$in}) OR dg.assistant_id IN ({$in}) OR o.creator_id IN ({$in}))");
}
if ($channelScopedExistsSql !== null && $channelScopedExistsSql !== '') {
$query->whereRaw($channelScopedExistsSql, $channelScopedExistsBindings);
}
$orderRows = $query->field([
Db::raw('dg.assistant_id AS diag_assistant_id'),
'o.creator_id',
'o.amount',
])->select()->toArray();
if ($orderRows === []) {
return ['count' => [], 'amount' => []];
}
$childrenOf = self::yejiFullDeptChildrenOf();
/** @var array<int, array<int, true>> $rowSubtreeFlips */
$rowSubtreeFlips = [];
foreach ($tableRowDeptIds as $rid) {
$rid = (int) $rid;
if ($rid > 0) {
$rowSubtreeFlips[$rid] = self::yejiDeptSubtreeIdFlip($rid, $childrenOf);
}
}
$adminIds = [];
foreach ($orderRows as $r) {
$a = (int) ($r['diag_assistant_id'] ?? 0);
$c = (int) ($r['creator_id'] ?? 0);
if ($a > 0) {
$adminIds[] = $a;
}
if ($c > 0) {
$adminIds[] = $c;
}
}
$adminIds = array_values(array_unique(array_filter($adminIds, static fn (int $x): bool => $x > 0)));
/** @var array<int, int[]> $adminDeptIdList */
$adminDeptIdList = [];
if ($adminIds !== []) {
$links = Db::name('admin_dept')->whereIn('admin_id', $adminIds)->field(['admin_id', 'dept_id'])->select()->toArray();
foreach ($links as $l) {
$aid = (int) $l['admin_id'];
$did = (int) $l['dept_id'];
if ($aid > 0 && $did > 0) {
$adminDeptIdList[$aid][] = $did;
}
}
}
$byCnt = [];
$byAmt = [];
foreach ($orderRows as $r) {
$asst = (int) ($r['diag_assistant_id'] ?? 0);
$cr = (int) ($r['creator_id'] ?? 0);
$cand = [];
if ($asst > 0) {
$cand[$asst] = true;
}
if ($cr > 0) {
$cand[$cr] = true;
}
$deptKey = self::resolveDeepestYejiTableRowForListDeptOr(
$cand,
$adminDeptIdList,
$rowSubtreeFlips,
$tableRowDeptIds,
$deptById,
$adminToPrimary
);
if ($deptKey <= 0) {
continue;
}
$amt = (float) ($r['amount'] ?? 0);
$byCnt[$deptKey] = ($byCnt[$deptKey] ?? 0) + 1;
$byAmt[$deptKey] = ($byAmt[$deptKey] ?? 0.0) + $amt;
}
return ['count' => $byCnt, 'amount' => $byAmt];
}
/**
* 成交单数按部门:**处方订单列表**按 assistant_dept_id 筛选时的业务单条数(诊单医助或创建人 ∪ 部门子树),
* 与 {@see PrescriptionOrderLists::applyDoctorAssistantFilters} 在 <b>未</b> 传 yeji_drawer_match_table_performance 时一致。
* 选定医助 tag 时收窄为(业绩归属医助 ∈ 集 **或** 诊单医助 ∈ 集 **或** 创建人 ∈ 集)。
*
* @return array<int, int>
*/
private static function aggregateDealOrderCount(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
array $tableRowDeptIds = [],
array $deptById = []
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
if ($tableRowDeptIds !== [] && $deptById !== []) {
return self::aggregateListAlignedOrderMetricsByDept(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds,
$tableRowDeptIds,
$deptById
)['count'];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs])
->where('o.diagnosis_id', '>', 0);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw('(dg.assistant_id > 0 OR o.creator_id > 0)');
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$in = implode(',', $ids);
$query->whereRaw("(({$att}) IN ({$in}) OR dg.assistant_id IN ({$in}) OR o.creator_id IN ({$in}))");
}
$query->whereRaw("({$att}) > 0", []);
$query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')])
->group($att);
$rows = $query->select()->toArray();
return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
}
/**
* 表格不展示「有下级的勾选根部门」行时,仍把该行对应进线数并入首个可见下级,避免投放成本分摊分母偏小。
*
* @param int[] $primaryDeptIds
* @param int[] $tableRowDeptIds
* @param array<int, int> $leads
* @param array<int, int[]> $deptToDescendants
*
* @return array<int, int>
*/
private static function mergeHiddenPrimaryLeadsForCostAllocation(
array $primaryDeptIds,
array $tableRowDeptIds,
array $leads,
array $deptToDescendants
): array {
if (array_diff($primaryDeptIds, $tableRowDeptIds) === [] && array_diff($tableRowDeptIds, $primaryDeptIds) === []) {
return $leads;
}
$out = $leads;
$hidden = array_values(array_diff($primaryDeptIds, $tableRowDeptIds));
if ($hidden === []) {
return $out;
}
foreach ($hidden as $h) {
$add = (int) ($out[$h] ?? 0);
if ($add <= 0) {
continue;
}
$descFlip = array_flip($deptToDescendants[$h] ?? []);
$target = null;
foreach ($tableRowDeptIds as $tid) {
if ($tid !== $h && isset($descFlip[$tid])) {
$target = $tid;
break;
}
}
if ($target !== null) {
$out[$target] = (int) ($out[$target] ?? 0) + $add;
}
}
return $out;
}
/**
* 将总投放成本按展示各部门「进线」条数比例分摊:部门成本 = 总成本 × (部门进线 / 进线合计)。
* 四舍五入尾差并入进线条数最多的部门。
*
* @param array<int, int> $leads
* @return array<int, float>
*/
private static function allocateCostByLeads(float $globalCost, array $primaryDeptIds, array $leads): array
{
$out = [];
foreach ($primaryDeptIds as $pid) {
$out[$pid] = 0.0;
}
if ($globalCost <= 0 || $primaryDeptIds === []) {
return $out;
}
$totalLeads = 0;
foreach ($primaryDeptIds as $pid) {
$totalLeads += (int) ($leads[$pid] ?? 0);
}
if ($totalLeads <= 0) {
return $out;
}
$allocated = 0.0;
$bestPid = null;
$bestLv = -1;
foreach ($primaryDeptIds as $pid) {
$lv = (int) ($leads[$pid] ?? 0);
if ($lv > $bestLv) {
$bestLv = $lv;
$bestPid = $pid;
}
$share = $globalCost * $lv / $totalLeads;
$out[$pid] = round($share, 1);
$allocated += $out[$pid];
}
$diff = round($globalCost - $allocated, 1);
if ($diff !== 0.0 && $bestPid !== null) {
$out[$bestPid] = round($out[$bestPid] + $diff, 1);
}
return $out;
}
/**
* 渠道总成本(合计行 & 进线分摊基数):account_cost 在日期区间内 SUM(amount),选定渠道时按 media_channel_code 过滤。
*/
private static function sumGlobalCost(string $startDate, string $endDate, string $channelCode): float
{
$query = Db::name('account_cost')->whereBetween('cost_date', [$startDate, $endDate]);
if ($channelCode !== '') {
$query->where('media_channel_code', $channelCode);
}
return (float) $query->sum('amount');
}
/**
* 展示部门名称是否属「二中心」系:选定渠道时该部门不参与该渠道进线/业绩/成交单/ROI 分子。
*/
private static function isErCenterDisplayPrimaryName(string $name): bool
{
return mb_strpos($name, '二中心') !== false;
}
/**
* 选定渠道时:名称含「二中心」的展示部门渠道业绩与渠道成交单置 0,并从后续全量渠道 SUM 中扣减同等金额。
*
* @param array<int, float|string|int> $channelPerformance
* @param array<int, int> $channelConsults
* @return float 扣减金额(与 sumGlobalChannelScopedPerformance 对齐)
*/
private static function excludeErCenterFromChannelMetrics(
array $primaryDeptIds,
array $deptNameMap,
array &$channelPerformance,
array &$channelConsults
): float {
$stripped = 0.0;
foreach ($primaryDeptIds as $pid) {
if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) {
continue;
}
$stripped += (float) ($channelPerformance[$pid] ?? 0);
$channelPerformance[$pid] = 0.0;
$channelConsults[$pid] = 0;
}
return round($stripped, 1);
}
/**
* 选定渠道时:名称含「二中心」的展示部门进线数置 0(与同渠道向业绩/成交单一致)。
*
* @param array<int, int> $leads
*/
private static function excludeErCenterFromLeads(array $primaryDeptIds, array $deptNameMap, array &$leads): void
{
foreach ($primaryDeptIds as $pid) {
if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) {
continue;
}
$leads[$pid] = 0;
}
}
/**
* 渠道 tag → 1) 该标签下客户对应的全部诊单 id 集合(含 external_userid 直挂 + unionid 兜底)
* 2) 这些客户的"跟进医助 admin id"集合(与 follow_users 中的医助 work_wechat_userid 对齐)
*
* 一次性算好,后续业绩/接诊用 whereIn 收敛,避免 EXISTS+LIKE 全表扫超时。
*
* @return array{0: int[], 1: array<int, true>}
*/
/** @var array<string, array{0: int[], 1: array<int,int>}> 同一请求内 tag → [diagIds, assistantIds] 缓存 */
private static array $tagScopeCache = [];
private static function resolveTagScopedDiagnosisAndAssistants(string $tagFilterId): array
{
if (isset(self::$tagScopeCache[$tagFilterId])) {
return self::$tagScopeCache[$tagFilterId];
}
// 1. 该标签下的客户:external_userid + unionid 集合
$rows = Db::name('qywx_external_contact_tag')
->alias('t')
->join((string) self::tableWithPrefix('qywx_external_contact') . ' ec', 'ec.external_userid = t.external_userid AND ec.delete_time IS NULL', 'INNER')
->where('t.tag_id', $tagFilterId)
->field(['t.external_userid', 'ec.unionid', 'ec.follow_users'])
->select()
->toArray();
if ($rows === []) {
return self::$tagScopeCache[$tagFilterId] = [[], []];
}
$extIds = [];
$unionIds = [];
$followWxUserids = [];
foreach ($rows as $r) {
$ext = (string) ($r['external_userid'] ?? '');
if ($ext !== '') {
$extIds[$ext] = true;
}
$u = (string) ($r['unionid'] ?? '');
if ($u !== '') {
$unionIds[$u] = true;
}
$fu = json_decode((string) ($r['follow_users'] ?? '[]'), true);
if (!is_array($fu)) {
continue;
}
foreach ($fu as $f) {
if (!is_array($f)) {
continue;
}
$wx = trim((string) ($f['userid'] ?? ''));
if ($wx !== '') {
$followWxUserids[$wx] = true;
}
}
}
// 2. 诊单:external_userid 直挂 unionid 兜底
$diagIds = [];
if ($extIds !== []) {
$ids = Db::name('tcm_diagnosis')
->whereNull('delete_time')
->whereIn('external_userid', array_keys($extIds))
->column('id');
foreach ($ids as $id) {
$diagIds[(int) $id] = true;
}
}
if ($unionIds !== []) {
$userIds = Db::name('user_auth')
->whereIn('unionid', array_keys($unionIds))
->column('user_id');
$userIds = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn (int $v) => $v > 0)));
if ($userIds !== []) {
$ids = Db::name('tcm_diagnosis')
->whereNull('delete_time')
->whereIn('patient_id', $userIds)
->column('id');
foreach ($ids as $id) {
$diagIds[(int) $id] = true;
}
}
}
// 3. 跟进医助:follow_users 中的 wx userid → admin.id(仅医助 role_id=2
$assistantIds = [];
if ($followWxUserids !== []) {
$rows = Db::name('admin')
->alias('a')
->join((string) self::tableWithPrefix('admin_role') . ' ar', 'ar.admin_id = a.id AND ar.role_id = 2', 'INNER')
->whereIn('a.work_wechat_userid', array_keys($followWxUserids))
->whereNull('a.delete_time')
->column('a.id');
foreach ($rows as $aid) {
$assistantIds[(int) $aid] = true;
}
}
return self::$tagScopeCache[$tagFilterId] = [array_keys($diagIds), $assistantIds];
}
/**
* 「未归属中心」业绩下钻:按诊单医助汇总无法映射到展示「中心」的金额(与 aggregatePerformance 部门行互补)。
*
* @return array{
* start_date: string,
* end_date: string,
* channel_code: string,
* rows: list<array{admin_id:int, name:string, order_count:int, amount:float}>,
* note: string
* }
*/
public static function unassignedCenterBreakdown(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$adminToPrimary = $c['adminToPrimary'];
$byAdminAmt = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, null, null);
$byAdminCnt = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, null, null);
$visFlip = null;
if (!empty($c['dataScopeRestricted']) && isset($c['dataScopeVisibleAdminIds'])) {
$visFlip = array_flip(array_values(array_unique(array_filter(
array_map('intval', $c['dataScopeVisibleAdminIds']),
static function (int $v): bool {
return $v > 0;
}
))));
}
$rows = [];
foreach ($byAdminAmt as $aid => $amt) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
if ($visFlip !== null && !isset($visFlip[$aid])) {
continue;
}
if ((int) ($adminToPrimary[$aid] ?? 0) > 0) {
continue;
}
$rows[] = [
'admin_id' => $aid,
'name' => '',
'order_count' => (int) ($byAdminCnt[$aid] ?? 0),
'amount' => round((float) $amt, 2),
];
}
$globalAmt = self::sumGlobalPerformanceAmount($startTs, $endTs);
$globalCnt = self::countGlobalDealOrders($startTs, $endTs);
$innerAmt = array_sum($byAdminAmt);
$innerCnt = array_sum($byAdminCnt);
$orphanAmt = round(max(0.0, $globalAmt - $innerAmt), 2);
$orphanCnt = max(0, $globalCnt - $innerCnt);
if ($orphanAmt >= 0.01 || $orphanCnt > 0) {
$rows[] = [
'admin_id' => 0,
'name' => '',
'order_count' => $orphanCnt,
'amount' => $orphanAmt,
];
}
usort($rows, static function (array $a, array $b): int {
$c = $b['amount'] <=> $a['amount'];
if ($c !== 0) {
return $c;
}
return $b['order_count'] <=> $a['order_count'];
});
$ids = [];
foreach ($rows as $row) {
if ($row['admin_id'] > 0) {
$ids[] = $row['admin_id'];
}
}
$names = self::batchAdminNames(array_values(array_unique($ids)));
foreach ($rows as &$row) {
$aid = (int) $row['admin_id'];
if ($aid <= 0) {
$row['name'] = '(无诊单或医助未填)';
} else {
$row['name'] = (string) ($names[$aid] ?? ('#' . $aid));
}
}
unset($row);
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'channel_code' => (string) $c['channelCode'],
'rows' => $rows,
'note' => '与看板「未归属中心」补差一致:按订单 create_time、剔除履约已取消(4);诊单医助无法映射到任一展示「中心」计入各行;其余(无诊单/医助为空)汇总在首行。账号数据权限下仅列出可见医助。',
];
}
/**
* @param int[] $ids
*
* @return array<int, string>
*/
private static function batchAdminNames(array $ids): array
{
if ($ids === []) {
return [];
}
$rows = Db::name('admin')
->whereIn('id', $ids)
->whereNull('delete_time')
->field(['id', 'name'])
->select()
->toArray();
$map = [];
foreach ($rows as $r) {
$map[(int) $r['id']] = (string) ($r['name'] ?? '');
}
return $map;
}
/**
* 表名 + 数据库前缀(用于 whereRaw 内嵌的 SQL,框架不会替换 raw 表名)
*/
private static function tableWithPrefix(string $table): string
{
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
return $prefix . $table;
}
/**
* 挂号字典 channels:与渠道配置名称匹配的 value 字符串列表(用于 ap.channel_source
*
* @return string[]
*/
private static function appointmentChannelDictStringValuesForDrawer(?array $channelInfo): array
{
if ($channelInfo === null) {
return [];
}
$names = array_values(array_unique(array_filter([
trim((string) ($channelInfo['channel_name'] ?? '')),
trim((string) ($channelInfo['source_tag_name'] ?? '')),
])));
if ($names === []) {
return [];
}
$vals = Db::name('dict_data')->where('type_value', 'channels')->whereIn('name', $names)->column('value');
$out = [];
foreach ($vals as $v) {
$s = trim((string) $v);
if ($s !== '') {
$out[$s] = true;
}
}
return array_keys($out);
}
/** @var bool|null 进程内缓存:doctor_appointment 是否已加 channel_source 列 */
private static ?bool $doctorAppointmentHasChannelSourceColumn = null;
private static function doctorAppointmentHasChannelSourceColumn(): bool
{
if (self::$doctorAppointmentHasChannelSourceColumn !== null) {
return self::$doctorAppointmentHasChannelSourceColumn;
}
try {
$fields = Db::name('doctor_appointment')->getTableFields();
self::$doctorAppointmentHasChannelSourceColumn = is_array($fields)
&& in_array('channel_source', $fields, true);
} catch (\Throwable) {
self::$doctorAppointmentHasChannelSourceColumn = false;
}
return self::$doctorAppointmentHasChannelSourceColumn;
}
/** @var bool|null 进程内缓存:doctor_appointment 是否存在 channels 列 */
private static ?bool $doctorAppointmentHasChannelsColumnCache = null;
private static function doctorAppointmentHasChannelsColumn(): bool
{
if (self::$doctorAppointmentHasChannelsColumnCache !== null) {
return self::$doctorAppointmentHasChannelsColumnCache;
}
try {
$fields = Db::name('doctor_appointment')->getTableFields();
self::$doctorAppointmentHasChannelsColumnCache = is_array($fields)
&& in_array('channels', $fields, true);
} catch (\Throwable) {
self::$doctorAppointmentHasChannelsColumnCache = false;
}
return self::$doctorAppointmentHasChannelsColumnCache;
}
/**
* 选定 dict(channels) 渠道时:患者存在已完成挂号,且挂号助理为医助(role_id=2),且 (ap.channels 或 ap.channel_source) 命中配置,
* 与 {@see self::applyYejiDrawerChannelFilterToPrescriptionOrderQuery} 一致。
*
* @param string $patientIdExpr 与 ap.patient_id 比较的 SQL 表达式,如 o.diagnosis_id、dg.id
*
* @return array{0: string, 1: array}|null 无可用工件列时返回 null
*/
private static function buildChannelScopedAppointmentExistsSqlAndBindings(
string $patientIdExpr,
array $normInts,
?array $channelInfo
): ?array {
$normInts = self::normalizeAppointmentChannelValues($normInts);
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normInts)));
$srcFromInfo = self::appointmentChannelDictStringValuesForDrawer($channelInfo);
$allSrcStrings = array_values(array_unique(array_filter(
array_merge($strVals, $srcFromInfo),
static fn (string $s): bool => $s !== ''
)));
$hasCh = self::doctorAppointmentHasChannelsColumn();
$hasSrc = self::doctorAppointmentHasChannelSourceColumn();
$parts = [];
$bind = [];
if ($hasCh && $strVals !== []) {
$ph = implode(',', array_fill(0, count($strVals), '?'));
$parts[] = "ap.channels IN ({$ph})";
foreach ($strVals as $sv) {
$bind[] = $sv;
}
}
if ($hasSrc && $allSrcStrings !== []) {
$ph = implode(',', array_fill(0, count($allSrcStrings), '?'));
$parts[] = "ap.channel_source IN ({$ph})";
foreach ($allSrcStrings as $s) {
$bind[] = $s;
}
}
if ($parts === []) {
return null;
}
$apTable = self::tableWithPrefix('doctor_appointment');
$adminRoleTable = self::tableWithPrefix('admin_role');
$innerOr = implode(' OR ', $parts);
$sql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar "
. "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = {$patientIdExpr} "
. "AND ap.status = 3 AND ({$innerOr}))";
return [$sql, $bind];
}
/**
* 业绩看板业务订单侧栏:按 channel_code 收窄(与 overview「{渠道}业绩」同患者判定;含 ap.channels 与 ap.channel_source
*
* @param Query $query PrescriptionOrder 主表查询
* @param array<string, mixed> $params
*/
public static function applyYejiDrawerChannelFilterToPrescriptionOrderQuery(Query $query, array $params): void
{
if ((int) ($params['yeji_order_drawer'] ?? 0) !== 1) {
return;
}
$channelCode = trim((string) ($params['channel_code'] ?? ''));
if ($channelCode === '') {
return;
}
$startDate = '';
$endDate = '';
$st = trim((string) ($params['start_time'] ?? ''));
if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $st, $m)) {
$startDate = $m[1];
}
$et = trim((string) ($params['end_time'] ?? ''));
if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $et, $m)) {
$endDate = $m[1];
}
if ($startDate === '' || $endDate === '') {
return;
}
$ctx = self::resolveYejiContext([
'start_date' => $startDate,
'end_date' => $endDate,
'channel_code' => $channelCode,
'dept_ids' => null,
]);
if (!$ctx['channelFilterActive'] || $ctx['channelInfo'] === null) {
$query->whereRaw('0 = 1');
return;
}
$poTbl = (new PrescriptionOrder())->getTable();
$apTable = self::tableWithPrefix('doctor_appointment');
$adminRoleTable = self::tableWithPrefix('admin_role');
$norm = self::normalizeAppointmentChannelValues($ctx['appointmentChannelValues']);
$srcStrings = self::appointmentChannelDictStringValuesForDrawer($ctx['channelInfo']);
if ($norm !== [] || $srcStrings !== []) {
$subParts = [];
$subBind = [];
if ($norm !== []) {
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm)));
$ph = implode(',', array_fill(0, count($strVals), '?'));
$subParts[] = "ap.channels IN ({$ph})";
$subBind = array_merge($subBind, $strVals);
}
if ($srcStrings !== [] && self::doctorAppointmentHasChannelSourceColumn()) {
$ph = implode(',', array_fill(0, count($srcStrings), '?'));
$subParts[] = "ap.channel_source IN ({$ph})";
$subBind = array_merge($subBind, $srcStrings);
}
if ($subParts !== []) {
$innerOr = implode(' OR ', $subParts);
$existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar "
. "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = `{$poTbl}`.`diagnosis_id` "
. "AND ap.status = 3 AND ({$innerOr}))";
$query->whereRaw($existsSql, $subBind);
return;
}
}
$tagDiagIds = $ctx['tagDiagIds'];
$tagAssistantIds = $ctx['tagAssistantIds'];
$tagFallback = $ctx['tagFallback'];
$tagFilterId = (string) ($ctx['tagFilterId'] ?? '');
if ($tagFilterId === '') {
$query->whereRaw('0 = 1');
return;
}
if ($tagDiagIds !== null && $tagDiagIds === []) {
$query->whereRaw('0 = 1');
return;
}
if ($tagAssistantIds !== null && $tagAssistantIds === [] && $tagFallback) {
$query->whereRaw('0 = 1');
return;
}
if ($tagDiagIds !== null) {
$query->whereIn('diagnosis_id', $tagDiagIds);
}
if ($tagFallback && $tagAssistantIds !== null && $tagAssistantIds !== []) {
$query->whereIn('creator_id', array_map('intval', array_keys($tagAssistantIds)));
}
if ($tagDiagIds === null && !($tagFallback && $tagAssistantIds !== null && $tagAssistantIds !== [])) {
$query->whereRaw('0 = 1');
}
}
}