或 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); } } [$primaryDeptIds, $deptNameMap] = 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, 'deptNameMap' => $deptNameMap, 'deptToDescendants' => $deptToDescendants, 'adminToPrimary' => $adminToPrimary, 'tagDiagIds' => $tagDiagIds, 'tagAssistantIds' => $tagAssistantIds, 'tagFallback' => $tagFallback, 'appointmentChannelValues' => $appointmentChannelValues, 'channelFilterActive' => ($channelCode !== ''), ]; } /** * @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): array { $c = self::resolveYejiContext($params); $startDate = $c['startDate']; $endDate = $c['endDate']; $startTs = $c['startTs']; $endTs = $c['endTs']; $channelCode = $c['channelCode']; $tagFilterId = $c['tagFilterId']; $channelInfo = $c['channelInfo']; $primaryDeptIds = $c['primaryDeptIds']; $deptNameMap = $c['deptNameMap']; $adminToPrimary = $c['adminToPrimary']; $tagDiagIds = $c['tagDiagIds']; $tagAssistantIds = $c['tagAssistantIds']; $tagFallback = $c['tagFallback']; $appointmentChannelValues = $c['appointmentChannelValues']; $channelFilterActive = $c['channelFilterActive']; $leads = self::countLeads($startTs, $endTs, $adminToPrimary, $tagFilterId); // 接诊: // - consults(全量) → "接诊诊单" 列;不限渠道 // - channelConsults(渠道) → 「{渠道}成交单」列;未选渠道时与 consults 相同 $consultsBoth = self::countConsults( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagAssistantIds, $appointmentChannelValues, $channelFilterActive ); $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']; /** 选定渠道时从全量渠道 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); $costs = self::allocateCostByLeads($globalCost, $primaryDeptIds, $leads); // ── 5) 组装每行 ────────────────────────────────────────────── $rows = []; $totalLead = 0; $totalConsult = 0; $totalChannelConsult = 0; $totalPerf = 0.0; $totalChannelPerf = 0.0; $totalCost = 0.0; foreach ($primaryDeptIds as $deptId) { $lead = (int) ($leads[$deptId] ?? 0); $cnt = (int) ($consults[$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; $rows[] = [ 'dept_id' => $deptId, 'dept_name' => $deptNameMap[$deptId] ?? ('部门#' . $deptId), 'lead_count' => $lead, 'consult_count' => $cnt, // {渠道}成交单:选定渠道时与 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; $totalConsult += $cnt; $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); } $unassignedPerf = round($globalPerfAll - $allocatedPerf, 1); $unassignedChannelPerf = round($globalPerfChannel - $allocatedChannelPerf, 1); if (abs($unassignedPerf) >= 0.01 || abs($unassignedChannelPerf) >= 0.01) { $uPerf = $unassignedPerf; $uCperf = $channelFilterActive ? $unassignedChannelPerf : $uPerf; $rows[] = [ 'dept_id' => 0, 'dept_name' => '未归属中心', 'lead_count' => 0, 'consult_count' => 0, 'channel_consult_count' => 0, 'performance_amount' => $uPerf, 'completed_performance_amount' => $uCperf, 'avg_price' => null, 'channel_avg_price' => null, 'cost_amount' => 0.0, 'roi' => null, ]; } $totalPerf = $globalPerfAll; $totalChannelPerf = $globalPerfChannel; $totalCost = round($globalCost, 1); // ROI 合计 = 渠道业绩合计(与「completed」列一致,**不含二中心**)÷ 投放成本合计 $totalRoiNumerator = round($totalChannelPerf, 1); return [ 'start_date' => $startDate, 'end_date' => $endDate, 'dept_ids' => array_values($primaryDeptIds), '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)的订单条数(非挂号条数)。' . ' 「接诊诊单」列仍按已完成挂号+预约时点统计。名称含「二中心」的部门**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例,从账务总投放金额中分摊(总成本÷总进线=单进线成本)。ROI = 渠道业绩(不含二中心)÷ 总投放成本。进线按所选标签客户精确过滤。' . ($tagFallback ? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。' : '')) : '业绩口径:除履约已取消(4)外全部计入(与订单列表 stats_order_amount_performance 一致)。无法归入「中心」展示部门的创建人业绩计入「未归属中心」行;合计=全表 SUM。**投放成本**按进线占比分摊至各部门。', 'rows' => $rows, 'total' => [ 'lead_count' => $totalLead, 'consult_count' => $totalConsult, '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): 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); $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): array { $c = self::resolveYejiContext($params); $startDate = $c['startDate']; $endDate = $c['endDate']; $startTs = $c['startTs']; $endTs = $c['endTs']; $channelCode = $c['channelCode']; $channelInfo = $c['channelInfo']; $tagFilterId = $c['tagFilterId']; $primaryDeptIds = $c['primaryDeptIds']; $deptNameMap = $c['deptNameMap']; $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']; $leadsByAdmin = self::countLeadsByAdmin($startTs, $endTs, $tagFilterId); $consultsByAdmin = self::aggregateConsultsByAdmin($startTs, $endTs, null, null); if ($channelFilterActive) { self::excludeErCenterFromAdminLeaderboard($adminToPrimary, $deptNameMap, $feeByAdmin, $leadsByAdmin); } $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 ($primaryDeptIds as $deptId) { $deptName = $deptNameMap[$deptId] ?? ('部门#' . $deptId); $deptAbbr = self::deptDisplayAbbr($deptName); $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); if ($fee < 0.005 && $lead === 0 && $consult === 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, '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, ]; } /** * 列出所有可选部门 * * @return array */ public static function deptOptions(): 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), ]; } 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); } /** * 默认 primary 部门:所有未删除、name 含「中心」、且 pid > 0 的;父子同存时去掉父 * * @return array{0: int[], 1: 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; } $deptIds = []; if (is_string($input) && $input !== '') { $input = explode(',', $input); } if (is_array($input)) { foreach ($input as $v) { $iv = (int) $v; if ($iv > 0) { $deptIds[] = $iv; } } $deptIds = array_values(array_unique($deptIds)); } if ($deptIds === []) { $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); } $names = []; foreach ($deptIds as $id) { $names[$id] = (string) ($byId[$id]['name'] ?? ('部门#' . $id)); } return [$deptIds, $names]; } /** * 每个 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: 医助 (role_id=2) 在区间内已完成挂号 (status=3) 的全部接诊数(不限渠道)。用于「接诊诊单」列。 * - 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 区间起点(含),与 overview 中 start_date 00:00:00 一致 * @param int $endTs 区间终点(含),与 overview 中 end_date 23:59:59 一致 * * @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 { if ($adminToPrimary === []) { return ['all' => [], 'scoped' => []]; } $allByDept = self::aggregateConsults($startTs, $endTs, $adminToPrimary, null, null); 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 + 医助角色 (role_id=2) + 可选 tag 过滤 * 时间:TIMESTAMP(appointment_date, appointment_time) ∈ [startTs, endTs] * * @return array */ private static function aggregateConsults( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $query = Db::name('doctor_appointment') ->alias('a') ->where('a.status', 3) ->where('a.assistant_id', '>', 0) ->field(['a.assistant_id', 'COUNT(*) AS cnt']) ->group('a.assistant_id'); self::applyAppointmentTimestampBetween($query, 'a', $startTs, $endTs); $adminRoleTable = self::tableWithPrefix('admin_role'); $query->whereRaw( "EXISTS (SELECT 1 FROM {$adminRoleTable} ar WHERE ar.admin_id = a.assistant_id AND ar.role_id = 2)" ); if ($tagDiagIds !== null) { $query->whereIn('a.patient_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('a.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; } /** * 接诊按医助汇总(不按部门归并)。 * * @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 []; } $query = Db::name('doctor_appointment') ->alias('a') ->where('a.status', 3) ->where('a.assistant_id', '>', 0) ->field(['a.assistant_id', 'COUNT(*) AS cnt']) ->group('a.assistant_id'); self::applyAppointmentTimestampBetween($query, 'a', $startTs, $endTs); $adminRoleTable = self::tableWithPrefix('admin_role'); $query->whereRaw( "EXISTS (SELECT 1 FROM {$adminRoleTable} ar WHERE ar.admin_id = a.assistant_id AND ar.role_id = 2)" ); if ($tagDiagIds !== null) { $query->whereIn('a.patient_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('a.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; } /** * 进线按接待人(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]; } /** * 业绩按 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 ): void { foreach ($adminToPrimary as $aid => $pid) { if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) { continue; } $feeByAdmin[$aid] = 0.0; $leadsByAdmin[$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); } /** * 挂号记录按「预约日期 + 预约时间」落入区间 [startTs, endTs](与页面 start_date~end_date 推导的整日边界一致) */ private static function applyAppointmentTimestampBetween($query, string $alias, int $startTs, int $endTs): void { $a0 = date('Y-m-d H:i:s', $startTs); $a1 = date('Y-m-d H:i:s', $endTs); $query->whereRaw( "TIMESTAMP({$alias}.appointment_date, {$alias}.appointment_time) BETWEEN ? AND ?", [$a0, $a1] ); } /** * 业绩/成交单统计:仅排除履约已取消 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; } /** * 将总投放成本按展示各部门「进线」条数比例分摊:部门成本 = 总成本 × (部门进线 / 进线合计)。 * 四舍五入尾差并入进线条数最多的部门。 * * @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; } }