0),按**被指派医助**归属展示部门;选定渠道时收窄与「{渠道}业绩」同源(channels EXISTS 或 tag 诊单/医助) * · **二中心展示部门在选定渠道下计 0**(与进线一致) * - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按诊单医助在组织树内归属 rollup(含下级),与 dept 模块 ErCenter 复诊算法一致 * - 接诊诊单 = 已完成挂号 status=3,**与 admin 挂号列表条数同口径**(appointment_date、诊单未软删),不限医助角色、不要求 assistant_id 非 0; * 部门归属:优先 COALESCE(挂号.assistant_id, 诊单.assistant_id) 映射到展示「中心」,否则回退接诊医生 doctor_id;仍无法归属则计入「未归属中心」行。 * - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4;NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag 口径均按订单计(tag 时按诊单医助收窄),非挂号条数。 * - 合计业绩 = 区间内 zyt_tcm_prescription_order.amount 求和(排除已取消(4) 与软删),按 dg.assistant_id 归属到「中心」展示部门; * 无法归属到任一展示部门的金额单列「未归属中心」行;合计行 = 与列表同条件的全表 SUM(不限部门) * · 时间窗与业绩条件与列表 stats_order_amount_performance 一致;列表若另有患者/单号等筛选或账号数据域收窄,金额可能仍不同 * · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**名称含「二中心」的展示部门不参与该渠道进线/业绩/成交单/ROI 分子** * - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按「诊单医助 dg.assistant_id」归部门(与处方列表 assistant_dept 一致); * 渠道判定仅用 EXISTS:"患者有任一已完成挂号 (status=3) 且 channels 命中字典",**不限挂号时点**;无字典映射时回退 tag/creator 口径 * - 成交单数 = 区间内非取消业务订单 **条数**;**按诊单医助 dg.assistant_id** 映射到「中心」部门(与处方订单列表 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); $assignsByDept = self::countDiagnosisAssignsByDept( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagFallback ? $tagAssistantIds : null, $appointmentChannelValues, $channelFilterActive, $dataScopeAdminIds ); // 接诊: // - consults(全量) → "接诊诊单" 列;不限渠道 // - channelConsults(渠道) → 「{渠道}成交单」列;未选渠道时与 consults 相同 $consultsBoth = self::countConsults( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagAssistantIds, $appointmentChannelValues, $channelFilterActive, $dataScopeAdminIds ); $consults = $consultsBoth['all']; $channelConsults = $consultsBoth['scoped']; $bookedByDept = self::aggregateBookedAppointmentsByDept( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagAssistantIds, $dataScopeAdminIds ); // 业绩:与 /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,按诊单医助归属部门,合计用全表 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); self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $assignsByDept); } $globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1); $leadsForCost = self::mergeHiddenPrimaryLeadsForCostAllocation( $primaryDeptIds, $tableRowDeptIds, $leads, $deptToDescendants ); $costs = self::allocateCostByLeads($globalCost, $tableRowDeptIds, $leadsForCost); @set_time_limit(120); $revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs); $revisitTotals = $revisitPack['totals']; $revisitSlotsByDept = $revisitPack['slots']; // ── 5) 组装每行 ────────────────────────────────────────────── $rows = []; $totalLead = 0; $totalAssign = 0; $totalRevisit = 0; /** @var array $totalRevisitSlots */ $totalRevisitSlots = []; $totalChannelConsult = 0; $totalPerf = 0.0; $totalChannelPerf = 0.0; $totalCost = 0.0; $unmappedConsult = (int) ($consults[0] ?? 0); $consultSumInTable = 0; $bookedSumInTable = 0; foreach ($tableRowDeptIds as $deptId) { $lead = (int) ($leads[$deptId] ?? 0); $cnt = (int) ($consults[$deptId] ?? 0); $consultSumInTable += $cnt; $bookedSumInTable += (int) ($bookedByDept[$deptId] ?? 0); $cCnt = (int) ($channelConsults[$deptId] ?? 0); // 合计业绩:部门下所有订单业绩(≠4),不限渠道 $perf = round((float) ($performance[$deptId] ?? 0), 1); // {渠道}业绩:部门 × 选定渠道下业绩(≠4) $cperf = round((float) ($channelPerformance[$deptId] ?? 0), 1); $cost = round((float) ($costs[$deptId] ?? 0), 1); $rowCost = $cost; $rowRoi = $rowCost > 0 ? round($cperf / $rowCost, 1) : null; $dDeal = (int) ($dealOrderByDept[$deptId] ?? 0); $assignCnt = (int) ($assignsByDept[$deptId] ?? 0); $rv = (int) ($revisitTotals[$deptId] ?? 0); $rvSlots = $revisitSlotsByDept[$deptId] ?? []; $rows[] = [ 'dept_id' => $deptId, 'dept_name' => self::formatYejiDeptRowDisplayName($deptId, $deptById), 'lead_count' => $lead, 'assign_count' => $assignCnt, 'revisit_count' => $rv, 'revisit_slots' => $rvSlots, 'appointment_booked_count' => (int) ($bookedByDept[$deptId] ?? 0), 'consult_count' => $cnt, 'deal_order_count' => $dDeal, // {渠道}成交单:选定渠道时与 completed_performance 同源;未选渠道时与 consult_count 相同 'channel_consult_count' => $cCnt, 'performance_amount' => $perf, 'completed_performance_amount' => $cperf, 'avg_price' => $cnt > 0 ? round($perf / $cnt, 1) : null, // {渠道}客单价 = {渠道}业绩 / {渠道}成交单 'channel_avg_price' => $cCnt > 0 ? round($cperf / $cCnt, 1) : null, 'cost_amount' => round($rowCost, 1), 'roi' => $rowRoi, ]; $totalLead += $lead; $totalAssign += $assignCnt; $totalRevisit += $rv; foreach ($rvSlots as $slot => $cntRv) { $slot = (int) $slot; $totalRevisitSlots[$slot] = ($totalRevisitSlots[$slot] ?? 0) + (int) $cntRv; } $totalChannelConsult += $cCnt; $totalPerf += $perf; $totalChannelPerf += $cperf; // 不再用各行成本叠加(避免父级未在展示列时丢失),合计直接用 $globalCost } $allocatedPerf = round($totalPerf, 1); $allocatedChannelPerf = round($totalChannelPerf, 1); $globalPerfAll = self::sumGlobalPerformanceAmount($startTs, $endTs); $globalPerfChannel = $globalPerfAll; if ($channelFilterActive) { $globalPerfChannel = self::sumGlobalChannelScopedPerformance( $startTs, $endTs, $appointmentChannelValues, $tagDiagIds, $tagFallback ? $tagAssistantIds : null ); $globalPerfChannel = round(max(0.0, $globalPerfChannel - $erCenterChannelStrip), 1); } $allocatedDealOrderSum = 0; foreach ($tableRowDeptIds as $rid) { $allocatedDealOrderSum += (int) ($dealOrderByDept[$rid] ?? 0); } $totalBooked = $narrowTotalsToTable ? $bookedSumInTable : array_sum($bookedByDept); if ($narrowTotalsToTable) { $totalConsult = $consultSumInTable; $unmappedDealOrder = 0; $unassignedPerf = 0.0; $unassignedChannelPerf = 0.0; $showUnassignedRow = false; } else { $totalConsult = array_sum($consults); $unmappedDealOrder = max(0, $globalDealOrderCount - $allocatedDealOrderSum); $unassignedPerf = round($globalPerfAll - $allocatedPerf, 1); $unassignedChannelPerf = round($globalPerfChannel - $allocatedChannelPerf, 1); $showUnassignedRow = abs($unassignedPerf) >= 0.01 || abs($unassignedChannelPerf) >= 0.01 || $unmappedConsult > 0 || $unmappedDealOrder > 0; } if ($showUnassignedRow) { $uPerf = $unassignedPerf; $uCperf = $channelFilterActive ? $unassignedChannelPerf : $uPerf; $rows[] = [ 'dept_id' => 0, 'dept_name' => '未归属中心', 'lead_count' => 0, 'assign_count' => 0, 'revisit_count' => 0, 'revisit_slots' => [], 'appointment_booked_count' => (int) ($bookedByDept[0] ?? 0), 'consult_count' => $unmappedConsult, 'deal_order_count' => $unmappedDealOrder, 'channel_consult_count' => 0, 'performance_amount' => $uPerf, 'completed_performance_amount' => $uCperf, 'avg_price' => null, 'channel_avg_price' => null, 'cost_amount' => 0.0, 'roi' => null, ]; } if ($narrowTotalsToTable) { $totalPerf = $allocatedPerf; $totalChannelPerf = $allocatedChannelPerf; $totalDealOrderCount = $allocatedDealOrderSum; } else { $totalPerf = $globalPerfAll; $totalChannelPerf = $globalPerfChannel; $totalDealOrderCount = $globalDealOrderCount; } $totalCost = round($globalCost, 1); // ROI 合计 = 渠道业绩合计(与「completed」列一致,**不含二中心**)÷ 投放成本合计 $totalRoiNumerator = round($totalChannelPerf, 1); if ($totalRevisitSlots !== []) { ksort($totalRevisitSlots, SORT_NUMERIC); } $scopedTableNote = ($explicitDeptFilter ? ' 已筛选展示部门时,表格合计仅含所选部门树内数据,不出现「全站未归属」补差行(避免把其他中心业绩/单量算入未归属)。若勾选的是带下级的部门(如某「中心」),表格只列出其下级部门行,不单独占一行父级。' : '') . ($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, 'assign_count' => $totalAssign, 'revisit_count' => $totalRevisit, 'revisit_slots' => $totalRevisitSlots, 'appointment_booked_count' => $totalBooked, 'consult_count' => $totalConsult, 'deal_order_count' => $totalDealOrderCount, 'channel_consult_count' => $totalChannelConsult, 'performance_amount' => $totalPerf, 'completed_performance_amount' => $totalChannelPerf, 'avg_price' => $totalConsult > 0 ? round($totalPerf / $totalConsult, 1) : null, 'channel_avg_price' => $totalChannelConsult > 0 ? round($totalChannelPerf / $totalChannelConsult, 1) : null, 'cost_amount' => $totalCost, 'cost_per_lead' => $totalLead > 0 && $totalCost > 0 ? round($totalCost / $totalLead, 1) : null, 'roi' => $totalCost > 0 ? round($totalRoiNumerator / $totalCost, 1) : null, ], ]; } /** * 多组时间区间一次性返回(前端"月/周/日"四张表共用一次请求)。 * * @param array $ranges * @return array> */ public static function overviewBatch(array $ranges, array $base, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $out = []; foreach ($ranges as $r) { $params = array_merge($base, [ 'start_date' => (string) ($r['start_date'] ?? date('Y-m-d')), 'end_date' => (string) ($r['end_date'] ?? date('Y-m-d')), ]); $stat = self::overview($params, $viewerAdminId, $viewerAdminInfo); $stat['label'] = (string) ($r['label'] ?? ($stat['start_date'] === $stat['end_date'] ? $stat['start_date'] : ($stat['start_date'] . '~' . $stat['end_date']))); $out[] = $stat; } return $out; } /** * 医助排行榜:按展示部门分表,每表内按诊金降序排名。 * 诊金口径:无渠道=诊单 dg.assistant_id 业绩(与列表 assistant_id 筛选一致);有渠道={渠道}业绩/dict 诊单医助;二中心在选渠道下注 0。 * 被指派数 = 区间内诊单指派日志(to_assistant_id>0)按**被指派医助**计数,与部门表「被指派数」同收窄/二中心规则。 * 接诊率 = 排行用诊金 ÷ 进线条数(元/进线,1 位小数;进线为 0 时为 0)。 * 复诊(属二中心组织架构的展示部门):同一诊单下业务单按 create_time 排序第 2 笔起分项,与部门业绩表「二中心复诊」同源;前端点击数字可下钻对应业务订单。 * * @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params * * @return array */ public static function assistantLeaderboards(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $c = self::resolveYejiContext($params); if ($viewerAdminId > 0) { self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); } $startDate = $c['startDate']; $endDate = $c['endDate']; $startTs = $c['startTs']; $endTs = $c['endTs']; $channelCode = $c['channelCode']; $channelInfo = $c['channelInfo']; $tagFilterId = $c['tagFilterId']; $primaryDeptIds = $c['primaryDeptIds']; $tableRowDeptIds = $c['tableRowDeptIds']; $deptNameMap = $c['deptNameMap']; $deptById = $c['deptById']; $adminToPrimary = $c['adminToPrimary']; $tagDiagIds = $c['tagDiagIds']; $tagAssistantIds = $c['tagAssistantIds']; $tagFallback = $c['tagFallback']; $appointmentChannelValues = $c['appointmentChannelValues']; $channelFilterActive = $c['channelFilterActive']; $dataScopeRestricted = !empty($c['dataScopeRestricted']); $dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds']) ? $c['dataScopeVisibleAdminIds'] : null; $channelName = $channelInfo ? (string) $channelInfo['channel_name'] : ''; if ($adminToPrimary === []) { return [ 'start_date' => $startDate, 'end_date' => $endDate, 'channel_code' => $channelCode, 'channel_name' => $channelName, 'channel_filter_note' => '', 'range_note' => '', 'leaderboards' => [], ]; } $scopedAssistants = $tagFallback ? $tagAssistantIds : null; $perfBoth = self::sumPerformanceByAdmin( $startTs, $endTs, $tagDiagIds, $scopedAssistants, $appointmentChannelValues, $channelFilterActive ); $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); $assignsByAdmin = self::countDiagnosisAssignsByAdmin( $startTs, $endTs, $tagDiagIds, $tagFallback ? $tagAssistantIds : null, $appointmentChannelValues, $channelFilterActive, $dataScopeAdminIds ); $appointmentByAdmin = self::countBookedAppointmentsByAdminForLeaderboard( $startTs, $endTs, $tagDiagIds, $tagFallback ? $tagAssistantIds : null, $dataScopeAdminIds ); if ($channelFilterActive) { self::excludeErCenterFromAdminLeaderboard( $adminToPrimary, $deptNameMap, $feeByAdmin, $leadsByAdmin, $dealOrderByAdmin, $assignsByAdmin ); } $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); $revisitByAssistant = DeptLogic::getErCenterRevisitCountsByAssistant($startTs, $endTs); $assistantIdRows = Db::name('admin_role')->where('role_id', 2)->column('admin_id'); $assistantSet = []; foreach ($assistantIdRows as $rid) { $assistantSet[(int) $rid] = true; } $nameIds = []; foreach (array_keys($assistantSet) as $aid) { if (isset($adminToPrimary[$aid])) { $nameIds[] = $aid; } } $adminNames = []; if ($nameIds !== []) { $nameRows = Db::name('admin') ->whereIn('id', $nameIds) ->whereNull('delete_time') ->column('name', 'id'); foreach ($nameRows as $id => $name) { $adminNames[(int) $id] = (string) $name; } } $rangeNote = $channelFilterActive ? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。' . '「二中心」医助在该渠道下诊金、进线与被指派计 0。' . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。') : '排行诊金 = 处方订单列表 assistant_id 口径(诊单医助业绩,与列表按医助筛选一致)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。' . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。'; $leaderboards = []; foreach ($tableRowDeptIds as $deptId) { $leafName = $deptNameMap[$deptId] ?? ('部门#' . $deptId); $deptName = self::formatYejiDeptRowDisplayName($deptId, $deptById); $deptAbbr = self::deptDisplayAbbr($leafName); $isErCenterLb = isset($erCenterDeptSet[$deptId]); $rows = []; foreach (array_keys($assistantSet) as $aid) { if (($adminToPrimary[$aid] ?? 0) !== $deptId) { continue; } $fee = round((float) ($feeByAdmin[$aid] ?? 0.0), 1); $lead = (int) ($leadsByAdmin[$aid] ?? 0); $consult = (int) ($consultsByAdmin[$aid] ?? 0); $dealOrders = (int) ($dealOrderByAdmin[$aid] ?? 0); $assignCnt = (int) ($assignsByAdmin[$aid] ?? 0); $apptCnt = (int) ($appointmentByAdmin[$aid] ?? 0); $rv = $isErCenterLb ? (int) ($revisitByAssistant['totals'][$aid] ?? 0) : 0; $rvSlots = $isErCenterLb ? ($revisitByAssistant['slots'][$aid] ?? []) : []; $rawFee = (float) ($feeByAdmin[$aid] ?? 0.0); $rate = $lead > 0 ? round($rawFee / $lead, 1) : 0.0; $row = [ 'admin_id' => $aid, 'name' => $adminNames[$aid] ?? ('#' . $aid), 'fee_amount' => $fee, 'lead_count' => $lead, 'assign_count' => $assignCnt, 'consult_count' => $consult, 'deal_order_count' => $dealOrders, 'appointment_count' => $apptCnt, 'consult_rate' => $rate, 'dept_abbr' => $deptAbbr, ]; if ($isErCenterLb) { $row['revisit_count'] = $rv; $row['revisit_slots'] = $rvSlots; } $rows[] = $row; } usort($rows, static function (array $a, array $b): int { if ($a['fee_amount'] != $b['fee_amount']) { return $b['fee_amount'] <=> $a['fee_amount']; } return $b['consult_rate'] <=> $a['consult_rate']; }); foreach ($rows as $i => $row) { $rows[$i]['rank'] = $i + 1; } $leaderboards[] = [ 'dept_id' => $deptId, 'dept_name' => $deptName, 'dept_abbr' => $deptAbbr, 'er_center_subtree' => $isErCenterLb, 'rows' => $rows, ]; } return [ 'start_date' => $startDate, 'end_date' => $endDate, 'channel_code' => $channelCode, 'channel_name' => $channelName, 'channel_filter_note' => $channelCode !== '' ? '医助榜:名称含「二中心」的部门在选定渠道下诊金、进线、被指派数计 0,与部门看板一致。' : '', 'range_note' => $rangeNote, 'leaderboards' => $leaderboards, ]; } /** * 数据范围下:可见成员所在部门及其下级部门 id(用于部门下拉收窄)。 * * @return array|null null 表示不限制,[] 表示无可选 */ private static function buildYejiDataScopeAllowedDeptIdMap(int $adminId, array $adminInfo): ?array { return DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo); } /** * 列出所有可选部门 * * @return array */ public static function deptOptions(int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $rows = Db::name('dept')->whereNull('delete_time')->order('pid asc, id asc')->select()->toArray(); if ($rows === []) { return []; } $byId = []; foreach ($rows as $r) { $byId[(int) $r['id']] = $r; } $buildPath = function (int $id) use ($byId): string { $names = []; $cur = $id; for ($i = 0; $i < 50; $i++) { if (!isset($byId[$cur])) { break; } array_unshift($names, (string) $byId[$cur]['name']); $pid = (int) $byId[$cur]['pid']; if ($pid <= 0) { break; } $cur = $pid; } return implode(' / ', array_filter($names, static fn($s): bool => $s !== '')); }; $out = []; foreach ($rows as $r) { $id = (int) $r['id']; $out[] = [ 'id' => $id, 'name' => (string) $r['name'], 'pid' => (int) $r['pid'], 'path' => $buildPath($id), ]; } $allowed = self::buildYejiDataScopeAllowedDeptIdMap($viewerAdminId, $viewerAdminInfo); if ($allowed !== null) { if ($allowed === []) { return []; } $out = array_values(array_filter( $out, static function (array $item) use ($allowed): bool { return isset($allowed[(int) ($item['id'] ?? 0)]); } )); } return $out; } /** * 渠道下拉(按 source_group_name 分组),同时附带客户/成本统计便于排序展示 * * @return array{ * groups: array * }>, * total: int * } */ public static function channelOptions(): array { $rows = Db::name('qywx_media_channel') ->where('status', 1) ->order('source_group_name asc, id asc') ->select() ->toArray(); if ($rows === []) { return ['groups' => [], 'total' => 0]; } // 顺路给 tag_* 渠道带上「打了该标签的客户数」(直接用 contact_tag 表) $tagIds = []; foreach ($rows as $r) { $code = (string) $r['channel_code']; if (str_starts_with($code, 'tag_')) { $tagIds[] = substr($code, 4); } } $tagToCount = []; if ($tagIds !== []) { $cntRows = Db::name('qywx_external_contact_tag') ->whereIn('tag_id', $tagIds) ->fieldRaw('tag_id, COUNT(DISTINCT external_userid) AS c') ->group('tag_id') ->select() ->toArray(); foreach ($cntRows as $cr) { $tagToCount[(string) $cr['tag_id']] = (int) $cr['c']; } } $groups = []; foreach ($rows as $r) { $g = (string) ($r['source_group_name'] ?: '其它'); $groups[$g] ??= ['group_name' => $g, 'channels' => []]; $code = (string) $r['channel_code']; $custCount = 0; if (str_starts_with($code, 'tag_')) { $tid = substr($code, 4); $custCount = (int) ($tagToCount[$tid] ?? 0); } $groups[$g]['channels'][] = [ 'channel_code' => $code, 'channel_name' => (string) $r['channel_name'], 'source_tag_id' => (string) $r['source_tag_id'], 'source_group_name' => $g, 'customer_count' => $custCount, ]; } // 「自媒体」类组排前 $sortedGroups = array_values($groups); usort($sortedGroups, static function (array $a, array $b): int { $aMedia = mb_strpos($a['group_name'], '自媒体') !== false ? 0 : 1; $bMedia = mb_strpos($b['group_name'], '自媒体') !== false ? 0 : 1; if ($aMedia !== $bMedia) { return $aMedia <=> $bMedia; } return strcmp($a['group_name'], $b['group_name']); }); return ['groups' => $sortedGroups, 'total' => count($rows)]; } // ───────────────────────────────────────────────────────────────── // 内部 // ───────────────────────────────────────────────────────────────── private static function normalizeDate(string $s): string { $s = trim($s); $ts = strtotime($s); return $ts === false ? date('Y-m-d') : date('Y-m-d', $ts); } /** * 请求是否显式带了部门筛选(非空 dept_ids),用于与「默认全部中心」区分。 */ private static function hasExplicitDeptIdsParam($raw): bool { if (\is_string($raw) && trim($raw) !== '') { foreach (explode(',', $raw) as $p) { if ((int) trim($p) > 0) { return true; } } } if (\is_array($raw)) { foreach ($raw as $v) { if ((int) $v > 0) { return true; } } } return false; } /** * 用户传入 dept_ids 时:展开为「所选部门 + 全子孙」作为 primary(用于归属与聚合);表格行序同序。 * 若用户勾选的是含下级部门的父节点(如「一中心」),则表格**仅展示其下级部门行**,不重复展示该中心父级行;其它中心不会出现在展开集合中。 * 默认(未传):primary 与表格行均为所有 name 含「中心」且 pid>0 的节点(父子同存时去掉父)。 * * @return array{0: int[], 1: array, 2: int[], 3: array>} */ private static function resolvePrimaryDepts($input): array { $rows = Db::name('dept')->whereNull('delete_time')->field(['id', 'name', 'pid'])->order('pid asc, id asc')->select()->toArray(); $byId = []; foreach ($rows as $r) { $byId[(int) $r['id']] = $r; } $childrenOf = []; foreach ($rows as $r) { $pid = (int) $r['pid']; $childrenOf[$pid][] = (int) $r['id']; } $userRoots = []; if (\is_string($input) && $input !== '') { $input = explode(',', $input); } if (\is_array($input)) { foreach ($input as $v) { $iv = (int) $v; if ($iv > 0) { $userRoots[] = $iv; } } $userRoots = array_values(array_unique($userRoots)); } if ($userRoots !== []) { $deptIds = self::expandDeptRootIdsWithDescendants($userRoots, $childrenOf); $deptIds = self::sortYejiDeptIdsAncestorsBeforeDescendants($deptIds, $byId); $inExpanded = array_flip($deptIds); $tableRowDeptIds = []; foreach ($deptIds as $id) { $isUserRoot = \in_array($id, $userRoots, true); $hasChildInExpanded = false; foreach (($childrenOf[$id] ?? []) as $cid) { if (isset($inExpanded[$cid])) { $hasChildInExpanded = true; break; } } if ($isUserRoot && $hasChildInExpanded) { continue; } $tableRowDeptIds[] = $id; } } else { $candidates = []; foreach ($rows as $r) { if (mb_strpos((string) $r['name'], '中心') !== false && (int) $r['pid'] > 0) { $candidates[(int) $r['id']] = true; } } // 父子都在 → 去掉父 foreach (array_keys($candidates) as $id) { $cur = (int) $byId[$id]['pid']; while ($cur > 0) { if (isset($candidates[$cur])) { unset($candidates[$cur]); } $cur = (int) ($byId[$cur]['pid'] ?? 0); } } $deptIds = array_keys($candidates); sort($deptIds); $tableRowDeptIds = $deptIds; } $names = []; foreach ($deptIds as $id) { $names[$id] = (string) ($byId[$id]['name'] ?? ('部门#' . $id)); } return [$deptIds, $names, $tableRowDeptIds, $byId]; } /** * 表格展示用部门名:自根到本级的全路径「父 / … / 子」;顶级「甄养堂互联网医院」不展示(与业务筛选里常见根节点重复冗长)。 * * @param array> $byId */ private static function formatYejiDeptRowDisplayName(int $deptId, array $byId): string { if ($deptId <= 0 || !isset($byId[$deptId])) { return '部门#' . $deptId; } $leafLabel = (string) $byId[$deptId]['name']; $names = []; $cur = $deptId; for ($i = 0; $i < 50; $i++) { if (!isset($byId[$cur])) { break; } array_unshift($names, (string) $byId[$cur]['name']); $pid = (int) $byId[$cur]['pid']; if ($pid <= 0) { break; } $cur = $pid; } $names = array_values(array_filter($names, static fn (string $s): bool => $s !== '')); if ($names !== [] && $names[0] === '甄养堂互联网医院') { array_shift($names); } if ($names === []) { return $leafLabel !== '' ? $leafLabel : ('部门#' . $deptId); } return implode(' / ', $names); } /** * @param array $childrenOf 父 id => 子部门 id 列表(来自未删除部门表) * * @return int[] */ private static function expandDeptRootIdsWithDescendants(array $rootIds, array $childrenOf): array { $set = []; $stack = array_values(array_filter(array_map('intval', $rootIds), static fn (int $id): bool => $id > 0)); while ($stack !== []) { $id = (int) array_pop($stack); if ($id <= 0 || isset($set[$id])) { continue; } $set[$id] = true; foreach (($childrenOf[$id] ?? []) as $cid) { $cid = (int) $cid; if ($cid > 0 && !isset($set[$cid])) { $stack[] = $cid; } } } return array_keys($set); } /** * 同一展开集合内:深度小的 id 在前,保证其后 buildDeptDescendants 写入 deptToPrimary 时子部门覆盖父部门。 * * @param array> $byId * * @return int[] */ private static function sortYejiDeptIdsAncestorsBeforeDescendants(array $deptIds, array $byId): array { $deptIds = array_values(array_unique(array_map('intval', $deptIds))); if ($deptIds === []) { return []; } $idSet = array_flip($deptIds); $memo = []; $depthFn = static function (int $id) use (&$depthFn, &$memo, $byId, $idSet): int { if (isset($memo[$id])) { return $memo[$id]; } if (!isset($idSet[$id])) { return $memo[$id] = -1; } $pid = (int) ($byId[$id]['pid'] ?? 0); if ($pid <= 0 || !isset($idSet[$pid])) { return $memo[$id] = 0; } return $memo[$id] = $depthFn($pid) + 1; }; usort($deptIds, static function (int $a, int $b) use ($depthFn): int { $da = $depthFn($a); $db = $depthFn($b); if ($da !== $db) { return $da <=> $db; } return $a <=> $b; }); return $deptIds; } /** * 每个 primary dept_id 的所有子孙(含自身)id 集合 * * @param int[] $primaryDeptIds * @return array */ private static function buildDeptDescendants(array $primaryDeptIds): array { $rows = Db::name('dept')->whereNull('delete_time')->field(['id', 'pid'])->select()->toArray(); $childrenOf = []; foreach ($rows as $r) { $pid = (int) $r['pid']; $childrenOf[$pid][] = (int) $r['id']; } $result = []; foreach ($primaryDeptIds as $rootId) { $stack = [$rootId]; $set = []; while ($stack !== []) { $cur = (int) array_pop($stack); if (isset($set[$cur])) { continue; } $set[$cur] = true; foreach (($childrenOf[$cur] ?? []) as $child) { if (!isset($set[$child])) { $stack[] = $child; } } } $result[$rootId] = array_keys($set); } return $result; } /** * admin_id → primary dept_id 归属 * * @param int[] $primaryDeptIds * @param array $deptToDescendants * @return array */ private static function buildAdminToPrimaryDept(array $primaryDeptIds, array $deptToDescendants): array { if ($primaryDeptIds === []) { return []; } $deptToPrimary = []; foreach ($deptToDescendants as $primary => $descs) { foreach ($descs as $d) { $deptToPrimary[$d] = $primary; } } if ($deptToPrimary === []) { return []; } $links = Db::name('admin_dept') ->whereIn('dept_id', array_keys($deptToPrimary)) ->field(['admin_id', 'dept_id']) ->select() ->toArray(); $adminToPrimary = []; foreach ($links as $l) { $adminId = (int) $l['admin_id']; $deptId = (int) $l['dept_id']; if (!isset($deptToPrimary[$deptId])) { continue; } if (!isset($adminToPrimary[$adminId])) { $adminToPrimary[$adminId] = $deptToPrimary[$deptId]; } } return $adminToPrimary; } /** * 进线数据: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; } /** * 被指派数:诊单指派日志(to_assistant_id>0)按被指派医助归属展示部门;选定渠道时收窄与「{渠道}业绩」同源。 * * @param array $adminToPrimary * @param int[]|null $tagDiagIds * @param array|null $tagAssistantIds tag 退化口径下按 to_assistant_id 过滤(与 aggregatePerformance 一致) * * @return array */ private static function countDiagnosisAssignsByDept( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds, array $appointmentChannelValues, bool $channelFilterActive, ?array $dataScopeAdminIds = null ): array { if ($adminToPrimary === []) { return []; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $query = Db::name('tcm_diagnosis_assign_log') ->alias('lg') ->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->where('lg.create_time', 'between', [$startTs, $endTs]) ->where('lg.to_assistant_id', '>', 0); if ($channelFilterActive) { if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { return []; } $apTable = self::tableWithPrefix('doctor_appointment'); $adminRoleTable = self::tableWithPrefix('admin_role'); $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm))); $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 = dg.id " . "AND ap.status = 3 AND {$channelCond})"; $query->whereRaw($existsSql, $strVals); } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } if ($tagDiagIds !== null) { $query->whereIn('lg.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds))); } } else { return []; } } $query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')]) ->group('lg.to_assistant_id'); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] ? array_flip($dataScopeAdminIds) : null; $byDept = []; foreach ($rows as $r) { $aid = (int) ($r['to_assistant_id'] ?? 0); if ($aid <= 0) { continue; } if ($visFlip !== null && !isset($visFlip[$aid])) { continue; } $primary = (int) ($adminToPrimary[$aid] ?? 0); if ($primary <= 0) { continue; } $byDept[$primary] = ($byDept[$primary] ?? 0) + (int) ($r['cnt'] ?? 0); } return $byDept; } /** * 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间,按 to_assistant_id 汇总(供排行榜)。 * * @return array */ private static function countDiagnosisAssignsByAdmin( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIds, array $appointmentChannelValues, bool $channelFilterActive, ?array $dataScopeAdminIds = null ): array { $diagTable = self::tableWithPrefix('tcm_diagnosis'); $query = Db::name('tcm_diagnosis_assign_log') ->alias('lg') ->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->where('lg.create_time', 'between', [$startTs, $endTs]) ->where('lg.to_assistant_id', '>', 0); if ($channelFilterActive) { if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { return []; } $apTable = self::tableWithPrefix('doctor_appointment'); $adminRoleTable = self::tableWithPrefix('admin_role'); $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm))); $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 = dg.id " . "AND ap.status = 3 AND {$channelCond})"; $query->whereRaw($existsSql, $strVals); } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } if ($tagDiagIds !== null) { $query->whereIn('lg.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds))); } } else { return []; } } $query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')]) ->group('lg.to_assistant_id'); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] ? array_flip($dataScopeAdminIds) : null; $byAdmin = []; foreach ($rows as $r) { $aid = (int) ($r['to_assistant_id'] ?? 0); if ($aid <= 0) { continue; } if ($visFlip !== null && !isset($visFlip[$aid])) { continue; } $byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) ($r['cnt'] ?? 0); } return $byAdmin; } /** * 接诊诊单聚合:返回两组按部门归属的接诊计数。 * - all: 区间内 status=3 挂号条数按部门归属(与列表计数一致),不含「仅 role_id=2」等额外限制。 * - scoped: 选定渠道时「{渠道}成交单」= **非取消业务订单条数**(与 {渠道}业绩同源):有 dict(channels) 走诊单医助+EXISTS 口径;否则走 tag(diagnosis_id IN / **诊单医助** 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::aggregateDealOrderCount( $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; } /** * 业绩表「预约诊单」列:status∈{1,3,4};部门归属规则与 {@see aggregateConsults} 一致。 * * @return array 展示部门 id => 条数(键 0 为无法归入展示「中心」) */ private static function aggregateBookedAppointmentsByDept( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds, ?array $dataScopeAdminIds = null ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $startDate = date('Y-m-d', $startTs); $endDate = date('Y-m-d', $endTs); $eff = self::consultEffectiveAssistantSql(); $query = Db::name('doctor_appointment')->alias('a') ->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id') ->whereBetween('a.appointment_date', [$startDate, $endDate]) ->whereIn('a.status', [1, 3, 4]) ->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)'); if ($tagDiagIds !== null) { $query->whereIn('a.patient_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')'); } $query ->field([ Db::raw("({$eff}) AS eff_a"), 'a.doctor_id', Db::raw('COUNT(*) AS cnt'), ]) ->group([$eff, 'a.doctor_id']); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] ? array_flip($dataScopeAdminIds) : null; $byDept = []; foreach ($rows as $r) { $effId = (int) ($r['eff_a'] ?? 0); $docId = (int) ($r['doctor_id'] ?? 0); $cnt = (int) ($r['cnt'] ?? 0); if ($cnt <= 0) { continue; } if ($visFlip !== null) { if ($effId > 0) { if (!isset($visFlip[$effId])) { continue; } } elseif ($docId > 0) { if (!isset($visFlip[$docId])) { continue; } } else { continue; } } $primary = 0; if ($effId > 0) { $primary = (int) ($adminToPrimary[$effId] ?? 0); } if ($primary <= 0 && $docId > 0) { $primary = (int) ($adminToPrimary[$docId] ?? 0); } if ($primary > 0) { $byDept[$primary] = ($byDept[$primary] ?? 0) + $cnt; } else { $byDept[0] = ($byDept[0] ?? 0) + $cnt; } } return $byDept; } /** * 接诊按医助汇总(不按部门归并)。 * * @return array */ private static function aggregateConsultsByAdmin( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIds ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $eff = self::consultEffectiveAssistantSql(); $query = Db::name('doctor_appointment')->alias('a'); self::applyConsultFiltersAlignedWithAppointmentLists($query, $startTs, $endTs); if ($tagDiagIds !== null) { $query->whereIn('a.patient_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')'); } $query->field([Db::raw("({$eff}) AS eff_a"), Db::raw('COUNT(*) AS cnt')]) ->group($eff); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byAdmin = []; foreach ($rows as $r) { $aid = (int) ($r['eff_a'] ?? 0); if ($aid <= 0) { continue; } $byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) $r['cnt']; } return $byAdmin; } /** * 与挂号列表一致的有效医助:优先挂号表创建人 assistant_id,为 0 时用诊单医助(列表 join 展示字段)。 */ private static function consultEffectiveAssistantSql(): string { return 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))'; } /** * 医助排行榜「预约诊单」:`doctor_appointment.appointment_date` 落在统计区间内; * 状态为已预约(1)、已完成(3)、已过号(4),不含已取消(2); * 有效医助与 {@see consultEffectiveAssistantSql()} 一致。 * * @return array */ private static function countBookedAppointmentsByAdminForLeaderboard( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIds, ?array $dataScopeAdminIds ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $startDate = date('Y-m-d', $startTs); $endDate = date('Y-m-d', $endTs); $eff = self::consultEffectiveAssistantSql(); $query = Db::name('doctor_appointment')->alias('a') ->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id') ->whereBetween('a.appointment_date', [$startDate, $endDate]) ->whereIn('a.status', [1, 3, 4]) ->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)'); if ($tagDiagIds !== null) { $query->whereIn('a.patient_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')'); } $query->field([Db::raw("({$eff}) AS eff_a"), Db::raw('COUNT(*) AS cnt')]) ->group($eff); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] ? array_flip($dataScopeAdminIds) : null; $byAdmin = []; foreach ($rows as $r) { $aid = (int) ($r['eff_a'] ?? 0); if ($aid <= 0) { continue; } if ($visFlip !== null && !isset($visFlip[$aid])) { continue; } $byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) ($r['cnt'] ?? 0); } return $byAdmin; } /** * doctor_appointment 别名 a:与 AppointmentLists 已完成挂号 **计数** 对齐(诊单软删、appointment_date、status=3)。 * 不按医助角色或 assistant_id 再收窄(与列表 tab 总数一致)。 * * @param \think\db\Query $query */ private static function applyConsultFiltersAlignedWithAppointmentLists($query, int $startTs, int $endTs): void { $startDate = date('Y-m-d', $startTs); $endDate = date('Y-m-d', $endTs); $query->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id') ->where('a.status', 3) ->whereBetween('a.appointment_date', [$startDate, $endDate]) ->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)'); } /** * 进线按接待人(admin)汇总;不按展示部门截断,后续按 adminToPrimary 归属。 * * @return array */ private static function countLeadsByAdmin(int $startTs, int $endTs, string $tagFilterId): array { $query = Db::name('qywx_external_contact_event') ->alias('e') ->where('e.change_type', 'add_external_contact') ->where('e.event_time', 'between', [$startTs, $endTs]) ->where('e.user_id', '<>', '') ->field(['e.user_id', 'COUNT(*) AS cnt']) ->group('e.user_id'); if ($tagFilterId !== '') { $tagTable = self::tableWithPrefix('qywx_external_contact_tag'); $query->whereRaw( "EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)", [$tagFilterId] ); } $rows = $query->select()->toArray(); if ($rows === []) { return []; } $userIds = array_column($rows, 'user_id'); $adminIdByWx = Db::name('admin') ->whereIn('work_wechat_userid', $userIds) ->whereNull('delete_time') ->column('id', 'work_wechat_userid'); $byAdmin = []; foreach ($rows as $r) { $wx = (string) $r['user_id']; $adminId = (int) ($adminIdByWx[$wx] ?? 0); if ($adminId <= 0) { continue; } $byAdmin[$adminId] = ($byAdmin[$adminId] ?? 0) + (int) $r['cnt']; } return $byAdmin; } /** * 进线事件查询公共条件(与 countLeads / countLeadsByAdmin 一致)。 * * @param \think\db\BaseQuery $query */ private static function applyLeadEventBaseFilter($query, int $startTs, int $endTs, string $tagFilterId): void { $query->where('e.change_type', 'add_external_contact') ->where('e.event_time', 'between', [$startTs, $endTs]) ->where('e.user_id', '<>', ''); if ($tagFilterId !== '') { $tagTable = self::tableWithPrefix('qywx_external_contact_tag'); $query->whereRaw( "EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)", [$tagFilterId] ); } } /** * 归属到指定展示「中心」行部门的接待成员企微 userid 列表。 * * @param array $adminToPrimary * * @return list */ private static function collectWorkWxUserIdsForYejiDeptRow(int $deptRowId, array $adminToPrimary): array { $adminIds = []; foreach ($adminToPrimary as $aid => $pid) { if ((int) $pid === $deptRowId) { $adminIds[] = (int) $aid; } } if ($adminIds === []) { return []; } $wids = Db::name('admin') ->whereIn('id', $adminIds) ->whereNull('delete_time') ->column('work_wechat_userid'); $out = []; foreach ($wids as $w) { $w = trim((string) $w); if ($w !== '') { $out[] = $w; } } return array_values(array_unique($out)); } /** * 进线明细:与看板「进线数据」同口径的 add_external_contact 逐条列表。 * * @param array{ * start_date?: string, * end_date?: string, * dept_ids?: int[]|string, * channel_code?: string, * tag_id?: string, * dept_id?: int|string, * page?: int|string, * page_size?: int|string * } $params * * @return array{ * start_date: string, * end_date: string, * dept_id: int, * dept_name: string, * channel_code: string, * count: int, * lists: list>, * note: string * } */ public static function leadLineList(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $c = self::resolveYejiContext($params); if ($viewerAdminId > 0) { self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); } $deptId = (int) ($params['dept_id'] ?? 0); $empty = static function (array $c, int $deptId, string $deptName, string $note) { return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'dept_id' => $deptId, 'dept_name' => $deptName, 'channel_code' => (string) $c['channelCode'], 'count' => 0, 'lists' => [], 'note' => $note, ]; }; if ($deptId <= 0) { return $empty($c, 0, '', '未归属中心无进线明细(看板该行进线为 0)。'); } if (!in_array($deptId, $c['tableRowDeptIds'], true)) { return $empty( $c, $deptId, self::formatYejiDeptRowDisplayName($deptId, $c['deptById']), '该部门不在当前展示部门筛选或数据权限范围内。' ); } $deptNameMap = $c['deptNameMap']; $channelCode = (string) $c['channelCode']; if ($channelCode !== '' && self::isErCenterDisplayPrimaryName((string) ($deptNameMap[$deptId] ?? ''))) { return $empty( $c, $deptId, self::formatYejiDeptRowDisplayName($deptId, $c['deptById']), '与看板一致:名称含「二中心」的展示部门在选定渠道下进线计 0,无明细。' ); } $startTs = (int) $c['startTs']; $endTs = (int) $c['endTs']; $tagFilterId = (string) $c['tagFilterId']; $page = max(1, (int) ($params['page'] ?? 1)); $limit = min(100, max(1, (int) ($params['page_size'] ?? 20))); $offset = ($page - 1) * $limit; $wxUserIds = self::collectWorkWxUserIdsForYejiDeptRow($deptId, $c['adminToPrimary']); $deptName = self::formatYejiDeptRowDisplayName($deptId, $c['deptById']); $ecTable = self::tableWithPrefix('qywx_external_contact'); if ($wxUserIds === []) { return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'dept_id' => $deptId, 'dept_name' => $deptName, 'channel_code' => $channelCode, 'count' => 0, 'lists' => [], 'note' => '当前部门下没有可映射到接待企微成员的管理员,无进线流水。', ]; } $countQuery = Db::name('qywx_external_contact_event')->alias('e'); self::applyLeadEventBaseFilter($countQuery, $startTs, $endTs, $tagFilterId); $countQuery->whereIn('e.user_id', $wxUserIds); $count = (int) $countQuery->count(); $listQuery = Db::name('qywx_external_contact_event')->alias('e') ->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL') ->leftJoin("{$ecTable} ec", 'ec.external_userid = e.external_userid AND ec.delete_time IS NULL'); self::applyLeadEventBaseFilter($listQuery, $startTs, $endTs, $tagFilterId); $listQuery->whereIn('e.user_id', $wxUserIds); $rawList = $listQuery ->field( 'e.id,e.event_time,e.external_userid,e.user_id,e.state,' . 'a.name AS reception_admin_name,ec.name AS external_contact_name' ) ->order('e.event_time', 'desc') ->order('e.id', 'desc') ->limit($offset, $limit) ->select() ->toArray(); $lists = []; foreach ($rawList as $rw) { $ts = (int) ($rw['event_time'] ?? 0); $lists[] = [ 'id' => (int) ($rw['id'] ?? 0), 'event_time' => $ts, 'event_time_text' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '', 'external_userid' => (string) ($rw['external_userid'] ?? ''), 'user_id' => (string) ($rw['user_id'] ?? ''), 'state' => (string) ($rw['state'] ?? ''), 'reception_admin_name' => (string) ($rw['reception_admin_name'] ?? ''), 'external_contact_name' => (string) ($rw['external_contact_name'] ?? ''), ]; } $note = '与看板「进线数据」同口径:企微 change_type=add_external_contact,按接待成员归属当前部门行;' . ($tagFilterId !== '' ? '已按所选渠道标签过滤客户。' : '未选渠道时不限制客户标签。'); return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'dept_id' => $deptId, 'dept_name' => $deptName, 'channel_code' => $channelCode, 'count' => $count, 'lists' => $lists, 'note' => $note, ]; } /** * admin → 展示「中心」映射:用于挂号明细按部门筛选(与 aggregateConsults 映射同源)。 */ private static function buildYejiAdminPrimaryMapUnionSql(array $adminToPrimary): string { if ($adminToPrimary === []) { return 'SELECT 0 AS map_aid, 0 AS map_pid WHERE 0 = 1'; } $parts = []; foreach ($adminToPrimary as $aid => $pid) { $parts[] = 'SELECT ' . (int) $aid . ' AS map_aid,' . (int) $pid . ' AS map_pid'; } return implode(' UNION ALL ', $parts); } /** * @return list> */ private static function formatYejiAppointmentLinePayloadRows(array $rawList, bool $hasChSrc): array { $statusMap = [ 1 => '已预约', 2 => '已取消', 3 => '已完成', 4 => '已过号', ]; $typeMap = [ 'video' => '视频问诊', 'text' => '图文问诊', 'phone' => '电话问诊', ]; $lists = []; foreach ($rawList as $rw) { $st = (int) ($rw['status'] ?? 0); $tp = (string) ($rw['appointment_type'] ?? ''); $lists[] = [ 'id' => (int) ($rw['id'] ?? 0), 'appointment_date' => (string) ($rw['appointment_date'] ?? ''), 'appointment_time' => (string) ($rw['appointment_time'] ?? ''), 'status' => $st, 'status_desc' => $statusMap[$st] ?? '未知', 'appointment_type' => $tp, 'appointment_type_desc' => $typeMap[$tp] ?? '未知', 'diagnosis_id' => (int) ($rw['patient_id'] ?? 0), 'patient_name' => (string) ($rw['patient_name'] ?? ''), 'patient_phone' => (string) ($rw['patient_phone'] ?? ''), 'doctor_name' => (string) ($rw['doctor_name'] ?? ''), 'channel_source' => $hasChSrc ? (string) ($rw['channel_source'] ?? '') : '', ]; } return $lists; } /** * 业绩表部门行「预约诊单」挂号明细(与 {@see aggregateBookedAppointmentsByDept} 同口径)。 * * @param array $c resolveYejiContext + applyYejiDataScope 后的上下文 * * @return array */ private static function yejiDeptAppointmentLinesInner(array $params, array $c): array { $deptIdParam = (int) ($params['dept_id'] ?? 0); $channelCode = (string) $c['channelCode']; $deptName = $deptIdParam === 0 ? '未归属中心' : self::formatYejiDeptRowDisplayName($deptIdParam, $c['deptById']); $empty = static function (string $note) use ($c, $channelCode, $deptIdParam, $deptName): array { return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'assistant_id' => 0, 'assistant_name' => '', 'dept_id' => $deptIdParam, 'dept_name' => $deptName, 'channel_code' => $channelCode, 'count' => 0, 'lists' => [], 'note' => $note, ]; }; if ($deptIdParam < 0) { return $empty('请指定有效部门。'); } if ($deptIdParam > 0 && !in_array($deptIdParam, $c['tableRowDeptIds'], true)) { return $empty('该部门不在当前展示部门筛选或数据权限范围内。'); } $tagDiagIds = $c['tagDiagIds']; $tagAssistantIds = $c['tagAssistantIds']; if ($tagDiagIds !== null && $tagDiagIds === []) { return $empty('当前渠道标签下无关联诊单,无挂号明细。'); } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return $empty('当前渠道标签下无关联医助,无挂号明细。'); } $startTs = (int) $c['startTs']; $endTs = (int) $c['endTs']; $startDate = date('Y-m-d', $startTs); $endDate = date('Y-m-d', $endTs); $eff = self::consultEffectiveAssistantSql(); $page = max(1, (int) ($params['page'] ?? 1)); $limit = min(100, max(1, (int) ($params['page_size'] ?? 20))); $offset = ($page - 1) * $limit; $hasChSrc = self::doctorAppointmentHasChannelSourceColumn(); $mapWrapped = '(' . self::buildYejiAdminPrimaryMapUnionSql($c['adminToPrimary']) . ')'; $dataScopeRestricted = !empty($c['dataScopeRestricted']); $dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds']) ? $c['dataScopeVisibleAdminIds'] : null; $buildBaseQuery = static function () use ( $eff, $startDate, $endDate, $tagDiagIds, $tagAssistantIds, $mapWrapped, $deptIdParam, $dataScopeAdminIds ) { $q = Db::name('doctor_appointment')->alias('a') ->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id') ->leftJoin('admin doc', 'a.doctor_id = doc.id AND doc.delete_time IS NULL') ->whereBetween('a.appointment_date', [$startDate, $endDate]) ->whereIn('a.status', [1, 3, 4]) ->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)'); if ($tagDiagIds !== null) { $q->whereIn('a.patient_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); if ($ids !== []) { $q->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')'); } } $primaryPred = 'COALESCE(' . "IF(({$eff}) > 0, (SELECT m.map_pid FROM {$mapWrapped} m WHERE m.map_aid = ({$eff}) LIMIT 1), NULL)," . " IF(a.doctor_id > 0, (SELECT m2.map_pid FROM {$mapWrapped} m2 WHERE m2.map_aid = a.doctor_id LIMIT 1), NULL)," . ' 0)'; $q->whereRaw("{$primaryPred} = ?", [$deptIdParam]); if ($dataScopeAdminIds !== null && $dataScopeAdminIds !== []) { $visIn = implode(',', array_map('intval', $dataScopeAdminIds)); $q->whereRaw( "(({$eff}) > 0 AND ({$eff}) IN ({$visIn})) OR (({$eff}) <= 0 AND a.doctor_id > 0 AND a.doctor_id IN ({$visIn}))" ); } return $q; }; $count = (int) $buildBaseQuery()->count(); $fieldParts = [ 'a.id', 'a.appointment_date', 'a.appointment_time', 'a.status', 'a.appointment_type', 'a.patient_id', 'u.patient_name', 'u.phone AS patient_phone', 'doc.name AS doctor_name', ]; if ($hasChSrc) { $fieldParts[] = 'a.channel_source'; } $rawList = $buildBaseQuery() ->field(implode(',', $fieldParts)) ->order('a.appointment_date', 'asc') ->order('a.appointment_time', 'asc') ->order('a.id', 'asc') ->limit($offset, $limit) ->select() ->toArray(); $lists = self::formatYejiAppointmentLinePayloadRows($rawList, $hasChSrc); $note = '与业绩表「预约诊单」列同口径:预约日期在统计区间内,状态为已预约 / 已完成 / 已过号(不含已取消);部门归属医助优先,否则接诊医生。'; return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'assistant_id' => 0, 'assistant_name' => '', 'dept_id' => $deptIdParam, 'dept_name' => $deptName, 'channel_code' => $channelCode, 'count' => $count, 'lists' => $lists, 'note' => $note, ]; } /** * 医助排行榜「预约诊单」逐条挂号明细(与 {@see countBookedAppointmentsByAdminForLeaderboard} 同口径)。 * * @param array{ * start_date?: string, * end_date?: string, * dept_ids?: int[]|string, * channel_code?: string, * tag_id?: string, * assistant_id?: int|string, * admin_id?: int|string, * dept_id?: int|string, * page?: int|string, * page_size?: int|string * } $params * * @return array{ * start_date: string, * end_date: string, * assistant_id: int, * assistant_name: string, * dept_id: int, * dept_name: string, * channel_code: string, * count: int, * lists: list>, * note: string * } */ public static function leaderboardAppointmentLines( array $params, int $viewerAdminId = 0, array $viewerAdminInfo = [] ): array { $c = self::resolveYejiContext($params); if ($viewerAdminId > 0) { self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); } $assistantId = (int) ($params['assistant_id'] ?? $params['admin_id'] ?? 0); if ($assistantId <= 0 && array_key_exists('dept_id', $params)) { return self::yejiDeptAppointmentLinesInner($params, $c); } $channelCode = (string) $c['channelCode']; $assistantName = ''; if ($assistantId > 0) { $assistantName = (string) (Db::name('admin')->where('id', $assistantId)->whereNull('delete_time')->value('name') ?: ''); } $baseOut = static function (array $c, string $note) use ($assistantId, $assistantName, $channelCode): array { return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'assistant_id' => $assistantId, 'assistant_name' => $assistantName, 'dept_id' => 0, 'dept_name' => '', 'channel_code' => $channelCode, 'count' => 0, 'lists' => [], 'note' => $note, ]; }; if ($assistantId <= 0) { return $baseOut($c, '请指定有效医助,或传入 dept_id 查看部门汇总明细。'); } $dataScopeRestricted = !empty($c['dataScopeRestricted']); $dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds']) ? $c['dataScopeVisibleAdminIds'] : null; if ($dataScopeRestricted && $dataScopeAdminIds !== null) { $flip = array_flip($dataScopeAdminIds); if (!isset($flip[$assistantId])) { return $baseOut($c, '当前账号数据权限下不可查看该医助挂号明细。'); } } if (!isset($c['adminToPrimary'][$assistantId])) { return $baseOut($c, '该医助不在当前业绩看板展示范围内。'); } $tagDiagIds = $c['tagDiagIds']; $tagAssistantIds = $c['tagAssistantIds']; $tagFallback = $c['tagFallback']; $scopedAssistants = $tagFallback ? $tagAssistantIds : null; if ($tagDiagIds !== null && $tagDiagIds === []) { return $baseOut($c, '当前渠道标签下无关联诊单,无挂号明细。'); } if ($scopedAssistants !== null && $scopedAssistants === []) { return $baseOut($c, '当前渠道标签下无关联医助,无挂号明细。'); } if ($scopedAssistants !== null && !isset($scopedAssistants[$assistantId])) { return $baseOut($c, '当前渠道标签口径下该医助无归属挂号(与排行榜「预约诊单」一致)。'); } $startTs = (int) $c['startTs']; $endTs = (int) $c['endTs']; $startDate = date('Y-m-d', $startTs); $endDate = date('Y-m-d', $endTs); $eff = self::consultEffectiveAssistantSql(); $page = max(1, (int) ($params['page'] ?? 1)); $limit = min(100, max(1, (int) ($params['page_size'] ?? 20))); $offset = ($page - 1) * $limit; $hasChSrc = self::doctorAppointmentHasChannelSourceColumn(); $buildBaseQuery = static function () use ( $eff, $assistantId, $startDate, $endDate, $tagDiagIds, $scopedAssistants ) { $q = Db::name('doctor_appointment')->alias('a') ->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id') ->leftJoin('admin doc', 'a.doctor_id = doc.id AND doc.delete_time IS NULL') ->whereBetween('a.appointment_date', [$startDate, $endDate]) ->whereIn('a.status', [1, 3, 4]) ->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)') ->whereRaw("({$eff}) = ?", [$assistantId]); if ($tagDiagIds !== null) { $q->whereIn('a.patient_id', $tagDiagIds); } if ($scopedAssistants !== null) { $ids = array_map('intval', array_keys($scopedAssistants)); if ($ids !== []) { $q->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')'); } } return $q; }; $count = (int) $buildBaseQuery()->count(); $fieldParts = [ 'a.id', 'a.appointment_date', 'a.appointment_time', 'a.status', 'a.appointment_type', 'a.patient_id', 'u.patient_name', 'u.phone AS patient_phone', 'doc.name AS doctor_name', ]; if ($hasChSrc) { $fieldParts[] = 'a.channel_source'; } $rawList = $buildBaseQuery() ->field(implode(',', $fieldParts)) ->order('a.appointment_date', 'asc') ->order('a.appointment_time', 'asc') ->order('a.id', 'asc') ->limit($offset, $limit) ->select() ->toArray(); $lists = self::formatYejiAppointmentLinePayloadRows($rawList, $hasChSrc); $note = '与排行榜「预约诊单」同口径:预约日期在统计区间内,状态为已预约 / 已完成 / 已过号(不含已取消);' . '有效医助优先取挂号表创建医助,否则取诊单医助。'; return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'assistant_id' => $assistantId, 'assistant_name' => $assistantName, 'dept_id' => 0, 'dept_name' => '', 'channel_code' => $channelCode, 'count' => $count, 'lists' => $lists, 'note' => $note, ]; } /** * 业绩看板:二中心复诊按部门行下钻 —— 医助姓名与业务订单笔数(与表格「复诊」列同口径)。 * * @param array{ * start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string, * dept_id?:int|string,revisit_slot?:int|string * } $params * revisit_slot:0=复诊合计(全部复诊笔),≥2=对应分项 * * @return array{ * start_date:string,end_date:string,dept_id:int,dept_name:string,revisit_slot:int,revisit_slot_label:string, * rows:list,note:string * } */ public static function revisitDeptAssistantBreakdown( array $params, int $viewerAdminId = 0, array $viewerAdminInfo = [] ): array { $c = self::resolveYejiContext($params); if ($viewerAdminId > 0) { self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); } $deptId = (int) ($params['dept_id'] ?? 0); $revisitSlot = (int) ($params['revisit_slot'] ?? 0); $deptName = $deptId > 0 ? self::formatYejiDeptRowDisplayName($deptId, $c['deptById']) : ''; $slotLabel = '复诊合计'; if ($revisitSlot >= 2) { if ($revisitSlot === 2) { $slotLabel = '复诊2'; } elseif ($revisitSlot >= 3 && $revisitSlot <= 10) { $cn = ['三', '四', '五', '六', '七', '八', '九', '十']; $slotLabel = '复诊' . ($cn[$revisitSlot - 3] ?? (string) $revisitSlot); } else { $slotLabel = '复诊' . (string) $revisitSlot; } } $out = [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'dept_id' => $deptId, 'dept_name' => $deptName, 'revisit_slot' => $revisitSlot, 'revisit_slot_label' => $slotLabel, 'rows' => [], 'note' => '', ]; if ($deptId <= 0) { $out['note'] = '请选择有效部门行。'; return $out; } if (!in_array($deptId, $c['tableRowDeptIds'], true)) { $out['note'] = '该部门不在当前展示部门或数据权限范围内。'; return $out; } if ($revisitSlot < 0 || $revisitSlot === 1) { $out['note'] = '无效的复诊分项。'; return $out; } $pack = DeptLogic::getErCenterRevisitAssistantBreakdownForDept( $deptId, $revisitSlot, (int) $c['startTs'], (int) $c['endTs'] ); $rows = $pack['rows']; if (!empty($c['dataScopeRestricted']) && isset($c['dataScopeVisibleAdminIds'])) { $flip = array_flip($c['dataScopeVisibleAdminIds']); $filtered = []; foreach ($rows as $rw) { if (isset($flip[(int) ($rw['admin_id'] ?? 0)])) { $filtered[] = $rw; } } $rows = $filtered; } $out['rows'] = $rows; if ($rows === []) { $out['note'] = '当前部门与区间下无符合条件的复诊业务订单(口径与看板「二中心复诊」列一致)。'; } return $out; } /** * 与 sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。 * * @param int[] $appointmentChannelValues * * @return array{all: array, scoped: array} */ private static function sumPerformanceByAdmin( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIdsForScoped, array $appointmentChannelValues, bool $channelFilterActive ): array { $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 * @param array|null $assignsByAdmin */ private static function excludeErCenterFromAdminLeaderboard( array $adminToPrimary, array $deptNameMap, array &$feeByAdmin, array &$leadsByAdmin, ?array &$dealOrderByAdmin = null, ?array &$assignsByAdmin = null ): void { foreach ($adminToPrimary as $aid => $pid) { if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) { continue; } $feeByAdmin[$aid] = 0.0; $leadsByAdmin[$aid] = 0; if ($dealOrderByAdmin !== null) { $dealOrderByAdmin[$aid] = 0; } if ($assignsByAdmin !== null) { $assignsByAdmin[$aid] = 0; } } } /** 排行榜部门列短名:取名称首字符 */ private static function deptDisplayAbbr(string $deptName): string { $t = trim($deptName); if ($t === '') { return '—'; } $ch = mb_substr($t, 0, 1, 'UTF-8'); return $ch !== '' ? $ch : substr($t, 0, 1); } /** * 业绩/成交单统计:仅排除履约已取消 fulfillment_status = 4,其余(含 NULL 与任意其它状态)全部计入。 * 与看板「合计业绩」、处方列表业绩 extend 口径一致;勿用 `<> 4`,以免误排除 NULL。 * * @param \think\db\BaseQuery|\think\Model $query Db::name / Model::where 等查询实例 * @param string $tableAlias 主表别名(如 Db 侧 `alias('o')` 传 `o`);无别名时传空字符串 */ public static function applyPrescriptionOrderNotCancelledForPerformanceQuery($query, string $tableAlias = ''): void { if ($tableAlias !== '') { $query->whereRaw("NOT ({$tableAlias}.fulfillment_status <=> ?)", [4]); } else { $query->whereRaw('NOT (fulfillment_status <=> ?)', [4]); } } /** * @param \think\db\BaseQuery|\think\Model $query */ private static function applyPrescriptionOrderNotCancelledForPerformance($query, string $tableAlias = 'o'): void { self::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, $tableAlias); } /** * 处方订单业绩全量合计(不按部门归属截断),与订单列表在**同一 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; } $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); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('dg.assistant_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; } /** * 单次业绩聚合:与 aggregatePerformanceAmountsByAdmin / aggregateDealOrderCount 同源 —— * INNER 诊单,按 dg.assistant_id SUM(amount) 后映射到展示「中心」(与 PrescriptionOrderLists assistant_dept 一致)。 * WHERE: delete_time IS NULL、NOT(fulfillment <=> 4)、create_time 区间、diagnosis_id>0、dg.assistant_id>0; * 可选 tag:diagnosis_id IN、dg.assistant_id IN。 * * @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 []; } $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 []; } $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; } /** * 全量成交单数:与 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]; } /** * 「未归属中心」业绩下钻:按诊单医助汇总无法映射到展示「中心」的金额(与 aggregatePerformance 部门行互补)。 * * @return array{ * start_date: string, * end_date: string, * channel_code: string, * rows: list, * note: string * } */ public static function unassignedCenterBreakdown(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $c = self::resolveYejiContext($params); if ($viewerAdminId > 0) { self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); } $startTs = (int) $c['startTs']; $endTs = (int) $c['endTs']; $adminToPrimary = $c['adminToPrimary']; $byAdminAmt = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, null, null); $byAdminCnt = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, null, null); $visFlip = null; if (!empty($c['dataScopeRestricted']) && isset($c['dataScopeVisibleAdminIds'])) { $visFlip = array_flip(array_values(array_unique(array_filter( array_map('intval', $c['dataScopeVisibleAdminIds']), static function (int $v): bool { return $v > 0; } )))); } $rows = []; foreach ($byAdminAmt as $aid => $amt) { $aid = (int) $aid; if ($aid <= 0) { continue; } if ($visFlip !== null && !isset($visFlip[$aid])) { continue; } if ((int) ($adminToPrimary[$aid] ?? 0) > 0) { continue; } $rows[] = [ 'admin_id' => $aid, 'name' => '', 'order_count' => (int) ($byAdminCnt[$aid] ?? 0), 'amount' => round((float) $amt, 2), ]; } $globalAmt = self::sumGlobalPerformanceAmount($startTs, $endTs); $globalCnt = self::countGlobalDealOrders($startTs, $endTs); $innerAmt = array_sum($byAdminAmt); $innerCnt = array_sum($byAdminCnt); $orphanAmt = round(max(0.0, $globalAmt - $innerAmt), 2); $orphanCnt = max(0, $globalCnt - $innerCnt); if ($orphanAmt >= 0.01 || $orphanCnt > 0) { $rows[] = [ 'admin_id' => 0, 'name' => '', 'order_count' => $orphanCnt, 'amount' => $orphanAmt, ]; } usort($rows, static function (array $a, array $b): int { $c = $b['amount'] <=> $a['amount']; if ($c !== 0) { return $c; } return $b['order_count'] <=> $a['order_count']; }); $ids = []; foreach ($rows as $row) { if ($row['admin_id'] > 0) { $ids[] = $row['admin_id']; } } $names = self::batchAdminNames(array_values(array_unique($ids))); foreach ($rows as &$row) { $aid = (int) $row['admin_id']; if ($aid <= 0) { $row['name'] = '(无诊单或医助未填)'; } else { $row['name'] = (string) ($names[$aid] ?? ('#' . $aid)); } } unset($row); return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'channel_code' => (string) $c['channelCode'], 'rows' => $rows, 'note' => '与看板「未归属中心」补差一致:按订单 create_time、剔除履约已取消(4);诊单医助无法映射到任一展示「中心」计入各行;其余(无诊单/医助为空)汇总在首行。账号数据权限下仅列出可见医助。', ]; } /** * @param int[] $ids * * @return array */ private static function batchAdminNames(array $ids): array { if ($ids === []) { return []; } $rows = Db::name('admin') ->whereIn('id', $ids) ->whereNull('delete_time') ->field(['id', 'name']) ->select() ->toArray(); $map = []; foreach ($rows as $r) { $map[(int) $r['id']] = (string) ($r['name'] ?? ''); } return $map; } /** * 表名 + 数据库前缀(用于 whereRaw 内嵌的 SQL,框架不会替换 raw 表名) */ private static function tableWithPrefix(string $table): string { $prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_'); return $prefix . $table; } /** * 挂号字典 channels:与渠道配置名称匹配的 value 字符串列表(用于 ap.channel_source) * * @return string[] */ private static function appointmentChannelDictStringValuesForDrawer(?array $channelInfo): array { if ($channelInfo === null) { return []; } $names = array_values(array_unique(array_filter([ trim((string) ($channelInfo['channel_name'] ?? '')), trim((string) ($channelInfo['source_tag_name'] ?? '')), ]))); if ($names === []) { return []; } $vals = Db::name('dict_data')->where('type_value', 'channels')->whereIn('name', $names)->column('value'); $out = []; foreach ($vals as $v) { $s = trim((string) $v); if ($s !== '') { $out[$s] = true; } } return array_keys($out); } /** @var bool|null 进程内缓存:doctor_appointment 是否已加 channel_source 列 */ private static ?bool $doctorAppointmentHasChannelSourceColumn = null; private static function doctorAppointmentHasChannelSourceColumn(): bool { if (self::$doctorAppointmentHasChannelSourceColumn !== null) { return self::$doctorAppointmentHasChannelSourceColumn; } try { $fields = Db::name('doctor_appointment')->getTableFields(); self::$doctorAppointmentHasChannelSourceColumn = is_array($fields) && in_array('channel_source', $fields, true); } catch (\Throwable) { self::$doctorAppointmentHasChannelSourceColumn = false; } return self::$doctorAppointmentHasChannelSourceColumn; } /** * 业绩看板业务订单侧栏:按 channel_code 收窄(与 overview「{渠道}业绩」同患者判定;含 ap.channels 与 ap.channel_source) * * @param Query $query PrescriptionOrder 主表查询 * @param array $params */ public static function applyYejiDrawerChannelFilterToPrescriptionOrderQuery(Query $query, array $params): void { if ((int) ($params['yeji_order_drawer'] ?? 0) !== 1) { return; } $channelCode = trim((string) ($params['channel_code'] ?? '')); if ($channelCode === '') { return; } $startDate = ''; $endDate = ''; $st = trim((string) ($params['start_time'] ?? '')); if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $st, $m)) { $startDate = $m[1]; } $et = trim((string) ($params['end_time'] ?? '')); if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $et, $m)) { $endDate = $m[1]; } if ($startDate === '' || $endDate === '') { return; } $ctx = self::resolveYejiContext([ 'start_date' => $startDate, 'end_date' => $endDate, 'channel_code' => $channelCode, 'dept_ids' => null, ]); if (!$ctx['channelFilterActive'] || $ctx['channelInfo'] === null) { $query->whereRaw('0 = 1'); return; } $poTbl = (new PrescriptionOrder())->getTable(); $apTable = self::tableWithPrefix('doctor_appointment'); $adminRoleTable = self::tableWithPrefix('admin_role'); $norm = self::normalizeAppointmentChannelValues($ctx['appointmentChannelValues']); $srcStrings = self::appointmentChannelDictStringValuesForDrawer($ctx['channelInfo']); if ($norm !== [] || $srcStrings !== []) { $subParts = []; $subBind = []; if ($norm !== []) { $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm))); $ph = implode(',', array_fill(0, count($strVals), '?')); $subParts[] = "ap.channels IN ({$ph})"; $subBind = array_merge($subBind, $strVals); } if ($srcStrings !== [] && self::doctorAppointmentHasChannelSourceColumn()) { $ph = implode(',', array_fill(0, count($srcStrings), '?')); $subParts[] = "ap.channel_source IN ({$ph})"; $subBind = array_merge($subBind, $srcStrings); } if ($subParts !== []) { $innerOr = implode(' OR ', $subParts); $existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar " . "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = `{$poTbl}`.`diagnosis_id` " . "AND ap.status = 3 AND ({$innerOr}))"; $query->whereRaw($existsSql, $subBind); return; } } $tagDiagIds = $ctx['tagDiagIds']; $tagAssistantIds = $ctx['tagAssistantIds']; $tagFallback = $ctx['tagFallback']; $tagFilterId = (string) ($ctx['tagFilterId'] ?? ''); if ($tagFilterId === '') { $query->whereRaw('0 = 1'); return; } if ($tagDiagIds !== null && $tagDiagIds === []) { $query->whereRaw('0 = 1'); return; } if ($tagAssistantIds !== null && $tagAssistantIds === [] && $tagFallback) { $query->whereRaw('0 = 1'); return; } if ($tagDiagIds !== null) { $query->whereIn('diagnosis_id', $tagDiagIds); } if ($tagFallback && $tagAssistantIds !== null && $tagAssistantIds !== []) { $query->whereIn('creator_id', array_map('intval', array_keys($tagAssistantIds))); } if ($tagDiagIds === null && !($tagFallback && $tagAssistantIds !== null && $tagAssistantIds !== [])) { $query->whereRaw('0 = 1'); } } }