assistant_dept_id / assistant_id 筛选同源,非订单 creator_id);无法归属计入「未归属中心」;合计=全表 COUNT。 * - 投放成本 = zyt_account_cost 在日期区间内 **amount** 合计(选定渠道时 `media_channel_code = channel_code` 过滤)。 * · **各部门行投放成本** = 总投放成本 × (该部门进线 ÷ 各展示部门进线合计)。相当于先算 **单进线成本** = 总成本 ÷ 总进线,再乘部门进线。 * · 选定渠道下二中心部门进线为 0,故该行分摊成本为 0;合计行成本 = 账务汇总总额(部门行分摊之和与之对齐,尾差归入进线最多的部门)。 * · ROI = 渠道业绩合计 ÷ 总投放成本(选定渠道时分子不含二中心渠道业绩)。 * - ROI = 选定渠道时为 **{渠道}业绩合计** ÷ 投放成本(与表格「渠道业绩」列合计一致;未选渠道时分子为全表业绩合计;成本为 0 时返回 null) * * 「业绩/接诊标签匹配」: * 优先 tcm_diagnosis.external_userid = 客户 external_userid; * 若为空则 patient_id → zyt_user → zyt_user_auth.unionid = qywx_external_contact.unionid(再验 contact_tag), * 与列表客户标签一致,避免因未回填 external_userid 导致业绩/接诊恒为 0。 * * 「部门归属」算法: * 配置 $primaryDeptIds(前端可指定;默认所有「含『中心』字样」的二级 / 三级部门), * 每个 admin 经 admin_dept → dept 沿 pid 上溯,命中第一个 $primaryDeptIds 即归属之;都不命中归到 0=未分配。 * * 「渠道」 = 「企微标签」: * `zyt_qywx_media_channel.channel_code` 形如 tag_ 或 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); // 接诊: // - consults(全量) → "接诊诊单" 列;不限渠道 // - channelConsults(渠道) → 「{渠道}成交单」列;未选渠道时与 consults 相同 $consultsBoth = self::countConsults( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagAssistantIds, $appointmentChannelValues, $channelFilterActive, $dataScopeAdminIds ); $consults = $consultsBoth['all']; $channelConsults = $consultsBoth['scoped']; // 业绩:与 /tcm.prescriptionOrder/lists 同口径(仅剔除已取消=4,含 NULL) // - performance(全量) → "合计业绩" 列;不受所选渠道影响 // - channelPerformance(渠道) → "{渠道}业绩" 列 + ROI 分子;未选渠道时与 performance 相同 $perfBoth = self::sumPerformance( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagFallback ? $tagAssistantIds : null, $appointmentChannelValues, $channelFilterActive ); $performance = $perfBoth['all']; $channelPerformance = $perfBoth['scoped']; // 成交单数(全量):与合计业绩同 WHERE,按 creator_id 归属部门,合计用全表 COUNT $dealOrderByDept = self::aggregateDealOrderCount($startTs, $endTs, $adminToPrimary, null, null); $globalDealOrderCount = self::countGlobalDealOrders($startTs, $endTs); /** 选定渠道时从全量渠道 SUM 与部门行中同步扣减「二中心」展示部门上的渠道业绩 */ $erCenterChannelStrip = 0.0; if ($channelCode !== '') { $erCenterChannelStrip = self::excludeErCenterFromChannelMetrics( $primaryDeptIds, $deptNameMap, $channelPerformance, $channelConsults ); self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $leads); } $globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1); $leadsForCost = self::mergeHiddenPrimaryLeadsForCostAllocation( $primaryDeptIds, $tableRowDeptIds, $leads, $deptToDescendants ); $costs = self::allocateCostByLeads($globalCost, $tableRowDeptIds, $leadsForCost); // ── 5) 组装每行 ────────────────────────────────────────────── $rows = []; $totalLead = 0; $totalChannelConsult = 0; $totalPerf = 0.0; $totalChannelPerf = 0.0; $totalCost = 0.0; $unmappedConsult = (int) ($consults[0] ?? 0); $consultSumInTable = 0; foreach ($tableRowDeptIds as $deptId) { $lead = (int) ($leads[$deptId] ?? 0); $cnt = (int) ($consults[$deptId] ?? 0); $consultSumInTable += $cnt; $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); $rows[] = [ 'dept_id' => $deptId, 'dept_name' => self::formatYejiDeptRowDisplayName($deptId, $deptById), 'lead_count' => $lead, '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; $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 ); $globalPerfChannel = round(max(0.0, $globalPerfChannel - $erCenterChannelStrip), 1); } $allocatedDealOrderSum = 0; foreach ($tableRowDeptIds as $rid) { $allocatedDealOrderSum += (int) ($dealOrderByDept[$rid] ?? 0); } 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, '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 = $allocatedPerf; $totalChannelPerf = $allocatedChannelPerf; $totalDealOrderCount = $allocatedDealOrderSum; } else { $totalPerf = $globalPerfAll; $totalChannelPerf = $globalPerfChannel; $totalDealOrderCount = $globalDealOrderCount; } $totalCost = round($globalCost, 1); // ROI 合计 = 渠道业绩合计(与「completed」列一致,**不含二中心**)÷ 投放成本合计 $totalRoiNumerator = round($totalChannelPerf, 1); $scopedTableNote = ($explicitDeptFilter ? ' 已筛选展示部门时,表格合计仅含所选部门树内数据,不出现「全站未归属」补差行(避免把其他中心业绩/单量算入未归属)。若勾选的是带下级的部门(如某「中心」),表格只列出其下级部门行,不单独占一行父级。' : '') . ($dataScopeRestricted ? ' 当前账号受角色「数据范围」限制,仅统计可见成员及其归属展示中心;合计与表格一致,不含权限范围外数据。' : ''); $baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入(与订单列表 stats_order_amount_performance 一致)。无法归入「中心」展示部门的创建人业绩计入「未归属中心」行;合计=全表 SUM。**投放成本**按进线占比分摊至各部门。'; 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)的订单条数(非挂号条数)。' . ' 「接诊诊单」与已完成挂号列表同条件(appointment_date、status=3、诊单未软删);部门列按医助→接诊医生归属,无法归属计入「未归属中心」。名称含「二中心」的部门**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例,从账务总投放金额中分摊(总成本÷总进线=单进线成本)。ROI = 渠道业绩(不含二中心)÷ 总投放成本。进线按所选标签客户精确过滤。' . ($tagFallback ? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。' : '') . $scopedTableNote) : ($baseNoteNoChannel . $scopedTableNote), 'rows' => $rows, 'total' => [ 'lead_count' => $totalLead, '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; } /** * 医助排行榜:按展示部门分表,每表内按诊金降序排名。 * 诊金口径:无渠道=诊单 dg.assistant_id 业绩(与列表 assistant_id 筛选一致);有渠道={渠道}业绩/dict 诊单医助;二中心在选渠道下注 0。 * 接诊率 = 排行用诊金 ÷ 进线条数(元/进线,1 位小数;进线为 0 时为 0)。 * * @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']; $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 ); $feeByAdmin = $channelFilterActive ? $perfBoth['scoped'] : $perfBoth['all']; $dealBoth = self::sumDealOrderByAdmin( $startTs, $endTs, $tagDiagIds, $scopedAssistants, $appointmentChannelValues, $channelFilterActive ); $dealOrderByAdmin = $channelFilterActive ? $dealBoth['scoped'] : $dealBoth['all']; $leadsByAdmin = self::countLeadsByAdmin($startTs, $endTs, $tagFilterId); /** 与部门表接诊同口径;标签/医助穿透时与业绩同源收窄,避免榜上证务与接诊脱节 */ $consultsByAdmin = self::aggregateConsultsByAdmin($startTs, $endTs, $tagDiagIds, $scopedAssistants); if ($channelFilterActive) { self::excludeErCenterFromAdminLeaderboard($adminToPrimary, $deptNameMap, $feeByAdmin, $leadsByAdmin, $dealOrderByAdmin); } $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。') : '排行诊金 = 处方订单列表 assistant_id 口径(诊单医助业绩,与列表按医助筛选一致)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'; $leaderboards = []; foreach ($tableRowDeptIds as $deptId) { $leafName = $deptNameMap[$deptId] ?? ('部门#' . $deptId); $deptName = self::formatYejiDeptRowDisplayName($deptId, $deptById); $deptAbbr = self::deptDisplayAbbr($leafName); $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); if ($fee < 0.005 && $lead === 0 && $consult === 0 && $dealOrders === 0) { continue; } $rawFee = (float) ($feeByAdmin[$aid] ?? 0.0); $rate = $lead > 0 ? round($rawFee / $lead, 1) : 0.0; $rows[] = [ 'admin_id' => $aid, 'name' => $adminNames[$aid] ?? ('#' . $aid), 'fee_amount' => $fee, 'lead_count' => $lead, 'consult_count' => $consult, 'deal_order_count' => $dealOrders, 'consult_rate' => $rate, 'dept_abbr' => $deptAbbr, ]; } 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; } $rows = array_slice($rows, 0, 50); $leaderboards[] = [ 'dept_id' => $deptId, 'dept_name' => $deptName, 'dept_abbr' => $deptAbbr, '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 { if ($adminId <= 0 || !DataScopeService::isEnabled()) { return null; } $visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo); if ($visibleIds === null) { return null; } if ($visibleIds === []) { return []; } $set = []; foreach ($visibleIds as $aid) { $deptRows = Db::name('admin_dept')->where('admin_id', (int) $aid)->column('dept_id'); foreach ($deptRows as $d) { $d = (int) $d; if ($d <= 0) { continue; } foreach (DeptLogic::getSelfAndDescendantIds($d) as $x) { $x = (int) $x; if ($x > 0) { $set[$x] = true; } } } } return $set; } /** * 列出所有可选部门 * * @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; } /** * 进线数据: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; } /** * 接诊诊单聚合:返回两组按部门归属的接诊计数。 * - all: 区间内 status=3 挂号条数按部门归属(与列表计数一致),不含「仅 role_id=2」等额外限制。 * - scoped: 选定渠道时「{渠道}成交单」= **非取消业务订单条数**(与 {渠道}业绩同源):有 dict(channels) 走诊单医助+EXISTS 口径;否则走 tag(diagnosis_id IN / creator_id IN)与 aggregatePerformance 完全一致。 * 未选渠道时 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 { if ($adminToPrimary === []) { return ['all' => [], 'scoped' => []]; } $allByDept = self::aggregateConsults($startTs, $endTs, $adminToPrimary, null, null, $dataScopeAdminIds); if (!$channelFilterActive) { return ['all' => $allByDept, 'scoped' => $allByDept]; } if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { $scopedByDept = []; } else { // 字典映射渠道:「{渠道}接诊」= 成交业务单数(非取消订单),与 aggregatePerformanceByAppointmentChannels 同源,非挂号条数 $scopedByDept = self::aggregateDealOrderCountByAppointmentChannels( $startTs, $endTs, $adminToPrimary, $norm ); } } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { $scopedByDept = self::aggregateDealOrderCountByTagScope( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagAssistantIds ); } 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 { 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 []; } $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; } /** * 接诊按医助汇总(不按部门归并)。 * * @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 别名 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; } /** * 与 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 { $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); } } 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 { $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); } } elseif ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) { $scoped = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped); } else { $scoped = []; } return ['all' => $all, 'scoped' => $scoped]; } /** * 成交单数按 dg.assistant_id 聚合(与 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'); $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) ->where('dg.assistant_id', '>', 0); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->field(['dg.assistant_id', 'COUNT(*) AS cnt']) ->group('dg.assistant_id'); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('dg.assistant_id', array_keys($tagAssistantIds)); } $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 { if ($appointmentChannelValues === []) { return []; } $apTable = self::tableWithPrefix('doctor_appointment'); $diagTable = self::tableWithPrefix('tcm_diagnosis'); $adminRoleTable = self::tableWithPrefix('admin_role'); $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues))); $ph = implode(',', array_fill(0, count($strVals), '?')); $channelCond = "ap.channels IN ({$ph})"; $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 = o.diagnosis_id " . "AND ap.status = 3 AND {$channelCond})"; $existsBindings = $strVals; $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::applyPrescriptionOrderNotCancelledForPerformance($query); $query->where('o.diagnosis_id', '>', 0) ->where('dg.assistant_id', '>', 0) ->whereRaw($existsSql, $existsBindings) ->field(['dg.assistant_id', 'COUNT(*) AS cnt']) ->group('dg.assistant_id'); $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; } /** * 业绩按 dg.assistant_id 聚合到 admin(与处方订单列表 assistant_id 筛选口径一致)。 * * @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'); $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) ->where('dg.assistant_id', '>', 0); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->field(['dg.assistant_id', 'SUM(o.amount) AS amt']) ->group('dg.assistant_id'); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('dg.assistant_id', array_keys($tagAssistantIds)); } $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) 渠道业绩按诊单医助 assistant_id 聚合(与 aggregatePerformanceByAppointmentChannels 同源)。 * * @param int[] $appointmentChannelValues * * @return array */ private static function aggregatePerformanceAmountsByAssistantChannel( int $startTs, int $endTs, array $appointmentChannelValues ): array { if ($appointmentChannelValues === []) { return []; } $apTable = self::tableWithPrefix('doctor_appointment'); $diagTable = self::tableWithPrefix('tcm_diagnosis'); $adminRoleTable = self::tableWithPrefix('admin_role'); $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues))); $ph = implode(',', array_fill(0, count($strVals), '?')); $channelCond = "ap.channels IN ({$ph})"; $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 = o.diagnosis_id " . "AND ap.status = 3 AND {$channelCond})"; $existsBindings = $strVals; $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::applyPrescriptionOrderNotCancelledForPerformance($query); $query->where('o.diagnosis_id', '>', 0) ->where('dg.assistant_id', '>', 0) ->whereRaw($existsSql, $existsBindings) ->field(['dg.assistant_id', 'SUM(o.amount) AS amt']) ->group('dg.assistant_id'); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byAdmin = []; foreach ($rows as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if ($aid <= 0) { continue; } $byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (float) $r['amt']; } return $byAdmin; } /** * 选定渠道时:二中心展示部门下属医助的渠道诊金、进线清零(与部门行一致)。 * * @param array $feeByAdmin * @param array $leadsByAdmin */ private static function excludeErCenterFromAdminLeaderboard( array $adminToPrimary, array $deptNameMap, array &$feeByAdmin, array &$leadsByAdmin, ?array &$dealOrderByAdmin = null ): void { foreach ($adminToPrimary as $aid => $pid) { if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) { continue; } $feeByAdmin[$aid] = 0.0; $leadsByAdmin[$aid] = 0; if ($dealOrderByAdmin !== null) { $dealOrderByAdmin[$aid] = 0; } } } /** 排行榜部门列短名:取名称首字符 */ 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 与任意其它状态)全部计入。 * 与处方订单列表 stats_order_amount_performance(总业绩 − 已取消业绩)一致;避免 SQL 中 `<> 4` 把 NULL 行误排除。 * * @param \think\db\Query $query */ private static function applyPrescriptionOrderNotCancelledForPerformance($query, string $tableAlias = 'o'): void { $query->whereRaw("NOT ({$tableAlias}.fulfillment_status <=> ?)", [4]); } /** * 处方订单业绩全量合计(不按部门归属截断),与订单列表在**同一 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); } /** * 标签/医助兜底口径下的渠道向业绩全量 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; } $query = Db::name('tcm_prescription_order') ->alias('o') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); self::applyPrescriptionOrderNotCancelledForPerformance($query); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('o.creator_id', array_keys($tagAssistantIds)); } return round((float) $query->sum('o.amount'), 1); } /** * dict(channels) 映射下渠道业绩全量 SUM(与 aggregatePerformanceByAppointmentChannels 同源,不按 primary dept 汇总)。 */ private static function sumGlobalPerformanceAppointmentChannels(int $startTs, int $endTs, array $appointmentChannelValues): float { if ($appointmentChannelValues === []) { return 0.0; } $apTable = self::tableWithPrefix('doctor_appointment'); $diagTable = self::tableWithPrefix('tcm_diagnosis'); $adminRoleTable = self::tableWithPrefix('admin_role'); $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues))); $ph = implode(',', array_fill(0, count($strVals), '?')); $channelCond = "ap.channels IN ({$ph})"; $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 = o.diagnosis_id " . "AND ap.status = 3 AND {$channelCond})"; $existsBindings = $strVals; $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::applyPrescriptionOrderNotCancelledForPerformance($query); $query->where('o.diagnosis_id', '>', 0) ->where('dg.assistant_id', '>', 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 ): float { if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { return 0.0; } return self::sumGlobalPerformanceAppointmentChannels($startTs, $endTs, $norm); } 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: 「{渠道}业绩」与 ROI 分子:优先挂号 channels(dict);否则 tag;已选渠道但两者皆不可用时为空。 * 未选渠道时 scoped == all(不再额外查一次)。 * * @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 { if ($adminToPrimary === []) { return ['all' => [], 'scoped' => []]; } // 1) 部门全量业绩(≠4),任何场景都跑一次 → "合计业绩" $allByDept = self::aggregatePerformance($startTs, $endTs, $adminToPrimary, null, null); if (!$channelFilterActive) { return ['all' => $allByDept, 'scoped' => $allByDept]; } if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { $scopedByDept = []; } else { $scopedByDept = self::aggregatePerformanceByAppointmentChannels( $startTs, $endTs, $adminToPrimary, $norm ); } } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { $scopedByDept = self::aggregatePerformance( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagAssistantIds ); } else { $scopedByDept = []; } return ['all' => $allByDept, 'scoped' => $scopedByDept]; } /** * 渠道业绩:与「处方订单列表」业绩完全同口径 —— 按 o.create_time 归日、仅剔除已取消(4)(含 NULL); * EXISTS 仅用于"该患者属于此渠道"的判定(有任一挂号 channels 命中 + status=3,**不限挂号时点**); * 部门归属按「诊单医助 dg.assistant_id」(与处方列表 assistant_dept 过滤条件一致)。 */ private static function aggregatePerformanceByAppointmentChannels( int $startTs, int $endTs, array $adminToPrimary, array $appointmentChannelValues ): array { if ($appointmentChannelValues === [] || $adminToPrimary === []) { return []; } $apTable = self::tableWithPrefix('doctor_appointment'); $diagTable = self::tableWithPrefix('tcm_diagnosis'); $adminRoleTable = self::tableWithPrefix('admin_role'); $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues))); $ph = implode(',', array_fill(0, count($strVals), '?')); $channelCond = "ap.channels IN ({$ph})"; $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 = o.diagnosis_id " . "AND ap.status = 3 AND {$channelCond})"; $existsBindings = $strVals; $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::applyPrescriptionOrderNotCancelledForPerformance($query); $query->where('o.diagnosis_id', '>', 0) ->where('dg.assistant_id', '>', 0) ->whereRaw($existsSql, $existsBindings) ->field(['dg.assistant_id', 'SUM(o.amount) AS amt']) ->group('dg.assistant_id'); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byDept = []; foreach ($rows as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if ($aid <= 0) { continue; } $primary = (int) ($adminToPrimary[$aid] ?? 0); if ($primary <= 0) { continue; } $byDept[$primary] = ($byDept[$primary] ?? 0) + (float) $r['amt']; } return $byDept; } /** * 「{渠道}接诊」在字典映射到 channels 时:按**成交单数**(非取消业务订单条数)计, * 与 aggregatePerformanceByAppointmentChannels 同一套 JOIN / EXISTS / 订单 create_time 窗口。 * * @return array */ private static function aggregateDealOrderCountByAppointmentChannels( int $startTs, int $endTs, array $adminToPrimary, array $appointmentChannelValues ): array { if ($appointmentChannelValues === [] || $adminToPrimary === []) { return []; } $apTable = self::tableWithPrefix('doctor_appointment'); $diagTable = self::tableWithPrefix('tcm_diagnosis'); $adminRoleTable = self::tableWithPrefix('admin_role'); $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues))); $ph = implode(',', array_fill(0, count($strVals), '?')); $channelCond = "ap.channels IN ({$ph})"; $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 = o.diagnosis_id " . "AND ap.status = 3 AND {$channelCond})"; $existsBindings = $strVals; $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::applyPrescriptionOrderNotCancelledForPerformance($query); $query->where('o.diagnosis_id', '>', 0) ->where('dg.assistant_id', '>', 0) ->whereRaw($existsSql, $existsBindings) ->field(['dg.assistant_id', 'COUNT(o.id) AS cnt']) ->group('dg.assistant_id'); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byDept = []; foreach ($rows as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if ($aid <= 0) { continue; } $primary = (int) ($adminToPrimary[$aid] ?? 0); if ($primary <= 0) { continue; } $byDept[$primary] = ($byDept[$primary] ?? 0) + (int) $r['cnt']; } return $byDept; } /** * 标签口径下的「{渠道}成交单」计数:与 aggregatePerformance 同一套 WHERE(仅剔除已取消=4,含 NULL), * 按 o.creator_id 分组 COUNT 后映射到 primary 部门,用于 tag 路径的「成交单」列展示。 * * @return array */ private static function aggregateDealOrderCountByTagScope( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $query = Db::name('tcm_prescription_order') ->alias('o') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->field(['o.creator_id', 'COUNT(o.id) AS cnt']) ->group('o.creator_id'); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('o.creator_id', array_keys($tagAssistantIds)); } $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byDept = []; foreach ($rows as $r) { $cid = (int) ($r['creator_id'] ?? 0); if ($cid <= 0) { continue; } $primary = (int) ($adminToPrimary[$cid] ?? 0); if ($primary <= 0) { continue; } $byDept[$primary] = ($byDept[$primary] ?? 0) + (int) $r['cnt']; } return $byDept; } /** * 单次业绩聚合查询。WHERE: delete_time IS NULL AND NOT(fulfillment <=> 4) AND create_time BETWEEN ... * 可选 diagnosis_id IN tagDiagIds、creator_id IN tagAssistantIds。 * * @return array */ private static function aggregatePerformance(int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $query = Db::name('tcm_prescription_order') ->alias('o') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->field(['o.creator_id', 'SUM(o.amount) AS amt']) ->group('o.creator_id'); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { // 兜底口径:限定订单创建人为「跟进过该标签客户的医助」 $query->whereIn('o.creator_id', array_keys($tagAssistantIds)); } $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byDept = []; foreach ($rows as $r) { $cid = (int) ($r['creator_id'] ?? 0); if ($cid <= 0) { continue; } $primary = (int) ($adminToPrimary[$cid] ?? 0); if ($primary <= 0) { continue; } $byDept[$primary] = ($byDept[$primary] ?? 0) + (float) $r['amt']; } return $byDept; } /** * 全量成交单数:与 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(); } /** * 成交单数按部门:关联诊单,按 dg.assistant_id 分组后映射到展示「中心」(与 PrescriptionOrderLists::applyDoctorAssistantFilters assistant_dept 一致)。 * 仅统计诊单医助 > 0 的订单行;诊单无医助或医助无法映射的不计入各部门(由 global − sum 归入未归属中心)。 * * @return array */ private static function aggregateDealOrderCount( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $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]) ->where('o.diagnosis_id', '>', 0) ->where('dg.assistant_id', '>', 0); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->field(['dg.assistant_id', 'COUNT(*) AS cnt']) ->group('dg.assistant_id'); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('dg.assistant_id', array_keys($tagAssistantIds)); } $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byDept = []; foreach ($rows as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if ($aid <= 0) { continue; } $primary = (int) ($adminToPrimary[$aid] ?? 0); if ($primary <= 0) { continue; } $byDept[$primary] = ($byDept[$primary] ?? 0) + (int) $r['cnt']; } return $byDept; } /** * 表格不展示「有下级的勾选根部门」行时,仍把该行对应进线数并入首个可见下级,避免投放成本分摊分母偏小。 * * @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'); } /** * 展示部门名称是否属「二中心」系:选定渠道时该部门不参与该渠道进线/业绩/成交单/ROI 分子。 */ private static function isErCenterDisplayPrimaryName(string $name): bool { return mb_strpos($name, '二中心') !== false; } /** * 选定渠道时:名称含「二中心」的展示部门渠道业绩与渠道成交单置 0,并从后续全量渠道 SUM 中扣减同等金额。 * * @param array $channelPerformance * @param array $channelConsults * @return float 扣减金额(与 sumGlobalChannelScopedPerformance 对齐) */ private static function excludeErCenterFromChannelMetrics( array $primaryDeptIds, array $deptNameMap, array &$channelPerformance, array &$channelConsults ): float { $stripped = 0.0; foreach ($primaryDeptIds as $pid) { if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) { continue; } $stripped += (float) ($channelPerformance[$pid] ?? 0); $channelPerformance[$pid] = 0.0; $channelConsults[$pid] = 0; } return round($stripped, 1); } /** * 选定渠道时:名称含「二中心」的展示部门进线数置 0(与同渠道向业绩/成交单一致)。 * * @param array $leads */ private static function excludeErCenterFromLeads(array $primaryDeptIds, array $deptNameMap, array &$leads): void { foreach ($primaryDeptIds as $pid) { if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) { continue; } $leads[$pid] = 0; } } /** * 渠道 tag → 1) 该标签下客户对应的全部诊单 id 集合(含 external_userid 直挂 + unionid 兜底) * 2) 这些客户的"跟进医助 admin id"集合(与 follow_users 中的医助 work_wechat_userid 对齐) * * 一次性算好,后续业绩/接诊用 whereIn 收敛,避免 EXISTS+LIKE 全表扫超时。 * * @return array{0: int[], 1: array} */ /** @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]; } /** * 表名 + 数据库前缀(用于 whereRaw 内嵌的 SQL,框架不会替换 raw 表名) */ private static function tableWithPrefix(string $table): string { $prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_'); return $prefix . $table; } }