0**,且 **related_po_create_time>0** 并落在区间(**一律按快照字段统计**,与履约释放写入一致;诊单后台指派时会回填该字段,见 DiagnosisLogic::assign) * · **二中心及其组织下级在选定渠道下计 0**(与进线一致) * - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按诊单医助在组织树内归属 rollup(含下级),与 dept 模块 ErCenter 复诊算法一致 * - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。 * - 接诊诊单 = 列名对应**业务订单条数**(非取消、create_time 区间),与处方列表按 **assistant_dept_id** 筛选时一致:优先按订单 **creator_id** 人事部门;无创建人时回退诊单 **assistant_id**;多行命中时取**最深的展示部门行**,与「业绩归属医助」分组可能不同。 * - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4;NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag 口径均按订单计(tag 时按诊单医助收窄),非挂号条数。 * - 合计业绩 = 区间内业务订单 amount 求和(排除已取消(4) 与软删),**部门列**与「接诊诊单」一致;与处方列表按 assistant_dept_id 筛选后的金额合计一致。 * 无法归入任一展示部门的金额单列「未归属中心」行;**全表合计行**仍为同时间/业绩条件的全表 SUM(不限部门)。 * · 时间窗与 stats_order_amount_performance 一致;列表若另有患者筛选或账号数据域收窄,金额可能仍不同 * · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**二中心及其组织下级展示部门不参与该渠道进线/业绩/成交单/ROI 分子**(与同模块 DeptLogic::getErCenterSubtreeDeptIdSet 一致) * - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按**业绩归属医助**归部门(与处方列表医助筛选一致); * 渠道判定仅用 EXISTS:"患者有任一已完成挂号 (status=3) 且 channels 命中字典",**不限挂号时点**;无字典映射时回退 tag/creator 口径 * - 成交单数 = 区间内非取消业务订单 **条数**;按**业绩归属医助**映射到「中心」(与处方订单列表医助条件一致);无法归属计入「未归属中心」;合计=全表 COUNT。 * - 投放成本 = zyt_account_cost 在日期区间内 **amount** 合计(选定渠道时 `media_channel_code = channel_code` 过滤)。 * · **各部门行投放成本** = 总投放成本 × (该部门进线 ÷ 各展示部门进线合计)。相当于先算 **单进线成本** = 总成本 ÷ 总进线,再乘部门进线。 * · **二中心及其组织下级**不参与均摊投放成本(进线分摊分母对其计 0,与选定渠道下进线清零规则独立);其余部门按进线占比分摊。合计行成本 = 账务汇总总额。 * · ROI = (渠道业绩合计或全量业绩合计,**均不含二中心子树各部门的对应业绩**)÷ 总投放成本(成本为 0 时返回 null) * - ROI = **不含二中心子树**后的 **{渠道}业绩合计**(或未选渠道时为全量业绩口径下同一套「部门行之和」)÷ 投放成本;部门行在四舍五入前若分摊成本为 0 则无 ROI * * 「业绩/接诊标签匹配」: * 优先 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_ 或 tagname_; * ① 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, * deptToDescendants:array, * adminToPrimary:array, * tagDiagIds:int[]|null, * tagAssistantIds:array|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,tableRowDeptIds:int[], * appointmentChannelValues:int[],channelFilterActive:bool, * tagDiagIds:int[]|null,tagAssistantIds:array|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 */ 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); $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); $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']; /** 二中心子树展示行「计入 ROI 分子的业绩」合计({渠道}业绩或与合计业绩相同),供未选渠道时从全表分子里扣除 */ $sumCompletedPerfErCenter = 0.0; foreach ($tableRowDeptIds as $erDeptId) { if (!isset($erCenterDeptSet[(int) $erDeptId])) { continue; } $sumCompletedPerfErCenter += round((float) ($channelPerformance[$erDeptId] ?? 0), 1); } // 成交单数(全量):与合计业绩列同列表部门口径 $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::excludeErCenterSubtreeFromChannelMetrics( $tableRowDeptIds, $erCenterDeptSet, $channelPerformance, $channelConsults ); } $globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1); $leadsForCost = self::mergeHiddenPrimaryLeadsForCostAllocation( $primaryDeptIds, $tableRowDeptIds, $leads, $deptToDescendants ); self::zeroIntKeyedDeptMetricsInErCenterSubtree($leadsForCost, $erCenterDeptSet); $costs = self::allocateCostByLeads($globalCost, $tableRowDeptIds, $leadsForCost); if ($channelCode !== '') { self::zeroIntKeyedDeptMetricsInErCenterSubtree($leads, $erCenterDeptSet); self::zeroIntKeyedDeptMetricsInErCenterSubtree($assignsByDept, $erCenterDeptSet); } @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 $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 合计:选渠道时底栏「{渠道}业绩合计」已扣除二中心子树($erCenterChannelStrip);未选渠道时用全表业绩并减去各二中心子树展示行之和 $totalRoiNumerator = round( max( 0.0, $channelFilterActive ? $totalChannelPerf : ($totalChannelPerf - $sumCompletedPerfErCenter) ), 1 ); if ($totalRevisitSlots !== []) { ksort($totalRevisitSlots, SORT_NUMERIC); } $scopedTableNote = ($explicitDeptFilter ? ' 已筛选展示部门时,表格「合计业绩」「接诊诊单」各行为同一套列表部门口径(**创建人人事部门优先**,无创建人则诊单医助;∪子树、最深展示行);底栏合计业绩/成交单与处方订单列表 extend(所选部门树+数据域)一致,可能与各行相加不同。' : '') . ($dataScopeRestricted ? ' 当前账号受数据范围限制时,底栏合计业绩与列表同域。' : ''); $baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」与「接诊诊单」均为列表 assistant_dept_id 口径(**创建人优先**,否则诊单医助;人事部门∪子树,多行命中取最深展示行)。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊,**二中心子树展示部门进线在分摊中计 0**(与部门 ErCenter 子树一致)。'; 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 筛选时一致(**创建人优先**,否则诊单医助;∪子树)。**二中心及其组织下级**(与部门模块 ErCenter 子树一致)**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例分摊,**二中心子树进线在分摊中计 0**(未选渠道时仍显示进线数,仅不参与均摊)。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 $ranges * @return array> */ 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 */ 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) { $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); self::excludeErCenterFromAdminLeaderboard( $adminToPrimary, $erCenterDeptSet, $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|null null 表示不限制,[] 表示无可选 */ private static function buildYejiDataScopeAllowedDeptIdMap(int $adminId, array $adminInfo): ?array { return DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo); } /** * 列出所有可选部门 * * @return array */ 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 * }>, * 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, 2: int[], 3: array>} */ 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> $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 $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> $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 */ 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 $deptToDescendants * @return array */ 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> $deptById * * @return array 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 $tableRowSet * @param array> $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 $tableRowSet * @param array> $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> $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> $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> $rows query 结果,含 assistant_id * @param array $adminToPrimary * @param int[] $tableRowDeptIds * @param array> $deptById * @param string $amtKey 数值字段名(金额 amt 或条数 cnt) * * @return array */ 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> $rows eff_a, doctor_id, cnt * * @return array */ 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 $adminToPrimary * @return array */ 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; } /** * 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0),**仅**按 **related_po_create_time** 落入区间(须 >0)。 * 选定字典渠道时 INNER JOIN 诊单以满足挂号 channels EXISTS;tag 退化口径只过滤日志字段,不强制挂诊单。 * * @param array $adminToPrimary * @param int[]|null $tagDiagIds * @param array|null $tagAssistantIds tag 退化口径下按 to_assistant_id 过滤(与 aggregatePerformance 一致) * * @return array */ 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 []; } $query = Db::name('tcm_diagnosis_assign_log') ->alias('lg') ->where('lg.related_po_create_time', '>', 0) ->where('lg.related_po_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 []; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER'); $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 */ private static function countDiagnosisAssignsByAdmin( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIds, array $appointmentChannelValues, bool $channelFilterActive, ?array $dataScopeAdminIds = null, ?array $channelInfo = null ): array { $query = Db::name('tcm_diagnosis_assign_log') ->alias('lg') ->where('lg.related_po_create_time', '>', 0) ->where('lg.related_po_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 []; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER'); $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, * scoped: array * } */ 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 */ 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 展示部门 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 */ 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 */ 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 */ 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 $adminToPrimary * * @return list */ 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>, * 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']), '该部门不在当前展示部门筛选或数据权限范围内。' ); } $channelCode = (string) $c['channelCode']; $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); if ($channelCode !== '' && isset($erCenterDeptSet[$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> */ 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 $c resolveYejiContext + applyYejiDataScope 后的上下文 * * @return array */ 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>, * 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_slot:0=复诊合计(全部复诊笔),≥2=对应分项 * * @return array{ * start_date:string,end_date:string,dept_id:int,dept_name:string,revisit_slot:int,revisit_slot_label:string, * rows:list,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, scoped: array} */ 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, scoped: array} */ 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 */ 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 */ 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 */ 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 */ 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; } /** * 选定渠道时:二中心子树(DeptLogic::getErCenterSubtreeDeptIdSet)下属医助的渠道诊金、进线清零(与部门行一致)。 * * @param array $adminToPrimary * @param array $erCenterDeptSet * @param array $feeByAdmin * @param array $leadsByAdmin * @param array|null $dealOrderByAdmin * @param array|null $assignsByAdmin */ private static function excludeErCenterFromAdminLeaderboard( array $adminToPrimary, array $erCenterDeptSet, array &$feeByAdmin, array &$leadsByAdmin, ?array &$dealOrderByAdmin = null, ?array &$assignsByAdmin = null ): void { foreach ($adminToPrimary as $aid => $pid) { $pid = (int) $pid; if ($pid <= 0 || !isset($erCenterDeptSet[$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 处方 rx(rx.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'); 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); $diagTb = (new Diagnosis())->getTable(); $adTb = (new AdminDept())->getTable(); $query->whereRaw(self::sqlPrescriptionOrderListDeptSubtreeMatchCreatorFirst('`o`', $diagTb, $adTb, $inList)); } 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, * scoped: array * } */ 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 */ 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; * 可选 tag:diagnosis_id IN、归属医助 IN。 * * @return array */ 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 */ 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 */ 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 $candidates admin_id => true * @param array $adminDeptIdList * @param array> $rowSubtreeFlips * @param int[] $tableRowDeptIds * @param array> $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, amount: array} */ 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> $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 ($c > 0) { $adminIds[] = $c; } elseif ($a > 0) { $adminIds[] = $a; } } $adminIds = array_values(array_unique(array_filter($adminIds, static fn (int $x): bool => $x > 0))); /** @var array $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 ($cr > 0) { $cand[$cr] = true; } elseif ($asst > 0) { $cand[$asst] = 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} 在 传 yeji_drawer_match_table_performance 时一致。 * 选定医助 tag 时收窄为(业绩归属医助 ∈ 集 **或** 诊单医助 ∈ 集 **或** 创建人 ∈ 集)。 * * @return array */ 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 $leads * @param array $deptToDescendants * * @return array */ 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 $leads * @return array */ 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'); } /** * 选定渠道时:二中心子树展示行渠道业绩与渠道成交单置 0,并返回扣减金额(与 sumGlobalChannelScopedPerformance 对齐)。 * * @param int[] $tableRowDeptIds * @param array $erCenterDeptSet * @param array $channelPerformance * @param array $channelConsults */ private static function excludeErCenterSubtreeFromChannelMetrics( array $tableRowDeptIds, array $erCenterDeptSet, array &$channelPerformance, array &$channelConsults ): float { $stripped = 0.0; foreach ($tableRowDeptIds as $tid) { $tid = (int) $tid; if (!isset($erCenterDeptSet[$tid])) { continue; } $stripped += (float) ($channelPerformance[$tid] ?? 0); $channelPerformance[$tid] = 0.0; $channelConsults[$tid] = 0; } return round($stripped, 1); } /** * 将 map 中落在二中心子树(DeptLogic)的部门 key 清零,用于进线分摊或渠道清零后的展示列。 * * @param array $map * @param array $erCenterDeptSet */ private static function zeroIntKeyedDeptMetricsInErCenterSubtree(array &$map, array $erCenterDeptSet): void { foreach (array_keys($map) as $k) { $id = (int) $k; if ($id > 0 && isset($erCenterDeptSet[$id])) { $map[$k] = 0; } } } /** * 渠道 tag → 1) 该标签下客户对应的全部诊单 id 集合(含 external_userid 直挂 + unionid 兜底) * 2) 这些客户的"跟进医助 admin id"集合(与 follow_users 中的医助 work_wechat_userid 对齐) * * 一次性算好,后续业绩/接诊用 whereIn 收敛,避免 EXISTS+LIKE 全表扫超时。 * * @return array{0: int[], 1: array} */ /** @var array}> 同一请求内 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, * 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 */ 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); * **二中心及其组织下级**展示部门与看板一致:选定渠道收窄时本条无渠道订单(表中渠道业绩已为 0)。 * * @param Query $query PrescriptionOrder 主表查询 * @param array $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; } /** @see excludeErCenterSubtreeFromChannelMetrics 看板选定渠道时对二中心子树渠道列置 0,侧栏须同步 */ $assistantDeptForDrawer = (int) ($params['assistant_dept_id'] ?? 0); if ($assistantDeptForDrawer > 0) { $erCenterSubtree = DeptLogic::getErCenterSubtreeDeptIdSet(); if (isset($erCenterSubtree[$assistantDeptForDrawer])) { $query->whereRaw('0 = 1'); 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'); } } /** * 业绩看板业务订单侧栏:在「渠道筛选」模式下,为每个诊单挑一条用于「关联挂号」展示的预约行—— * 须为已完成(status=3)、挂号助理为医助(role_id=2),且 channels/channel_source 与当前 channel_code 同源命中(与 {@see applyYejiDrawerChannelFilterToPrescriptionOrderQuery} 的 dict 收窄一致)。 * 若为 tag 等未走 channels 表达式的情况则返回空图,交由列表沿用处方/最近挂号。 * * @param int[] $diagnosisIds doctor_appointment.patient_id(与本系统诊单 id 一致) * @param array $params 与列表同源:yeji_order_drawer、channel_code、start_time、end_time * * @return array diagnosis_id => appointment_id */ public static function batchResolveYejiChannelDrawerHighlightAppointmentIds(array $diagnosisIds, array $params): array { $diagnosisIds = array_values(array_unique(array_filter(array_map('intval', $diagnosisIds), static fn (int $x): bool => $x > 0))); if ($diagnosisIds === []) { return []; } 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) { return []; } $norm = self::normalizeAppointmentChannelValues($ctx['appointmentChannelValues']); $srcStrings = self::appointmentChannelDictStringValuesForDrawer($ctx['channelInfo']); if ($norm === [] && $srcStrings === []) { return []; } $apTable = self::tableWithPrefix('doctor_appointment'); $arTable = self::tableWithPrefix('admin_role'); $hasCh = self::doctorAppointmentHasChannelsColumn(); $orParts = []; $channelBind = []; if ($norm !== [] && $hasCh) { $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm))); $ph = implode(',', array_fill(0, count($strVals), '?')); $orParts[] = "ap.`channels` IN ({$ph})"; foreach ($strVals as $sv) { $channelBind[] = $sv; } } if ($srcStrings !== [] && self::doctorAppointmentHasChannelSourceColumn()) { $ph = implode(',', array_fill(0, count($srcStrings), '?')); $orParts[] = "ap.`channel_source` IN ({$ph})"; foreach ($srcStrings as $s) { $channelBind[] = $s; } } if ($orParts === []) { return []; } $inPh = implode(',', array_fill(0, count($diagnosisIds), '?')); $bind = array_merge($diagnosisIds, $channelBind); $orSql = '(' . implode(' OR ', $orParts) . ')'; $sql = "SELECT ap.`patient_id` AS dg, MAX(ap.`id`) AS aid FROM `{$apTable}` ap " . "INNER JOIN `{$arTable}` ar ON ar.`admin_id` = ap.`assistant_id` AND ar.`role_id` = 2 " . "WHERE ap.`patient_id` IN ({$inPh}) AND ap.`status` = 3 AND {$orSql} " . 'GROUP BY ap.`patient_id`'; try { $rows = Db::query($sql, $bind); } catch (\Throwable) { return []; } $out = []; foreach ($rows as $r) { $dg = (int) ($r['dg'] ?? $r['DG'] ?? 0); $aid = (int) ($r['aid'] ?? $r['AID'] ?? 0); if ($dg > 0 && $aid > 0) { $out[$dg] = $aid; } } return $out; } /** * 业务订单列表 WHERE 片段:有创建人则仅按创建人人事部门落在子树;无创建人则按诊单医助(与 * {@see aggregateListAlignedOrderMetricsByDept} 及 * {@see PrescriptionOrderLists::applyDoctorAssistantFilters} 中 assistant_dept_id 一致)。 * * @param string $orderRefBackticked 主查询里业务订单表标识,须已加反引号:列表/Model 无别名时用 `` `zyt_tcm_prescription_order` ``;Db::name()->alias('o') 时用 `` `o` `` * @param string $inListComma 已净化的 id 列表(仅数字与逗号) */ public static function sqlPrescriptionOrderListDeptSubtreeMatchCreatorFirst( string $orderRefBackticked, string $diagTbl, string $adTbl, string $inListComma ): string { $creatorInSubtree = "EXISTS (SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = {$orderRefBackticked}.`creator_id` AND adc.`dept_id` IN ({$inListComma}))"; $assistantInSubtree = "EXISTS (SELECT 1 FROM `{$adTbl}` ad INNER JOIN `{$diagTbl}` dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL " . "WHERE dg.`id` = {$orderRefBackticked}.`diagnosis_id` AND ad.dept_id IN ({$inListComma}))"; return '(' . '((IFNULL(' . $orderRefBackticked . '.`creator_id`,0) > 0) AND (' . $creatorInSubtree . '))' . ' OR ' . '((IFNULL(' . $orderRefBackticked . '.`creator_id`,0) <= 0) AND (' . $assistantInSubtree . '))' . ')'; } /** * 业绩看板侧栏:与「接诊诊单」列 {@see aggregateListAlignedOrderMetricsByDept} 同源——在**同一表格** * 展示行集合内,创建人优先归属;无创建人则医助;多行命中时取组织深度最大的展示行(同深取较小 dept id)。 * 传入当前表全部展示行 dept id。 * * @param int[] $tableRowDeptIds 当前业绩表 body 行 dept_id(含点击行及兄弟行) */ public static function applyPrescriptionOrderListAlignedWinnerDeptFilter(Query $query, int $targetRowDeptId, array $tableRowDeptIds): void { $targetRowDeptId = (int) $targetRowDeptId; if ($targetRowDeptId <= 0) { $query->whereRaw('0 = 1'); return; } $uniq = []; foreach ($tableRowDeptIds as $id) { $id = (int) $id; if ($id > 0) { $uniq[$id] = true; } } $tableRowDeptIds = array_keys($uniq); if ($tableRowDeptIds === []) { $query->whereRaw('0 = 1'); return; } $deptRows = Db::name('dept')->whereNull('delete_time')->field(['id', 'pid'])->select()->toArray(); $deptById = []; foreach ($deptRows as $row) { $deptById[(int) $row['id']] = $row; } $dT = self::deptDepthFromRoot($targetRowDeptId, $deptById); $beaters = []; foreach ($tableRowDeptIds as $r) { $r = (int) $r; if ($r <= 0 || $r === $targetRowDeptId) { continue; } $dR = self::deptDepthFromRoot($r, $deptById); if ($dR > $dT || ($dR === $dT && $r < $targetRowDeptId)) { $beaters[] = $r; } } $poRef = '`' . str_replace('`', '', (new PrescriptionOrder())->getTable()) . '`'; $diagTbl = (new Diagnosis())->getTable(); $adTbl = (new AdminDept())->getTable(); $tSubtree = DeptLogic::getSelfAndDescendantIds($targetRowDeptId); $tSubtree = array_values(array_filter(array_map('intval', $tSubtree), static fn (int $id): bool => $id > 0)); if ($tSubtree === []) { $query->whereRaw('0 = 1'); return; } $tIn = implode(',', $tSubtree); $clauses = [self::sqlPrescriptionOrderListDeptSubtreeMatchCreatorFirst($poRef, $diagTbl, $adTbl, $tIn)]; foreach ($beaters as $b) { $bSubtree = DeptLogic::getSelfAndDescendantIds((int) $b); $bSubtree = array_values(array_filter(array_map('intval', $bSubtree), static fn (int $id): bool => $id > 0)); if ($bSubtree === []) { continue; } $bIn = implode(',', $bSubtree); $clauses[] = 'NOT (' . self::sqlPrescriptionOrderListDeptSubtreeMatchCreatorFirst($poRef, $diagTbl, $adTbl, $bIn) . ')'; } $query->whereRaw('(' . implode(' AND ', $clauses) . ')'); } }