0**,**剔除勾选「继承」的指派 is_inherit=1**,按**指派操作时间 lg.create_time** 落区间, * 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面「当月被指派总数」完全同口径(见 RevisitRateLogic) * · **二中心及其组织下级在选定渠道下计 0**(与进线一致) * - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致 * - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。 * - 接诊诊单 = 列名对应**业务订单条数**(剔除履约 4/9/10、create_time 区间),按**订单创建人**人事部门落在该行子树即计入;多行命中时取**最深的展示部门行**。与处方列表按 assistant_dept_id 筛选不同(列表保留旧口径:诊单医助 ∪ 创建人)。 * - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **计入业绩的业务订单条数**(剔除 fulfillment_status ∈ {4,9,10};NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag 口径均按订单计(按订单创建人归属),非挂号条数。 * - 合计业绩 = 区间内业务订单 amount 求和(排除已取消/拒收/退款(4,9,10) 与软删),**部门列**与「接诊诊单」一致:**订单创建人**人事部门落在该展示行子树则计入,多行命中取最深展示行;与排行榜诊金同口径。 * 无法归入任一展示部门的金额单列「未归属中心」行;**底栏「合计业绩」「接诊诊单」= 当前表内各行业绩列相加**(含未归属行),与弹窗摊分口径一致。 * · 时间窗与 stats_order_amount_performance 一致;列表若另有患者筛选或账号数据域收窄,金额可能仍不同 * · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**二中心及其组织下级展示部门不参与该渠道进线/业绩/成交单/ROI 分子**(与同模块 DeptLogic::getErCenterSubtreeDeptIdSet 一致) * - {渠道}业绩 = 与「处方订单列表」业绩同口径(剔除 4/9/10):按 **o.create_time** 落入区间,按**订单创建人**归部门; * 渠道判定仅用 EXISTS:"患者有任一已完成挂号 (status=3) 且 channels 命中字典",**不限挂号时点**;无字典映射时回退 tag/creator 口径 * - 成交单数 = 区间内计入业绩的业务订单 **条数**(剔除 4/9/10);按**订单创建人**映射到「中心」;无法归属计入「未归属中心」;合计=全表 COUNT。 * - 投放成本 = zyt_account_cost 在日期区间内 **amount** 合计(选定渠道时 `media_channel_code = channel_code` 过滤)。 * · **各部门行投放成本** = 总投放成本 × (该部门进线 ÷ 各展示部门进线合计)。相当于先算 **单进线成本** = 总成本 ÷ 总进线,再乘部门进线。 * · **二中心及其组织下级**不参与均摊投放成本(进线分摊分母对其计 0,与选定渠道下进线清零规则独立);其余部门按进线占比分摊。合计行成本 = 账务汇总总额。 * · ROI = (渠道业绩合计或全量业绩合计,**均不含二中心子树各部门的对应业绩**)÷ 总投放成本(成本为 0 时返回 null) * - ROI = **不含二中心子树**后的 **{渠道}业绩合计**(或未选渠道时为全量业绩口径下同一套「部门行之和」)÷ 投放成本;部门行在四舍五入前若分摊成本为 0 则无 ROI * * 「业绩/接诊标签匹配」: * 优先 tcm_diagnosis.external_userid = 客户 external_userid; * 若为空则 patient_id → zyt_user → zyt_user_auth.unionid = qywx_external_contact.unionid(再验 contact_tag), * 与列表客户标签一致,避免因未回填 external_userid 导致业绩/接诊恒为 0。 * * 「部门归属」算法: * 配置 $primaryDeptIds(前端可指定;默认所有「含『中心』字样」的二级 / 三级部门), * 每个 admin 经 admin_dept → dept 沿 pid 上溯,命中第一个 $primaryDeptIds 即归属之;都不命中归到 0=未分配。 * * 「渠道」 = 「企微标签」: * `zyt_qywx_media_channel.channel_code` 形如 tag_ 或 tagname_; * ① tag_ 前缀:剥前缀得到企业 tag_id,参与 zyt_qywx_external_contact_tag 过滤; * ② tagname_ 前缀(个人标签):当前不参与进线/业绩/诊单过滤(contact_tag 表只跟踪企业标签),仅按成本过滤。 */ class YejiStatsLogic { /** 不计入业绩的履约状态:已取消(4)、拒收(9)、退款(10);NULL 与其它值仍计业绩 */ public const PRESCRIPTION_ORDER_FULFILLMENT_EXCLUDED_FROM_PERFORMANCE = [4, 9, 10]; /** * 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。 * * 表格仍保持默认「中心」层级(图1);合计业绩等数值在聚合层按 dataScopeVisibleAdminIds 收窄。 */ 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; } self::narrowYejiContextTableRowsByVisibleAdmins($c, $visibleIds); } /** * 在现有展示行(默认中心层级)上按可见 admin 收窄表格行与台账归属。 * * @param int[] $visibleIds */ private static function narrowYejiContextTableRowsByVisibleAdmins(array &$c, array $visibleIds): void { $flip = array_flip($visibleIds); $filtered = []; foreach ($c['adminToPrimary'] as $aid => $pid) { if (isset($flip[$aid])) { $filtered[$aid] = $pid; } } $c['adminToPrimary'] = $filtered; $primariesFiltered = array_values(array_unique(array_filter(array_values($filtered), static fn (int $v): bool => $v > 0))); if ($primariesFiltered === []) { $c['tableRowDeptIds'] = []; return; } $tableRowDeptIdsSrc = array_values(array_map('intval', $c['tableRowDeptIds'] ?? [])); $deptById = isset($c['deptById']) && \is_array($c['deptById']) ? $c['deptById'] : []; $visibleAdminIds = array_map('intval', array_keys($filtered)); /** 与折叠业绩到表格行同源:SELF/本部门 等场景下台账锚点为父「中心」、表格仅有子行为时不能用 id 精确交集 */ $keepFlip = []; if ($tableRowDeptIdsSrc !== [] && $deptById !== [] && $visibleAdminIds !== []) { $mapped = self::batchMapPerformanceAdminsToTableDept($visibleAdminIds, $tableRowDeptIdsSrc, $deptById); foreach ($mapped as $__tid) { $t = (int) $__tid; if ($t > 0) { $keepFlip[$t] = true; } } } if ($keepFlip === [] && $tableRowDeptIdsSrc !== [] && $deptById !== []) { foreach ($primariesFiltered as $p) { foreach ($tableRowDeptIdsSrc as $tid) { if ($tid <= 0) { continue; } if (self::yejiDeptOnPathBetween($tid, $p, $deptById)) { $keepFlip[$tid] = true; } } } } if ($keepFlip === []) { $c['tableRowDeptIds'] = []; return; } $c['tableRowDeptIds'] = array_values(array_filter( $tableRowDeptIdsSrc, static function (int $did) use ($keepFlip): bool { return $did > 0 && isset($keepFlip[$did]); } )); } /** * 任一部门是否为另一部门的上级(ancestor 祖先、descendant 沿 pid 上移可遇到 ancestor)。 */ private static function yejiDeptIsAncestorOfDept(int $ancestorId, int $descendantId, array $deptById): bool { if ($ancestorId <= 0 || $descendantId <= 0) { return false; } $cur = $descendantId; for ($i = 0; $i < 64; $i++) { if ($cur === $ancestorId) { return true; } if (!isset($deptById[$cur])) { return false; } $cur = (int) ($deptById[$cur]['pid'] ?? 0); if ($cur <= 0) { return false; } } return false; } /** * 表格行 dept 与台账 primary 是否应对齐到同一 subtree(任一为另一祖先)。 * * @param array> $deptById */ private static function yejiDeptOnPathBetween(int $tableRowDeptId, int $ledgerPrimaryDeptId, array $deptById): bool { if ($tableRowDeptId <= 0 || $ledgerPrimaryDeptId <= 0) { return false; } if ($tableRowDeptId === $ledgerPrimaryDeptId) { return true; } return self::yejiDeptIsAncestorOfDept($tableRowDeptId, $ledgerPrimaryDeptId, $deptById) || self::yejiDeptIsAncestorOfDept($ledgerPrimaryDeptId, $tableRowDeptId, $deptById); } /** * 与业绩看板相同的日期、部门树、渠道、标签与数据范围上下文,供医生日统计等复用。 * * @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params * * @return array{ * startDate:string,endDate:string,startTs:int,endTs:int,channelCode:string, * adminToPrimary:array,tableRowDeptIds:int[], * appointmentChannelValues:int[],channelFilterActive:bool, * tagDiagIds:int[]|null,tagAssistantIds:array|null,tagFallback:bool * } */ public static function resolveSharedYejiFilterContext( array $params, int $viewerAdminId = 0, array $viewerAdminInfo = [] ): array { $c = self::resolveYejiContext($params); if ($viewerAdminId > 0) { self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); } return [ 'startDate' => $c['startDate'], 'endDate' => $c['endDate'], 'startTs' => $c['startTs'], 'endTs' => $c['endTs'], 'channelCode' => $c['channelCode'], 'adminToPrimary' => $c['adminToPrimary'], 'tableRowDeptIds' => $c['tableRowDeptIds'], 'appointmentChannelValues' => $c['appointmentChannelValues'], 'channelFilterActive' => $c['channelFilterActive'], 'tagDiagIds' => $c['tagDiagIds'], 'tagAssistantIds' => $c['tagAssistantIds'], 'tagFallback' => $c['tagFallback'], ]; } /** * @param array{ * start_date?: string, * end_date?: string, * dept_ids?: int[]|string, * channel_code?: string, * tag_id?: string, * } $params * * @return array */ public static function overview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $c = self::resolveYejiContext($params); if ($viewerAdminId > 0) { self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); } $startDate = $c['startDate']; $endDate = $c['endDate']; $startTs = $c['startTs']; $endTs = $c['endTs']; $channelCode = $c['channelCode']; $tagFilterId = $c['tagFilterId']; $channelInfo = $c['channelInfo']; $primaryDeptIds = $c['primaryDeptIds']; $tableRowDeptIds = $c['tableRowDeptIds']; $deptToDescendants = $c['deptToDescendants']; $deptNameMap = $c['deptNameMap']; $deptById = $c['deptById']; $adminToPrimary = $c['adminToPrimary']; $tagDiagIds = $c['tagDiagIds']; $tagAssistantIds = $c['tagAssistantIds']; $tagFallback = $c['tagFallback']; $appointmentChannelValues = $c['appointmentChannelValues']; $channelFilterActive = $c['channelFilterActive']; $explicitDeptFilter = $c['explicitDeptFilter']; $dataScopeRestricted = !empty($c['dataScopeRestricted']); $narrowTotalsToTable = $explicitDeptFilter || $dataScopeRestricted; $dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds']) ? $c['dataScopeVisibleAdminIds'] : null; $leads = self::countLeads($startTs, $endTs, $adminToPrimary, $tagFilterId); $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); $assignsByDept = self::countDiagnosisAssignsByDept( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagFallback ? $tagAssistantIds : null, $appointmentChannelValues, $channelFilterActive, $dataScopeAdminIds, $channelInfo ); // 接诊: // - consults(全量) → "接诊诊单" 列;不限渠道 // - channelConsults(渠道) → 「{渠道}成交单」列;未选渠道时与 consults 相同 $consultsBoth = self::countConsults( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagAssistantIds, $appointmentChannelValues, $channelFilterActive, $dataScopeAdminIds, $tableRowDeptIds, $deptById, $channelInfo ); $consults = $consultsBoth['all']; $channelConsults = $consultsBoth['scoped']; $bookedByDept = self::aggregateBookedAppointmentsByDept( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagAssistantIds, $dataScopeAdminIds ); $listAlignedPack = ($tableRowDeptIds !== [] && $deptById !== []) ? self::aggregateListAlignedOrderMetricsByDept( $startTs, $endTs, $adminToPrimary, null, null, $tableRowDeptIds, $deptById, null, [], $dataScopeAdminIds ) : null; // 业绩:部门行「合计业绩」与列表部门摊行一致;「{渠道}业绩」同列表摊行 + 渠道 EXISTS(与抽屉一致) $perfBoth = self::sumPerformance( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagFallback ? $tagAssistantIds : null, $appointmentChannelValues, $channelFilterActive, $tableRowDeptIds, $deptById, $listAlignedPack !== null ? $listAlignedPack['amount'] : null, $channelInfo, $dataScopeAdminIds ); $performance = $perfBoth['all']; $channelPerformance = $perfBoth['scoped']; /** 二中心子树展示行「计入 ROI 分子的业绩」合计({渠道}业绩或与合计业绩相同),供未选渠道时从全表分子里扣除 */ $sumCompletedPerfErCenter = 0.0; foreach ($tableRowDeptIds as $erDeptId) { if (!isset($erCenterDeptSet[(int) $erDeptId])) { continue; } $sumCompletedPerfErCenter += round((float) ($channelPerformance[$erDeptId] ?? 0), 1); } // 成交单数(全量):与合计业绩列同列表部门口径 $dealOrderByDept = $listAlignedPack !== null ? $listAlignedPack['count'] : self::aggregateDealOrderCount( $startTs, $endTs, $adminToPrimary, null, null, $tableRowDeptIds, $deptById ); $globalDealOrderCount = self::countGlobalDealOrders($startTs, $endTs); /** 选定渠道时从全量渠道 SUM 与部门行中同步扣减二中心子树部门的渠道业绩/成交单;进线、被指派同清零 */ $erCenterChannelStrip = 0.0; if ($channelCode !== '') { $erCenterChannelStrip = self::excludeErCenterSubtreeFromChannelMetrics( $tableRowDeptIds, $erCenterDeptSet, $channelPerformance, $channelConsults ); } $globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1); $leadsForCost = self::mergeHiddenPrimaryLeadsForCostAllocation( $primaryDeptIds, $tableRowDeptIds, $leads, $deptToDescendants ); self::zeroIntKeyedDeptMetricsInErCenterSubtree($leadsForCost, $erCenterDeptSet); $costs = self::allocateCostByLeads($globalCost, $tableRowDeptIds, $leadsForCost); if ($channelCode !== '') { self::zeroIntKeyedDeptMetricsInErCenterSubtree($leads, $erCenterDeptSet); self::zeroIntKeyedDeptMetricsInErCenterSubtree($assignsByDept, $erCenterDeptSet); } @set_time_limit(120); $revisitCreatorFilter = self::resolveYejiRevisitCreatorFilter($c); $revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs, $revisitCreatorFilter); $revisitTotals = $revisitPack['totals']; $revisitSlotsByDept = $revisitPack['slots']; // ── 5) 组装每行 ────────────────────────────────────────────── $rows = []; $totalLead = 0; $totalAssign = 0; $totalRevisit = 0; /** @var array $totalRevisitSlots */ $totalRevisitSlots = []; $totalChannelConsult = 0; $totalPerf = 0.0; $totalChannelPerf = 0.0; $totalCost = 0.0; $unmappedConsult = (int) ($consults[0] ?? 0); $consultSumInTable = 0; $bookedSumInTable = 0; foreach ($tableRowDeptIds as $deptId) { $lead = (int) ($leads[$deptId] ?? 0); $cnt = (int) ($consults[$deptId] ?? 0); $consultSumInTable += $cnt; $bookedSumInTable += (int) ($bookedByDept[$deptId] ?? 0); $cCnt = (int) ($channelConsults[$deptId] ?? 0); // 合计业绩:部门下所有订单业绩(≠4),不限渠道 $perf = round((float) ($performance[$deptId] ?? 0), 1); // {渠道}业绩:部门 × 选定渠道下业绩(≠4) $cperf = round((float) ($channelPerformance[$deptId] ?? 0), 1); $cost = round((float) ($costs[$deptId] ?? 0), 1); $rowCost = $cost; $rowRoi = $rowCost > 0 ? round($cperf / $rowCost, 1) : null; $dDeal = (int) ($dealOrderByDept[$deptId] ?? 0); $assignCnt = (int) ($assignsByDept[$deptId] ?? 0); $rv = (int) ($revisitTotals[$deptId] ?? 0); $rvSlots = $revisitSlotsByDept[$deptId] ?? []; $rows[] = [ 'dept_id' => $deptId, 'dept_name' => self::formatYejiDeptRowDisplayName($deptId, $deptById), 'lead_count' => $lead, 'assign_count' => $assignCnt, 'revisit_count' => $rv, 'revisit_slots' => $rvSlots, 'appointment_booked_count' => (int) ($bookedByDept[$deptId] ?? 0), 'consult_count' => $cnt, 'deal_order_count' => $dDeal, // {渠道}成交单:选定渠道时与 completed_performance 同源;未选渠道时与 consult_count 相同 'channel_consult_count' => $cCnt, 'performance_amount' => $perf, 'completed_performance_amount' => $cperf, 'avg_price' => $cnt > 0 ? round($perf / $cnt, 1) : null, // {渠道}客单价 = {渠道}业绩 / {渠道}成交单 'channel_avg_price' => $cCnt > 0 ? round($cperf / $cCnt, 1) : null, 'cost_amount' => round($rowCost, 1), 'roi' => $rowRoi, ]; $totalLead += $lead; $totalAssign += $assignCnt; $totalRevisit += $rv; foreach ($rvSlots as $slot => $cntRv) { $slot = (int) $slot; $totalRevisitSlots[$slot] = ($totalRevisitSlots[$slot] ?? 0) + (int) $cntRv; } $totalChannelConsult += $cCnt; $totalPerf += $perf; $totalChannelPerf += $cperf; // 不再用各行成本叠加(避免父级未在展示列时丢失),合计直接用 $globalCost } $allocatedPerf = round($totalPerf, 1); $allocatedChannelPerf = round($totalChannelPerf, 1); $globalPerfAll = self::sumGlobalPerformanceAmount($startTs, $endTs); $globalPerfChannel = $globalPerfAll; if ($channelFilterActive) { $globalPerfChannel = self::sumGlobalChannelScopedPerformance( $startTs, $endTs, $appointmentChannelValues, $tagDiagIds, $tagFallback ? $tagAssistantIds : null, $channelInfo ); $globalPerfChannel = round(max(0.0, $globalPerfChannel - $erCenterChannelStrip), 1); } $allocatedDealOrderSum = 0; foreach ($tableRowDeptIds as $rid) { $allocatedDealOrderSum += (int) ($dealOrderByDept[$rid] ?? 0); } $totalBooked = $narrowTotalsToTable ? $bookedSumInTable : array_sum($bookedByDept); 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, ]; } // 底栏「合计业绩」「{渠道}业绩」「接诊诊单」= 当前表格全部数据行相加(含「未归属中心」), // 与各行展示口径一致;不再使用全表 SUM(会与仅含展示部门的摊分行脱钩)。 $sumPerfRows = 0.0; $sumChannelPerfRows = 0.0; $sumDealRows = 0; foreach ($rows as $rw) { $sumPerfRows += (float) ($rw['performance_amount'] ?? 0); $sumChannelPerfRows += (float) ($rw['completed_performance_amount'] ?? 0); $sumDealRows += (int) ($rw['deal_order_count'] ?? 0); } $totalPerf = round($sumPerfRows, 1); $totalChannelPerf = round($sumChannelPerfRows, 1); $totalDealOrderCount = $sumDealRows; $totalCost = round($globalCost, 1); // ROI 合计:选渠道时底栏「{渠道}业绩合计」已扣除二中心子树($erCenterChannelStrip);未选渠道时用全表业绩并减去各二中心子树展示行之和 $totalRoiNumerator = round( max( 0.0, $channelFilterActive ? $totalChannelPerf : ($totalChannelPerf - $sumCompletedPerfErCenter) ), 1 ); if ($totalRevisitSlots !== []) { ksort($totalRevisitSlots, SORT_NUMERIC); } $scopedTableNote = ($explicitDeptFilter ? ' 已筛选展示部门时,表格仅展示所选树中的部门行;底栏「合计业绩」「接诊诊单」与当前表内各行业绩列相加一致(含「未归属中心」行若展示)。' : '') . ($dataScopeRestricted ? ' 当前账号受数据范围限制时,底栏合计业绩与列表同域。' : ''); $baseNoteNoChannel = '业绩口径:剔除履约已取消(4)、拒收(9)、退款(10),其余(含 NULL)计入。部门行「合计业绩」「接诊诊单」「复诊」均按**订单创建人人事部门 ∈ 该展示行子树**归属(多行命中取最深展示行),与医助排行榜诊金一致;与处方订单列表 assistant_dept_id 默认筛选(诊单医助 ∪ 创建人)不同——侧栏点击会自动收窄为创建人口径。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊,**二中心子树展示部门进线在分摊中计 0**(与部门 ErCenter 子树一致)。'; return [ 'start_date' => $startDate, 'end_date' => $endDate, 'dept_ids' => array_values($tableRowDeptIds), 'channel_code' => $channelCode, 'channel_name' => $channelInfo ? (string) $channelInfo['channel_name'] : '', 'channel_group' => $channelInfo ? (string) $channelInfo['source_group_name'] : '', 'channel_filter_note' => $channelCode !== '' ? ('{渠道}业绩:按订单 create_time 归日、剔除履约 4/9/10(NULL 与其它状态均计入);渠道判定用"该患者是否有挂号 channels 命中 dict + status=3"(不限挂号时点)。部门归属按**订单创建人**人事部门 ∈ 子树。' . ' {渠道}成交单数:与{渠道}业绩同源,剔除 4/9/10 的订单条数(非挂号条数),按订单创建人归部门。' . ' 「已完成挂号」列同 admin 挂号已完成列表;「接诊诊单」列为业务订单条数(同业绩剔除口径),同样按订单创建人归部门。**二中心及其组织下级**(与部门模块 ErCenter 子树一致)**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例分摊,**二中心子树进线在分摊中计 0**(未选渠道时仍显示进线数,仅不参与均摊)。ROI = 渠道业绩(不含二中心子树)÷ 总投放成本。进线按所选标签客户精确过滤。' . ($tagFallback ? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。' : '') . $scopedTableNote) : ($baseNoteNoChannel . $scopedTableNote), 'rows' => $rows, 'total' => [ 'lead_count' => $totalLead, 'assign_count' => $totalAssign, 'revisit_count' => $totalRevisit, 'revisit_slots' => $totalRevisitSlots, 'appointment_booked_count' => $totalBooked, 'consult_count' => $totalConsult, 'deal_order_count' => $totalDealOrderCount, 'channel_consult_count' => $totalChannelConsult, 'performance_amount' => $totalPerf, 'completed_performance_amount' => $totalChannelPerf, 'avg_price' => $totalConsult > 0 ? round($totalPerf / $totalConsult, 1) : null, 'channel_avg_price' => $totalChannelConsult > 0 ? round($totalChannelPerf / $totalChannelConsult, 1) : null, 'cost_amount' => $totalCost, 'cost_per_lead' => $totalLead > 0 && $totalCost > 0 ? round($totalCost / $totalLead, 1) : null, 'roi' => $totalCost > 0 ? round($totalRoiNumerator / $totalCost, 1) : null, ], ]; } /** * 多组时间区间一次性返回(前端"月/周/日"四张表共用一次请求)。 * * @param array $ranges * @return array> */ public static function overviewBatch(array $ranges, array $base, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $out = []; foreach ($ranges as $r) { $params = array_merge($base, [ 'start_date' => (string) ($r['start_date'] ?? date('Y-m-d')), 'end_date' => (string) ($r['end_date'] ?? date('Y-m-d')), ]); $stat = self::overview($params, $viewerAdminId, $viewerAdminInfo); $stat['label'] = (string) ($r['label'] ?? ($stat['start_date'] === $stat['end_date'] ? $stat['start_date'] : ($stat['start_date'] . '~' . $stat['end_date']))); $out[] = $stat; } return $out; } /** * 医助排行榜:按展示部门分表,每表内按诊金降序排名。 * 诊金口径:无渠道=业绩归属医助(与列表医助筛选一致);有渠道={渠道}业绩/dict;二中心在选渠道下注 0。 * 被指派数 = 区间内诊单指派日志(to_assistant_id>0)按**被指派医助**计数,与部门表「被指派数」同收窄/二中心规则。 * 接诊率 = 排行用诊金 ÷ 进线条数(元/进线,1 位小数;进线为 0 时为 0)。 * 复诊(属二中心组织架构的展示部门):同一诊单下业务单按 create_time 排序第 2 笔起分项,与部门业绩表「二中心复诊」同源;前端点击数字可下钻对应业务订单。 * * @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params * * @return array */ public static function assistantLeaderboards(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $c = self::resolveYejiContext($params); if ($viewerAdminId > 0) { self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); } $startDate = $c['startDate']; $endDate = $c['endDate']; $startTs = $c['startTs']; $endTs = $c['endTs']; $channelCode = $c['channelCode']; $channelInfo = $c['channelInfo']; $tagFilterId = $c['tagFilterId']; $primaryDeptIds = $c['primaryDeptIds']; $tableRowDeptIds = $c['tableRowDeptIds']; $deptNameMap = $c['deptNameMap']; $deptById = $c['deptById']; $adminToPrimary = $c['adminToPrimary']; $tagDiagIds = $c['tagDiagIds']; $tagAssistantIds = $c['tagAssistantIds']; $tagFallback = $c['tagFallback']; $appointmentChannelValues = $c['appointmentChannelValues']; $channelFilterActive = $c['channelFilterActive']; $dataScopeRestricted = !empty($c['dataScopeRestricted']); $dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds']) ? $c['dataScopeVisibleAdminIds'] : null; $channelName = $channelInfo ? (string) $channelInfo['channel_name'] : ''; if ($adminToPrimary === []) { return [ 'start_date' => $startDate, 'end_date' => $endDate, 'channel_code' => $channelCode, 'channel_name' => $channelName, 'channel_filter_note' => '', 'range_note' => '', 'leaderboards' => [], ]; } $scopedAssistants = $tagFallback ? $tagAssistantIds : null; $perfBoth = self::sumPerformanceByAdmin( $startTs, $endTs, $tagDiagIds, $scopedAssistants, $appointmentChannelValues, $channelFilterActive, $channelInfo ); $feeByAdmin = $channelFilterActive ? $perfBoth['scoped'] : $perfBoth['all']; $dealBoth = self::sumDealOrderByAdmin( $startTs, $endTs, $tagDiagIds, $scopedAssistants, $appointmentChannelValues, $channelFilterActive, $channelInfo ); $dealOrderByAdmin = $channelFilterActive ? $dealBoth['scoped'] : $dealBoth['all']; $leadsByAdmin = self::countLeadsByAdmin($startTs, $endTs, $tagFilterId); /** 与部门表接诊同口径;标签/医助穿透时与业绩同源收窄,避免榜上证务与接诊脱节 */ $consultsByAdmin = self::aggregateConsultsByAdmin($startTs, $endTs, $tagDiagIds, $scopedAssistants); $assignsByAdmin = self::countDiagnosisAssignsByAdmin( $startTs, $endTs, $tagDiagIds, $tagFallback ? $tagAssistantIds : null, $appointmentChannelValues, $channelFilterActive, $dataScopeAdminIds, $channelInfo ); $appointmentByAdmin = self::countBookedAppointmentsByAdminForLeaderboard( $startTs, $endTs, $tagDiagIds, $tagFallback ? $tagAssistantIds : null, $dataScopeAdminIds ); if ($channelFilterActive) { $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); self::excludeErCenterFromAdminLeaderboard( $adminToPrimary, $erCenterDeptSet, $feeByAdmin, $leadsByAdmin, $dealOrderByAdmin, $assignsByAdmin ); } $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); /** 医助排行榜复诊:按订单创建人归属(与诊金 / 接诊诊单同口径),不再用 dg.assistant_id */ $revisitCreatorFilter = ($dataScopeRestricted && $dataScopeAdminIds !== null && $dataScopeAdminIds !== []) ? $dataScopeAdminIds : null; $revisitByAssistant = DeptLogic::getErCenterRevisitCountsByCreator($startTs, $endTs, $revisitCreatorFilter); $assistantIdRows = Db::name('admin_role')->where('role_id', 2)->column('admin_id'); $assistantSet = []; foreach ($assistantIdRows as $rid) { $assistantSet[(int) $rid] = true; } $nameIds = []; foreach (array_keys($assistantSet) as $aid) { if (isset($adminToPrimary[$aid])) { $nameIds[] = $aid; } } $adminNames = []; if ($nameIds !== []) { $nameRows = Db::name('admin') ->whereIn('id', $nameIds) ->whereNull('delete_time') ->column('name', 'id'); foreach ($nameRows as $id => $name) { $adminNames[(int) $id] = (string) $name; } } $rangeNote = $channelFilterActive ? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。' . '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。' . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。') : '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。' . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。'; $leaderboards = []; foreach ($tableRowDeptIds as $deptId) { $leafName = $deptNameMap[$deptId] ?? ('部门#' . $deptId); $deptName = self::formatYejiDeptRowDisplayName($deptId, $deptById); $deptAbbr = self::deptDisplayAbbr($leafName); $isErCenterLb = isset($erCenterDeptSet[$deptId]); $rows = []; foreach (array_keys($assistantSet) as $aid) { if (($adminToPrimary[$aid] ?? 0) !== $deptId) { continue; } $fee = round((float) ($feeByAdmin[$aid] ?? 0.0), 1); $lead = (int) ($leadsByAdmin[$aid] ?? 0); $consult = (int) ($consultsByAdmin[$aid] ?? 0); $dealOrders = (int) ($dealOrderByAdmin[$aid] ?? 0); $assignCnt = (int) ($assignsByAdmin[$aid] ?? 0); $apptCnt = (int) ($appointmentByAdmin[$aid] ?? 0); $rv = $isErCenterLb ? (int) ($revisitByAssistant['totals'][$aid] ?? 0) : 0; $rvSlots = $isErCenterLb ? ($revisitByAssistant['slots'][$aid] ?? []) : []; $rawFee = (float) ($feeByAdmin[$aid] ?? 0.0); $rate = $lead > 0 ? round($rawFee / $lead, 1) : 0.0; $row = [ 'admin_id' => $aid, 'name' => $adminNames[$aid] ?? ('#' . $aid), 'fee_amount' => $fee, 'lead_count' => $lead, 'assign_count' => $assignCnt, 'consult_count' => $consult, 'deal_order_count' => $dealOrders, 'appointment_count' => $apptCnt, 'consult_rate' => $rate, 'dept_abbr' => $deptAbbr, ]; if ($isErCenterLb) { $row['revisit_count'] = $rv; $row['revisit_slots'] = $rvSlots; } $rows[] = $row; } usort($rows, static function (array $a, array $b): int { if ($a['fee_amount'] != $b['fee_amount']) { return $b['fee_amount'] <=> $a['fee_amount']; } return $b['consult_rate'] <=> $a['consult_rate']; }); foreach ($rows as $i => $row) { $rows[$i]['rank'] = $i + 1; } $leaderboards[] = [ 'dept_id' => $deptId, 'dept_name' => $deptName, 'dept_abbr' => $deptAbbr, 'er_center_subtree' => $isErCenterLb, 'rows' => $rows, ]; } return [ 'start_date' => $startDate, 'end_date' => $endDate, 'channel_code' => $channelCode, 'channel_name' => $channelName, 'channel_filter_note' => $channelCode !== '' ? '医助榜:**二中心及其组织下级**在选定渠道下诊金、进线、被指派数计 0,与部门看板一致。' : '', 'range_note' => $rangeNote, 'leaderboards' => $leaderboards, ]; } /** * 数据范围下:可见成员所在部门及其下级部门 id(用于部门下拉收窄)。 * * @return array|null null 表示不限制,[] 表示无可选 */ private static function buildYejiDataScopeAllowedDeptIdMap(int $adminId, array $adminInfo): ?array { return DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo); } /** * 列出所有可选部门 * * @return array */ public static function deptOptions(int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $rows = Db::name('dept')->whereNull('delete_time')->order('pid asc, id asc')->select()->toArray(); if ($rows === []) { return []; } $byId = []; foreach ($rows as $r) { $byId[(int) $r['id']] = $r; } $buildPath = function (int $id) use ($byId): string { $names = []; $cur = $id; for ($i = 0; $i < 50; $i++) { if (!isset($byId[$cur])) { break; } array_unshift($names, (string) $byId[$cur]['name']); $pid = (int) $byId[$cur]['pid']; if ($pid <= 0) { break; } $cur = $pid; } return implode(' / ', array_filter($names, static fn($s): bool => $s !== '')); }; $out = []; foreach ($rows as $r) { $id = (int) $r['id']; $out[] = [ 'id' => $id, 'name' => (string) $r['name'], 'pid' => (int) $r['pid'], 'path' => $buildPath($id), ]; } $allowed = self::buildYejiDataScopeAllowedDeptIdMap($viewerAdminId, $viewerAdminInfo); if ($allowed !== null) { if ($allowed === []) { return []; } $out = array_values(array_filter( $out, static function (array $item) use ($allowed): bool { return isset($allowed[(int) ($item['id'] ?? 0)]); } )); } return $out; } /** * 渠道下拉(按 source_group_name 分组),同时附带客户/成本统计便于排序展示 * * @param bool $includeTagCustomerCounts 为 false 时跳过标签客户数统计(qywx_external_contact_tag 大表聚合较慢);提成结算等仅需筛选项时可关闭以提速。 * * @return array{ * groups: array * }>, * total: int * } */ public static function channelOptions(bool $includeTagCustomerCounts = true): 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_* 渠道带上「打了该标签的客户数」(qywx_external_contact_tag 数据量大时聚合较慢,可按需关闭) $tagToCount = []; if ($includeTagCustomerCounts) { $tagIds = []; foreach ($rows as $r) { $code = (string) $r['channel_code']; if (str_starts_with($code, 'tag_')) { $tagIds[] = substr($code, 4); } } if ($tagIds !== []) { $cntRows = Db::name('qywx_external_contact_tag') ->whereIn('tag_id', $tagIds) ->fieldRaw('tag_id, COUNT(DISTINCT external_userid) AS c') ->group('tag_id') ->select() ->toArray(); foreach ($cntRows as $cr) { $tagToCount[(string) $cr['tag_id']] = (int) $cr['c']; } } } $groups = []; foreach ($rows as $r) { $g = (string) ($r['source_group_name'] ?: '其它'); $groups[$g] ??= ['group_name' => $g, 'channels' => []]; $code = (string) $r['channel_code']; $custCount = 0; if (str_starts_with($code, 'tag_')) { $tid = substr($code, 4); $custCount = (int) ($tagToCount[$tid] ?? 0); } $groups[$g]['channels'][] = [ 'channel_code' => $code, 'channel_name' => (string) $r['channel_name'], 'source_tag_id' => (string) $r['source_tag_id'], 'source_group_name' => $g, 'customer_count' => $custCount, ]; } // 「自媒体」类组排前 $sortedGroups = array_values($groups); usort($sortedGroups, static function (array $a, array $b): int { $aMedia = mb_strpos($a['group_name'], '自媒体') !== false ? 0 : 1; $bMedia = mb_strpos($b['group_name'], '自媒体') !== false ? 0 : 1; if ($aMedia !== $bMedia) { return $aMedia <=> $bMedia; } return strcmp($a['group_name'], $b['group_name']); }); return ['groups' => $sortedGroups, 'total' => count($rows)]; } // ───────────────────────────────────────────────────────────────── // 内部 // ───────────────────────────────────────────────────────────────── private static function normalizeDate(string $s): string { $s = trim($s); $ts = strtotime($s); return $ts === false ? date('Y-m-d') : date('Y-m-d', $ts); } /** * 请求是否显式带了部门筛选(非空 dept_ids),用于与「默认全部中心」区分。 */ private static function hasExplicitDeptIdsParam($raw): bool { if (\is_string($raw) && trim($raw) !== '') { foreach (explode(',', $raw) as $p) { if ((int) trim($p) > 0) { return true; } } } if (\is_array($raw)) { foreach ($raw as $v) { if ((int) $v > 0) { return true; } } } return false; } /** * 用户传入 dept_ids 时:展开为「所选部门 + 全子孙」作为 primary(用于归属与聚合);表格行序同序。 * 若用户勾选的是含下级部门的父节点(如「一中心」),则表格**仅展示其下级部门行**,不重复展示该中心父级行;其它中心不会出现在展开集合中。 * 默认(未传):primary 与表格行均为所有 name 含「中心」且 pid>0 的节点(父子同存时去掉父)。 * * @return array{0: int[], 1: array, 2: int[], 3: array>} */ private static function resolvePrimaryDepts($input): array { $rows = Db::name('dept')->whereNull('delete_time')->field(['id', 'name', 'pid'])->order('pid asc, id asc')->select()->toArray(); $byId = []; foreach ($rows as $r) { $byId[(int) $r['id']] = $r; } $childrenOf = []; foreach ($rows as $r) { $pid = (int) $r['pid']; $childrenOf[$pid][] = (int) $r['id']; } $userRoots = []; if (\is_string($input) && $input !== '') { $input = explode(',', $input); } if (\is_array($input)) { foreach ($input as $v) { $iv = (int) $v; if ($iv > 0) { $userRoots[] = $iv; } } $userRoots = array_values(array_unique($userRoots)); } if ($userRoots !== []) { $deptIds = self::expandDeptRootIdsWithDescendants($userRoots, $childrenOf); $deptIds = self::sortYejiDeptIdsAncestorsBeforeDescendants($deptIds, $byId); $inExpanded = array_flip($deptIds); $tableRowDeptIds = []; foreach ($deptIds as $id) { $isUserRoot = \in_array($id, $userRoots, true); $hasChildInExpanded = false; foreach (($childrenOf[$id] ?? []) as $cid) { if (isset($inExpanded[$cid])) { $hasChildInExpanded = true; break; } } if ($isUserRoot && $hasChildInExpanded) { continue; } $tableRowDeptIds[] = $id; } } else { $candidates = []; foreach ($rows as $r) { if (mb_strpos((string) $r['name'], '中心') !== false && (int) $r['pid'] > 0) { $candidates[(int) $r['id']] = true; } } // 父子都在 → 去掉父 foreach (array_keys($candidates) as $id) { $cur = (int) $byId[$id]['pid']; while ($cur > 0) { if (isset($candidates[$cur])) { unset($candidates[$cur]); } $cur = (int) ($byId[$cur]['pid'] ?? 0); } } $deptIds = array_keys($candidates); sort($deptIds); $tableRowDeptIds = $deptIds; } $names = []; foreach ($deptIds as $id) { $names[$id] = (string) ($byId[$id]['name'] ?? ('部门#' . $id)); } return [$deptIds, $names, $tableRowDeptIds, $byId]; } /** * 表格展示用部门名:自根到本级的全路径「父 / … / 子」;顶级「甄养堂互联网医院」不展示(与业务筛选里常见根节点重复冗长)。 * * @param array> $byId */ private static function formatYejiDeptRowDisplayName(int $deptId, array $byId): string { if ($deptId <= 0 || !isset($byId[$deptId])) { return '部门#' . $deptId; } $leafLabel = (string) $byId[$deptId]['name']; $names = []; $cur = $deptId; for ($i = 0; $i < 50; $i++) { if (!isset($byId[$cur])) { break; } array_unshift($names, (string) $byId[$cur]['name']); $pid = (int) $byId[$cur]['pid']; if ($pid <= 0) { break; } $cur = $pid; } $names = array_values(array_filter($names, static fn (string $s): bool => $s !== '')); if ($names !== [] && $names[0] === '甄养堂互联网医院') { array_shift($names); } if ($names === []) { return $leafLabel !== '' ? $leafLabel : ('部门#' . $deptId); } return implode(' / ', $names); } /** * @param array $childrenOf 父 id => 子部门 id 列表(来自未删除部门表) * * @return int[] */ private static function expandDeptRootIdsWithDescendants(array $rootIds, array $childrenOf): array { $set = []; $stack = array_values(array_filter(array_map('intval', $rootIds), static fn (int $id): bool => $id > 0)); while ($stack !== []) { $id = (int) array_pop($stack); if ($id <= 0 || isset($set[$id])) { continue; } $set[$id] = true; foreach (($childrenOf[$id] ?? []) as $cid) { $cid = (int) $cid; if ($cid > 0 && !isset($set[$cid])) { $stack[] = $cid; } } } return array_keys($set); } /** * 同一展开集合内:深度小的 id 在前,保证其后 buildDeptDescendants 写入 deptToPrimary 时子部门覆盖父部门。 * * @param array> $byId * * @return int[] */ private static function sortYejiDeptIdsAncestorsBeforeDescendants(array $deptIds, array $byId): array { $deptIds = array_values(array_unique(array_map('intval', $deptIds))); if ($deptIds === []) { return []; } $idSet = array_flip($deptIds); $memo = []; $depthFn = static function (int $id) use (&$depthFn, &$memo, $byId, $idSet): int { if (isset($memo[$id])) { return $memo[$id]; } if (!isset($idSet[$id])) { return $memo[$id] = -1; } $pid = (int) ($byId[$id]['pid'] ?? 0); if ($pid <= 0 || !isset($idSet[$pid])) { return $memo[$id] = 0; } return $memo[$id] = $depthFn($pid) + 1; }; usort($deptIds, static function (int $a, int $b) use ($depthFn): int { $da = $depthFn($a); $db = $depthFn($b); if ($da !== $db) { return $da <=> $db; } return $a <=> $b; }); return $deptIds; } /** * 每个 primary dept_id 的所有子孙(含自身)id 集合 * * @param int[] $primaryDeptIds * @return array */ private static function buildDeptDescendants(array $primaryDeptIds): array { $rows = Db::name('dept')->whereNull('delete_time')->field(['id', 'pid'])->select()->toArray(); $childrenOf = []; foreach ($rows as $r) { $pid = (int) $r['pid']; $childrenOf[$pid][] = (int) $r['id']; } $result = []; foreach ($primaryDeptIds as $rootId) { $stack = [$rootId]; $set = []; while ($stack !== []) { $cur = (int) array_pop($stack); if (isset($set[$cur])) { continue; } $set[$cur] = true; foreach (($childrenOf[$cur] ?? []) as $child) { if (!isset($set[$child])) { $stack[] = $child; } } } $result[$rootId] = array_keys($set); } return $result; } /** * admin_id → primary dept_id 归属 * * @param int[] $primaryDeptIds * @param array $deptToDescendants * @return array */ private static function buildAdminToPrimaryDept(array $primaryDeptIds, array $deptToDescendants): array { if ($primaryDeptIds === []) { return []; } $deptToPrimary = []; foreach ($deptToDescendants as $primary => $descs) { foreach ($descs as $d) { $deptToPrimary[$d] = $primary; } } if ($deptToPrimary === []) { return []; } $links = Db::name('admin_dept') ->whereIn('dept_id', array_keys($deptToPrimary)) ->field(['admin_id', 'dept_id']) ->select() ->toArray(); $adminToPrimary = []; foreach ($links as $l) { $adminId = (int) $l['admin_id']; $deptId = (int) $l['dept_id']; if (!isset($deptToPrimary[$deptId])) { continue; } if (!isset($adminToPrimary[$adminId])) { $adminToPrimary[$adminId] = $deptToPrimary[$deptId]; } } return $adminToPrimary; } /** * 业绩/成交单按「表格展示部门行」归类:先自 admin 的部门链向上命中第一个 $tableRowDeptIds, * 否则再向下取「位于该部门子树内的首个表格行」(与勾选父部门时隐藏父行、业绩需落到可见子行一致)。 * * @param int[] $adminIds * @param int[] $tableRowDeptIds * @param array> $deptById * * @return array admin_id => table_row_dept_id */ private static function batchMapPerformanceAdminsToTableDept(array $adminIds, array $tableRowDeptIds, array $deptById): array { $adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static fn (int $id): bool => $id > 0))); if ($adminIds === [] || $tableRowDeptIds === []) { return []; } $tableRowSet = array_flip(array_map('intval', $tableRowDeptIds)); $links = Db::name('admin_dept') ->whereIn('admin_id', $adminIds) ->field(['admin_id', 'dept_id']) ->select() ->toArray(); $byAdmin = []; foreach ($links as $l) { $aid = (int) $l['admin_id']; $did = (int) $l['dept_id']; if ($aid > 0 && $did > 0) { $byAdmin[$aid][$did] = true; } } $out = []; foreach ($adminIds as $aid) { $deptIds = array_keys($byAdmin[$aid] ?? []); if ($deptIds === []) { continue; } $mapped = self::mapAdminDeptLinksToTableRow($deptIds, $tableRowDeptIds, $tableRowSet, $deptById); if ($mapped > 0) { $out[$aid] = $mapped; } } return $out; } /** * @param int[] $deptIdsForAdmin * @param array $tableRowSet * @param array> $deptById * * 多链路 admin 的归属选择:按**原始 admin_dept 链路的深度**择优(更深 = 更具体的岗位归属), * 而非按解析出的表格行深度。否则同层中心兄弟节点在父视图(如 [郑州一中心, 郑州二中心])会 * 以 id 大小决胜,而子视图(钻取到某中心后行集变为其子组)则只剩唯一候选——同一条挂号在两个 * 视图被路由到不同行,导致 admin「已完成挂号」父行 < 子行合计。 * * 向下兜底(resolveFirstTableRowUnderDept)按「链路深度 ≥ 最浅表格行深度 - 1」收窄: * 仅当链路本身就是用户筛选根(与表格行同层或刚刚高一层)时才允许向下归到首个子表格行; * 否则像「只挂在甄养堂(root) / 一中心」等过宽链路,会在不同视图被任意攀附到第一个子节点, * 同一挂号父视图归到第一个中心、子视图归到该中心第一个子组 → 父子合计错位。 */ private static function mapAdminDeptLinksToTableRow(array $deptIdsForAdmin, array $tableRowDeptIds, array $tableRowSet, array $deptById): int { $best = 0; $bestDepth = -1; foreach ($deptIdsForAdmin as $d) { $d = (int) $d; if ($d <= 0) { continue; } $hit = self::resolveDeptToNearestTableRowUpward($d, $tableRowSet, $deptById); if ($hit > 0) { $depth = self::deptDepthFromRoot($d, $deptById); if ($depth > $bestDepth || ($depth === $bestDepth && ($best === 0 || $hit < $best))) { $best = $hit; $bestDepth = $depth; } } } if ($best > 0) { return $best; } $minTableRowDepth = PHP_INT_MAX; foreach ($tableRowDeptIds as $tid) { $td = self::deptDepthFromRoot((int) $tid, $deptById); if ($td > 0 && $td < $minTableRowDepth) { $minTableRowDepth = $td; } } $linkDepthFloor = $minTableRowDepth === PHP_INT_MAX ? PHP_INT_MAX : ($minTableRowDepth - 1); foreach ($deptIdsForAdmin as $d) { $d = (int) $d; if ($d <= 0) { continue; } if (self::deptDepthFromRoot($d, $deptById) < $linkDepthFloor) { continue; } $hit = self::resolveFirstTableRowUnderDept($d, $tableRowDeptIds, $deptById); if ($hit > 0) { return $hit; } } return 0; } private static function deptDepthFromRoot(int $deptId, array $deptById): int { $n = 0; $cur = $deptId; for ($i = 0; $i < 50 && $cur > 0; $i++) { $n++; $cur = (int) ($deptById[$cur]['pid'] ?? 0); } return $n; } /** * @param array $tableRowSet * @param array> $deptById */ private static function resolveDeptToNearestTableRowUpward(int $deptId, array $tableRowSet, array $deptById): int { $cur = $deptId; for ($i = 0; $i < 50 && $cur > 0; $i++) { if (isset($tableRowSet[$cur])) { return $cur; } $cur = (int) ($deptById[$cur]['pid'] ?? 0); } return 0; } /** * $ancestorId 是否为 $deptId 的祖先(含自身相等)。 * * @param array> $deptById */ private static function deptIsAncestorOf(int $ancestorId, int $deptId, array $deptById): bool { $cur = $deptId; for ($i = 0; $i < 50 && $cur > 0; $i++) { if ($cur === $ancestorId) { return true; } $cur = (int) ($deptById[$cur]['pid'] ?? 0); } return false; } /** * @param int[] $tableRowDeptIds * @param array> $deptById */ private static function resolveFirstTableRowUnderDept(int $adminDeptId, array $tableRowDeptIds, array $deptById): int { foreach ($tableRowDeptIds as $tid) { $tid = (int) $tid; if ($tid > 0 && self::deptIsAncestorOf($adminDeptId, $tid, $deptById)) { return $tid; } } return 0; } /** * 将聚合结果行映射到展示部门:优先 batchMap(父部门仅挂在表格外时仍能落到子行),否则退回 adminToPrimary。 * * @param array> $rows query 结果,含 assistant_id * @param array $adminToPrimary * @param int[] $tableRowDeptIds * @param array> $deptById * @param string $amtKey 数值字段名(金额 amt 或条数 cnt) * * @return array */ private static function foldPerformanceRowsToDeptByTable( array $rows, array $adminToPrimary, array $tableRowDeptIds, array $deptById, string $amtKey ): array { if ($rows === []) { return []; } $aids = []; foreach ($rows as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if ($aid > 0) { $aids[] = $aid; } } $adminToTable = ($tableRowDeptIds !== [] && $deptById !== []) ? self::batchMapPerformanceAdminsToTableDept($aids, $tableRowDeptIds, $deptById) : []; $byDept = []; foreach ($rows as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if ($aid <= 0) { continue; } $primary = (int) ($adminToTable[$aid] ?? 0); if ($primary <= 0) { $primary = (int) ($adminToPrimary[$aid] ?? 0); } if ($primary <= 0) { continue; } $byDept[$primary] = ($byDept[$primary] ?? 0) + ($amtKey === 'cnt' ? (int) ($r['cnt'] ?? 0) : (float) ($r['amt'] ?? 0)); } return $byDept; } /** * 接诊挂号 aggregateConsults 结果行 → 表格展示部门:规则同业绩 fold(优先 batchMap 到 tableRow,再 adminToPrimary;先有效医助再接诊医生)。 * * @param array> $rows eff_a, doctor_id, cnt * * @return array */ private static function foldConsultCountRowsToDeptByTable( array $rows, array $adminToPrimary, array $tableRowDeptIds, array $deptById, ?array $dataScopeAdminIds ): array { if ($rows === []) { return []; } $visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] ? array_flip($dataScopeAdminIds) : null; $aids = []; foreach ($rows as $r) { $e = (int) ($r['eff_a'] ?? 0); $d = (int) ($r['doctor_id'] ?? 0); if ($e > 0) { $aids[] = $e; } if ($d > 0) { $aids[] = $d; } } $aids = array_values(array_unique($aids)); $adminToTable = ($tableRowDeptIds !== [] && $deptById !== []) ? self::batchMapPerformanceAdminsToTableDept($aids, $tableRowDeptIds, $deptById) : []; /** * 视图无关的「该 admin 是否有 admin_dept 归属」。 * 用途:仅当 admin 完全无部门归属时才允许由"医助"回退到"接诊医生"。 * 否则父视图能解析到 admin 的中心行 → 计入 assistant;子视图(钻取另一中心) * adminToTable / adminToPrimary 双双 0 时会回退到 doctor,造成同一挂号在父视图归 assistant、 * 在子视图归 doctor,父行 < 子行合计。 * * @var array $adminHasAnyDept */ $adminHasAnyDept = []; if ($aids !== []) { $linkedAids = Db::name('admin_dept')->whereIn('admin_id', $aids)->column('admin_id'); foreach ($linkedAids as $lid) { $lid = (int) $lid; if ($lid > 0) { $adminHasAnyDept[$lid] = true; } } } $byDept = []; foreach ($rows as $r) { $effId = (int) ($r['eff_a'] ?? 0); $docId = (int) ($r['doctor_id'] ?? 0); $cnt = (int) ($r['cnt'] ?? 0); if ($cnt <= 0) { continue; } if ($visFlip !== null) { if ($effId > 0) { if (!isset($visFlip[$effId])) { continue; } } elseif ($docId > 0) { if (!isset($visFlip[$docId])) { continue; } } else { continue; } } $primary = 0; foreach ([$effId, $docId] as $aid) { if ($aid <= 0) { continue; } $p = (int) ($adminToTable[$aid] ?? 0); if ($p <= 0) { $p = (int) ($adminToPrimary[$aid] ?? 0); } if ($p > 0) { $primary = $p; break; } /** * 视图无关地有部门归属,仅是当前视图行集 / primaryDeptIds 不含 → * 视为该 admin 的归属(视图外不可见),不再回退到下一候选(医生), * 以避免父子视图归属分裂。narrowTotalsToTable 视图下 unmapped 会被自动隐藏。 */ if (isset($adminHasAnyDept[$aid])) { break; } } if ($primary > 0) { $byDept[$primary] = ($byDept[$primary] ?? 0) + $cnt; } else { $byDept[0] = ($byDept[0] ?? 0) + $cnt; } } return $byDept; } /** * 进线数据:add_external_contact 事件按 user_id(work_wechat_userid) → admin → primary dept * * @param array $adminToPrimary * @return array */ private static function countLeads(int $startTs, int $endTs, array $adminToPrimary, string $tagFilterId): array { if ($adminToPrimary === []) { return []; } $query = Db::name('qywx_external_contact_event') ->alias('e') ->where('e.change_type', 'add_external_contact') ->where('e.event_time', 'between', [$startTs, $endTs]) ->where('e.user_id', '<>', '') ->field(['e.user_id', 'COUNT(*) AS cnt']) ->group('e.user_id'); if ($tagFilterId !== '') { // 客户身上有该标签(带前缀以避免 raw SQL 不被框架 prefix 替换) $tagTable = self::tableWithPrefix('qywx_external_contact_tag'); $query->whereRaw( "EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)", [$tagFilterId] ); } $rows = $query->select()->toArray(); if ($rows === []) { return []; } $userIds = array_column($rows, 'user_id'); $adminIdByWx = Db::name('admin') ->whereIn('work_wechat_userid', $userIds) ->whereNull('delete_time') ->column('id', 'work_wechat_userid'); $byDept = []; foreach ($rows as $r) { $wx = (string) $r['user_id']; $adminId = (int) ($adminIdByWx[$wx] ?? 0); if ($adminId <= 0) { continue; } $primary = (int) ($adminToPrimary[$adminId] ?? 0); if ($primary <= 0) { continue; } $byDept[$primary] = ($byDept[$primary] ?? 0) + (int) $r['cnt']; } return $byDept; } /** * 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0,**剔除勾选「继承」的指派 is_inherit=1**), * 按**指派操作时间 lg.create_time** 落入区间,「医助 × 诊单」去重(COUNT DISTINCT diagnosis_id)、剔除已删诊单, * 与「复诊接诊率」页面同口径。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。 * * @param array $adminToPrimary * @param int[]|null $tagDiagIds * @param array|null $tagAssistantIds tag 退化口径下按 to_assistant_id 过滤(与 aggregatePerformance 一致) * * @return array */ private static function countDiagnosisAssignsByDept( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds, array $appointmentChannelValues, bool $channelFilterActive, ?array $dataScopeAdminIds = null, ?array $channelInfo = null ): array { if ($adminToPrimary === []) { return []; } $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) ->where('lg.is_inherit', 0); if ($channelFilterActive) { if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { return []; } $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo); if ($pack === null) { return []; } $query->whereRaw($pack[0], $pack[1]); } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } if ($tagDiagIds !== null) { $query->whereIn('lg.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds))); } } else { return []; } } $query->field(['lg.to_assistant_id', Db::raw('COUNT(DISTINCT lg.diagnosis_id) 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 同过滤与区间(同样剔除 is_inherit=1),按 to_assistant_id 汇总(供排行榜)。 * * @return array */ private static function countDiagnosisAssignsByAdmin( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIds, array $appointmentChannelValues, bool $channelFilterActive, ?array $dataScopeAdminIds = null, ?array $channelInfo = null ): array { $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) ->where('lg.is_inherit', 0); if ($channelFilterActive) { if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { return []; } $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo); if ($pack === null) { return []; } $query->whereRaw($pack[0], $pack[1]); } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } if ($tagDiagIds !== null) { $query->whereIn('lg.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds))); } } else { return []; } } $query->field(['lg.to_assistant_id', Db::raw('COUNT(DISTINCT lg.diagnosis_id) 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: 选定渠道时「{渠道}成交单」= **计入业绩的业务订单条数**(与 {渠道}业绩同源,剔除 4/9/10):dict 下 EXISTS 与侧栏一致(channels ∪ channel_source);有展示行时按列表部门摊行;否则走归因折叠。 * 未选渠道时 scoped == all(不再额外查一次)。 * * @param int[] $appointmentChannelValues dict_data value 整型列表(通常 1=自媒体1) * @param int $startTs 区间起点日 00:00:00 对应日期(用于取 start_date) * @param int $endTs 区间终点日 23:59:59 对应日期(用于取 end_date) * * @return array{ * all: array, * scoped: array * } */ private static function countConsults( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds, array $appointmentChannelValues = [], bool $channelFilterActive = false, ?array $dataScopeAdminIds = null, array $tableRowDeptIds = [], array $deptById = [], ?array $channelInfo = null ): array { if ($adminToPrimary === []) { return ['all' => [], 'scoped' => []]; } $allByDept = self::aggregateConsults( $startTs, $endTs, $adminToPrimary, null, null, $dataScopeAdminIds, $tableRowDeptIds, $deptById ); if (!$channelFilterActive) { return ['all' => $allByDept, 'scoped' => $allByDept]; } if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { $scopedByDept = []; } else { $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); if ($pack === null) { $scopedByDept = []; } elseif ($tableRowDeptIds !== [] && $deptById !== []) { $scopedAligned = self::aggregateListAlignedOrderMetricsByDept( $startTs, $endTs, $adminToPrimary, null, null, $tableRowDeptIds, $deptById, $pack[0], $pack[1], $dataScopeAdminIds ); $scopedByDept = $scopedAligned['count']; } else { $scopedByDept = self::aggregateDealOrderCountByAppointmentChannels( $startTs, $endTs, $adminToPrimary, $norm, $tableRowDeptIds, $deptById, $channelInfo ); } } } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { $scopedByDept = self::aggregateDealOrderCount( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagAssistantIds, $tableRowDeptIds, $deptById ); } else { $scopedByDept = []; } return ['all' => $allByDept, 'scoped' => $scopedByDept]; } /** * 单次接诊聚合查询:status=3 + 可选 tag;与 admin 挂号列表计数条件一致(不限医助角色、不要求 assistant_id)。 * 按 (有效医助 id, doctor_id) 分组后,再映射到展示「中心」:先医助、再接诊医生;无法归属则 dept_key=0。 * * @return array */ private static function aggregateConsults( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds, ?array $dataScopeAdminIds = null, array $tableRowDeptIds = [], array $deptById = [] ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $eff = self::consultEffectiveAssistantSql(); $query = Db::name('doctor_appointment')->alias('a'); self::applyConsultFiltersAlignedWithAppointmentLists($query, $startTs, $endTs); if ($tagDiagIds !== null) { $query->whereIn('a.patient_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')'); } $query ->field([ Db::raw("({$eff}) AS eff_a"), 'a.doctor_id', Db::raw('COUNT(*) AS cnt'), ]) ->group([$eff, 'a.doctor_id']); $rows = $query->select()->toArray(); if ($rows === []) { return []; } if ($tableRowDeptIds !== [] && $deptById !== []) { return self::foldConsultCountRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, $dataScopeAdminIds); } $visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] ? array_flip($dataScopeAdminIds) : null; $byDept = []; foreach ($rows as $r) { $effId = (int) ($r['eff_a'] ?? 0); $docId = (int) ($r['doctor_id'] ?? 0); $cnt = (int) ($r['cnt'] ?? 0); if ($cnt <= 0) { continue; } // 数据范围:仅计有效医助/接诊医生在可见名单内的挂号,避免 invisible 医助挂到 visible 医生上造成跨范围泄露 if ($visFlip !== null) { if ($effId > 0) { if (!isset($visFlip[$effId])) { continue; } } elseif ($docId > 0) { if (!isset($visFlip[$docId])) { continue; } } else { continue; } } $primary = 0; if ($effId > 0) { $primary = (int) ($adminToPrimary[$effId] ?? 0); } if ($primary <= 0 && $docId > 0) { $primary = (int) ($adminToPrimary[$docId] ?? 0); } if ($primary > 0) { $byDept[$primary] = ($byDept[$primary] ?? 0) + $cnt; } else { $byDept[0] = ($byDept[0] ?? 0) + $cnt; } } return $byDept; } /** * 业绩表「预约诊单」列:status∈{1,3,4};部门归属规则与 {@see aggregateConsults} 一致。 * * @return array 展示部门 id => 条数(键 0 为无法归入展示「中心」) */ private static function aggregateBookedAppointmentsByDept( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds, ?array $dataScopeAdminIds = null ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $startDate = date('Y-m-d', $startTs); $endDate = date('Y-m-d', $endTs); $eff = self::consultEffectiveAssistantSql(); $query = Db::name('doctor_appointment')->alias('a') ->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id') ->whereBetween('a.appointment_date', [$startDate, $endDate]) ->whereIn('a.status', [1, 3, 4]) ->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)'); if ($tagDiagIds !== null) { $query->whereIn('a.patient_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')'); } $query ->field([ Db::raw("({$eff}) AS eff_a"), 'a.doctor_id', Db::raw('COUNT(*) AS cnt'), ]) ->group([$eff, 'a.doctor_id']); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] ? array_flip($dataScopeAdminIds) : null; $byDept = []; foreach ($rows as $r) { $effId = (int) ($r['eff_a'] ?? 0); $docId = (int) ($r['doctor_id'] ?? 0); $cnt = (int) ($r['cnt'] ?? 0); if ($cnt <= 0) { continue; } if ($visFlip !== null) { if ($effId > 0) { if (!isset($visFlip[$effId])) { continue; } } elseif ($docId > 0) { if (!isset($visFlip[$docId])) { continue; } } else { continue; } } $primary = 0; if ($effId > 0) { $primary = (int) ($adminToPrimary[$effId] ?? 0); } if ($primary <= 0 && $docId > 0) { $primary = (int) ($adminToPrimary[$docId] ?? 0); } if ($primary > 0) { $byDept[$primary] = ($byDept[$primary] ?? 0) + $cnt; } else { $byDept[0] = ($byDept[0] ?? 0) + $cnt; } } return $byDept; } /** * 接诊按医助汇总(不按部门归并)。 * * @return array */ private static function aggregateConsultsByAdmin( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIds ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $eff = self::consultEffectiveAssistantSql(); $query = Db::name('doctor_appointment')->alias('a'); self::applyConsultFiltersAlignedWithAppointmentLists($query, $startTs, $endTs); if ($tagDiagIds !== null) { $query->whereIn('a.patient_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')'); } $query->field([Db::raw("({$eff}) AS eff_a"), Db::raw('COUNT(*) AS cnt')]) ->group($eff); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byAdmin = []; foreach ($rows as $r) { $aid = (int) ($r['eff_a'] ?? 0); if ($aid <= 0) { continue; } $byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) $r['cnt']; } return $byAdmin; } /** * 与挂号列表一致的有效医助:优先挂号表创建人 assistant_id,为 0 时用诊单医助(列表 join 展示字段)。 */ private static function consultEffectiveAssistantSql(): string { return 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))'; } /** * 医助排行榜「预约诊单」:`doctor_appointment.appointment_date` 落在统计区间内; * 状态为已预约(1)、已完成(3)、已过号(4),不含已取消(2); * 有效医助与 {@see consultEffectiveAssistantSql()} 一致。 * * @return array */ private static function countBookedAppointmentsByAdminForLeaderboard( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIds, ?array $dataScopeAdminIds ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $startDate = date('Y-m-d', $startTs); $endDate = date('Y-m-d', $endTs); $eff = self::consultEffectiveAssistantSql(); $query = Db::name('doctor_appointment')->alias('a') ->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id') ->whereBetween('a.appointment_date', [$startDate, $endDate]) ->whereIn('a.status', [1, 3, 4]) ->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)'); if ($tagDiagIds !== null) { $query->whereIn('a.patient_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')'); } $query->field([Db::raw("({$eff}) AS eff_a"), Db::raw('COUNT(*) AS cnt')]) ->group($eff); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] ? array_flip($dataScopeAdminIds) : null; $byAdmin = []; foreach ($rows as $r) { $aid = (int) ($r['eff_a'] ?? 0); if ($aid <= 0) { continue; } if ($visFlip !== null && !isset($visFlip[$aid])) { continue; } $byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) ($r['cnt'] ?? 0); } return $byAdmin; } /** * doctor_appointment 别名 a:与 AppointmentLists 已完成挂号 **计数** 对齐(诊单软删、appointment_date、status=3)。 * 不按医助角色或 assistant_id 再收窄(与列表 tab 总数一致)。 * * @param \think\db\Query $query */ private static function applyConsultFiltersAlignedWithAppointmentLists($query, int $startTs, int $endTs): void { $startDate = date('Y-m-d', $startTs); $endDate = date('Y-m-d', $endTs); $query->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id') ->where('a.status', 3) ->whereBetween('a.appointment_date', [$startDate, $endDate]) ->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)'); } /** * 进线按接待人(admin)汇总;不按展示部门截断,后续按 adminToPrimary 归属。 * * @return array */ private static function countLeadsByAdmin(int $startTs, int $endTs, string $tagFilterId): array { $query = Db::name('qywx_external_contact_event') ->alias('e') ->where('e.change_type', 'add_external_contact') ->where('e.event_time', 'between', [$startTs, $endTs]) ->where('e.user_id', '<>', '') ->field(['e.user_id', 'COUNT(*) AS cnt']) ->group('e.user_id'); if ($tagFilterId !== '') { $tagTable = self::tableWithPrefix('qywx_external_contact_tag'); $query->whereRaw( "EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)", [$tagFilterId] ); } $rows = $query->select()->toArray(); if ($rows === []) { return []; } $userIds = array_column($rows, 'user_id'); $adminIdByWx = Db::name('admin') ->whereIn('work_wechat_userid', $userIds) ->whereNull('delete_time') ->column('id', 'work_wechat_userid'); $byAdmin = []; foreach ($rows as $r) { $wx = (string) $r['user_id']; $adminId = (int) ($adminIdByWx[$wx] ?? 0); if ($adminId <= 0) { continue; } $byAdmin[$adminId] = ($byAdmin[$adminId] ?? 0) + (int) $r['cnt']; } return $byAdmin; } /** * 进线事件查询公共条件(与 countLeads / countLeadsByAdmin 一致)。 * * @param \think\db\BaseQuery $query */ private static function applyLeadEventBaseFilter($query, int $startTs, int $endTs, string $tagFilterId): void { $query->where('e.change_type', 'add_external_contact') ->where('e.event_time', 'between', [$startTs, $endTs]) ->where('e.user_id', '<>', ''); if ($tagFilterId !== '') { $tagTable = self::tableWithPrefix('qywx_external_contact_tag'); $query->whereRaw( "EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)", [$tagFilterId] ); } } /** * 归属到指定展示「中心」行部门的接待成员企微 userid 列表。 * * @param array $adminToPrimary * * @return list */ private static function collectWorkWxUserIdsForYejiDeptRow(int $deptRowId, array $adminToPrimary): array { $adminIds = []; foreach ($adminToPrimary as $aid => $pid) { if ((int) $pid === $deptRowId) { $adminIds[] = (int) $aid; } } if ($adminIds === []) { return []; } $wids = Db::name('admin') ->whereIn('id', $adminIds) ->whereNull('delete_time') ->column('work_wechat_userid'); $out = []; foreach ($wids as $w) { $w = trim((string) $w); if ($w !== '') { $out[] = $w; } } return array_values(array_unique($out)); } /** * 进线明细:与看板「进线数据」同口径的 add_external_contact 逐条列表。 * * @param array{ * start_date?: string, * end_date?: string, * dept_ids?: int[]|string, * channel_code?: string, * tag_id?: string, * dept_id?: int|string, * page?: int|string, * page_size?: int|string * } $params * * @return array{ * start_date: string, * end_date: string, * dept_id: int, * dept_name: string, * channel_code: string, * count: int, * lists: list>, * note: string * } */ public static function leadLineList(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $c = self::resolveYejiContext($params); if ($viewerAdminId > 0) { self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); } $deptId = (int) ($params['dept_id'] ?? 0); $empty = static function (array $c, int $deptId, string $deptName, string $note) { return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'dept_id' => $deptId, 'dept_name' => $deptName, 'channel_code' => (string) $c['channelCode'], 'count' => 0, 'lists' => [], 'note' => $note, ]; }; if ($deptId <= 0) { return $empty($c, 0, '', '未归属中心无进线明细(看板该行进线为 0)。'); } if (!in_array($deptId, $c['tableRowDeptIds'], true)) { return $empty( $c, $deptId, self::formatYejiDeptRowDisplayName($deptId, $c['deptById']), '该部门不在当前展示部门筛选或数据权限范围内。' ); } $channelCode = (string) $c['channelCode']; $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); if ($channelCode !== '' && isset($erCenterDeptSet[$deptId])) { return $empty( $c, $deptId, self::formatYejiDeptRowDisplayName($deptId, $c['deptById']), '与看板一致:二中心及其组织下级展示部门在选定渠道下进线计 0,无明细。' ); } $startTs = (int) $c['startTs']; $endTs = (int) $c['endTs']; $tagFilterId = (string) $c['tagFilterId']; $page = max(1, (int) ($params['page'] ?? 1)); $limit = min(100, max(1, (int) ($params['page_size'] ?? 20))); $offset = ($page - 1) * $limit; $wxUserIds = self::collectWorkWxUserIdsForYejiDeptRow($deptId, $c['adminToPrimary']); $deptName = self::formatYejiDeptRowDisplayName($deptId, $c['deptById']); $ecTable = self::tableWithPrefix('qywx_external_contact'); if ($wxUserIds === []) { return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'dept_id' => $deptId, 'dept_name' => $deptName, 'channel_code' => $channelCode, 'count' => 0, 'lists' => [], 'note' => '当前部门下没有可映射到接待企微成员的管理员,无进线流水。', ]; } $countQuery = Db::name('qywx_external_contact_event')->alias('e'); self::applyLeadEventBaseFilter($countQuery, $startTs, $endTs, $tagFilterId); $countQuery->whereIn('e.user_id', $wxUserIds); $count = (int) $countQuery->count(); $listQuery = Db::name('qywx_external_contact_event')->alias('e') ->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL') ->leftJoin("{$ecTable} ec", 'ec.external_userid = e.external_userid AND ec.delete_time IS NULL'); self::applyLeadEventBaseFilter($listQuery, $startTs, $endTs, $tagFilterId); $listQuery->whereIn('e.user_id', $wxUserIds); $rawList = $listQuery ->field( 'e.id,e.event_time,e.external_userid,e.user_id,e.state,' . 'a.name AS reception_admin_name,ec.name AS external_contact_name' ) ->order('e.event_time', 'desc') ->order('e.id', 'desc') ->limit($offset, $limit) ->select() ->toArray(); $lists = []; foreach ($rawList as $rw) { $ts = (int) ($rw['event_time'] ?? 0); $lists[] = [ 'id' => (int) ($rw['id'] ?? 0), 'event_time' => $ts, 'event_time_text' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '', 'external_userid' => (string) ($rw['external_userid'] ?? ''), 'user_id' => (string) ($rw['user_id'] ?? ''), 'state' => (string) ($rw['state'] ?? ''), 'reception_admin_name' => (string) ($rw['reception_admin_name'] ?? ''), 'external_contact_name' => (string) ($rw['external_contact_name'] ?? ''), ]; } $note = '与看板「进线数据」同口径:企微 change_type=add_external_contact,按接待成员归属当前部门行;' . ($tagFilterId !== '' ? '已按所选渠道标签过滤客户。' : '未选渠道时不限制客户标签。'); return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'dept_id' => $deptId, 'dept_name' => $deptName, 'channel_code' => $channelCode, 'count' => $count, 'lists' => $lists, 'note' => $note, ]; } /** * admin → 展示「中心」映射:用于挂号明细按部门筛选(与 aggregateConsults 映射同源)。 */ private static function buildYejiAdminPrimaryMapUnionSql(array $adminToPrimary): string { if ($adminToPrimary === []) { return 'SELECT 0 AS map_aid, 0 AS map_pid WHERE 0 = 1'; } $parts = []; foreach ($adminToPrimary as $aid => $pid) { $parts[] = 'SELECT ' . (int) $aid . ' AS map_aid,' . (int) $pid . ' AS map_pid'; } return implode(' UNION ALL ', $parts); } /** * @return list> */ private static function formatYejiAppointmentLinePayloadRows(array $rawList, bool $hasChSrc): array { $statusMap = [ 1 => '已预约', 2 => '已取消', 3 => '已完成', 4 => '已过号', ]; $typeMap = [ 'video' => '视频问诊', 'text' => '图文问诊', 'phone' => '电话问诊', ]; $lists = []; foreach ($rawList as $rw) { $st = (int) ($rw['status'] ?? 0); $tp = (string) ($rw['appointment_type'] ?? ''); $lists[] = [ 'id' => (int) ($rw['id'] ?? 0), 'appointment_date' => (string) ($rw['appointment_date'] ?? ''), 'appointment_time' => (string) ($rw['appointment_time'] ?? ''), 'status' => $st, 'status_desc' => $statusMap[$st] ?? '未知', 'appointment_type' => $tp, 'appointment_type_desc' => $typeMap[$tp] ?? '未知', 'diagnosis_id' => (int) ($rw['patient_id'] ?? 0), 'patient_name' => (string) ($rw['patient_name'] ?? ''), 'patient_phone' => (string) ($rw['patient_phone'] ?? ''), 'doctor_name' => (string) ($rw['doctor_name'] ?? ''), 'assistant_name' => (string) ($rw['assistant_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') ->leftJoin('admin appt_asst', 'a.assistant_id = appt_asst.id AND appt_asst.delete_time IS NULL') ->leftJoin('admin diag_asst', 'u.assistant_id = diag_asst.id AND diag_asst.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', 'COALESCE(appt_asst.name, diag_asst.name) AS assistant_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') ->leftJoin('admin appt_asst', 'a.assistant_id = appt_asst.id AND appt_asst.delete_time IS NULL') ->leftJoin('admin diag_asst', 'u.assistant_id = diag_asst.id AND diag_asst.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', 'COALESCE(appt_asst.name, diag_asst.name) AS assistant_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; } $revisitCreatorFilter = self::resolveYejiRevisitCreatorFilter($c); $pack = DeptLogic::getErCenterRevisitAssistantBreakdownForDept( $deptId, $revisitSlot, (int) $c['startTs'], (int) $c['endTs'], $revisitCreatorFilter ); $rows = $pack['rows']; $out['rows'] = $rows; if ($rows === []) { $out['note'] = '当前部门与区间下无符合条件的复诊业务订单(口径与看板「二中心复诊」列一致)。'; } return $out; } /** * 二中心复诊统计:数据范围下仅统计可见创建人的复诊笔数(与合计业绩一致)。 * * @param array $c * * @return int[]|null null=不限制;[]=无可见创建人 */ private static function resolveYejiRevisitCreatorFilter(array $c): ?array { if (empty($c['dataScopeRestricted']) || !isset($c['dataScopeVisibleAdminIds'])) { return null; } if (!\is_array($c['dataScopeVisibleAdminIds']) || $c['dataScopeVisibleAdminIds'] === []) { return []; } return array_values(array_unique(array_filter( array_map('intval', $c['dataScopeVisibleAdminIds']), static fn (int $v): bool => $v > 0 ))); } /** * 与 sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。 * 归属固定为 sqlPerformanceAttributionAdminExpr()——按订单创建人。 * * @param int[] $appointmentChannelValues * * @return array{all: array, scoped: array} */ private static function sumPerformanceByAdmin( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIdsForScoped, array $appointmentChannelValues, bool $channelFilterActive, ?array $channelInfo = null ): array { $all = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, null, null); if (!$channelFilterActive) { return ['all' => $all, 'scoped' => $all]; } if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { $scoped = []; } else { $scoped = self::aggregatePerformanceAmountsByAssistantChannel($startTs, $endTs, $norm, $channelInfo); } } elseif ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) { $scoped = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped); } else { $scoped = []; } return ['all' => $all, 'scoped' => $scoped]; } /** * 与 sumPerformanceByAdmin 对应:按订单创建人统计成交单数(剔除履约 4/9/10),全量/渠道分支一致。 * * @param int[] $appointmentChannelValues * * @return array{all: array, scoped: array} */ private static function sumDealOrderByAdmin( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIdsForScoped, array $appointmentChannelValues, bool $channelFilterActive, ?array $channelInfo = null ): array { $all = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, null, null); if (!$channelFilterActive) { return ['all' => $all, 'scoped' => $all]; } if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { $scoped = []; } else { $scoped = self::aggregateDealOrderCountByAssistantChannel($startTs, $endTs, $norm, $channelInfo); } } elseif ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) { $scoped = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped); } else { $scoped = []; } return ['all' => $all, 'scoped' => $scoped]; } /** * 成交单数按业绩归属医助聚合(与 aggregatePerformanceAmountsByAdmin 同一 WHERE,计条数非金额)。 * * @return array */ private static function aggregateDealOrderCountGroupedByAssistant( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIds ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]) ->where('o.diagnosis_id', '>', 0); self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->whereRaw("({$att}) > 0", []); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')'); } $query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')]) ->group($att); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byAdmin = []; foreach ($rows as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if ($aid <= 0) { continue; } $byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) $r['cnt']; } return $byAdmin; } /** * dict(channels) 渠道成交单数按业绩归属医助聚合(与 aggregatePerformanceAmountsByAssistantChannel 同源)。 * * @param int[] $appointmentChannelValues * * @return array */ private static function aggregateDealOrderCountByAssistantChannel( int $startTs, int $endTs, array $appointmentChannelValues, ?array $channelInfo = null ): array { if ($appointmentChannelValues === []) { return []; } $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); if ($pack === null) { return []; } $existsSql = $pack[0]; $existsBindings = $pack[1]; $diagTable = self::tableWithPrefix('tcm_diagnosis'); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $att = self::sqlPerformanceAttributionAdminExpr(); $query->where('o.diagnosis_id', '>', 0) ->whereRaw("({$att}) > 0", []) ->whereRaw($existsSql, $existsBindings) ->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')]) ->group($att); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byAdmin = []; foreach ($rows as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if ($aid <= 0) { continue; } $byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) $r['cnt']; } return $byAdmin; } /** * 业绩按归属医助聚合到 admin(统一按订单创建人 o.creator_id;与 sqlPerformanceAttributionAdminExpr 一致)。 * * @return array */ private static function aggregatePerformanceAmountsByAdmin( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIds ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]) ->where('o.diagnosis_id', '>', 0); self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->whereRaw("({$att}) > 0", []); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')'); } $query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')]) ->group($att); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byAdmin = []; foreach ($rows as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if ($aid <= 0) { continue; } $byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (float) $r['amt']; } return $byAdmin; } /** * dict(channels) 渠道业绩按业绩归属医助聚合(与 aggregatePerformanceByAppointmentChannels 同源)。 * * @param int[] $appointmentChannelValues * * @return array */ private static function aggregatePerformanceAmountsByAssistantChannel( int $startTs, int $endTs, array $appointmentChannelValues, ?array $channelInfo = null ): array { if ($appointmentChannelValues === []) { return []; } $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); if ($pack === null) { return []; } $existsSql = $pack[0]; $existsBindings = $pack[1]; $diagTable = self::tableWithPrefix('tcm_diagnosis'); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $att = self::sqlPerformanceAttributionAdminExpr(); $query->where('o.diagnosis_id', '>', 0) ->whereRaw("({$att}) > 0", []) ->whereRaw($existsSql, $existsBindings) ->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')]) ->group($att); $rows = $query->select()->toArray(); if ($rows === []) { return []; } $byAdmin = []; foreach ($rows as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if ($aid <= 0) { continue; } $byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (float) $r['amt']; } return $byAdmin; } /** * 选定渠道时:二中心子树(DeptLogic::getErCenterSubtreeDeptIdSet)下属医助的渠道诊金、进线清零(与部门行一致)。 * * @param array $adminToPrimary * @param array $erCenterDeptSet * @param array $feeByAdmin * @param array $leadsByAdmin * @param array|null $dealOrderByAdmin * @param array|null $assignsByAdmin */ private static function excludeErCenterFromAdminLeaderboard( array $adminToPrimary, array $erCenterDeptSet, array &$feeByAdmin, array &$leadsByAdmin, ?array &$dealOrderByAdmin = null, ?array &$assignsByAdmin = null ): void { foreach ($adminToPrimary as $aid => $pid) { $pid = (int) $pid; if ($pid <= 0 || !isset($erCenterDeptSet[$pid])) { continue; } $feeByAdmin[$aid] = 0.0; $leadsByAdmin[$aid] = 0; if ($dealOrderByAdmin !== null) { $dealOrderByAdmin[$aid] = 0; } if ($assignsByAdmin !== null) { $assignsByAdmin[$aid] = 0; } } } /** 排行榜部门列短名:取名称首字符 */ private static function deptDisplayAbbr(string $deptName): string { $t = trim($deptName); if ($t === '') { return '—'; } $ch = mb_substr($t, 0, 1, 'UTF-8'); return $ch !== '' ? $ch : substr($t, 0, 1); } /** * 业绩/成交单统计:排除履约「已取消/拒收/退款」(fulfillment_status 4、9、10),NULL 与其它状态计入。 * 与看板「合计业绩」、处方列表业绩 extend 口径一致。 * * @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 { $inList = implode(',', array_map(static fn (int $v): string => (string) $v, self::PRESCRIPTION_ORDER_FULFILLMENT_EXCLUDED_FROM_PERFORMANCE)); if ($tableAlias !== '') { $query->whereRaw("({$tableAlias}.fulfillment_status IS NULL OR {$tableAlias}.fulfillment_status NOT IN ({$inList}))"); } else { $query->whereRaw("(fulfillment_status IS NULL OR fulfillment_status NOT IN ({$inList}))"); } } /** * @param \think\db\BaseQuery|\think\Model $query */ private static function applyPrescriptionOrderNotCancelledForPerformance($query, string $tableAlias = 'o'): void { self::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, $tableAlias); } /** * 业绩按医助归属:须 alias 订单 o、诊单 dg,并调用本方法 left join 处方 rx(rx.id = o.prescription_id) */ private static function joinPrescriptionForPerformanceAttribution($query): void { $rxTable = self::tableWithPrefix('tcm_prescription'); $query->leftJoin("{$rxTable} rx", 'rx.id = o.prescription_id AND rx.delete_time IS NULL'); } /** * 业绩归属医助 admin_id SQL 表达式(表别名固定为 o / dg / rx)。 * 业绩看板(部门表 / 医助排行榜 / 渠道列 / ROI / 复诊 / 未归属中心)统一按订单创建人 o.creator_id 归属。 * 即:诊金、接诊诊单、复诊、渠道业绩等所有金额/条数仅记给实际下单的医助;不再回退到诊单 assistant_id。 * 处方订单列表侧栏(yeji_order_drawer=1)的「医助」/「部门」筛选与本归属对齐;其它管理端的 assistant_id / * assistant_dept_id 列表过滤保留旧口径(创建人 ∪ 诊单医助),不受本表达式影响。 */ private static function sqlPerformanceAttributionAdminExpr(): string { return 'o.creator_id'; } /** * 处方订单业绩全量合计(不按部门归属截断),与订单列表在**同一 create_time 区间、同一业绩条件**下的 SUM(amount) 对齐。 */ private static function sumGlobalPerformanceAmount(int $startTs, int $endTs): float { $query = Db::name('tcm_prescription_order') ->alias('o') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); self::applyPrescriptionOrderNotCancelledForPerformance($query); return round((float) $query->sum('o.amount'), 1); } /** * 看板「合计业绩」某部门行对应的业绩归属医助 id(与 overview 中 foldPerformanceRowsToDeptByTable 摊行一致)。 * $yejiDeptIdsParam 与看板本次请求的 dept_ids 一致(未选则为 null)。 * * @param int|string|int[]|string[]|null $yejiDeptIdsParam * * @return int[] */ public static function collectAdminIdsAttributedToYejiTableRow(int $tableRowDeptId, $yejiDeptIdsParam): array { if ($tableRowDeptId <= 0) { return []; } [$primaryDeptIds, , $tableRowDeptIds, $deptById] = self::resolvePrimaryDepts($yejiDeptIdsParam); if ($tableRowDeptIds === []) { return []; } $deptToDescendants = self::buildDeptDescendants($primaryDeptIds); $adminToPrimary = self::buildAdminToPrimaryDept($primaryDeptIds, $deptToDescendants); $links = Db::name('admin_dept')->field(['admin_id', 'dept_id'])->select()->toArray(); $byAdmin = []; foreach ($links as $l) { $aid = (int) $l['admin_id']; $did = (int) $l['dept_id']; if ($aid > 0 && $did > 0) { $byAdmin[$aid][$did] = true; } } $candidateIds = array_values(array_unique(array_merge(array_keys($byAdmin), array_keys($adminToPrimary)))); $tableRowSet = array_flip(array_map('intval', $tableRowDeptIds)); $out = []; foreach ($candidateIds as $aid) { $aid = (int) $aid; if ($aid <= 0) { continue; } $deptIdsForAdmin = array_keys($byAdmin[$aid] ?? []); $mapped = 0; if ($deptIdsForAdmin !== []) { $mapped = self::mapAdminDeptLinksToTableRow($deptIdsForAdmin, $tableRowDeptIds, $tableRowSet, $deptById); } if ($mapped <= 0) { $mapped = (int) ($adminToPrimary[$aid] ?? 0); } if ($mapped === $tableRowDeptId) { $out[] = $aid; } } return array_values(array_unique($out)); } /** * 处方订单列表(yeji_order_drawer=1 + yeji_table_row_dept_ids):assistant_dept_id 部门筛选与 {@see aggregateListAlignedOrderMetricsByDept} * 一致——仅按订单创建人人事部门映射到当前表格展示行,命中多行取最深;只保留映射结果为 $winnerDeptId 的订单。 * * @param Query $query tcm_prescription_order 主查询(无表别名) * @param int[] $yejiTableRowDeptIds 请求 yeji_table_row_dept_ids * @param int|string|array|array|null $yejiDeptIdsParam 请求 dept_ids,传入 {@see resolvePrimaryDepts} */ public static function applyPrescriptionOrderListAlignedWinnerDeptFilter( Query $query, int $winnerDeptId, array $yejiTableRowDeptIds, $yejiDeptIdsParam = null ): void { $winnerDeptId = (int) $winnerDeptId; $tableRowDeptIds = array_values(array_unique(array_filter(array_map('intval', $yejiTableRowDeptIds), static function (int $x): bool { return $x > 0; }))); if ($winnerDeptId <= 0 || $tableRowDeptIds === []) { $query->whereRaw('0 = 1'); return; } if (!in_array($winnerDeptId, $tableRowDeptIds, true)) { $query->whereRaw('0 = 1'); return; } [$primaryDeptIds, , , $deptById] = self::resolvePrimaryDepts($yejiDeptIdsParam); $deptToDescendants = self::buildDeptDescendants($primaryDeptIds); $adminToPrimary = self::buildAdminToPrimaryDept($primaryDeptIds, $deptToDescendants); $childrenOf = self::yejiFullDeptChildrenOf(); /** @var array> $rowSubtreeFlips */ $rowSubtreeFlips = []; foreach ($tableRowDeptIds as $rid) { $rid = (int) $rid; if ($rid > 0) { $rowSubtreeFlips[$rid] = self::yejiDeptSubtreeIdFlip($rid, $childrenOf); } } $links = Db::name('admin_dept')->field(['admin_id', 'dept_id'])->select()->toArray(); /** @var array $adminDeptIdList */ $adminDeptIdList = []; foreach ($links as $l) { $aid = (int) $l['admin_id']; $did = (int) $l['dept_id']; if ($aid > 0 && $did > 0) { $adminDeptIdList[$aid][] = $did; } } $candidateIds = array_values(array_unique(array_merge(array_keys($adminDeptIdList), array_keys($adminToPrimary)))); $allowedCreators = []; foreach ($candidateIds as $aid) { $aid = (int) $aid; if ($aid <= 0) { continue; } $mapped = self::resolveDeepestYejiTableRowForListDeptOr( [$aid => true], $adminDeptIdList, $rowSubtreeFlips, $tableRowDeptIds, $deptById, $adminToPrimary ); if ($mapped === $winnerDeptId) { $allowedCreators[] = $aid; } } $allowedCreators = array_values(array_unique(array_filter($allowedCreators, static fn (int $x): bool => $x > 0))); if ($allowedCreators === []) { $query->whereRaw('0 = 1'); return; } $query->whereIn('creator_id', $allowedCreators); } /** * 标签/医助兜底口径下的渠道向业绩全量 SUM(与 sumPerformance 的 scoped 分支同一组 WHERE,无部门归属过滤)。 */ private static function sumGlobalPerformanceTagScoped( int $startTs, int $endTs, ?array $tagDiagIds, ?array $tagAssistantIds ): float { if ($tagDiagIds !== null && $tagDiagIds === []) { return 0.0; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return 0.0; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]) ->where('o.diagnosis_id', '>', 0); self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->whereRaw("({$att}) > 0", []); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')'); } return round((float) $query->sum('o.amount'), 1); } /** * dict(channels) 映射下渠道业绩全量 SUM(与 aggregatePerformanceByAppointmentChannels 同源,不按 primary dept 汇总)。 */ private static function sumGlobalPerformanceAppointmentChannels( int $startTs, int $endTs, array $appointmentChannelValues, ?array $channelInfo = null ): float { if ($appointmentChannelValues === []) { return 0.0; } $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { return 0.0; } $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); if ($pack === null) { return 0.0; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $existsSql = $pack[0]; $existsBindings = $pack[1]; $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $att = self::sqlPerformanceAttributionAdminExpr(); $query->where('o.diagnosis_id', '>', 0) ->whereRaw("({$att}) > 0", []) ->whereRaw($existsSql, $existsBindings); return round((float) $query->sum('o.amount'), 1); } /** * 选定渠道时:与 overview 中 sumPerformance「scoped」同规则的全局业绩金额(用于未归属补差)。 */ private static function sumGlobalChannelScopedPerformance( int $startTs, int $endTs, array $appointmentChannelValues, ?array $tagDiagIds, ?array $tagAssistantIdsForScoped, ?array $channelInfo = null ): float { if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { return 0.0; } return self::sumGlobalPerformanceAppointmentChannels($startTs, $endTs, $norm, $channelInfo); } if ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) { return self::sumGlobalPerformanceTagScoped($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped); } return 0.0; } /** @param int[] $raw */ private static function normalizeAppointmentChannelValues(array $raw): array { $out = []; foreach ($raw as $v) { $i = (int) $v; if ($i > 0) { $out[$i] = true; } } return array_keys($out); } /** * 业绩聚合:返回两组按部门归属的业绩金额。 * 口径与 /tcm.prescriptionOrder/lists 的 stats_*_performance 完全一致: * 业绩 = SUM(amount) WHERE fulfillment_status 为 NULL 或 NOT IN (4,9,10),且 delete_time IS NULL * * - all: 部门全量业绩(不限渠道)。用于「合计业绩」列展示。 * - scoped: 「{渠道}业绩」:患者渠道 EXISTS 与侧栏/抽屉一致(channels ∪ channel_source);有展示行时部门列与「合计业绩」同为列表部门摊行。 * * @return array{ * all: array, * scoped: array * } */ private static function sumPerformance( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds = null, array $appointmentChannelValues = [], bool $channelFilterActive = false, array $tableRowDeptIds = [], array $deptById = [], ?array $precomputedListAlignedAmountByDept = null, ?array $channelInfo = null, ?array $dataScopeAdminIds = null ): array { if ($adminToPrimary === []) { return ['all' => [], 'scoped' => []]; } // 1) 部门「合计业绩」:有展示行时与「接诊诊单」同属 {@see aggregateListAlignedOrderMetricsByDept}(仅订单创建人摊行) if ($precomputedListAlignedAmountByDept !== null) { $allByDept = []; foreach ($precomputedListAlignedAmountByDept as $deptId => $v) { $allByDept[(int) $deptId] = round((float) $v, 1); } } elseif ($tableRowDeptIds !== [] && $deptById !== []) { $aligned = self::aggregateListAlignedOrderMetricsByDept( $startTs, $endTs, $adminToPrimary, null, null, $tableRowDeptIds, $deptById, null, [], $dataScopeAdminIds ); $allByDept = []; foreach ($aligned['amount'] as $deptId => $v) { $allByDept[(int) $deptId] = round((float) $v, 1); } } else { $allByDept = self::aggregatePerformance( $startTs, $endTs, $adminToPrimary, null, null, $tableRowDeptIds, $deptById ); } if (!$channelFilterActive) { return ['all' => $allByDept, 'scoped' => $allByDept]; } if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); if ($norm === []) { $scopedByDept = []; } else { $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); if ($pack === null) { $scopedByDept = []; } elseif ($tableRowDeptIds !== [] && $deptById !== []) { $scopedAligned = self::aggregateListAlignedOrderMetricsByDept( $startTs, $endTs, $adminToPrimary, null, null, $tableRowDeptIds, $deptById, $pack[0], $pack[1], $dataScopeAdminIds ); $scopedByDept = []; foreach ($scopedAligned['amount'] as $deptId => $v) { $scopedByDept[(int) $deptId] = round((float) $v, 1); } } else { $scopedByDept = self::aggregatePerformanceByAppointmentChannels( $startTs, $endTs, $adminToPrimary, $norm, $tableRowDeptIds, $deptById, $channelInfo ); } } } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { $scopedByDept = self::aggregatePerformance( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagAssistantIds, $tableRowDeptIds, $deptById ); } else { $scopedByDept = []; } return ['all' => $allByDept, 'scoped' => $scopedByDept]; } /** * 渠道业绩:与「处方订单列表」业绩完全同口径 —— 按 o.create_time 归日、剔除履约 4/9/10(NULL 与其它状态计入); * EXISTS 仅用于"该患者属于此渠道"的判定(有任一挂号 channels 命中 + status=3,**不限挂号时点**); * 部门归属按业绩归属表达式(订单创建人 o.creator_id)。 */ private static function aggregatePerformanceByAppointmentChannels( int $startTs, int $endTs, array $adminToPrimary, array $appointmentChannelValues, array $tableRowDeptIds = [], array $deptById = [], ?array $channelInfo = null ): array { if ($appointmentChannelValues === [] || $adminToPrimary === []) { return []; } $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); if ($pack === null) { return []; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->where('o.diagnosis_id', '>', 0) ->whereRaw("({$att}) > 0", []) ->whereRaw($pack[0], $pack[1]) ->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')]) ->group($att); $rows = $query->select()->toArray(); return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'amt'); } /** * 「{渠道}接诊」在字典映射到 channels 时:按**成交单数**(非取消业务订单条数)计, * 与 aggregatePerformanceByAppointmentChannels 同一套 JOIN / EXISTS / 订单 create_time 窗口。 * * @return array */ private static function aggregateDealOrderCountByAppointmentChannels( int $startTs, int $endTs, array $adminToPrimary, array $appointmentChannelValues, array $tableRowDeptIds = [], array $deptById = [], ?array $channelInfo = null ): array { if ($appointmentChannelValues === [] || $adminToPrimary === []) { return []; } $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); if ($pack === null) { return []; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->where('o.diagnosis_id', '>', 0) ->whereRaw("({$att}) > 0", []) ->whereRaw($pack[0], $pack[1]) ->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(o.id) AS cnt')]) ->group($att); $rows = $query->select()->toArray(); return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt'); } /** * 单次业绩聚合:与 aggregatePerformanceAmountsByAdmin / aggregateDealOrderCount 同源 —— * INNER 诊单,按业绩归属医助 SUM(amount) 后映射到展示「中心」。 * WHERE: delete_time IS NULL、fulfillment ∈ (NULL ∪ 除 4/9/10 外)、create_time 区间、diagnosis_id>0、归属医助>0; * 可选 tag:diagnosis_id IN、归属医助 IN。 * * @return array */ private static function aggregatePerformance( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds, array $tableRowDeptIds = [], array $deptById = [] ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]) ->where('o.diagnosis_id', '>', 0); self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->whereRaw("({$att}) > 0", []); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')'); } $query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')]) ->group($att); $rows = $query->select()->toArray(); return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'amt'); } /** * 全量成交单数:与 sumGlobalPerformanceAmount 同一 WHERE/COUNT(剔除 4/9/10)。 */ private static function countGlobalDealOrders(int $startTs, int $endTs): int { $query = Db::name('tcm_prescription_order') ->alias('o') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); self::applyPrescriptionOrderNotCancelledForPerformance($query); return (int) $query->count(); } /** * 全量部门 org 树:pid => 子部门 id 列表(缓存一次请求内多次 aggregate 复用)。 * * @return array */ private static function yejiFullDeptChildrenOf(): array { static $cache = null; if ($cache !== null) { return $cache; } $rows = Db::name('dept')->whereNull('delete_time')->field(['id', 'pid'])->select()->toArray(); $childrenOf = []; foreach ($rows as $r) { $pid = (int) $r['pid']; $childrenOf[$pid][] = (int) $r['id']; } return $cache = $childrenOf; } /** * @return array */ private static function yejiDeptSubtreeIdFlip(int $rootDeptId, array $childrenOf): array { $flip = []; $stack = [$rootDeptId]; while ($stack !== []) { $cur = (int) array_pop($stack); if ($cur <= 0 || isset($flip[$cur])) { continue; } $flip[$cur] = true; foreach ($childrenOf[$cur] ?? [] as $cid) { $stack[] = (int) $cid; } } return $flip; } /** * 与业绩看板部门列一致:仅按订单创建人候选,将其人事部门落在展示行子树即命中;命中多行时取组织深度最大的展示行。 * (处方订单列表默认 assistant_dept_id 仍为「诊单医助 ∪ 创建人」并集,与看板有别。) * * @param array $candidates admin_id => true * @param array $adminDeptIdList * @param array> $rowSubtreeFlips * @param int[] $tableRowDeptIds * @param array> $deptById * * @return int 表格展示部门 id,无法归属则为 0 */ private static function resolveDeepestYejiTableRowForListDeptOr( array $candidates, array $adminDeptIdList, array $rowSubtreeFlips, array $tableRowDeptIds, array $deptById, array $adminToPrimary ): int { if ($candidates === []) { return 0; } /** * 每个命中的表格行追加 ['rid'=>表格行 id, 'link_depth'=>原始 admin_dept 链路深度]。 * 用链路深度(而非表格行深度)择优,保证父视图(同层中心兄弟节点行集)与子视图(钻取单中心后行集为其子组) * 对同一 admin 的归属选择一致——否则同 admin 在父行被路由到「错」的中心,在子行被路由到正确中心子组, * 表现为父行计数 < 子行合计。 * * @var array $matching */ $matching = []; foreach ($tableRowDeptIds as $rid) { $rid = (int) $rid; if ($rid <= 0) { continue; } $flip = $rowSubtreeFlips[$rid] ?? []; if ($flip === []) { continue; } $bestLinkDepth = -1; foreach ($candidates as $aid => $_) { $aid = (int) $aid; if ($aid <= 0) { continue; } foreach ($adminDeptIdList[$aid] ?? [] as $d) { $d = (int) $d; if ($d > 0 && isset($flip[$d])) { $linkDepth = self::deptDepthFromRoot($d, $deptById); if ($linkDepth > $bestLinkDepth) { $bestLinkDepth = $linkDepth; } } } } if ($bestLinkDepth >= 0) { $matching[] = ['rid' => $rid, 'link_depth' => $bestLinkDepth]; } } if ($matching === []) { foreach ($candidates as $aid => $_) { $p = (int) ($adminToPrimary[(int) $aid] ?? 0); if ($p > 0) { foreach ($tableRowDeptIds as $rid) { if ((int) $rid === $p) { return $p; } } } } return 0; } $best = 0; $bestDepth = -1; foreach ($matching as $m) { $rid = $m['rid']; $depth = $m['link_depth']; if ($depth > $bestDepth || ($depth === $bestDepth && ($best === 0 || $rid < $best))) { $best = $rid; $bestDepth = $depth; } } return $best; } /** * 看板「合计业绩 / 接诊诊单」列用的部门摊行:仅按订单创建人 o.creator_id 的人事部门映射到展示行子树(命中多行取最深), * 与侧栏 yeji_order_drawer=1、{@see sqlPerformanceAttributionAdminExpr} 一致。不再把诊单医助纳入归属候选。 * * @return array{count: array, amount: array} */ private static function aggregateListAlignedOrderMetricsByDept( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds, array $tableRowDeptIds, array $deptById, ?string $channelScopedExistsSql = null, array $channelScopedExistsBindings = [], ?array $dataScopeAdminIds = null ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return ['count' => [], 'amount' => []]; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return ['count' => [], 'amount' => []]; } if ($tableRowDeptIds === [] || $deptById === []) { return ['count' => [], 'amount' => []]; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]) ->where('o.diagnosis_id', '>', 0); self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->where('o.creator_id', '>', 0); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $in = implode(',', $ids); $query->whereRaw("o.creator_id IN ({$in})"); } if ($dataScopeAdminIds !== null && $dataScopeAdminIds !== []) { $query->whereIn('o.creator_id', $dataScopeAdminIds); } if ($channelScopedExistsSql !== null && $channelScopedExistsSql !== '') { $query->whereRaw($channelScopedExistsSql, $channelScopedExistsBindings); } $orderRows = $query->field([ 'o.creator_id', 'o.amount', ])->select()->toArray(); if ($orderRows === []) { return ['count' => [], 'amount' => []]; } $childrenOf = self::yejiFullDeptChildrenOf(); /** @var array> $rowSubtreeFlips */ $rowSubtreeFlips = []; foreach ($tableRowDeptIds as $rid) { $rid = (int) $rid; if ($rid > 0) { $rowSubtreeFlips[$rid] = self::yejiDeptSubtreeIdFlip($rid, $childrenOf); } } $adminIds = []; foreach ($orderRows as $r) { $c = (int) ($r['creator_id'] ?? 0); if ($c > 0) { $adminIds[] = $c; } elseif ($a > 0) { $adminIds[] = $a; } } $adminIds = array_values(array_unique(array_filter($adminIds, static fn (int $x): bool => $x > 0))); /** @var array $adminDeptIdList */ $adminDeptIdList = []; if ($adminIds !== []) { $links = Db::name('admin_dept')->whereIn('admin_id', $adminIds)->field(['admin_id', 'dept_id'])->select()->toArray(); foreach ($links as $l) { $aid = (int) $l['admin_id']; $did = (int) $l['dept_id']; if ($aid > 0 && $did > 0) { $adminDeptIdList[$aid][] = $did; } } } $byCnt = []; $byAmt = []; foreach ($orderRows as $r) { $cr = (int) ($r['creator_id'] ?? 0); if ($cr <= 0) { continue; } $cand = [$cr => true]; $deptKey = self::resolveDeepestYejiTableRowForListDeptOr( $cand, $adminDeptIdList, $rowSubtreeFlips, $tableRowDeptIds, $deptById, $adminToPrimary ); if ($deptKey <= 0) { continue; } $amt = (float) ($r['amount'] ?? 0); $byCnt[$deptKey] = ($byCnt[$deptKey] ?? 0) + 1; $byAmt[$deptKey] = ($byAmt[$deptKey] ?? 0.0) + $amt; } return ['count' => $byCnt, 'amount' => $byAmt]; } /** * 成交单数按部门:有展示行时与 {@see aggregateListAlignedOrderMetricsByDept} 同口径(仅订单创建人归属)。 * 无展示行时按 sqlPerformanceAttributionAdminExpr 分组再 fold(同样为创建人)。 * 选定医助 tag 时收窄为 o.creator_id ∈ 集。 * * @return array */ private static function aggregateDealOrderCount( int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds, array $tableRowDeptIds = [], array $deptById = [] ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } if ($tableRowDeptIds !== [] && $deptById !== []) { return self::aggregateListAlignedOrderMetricsByDept( $startTs, $endTs, $adminToPrimary, $tagDiagIds, $tagAssistantIds, $tableRowDeptIds, $deptById )['count']; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]) ->where('o.diagnosis_id', '>', 0); self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->where('o.creator_id', '>', 0); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); $in = implode(',', $ids); $query->whereRaw("o.creator_id IN ({$in})"); } $query->whereRaw("({$att}) > 0", []); $query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')]) ->group($att); $rows = $query->select()->toArray(); return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt'); } /** * 表格不展示「有下级的勾选根部门」行时,仍把该行对应进线数并入首个可见下级,避免投放成本分摊分母偏小。 * * @param int[] $primaryDeptIds * @param int[] $tableRowDeptIds * @param array $leads * @param array $deptToDescendants * * @return array */ private static function mergeHiddenPrimaryLeadsForCostAllocation( array $primaryDeptIds, array $tableRowDeptIds, array $leads, array $deptToDescendants ): array { if (array_diff($primaryDeptIds, $tableRowDeptIds) === [] && array_diff($tableRowDeptIds, $primaryDeptIds) === []) { return $leads; } $out = $leads; $hidden = array_values(array_diff($primaryDeptIds, $tableRowDeptIds)); if ($hidden === []) { return $out; } foreach ($hidden as $h) { $add = (int) ($out[$h] ?? 0); if ($add <= 0) { continue; } $descFlip = array_flip($deptToDescendants[$h] ?? []); $target = null; foreach ($tableRowDeptIds as $tid) { if ($tid !== $h && isset($descFlip[$tid])) { $target = $tid; break; } } if ($target !== null) { $out[$target] = (int) ($out[$target] ?? 0) + $add; } } return $out; } /** * 将总投放成本按展示各部门「进线」条数比例分摊:部门成本 = 总成本 × (部门进线 / 进线合计)。 * 四舍五入尾差并入进线条数最多的部门。 * * @param array $leads * @return array */ private static function allocateCostByLeads(float $globalCost, array $primaryDeptIds, array $leads): array { $out = []; foreach ($primaryDeptIds as $pid) { $out[$pid] = 0.0; } if ($globalCost <= 0 || $primaryDeptIds === []) { return $out; } $totalLeads = 0; foreach ($primaryDeptIds as $pid) { $totalLeads += (int) ($leads[$pid] ?? 0); } if ($totalLeads <= 0) { return $out; } $allocated = 0.0; $bestPid = null; $bestLv = -1; foreach ($primaryDeptIds as $pid) { $lv = (int) ($leads[$pid] ?? 0); if ($lv > $bestLv) { $bestLv = $lv; $bestPid = $pid; } $share = $globalCost * $lv / $totalLeads; $out[$pid] = round($share, 1); $allocated += $out[$pid]; } $diff = round($globalCost - $allocated, 1); if ($diff !== 0.0 && $bestPid !== null) { $out[$bestPid] = round($out[$bestPid] + $diff, 1); } return $out; } /** * 渠道总成本(合计行 & 进线分摊基数):account_cost 在日期区间内 SUM(amount),选定渠道时按 media_channel_code 过滤。 */ private static function sumGlobalCost(string $startDate, string $endDate, string $channelCode): float { $query = Db::name('account_cost')->whereBetween('cost_date', [$startDate, $endDate]); if ($channelCode !== '') { $query->where('media_channel_code', $channelCode); } return (float) $query->sum('amount'); } /** * 选定渠道时:二中心子树展示行渠道业绩与渠道成交单置 0,并返回扣减金额(与 sumGlobalChannelScopedPerformance 对齐)。 * * @param int[] $tableRowDeptIds * @param array $erCenterDeptSet * @param array $channelPerformance * @param array $channelConsults */ private static function excludeErCenterSubtreeFromChannelMetrics( array $tableRowDeptIds, array $erCenterDeptSet, array &$channelPerformance, array &$channelConsults ): float { $stripped = 0.0; foreach ($tableRowDeptIds as $tid) { $tid = (int) $tid; if (!isset($erCenterDeptSet[$tid])) { continue; } $stripped += (float) ($channelPerformance[$tid] ?? 0); $channelPerformance[$tid] = 0.0; $channelConsults[$tid] = 0; } return round($stripped, 1); } /** * 将 map 中落在二中心子树(DeptLogic)的部门 key 清零,用于进线分摊或渠道清零后的展示列。 * * @param array $map * @param array $erCenterDeptSet */ private static function zeroIntKeyedDeptMetricsInErCenterSubtree(array &$map, array $erCenterDeptSet): void { foreach (array_keys($map) as $k) { $id = (int) $k; if ($id > 0 && isset($erCenterDeptSet[$id])) { $map[$k] = 0; } } } /** * 渠道 tag → 1) 该标签下客户对应的全部诊单 id 集合(含 external_userid 直挂 + unionid 兜底) * 2) 这些客户的"跟进医助 admin id"集合(与 follow_users 中的医助 work_wechat_userid 对齐) * * 一次性算好,后续业绩/接诊用 whereIn 收敛,避免 EXISTS+LIKE 全表扫超时。 * * @return array{0: int[], 1: array} */ /** @var array}> 同一请求内 tag → [diagIds, assistantIds] 缓存 */ private static array $tagScopeCache = []; private static function resolveTagScopedDiagnosisAndAssistants(string $tagFilterId): array { if (isset(self::$tagScopeCache[$tagFilterId])) { return self::$tagScopeCache[$tagFilterId]; } // 1. 该标签下的客户:external_userid + unionid 集合 $rows = Db::name('qywx_external_contact_tag') ->alias('t') ->join((string) self::tableWithPrefix('qywx_external_contact') . ' ec', 'ec.external_userid = t.external_userid AND ec.delete_time IS NULL', 'INNER') ->where('t.tag_id', $tagFilterId) ->field(['t.external_userid', 'ec.unionid', 'ec.follow_users']) ->select() ->toArray(); if ($rows === []) { return self::$tagScopeCache[$tagFilterId] = [[], []]; } $extIds = []; $unionIds = []; $followWxUserids = []; foreach ($rows as $r) { $ext = (string) ($r['external_userid'] ?? ''); if ($ext !== '') { $extIds[$ext] = true; } $u = (string) ($r['unionid'] ?? ''); if ($u !== '') { $unionIds[$u] = true; } $fu = json_decode((string) ($r['follow_users'] ?? '[]'), true); if (!is_array($fu)) { continue; } foreach ($fu as $f) { if (!is_array($f)) { continue; } $wx = trim((string) ($f['userid'] ?? '')); if ($wx !== '') { $followWxUserids[$wx] = true; } } } // 2. 诊单:external_userid 直挂 ∪ unionid 兜底 $diagIds = []; if ($extIds !== []) { $ids = Db::name('tcm_diagnosis') ->whereNull('delete_time') ->whereIn('external_userid', array_keys($extIds)) ->column('id'); foreach ($ids as $id) { $diagIds[(int) $id] = true; } } if ($unionIds !== []) { $userIds = Db::name('user_auth') ->whereIn('unionid', array_keys($unionIds)) ->column('user_id'); $userIds = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn (int $v) => $v > 0))); if ($userIds !== []) { $ids = Db::name('tcm_diagnosis') ->whereNull('delete_time') ->whereIn('patient_id', $userIds) ->column('id'); foreach ($ids as $id) { $diagIds[(int) $id] = true; } } } // 3. 跟进医助:follow_users 中的 wx userid → admin.id(仅医助 role_id=2) $assistantIds = []; if ($followWxUserids !== []) { $rows = Db::name('admin') ->alias('a') ->join((string) self::tableWithPrefix('admin_role') . ' ar', 'ar.admin_id = a.id AND ar.role_id = 2', 'INNER') ->whereIn('a.work_wechat_userid', array_keys($followWxUserids)) ->whereNull('a.delete_time') ->column('a.id'); foreach ($rows as $aid) { $assistantIds[(int) $aid] = true; } } return self::$tagScopeCache[$tagFilterId] = [array_keys($diagIds), $assistantIds]; } /** * 「未归属中心」业绩下钻:按订单创建人汇总无法映射到展示「中心」的金额(与 aggregatePerformance 部门行互补)。 * * @return array{ * start_date: string, * end_date: string, * channel_code: string, * rows: list, * note: string * } */ public static function unassignedCenterBreakdown(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $c = self::resolveYejiContext($params); if ($viewerAdminId > 0) { self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); } $startTs = (int) $c['startTs']; $endTs = (int) $c['endTs']; $adminToPrimary = $c['adminToPrimary']; $byAdminAmt = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, null, null); $byAdminCnt = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, null, null); $visFlip = null; if (!empty($c['dataScopeRestricted']) && isset($c['dataScopeVisibleAdminIds'])) { $visFlip = array_flip(array_values(array_unique(array_filter( array_map('intval', $c['dataScopeVisibleAdminIds']), static function (int $v): bool { return $v > 0; } )))); } $rows = []; foreach ($byAdminAmt as $aid => $amt) { $aid = (int) $aid; if ($aid <= 0) { continue; } if ($visFlip !== null && !isset($visFlip[$aid])) { continue; } if ((int) ($adminToPrimary[$aid] ?? 0) > 0) { continue; } $rows[] = [ 'admin_id' => $aid, 'name' => '', 'order_count' => (int) ($byAdminCnt[$aid] ?? 0), 'amount' => round((float) $amt, 2), ]; } $globalAmt = self::sumGlobalPerformanceAmount($startTs, $endTs); $globalCnt = self::countGlobalDealOrders($startTs, $endTs); $innerAmt = array_sum($byAdminAmt); $innerCnt = array_sum($byAdminCnt); $orphanAmt = round(max(0.0, $globalAmt - $innerAmt), 2); $orphanCnt = max(0, $globalCnt - $innerCnt); if ($orphanAmt >= 0.01 || $orphanCnt > 0) { $rows[] = [ 'admin_id' => 0, 'name' => '', 'order_count' => $orphanCnt, 'amount' => $orphanAmt, ]; } usort($rows, static function (array $a, array $b): int { $c = $b['amount'] <=> $a['amount']; if ($c !== 0) { return $c; } return $b['order_count'] <=> $a['order_count']; }); $ids = []; foreach ($rows as $row) { if ($row['admin_id'] > 0) { $ids[] = $row['admin_id']; } } $names = self::batchAdminNames(array_values(array_unique($ids))); foreach ($rows as &$row) { $aid = (int) $row['admin_id']; if ($aid <= 0) { $row['name'] = '(无订单或创建人未填)'; } else { $row['name'] = (string) ($names[$aid] ?? ('#' . $aid)); } } unset($row); return [ 'start_date' => (string) $c['startDate'], 'end_date' => (string) $c['endDate'], 'channel_code' => (string) $c['channelCode'], 'rows' => $rows, 'note' => '与看板「未归属中心」补差一致:按订单 create_time、剔除履约 4/9/10;**订单创建人**人事部门无法映射到任一展示「中心」计入各行;其余(无诊单/创建人为空)汇总在首行。账号数据权限下仅列出可见医助。', ]; } /** * @param int[] $ids * * @return array */ private static function batchAdminNames(array $ids): array { if ($ids === []) { return []; } $rows = Db::name('admin') ->whereIn('id', $ids) ->whereNull('delete_time') ->field(['id', 'name']) ->select() ->toArray(); $map = []; foreach ($rows as $r) { $map[(int) $r['id']] = (string) ($r['name'] ?? ''); } return $map; } /** * 表名 + 数据库前缀(用于 whereRaw 内嵌的 SQL,框架不会替换 raw 表名) */ private static function tableWithPrefix(string $table): string { $prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_'); return $prefix . $table; } /** * 挂号字典 channels:与渠道配置名称匹配的 value 字符串列表(用于 ap.channel_source) * * @return string[] */ private static function appointmentChannelDictStringValuesForDrawer(?array $channelInfo): array { if ($channelInfo === null) { return []; } $names = array_values(array_unique(array_filter([ trim((string) ($channelInfo['channel_name'] ?? '')), trim((string) ($channelInfo['source_tag_name'] ?? '')), ]))); if ($names === []) { return []; } $vals = Db::name('dict_data')->where('type_value', 'channels')->whereIn('name', $names)->column('value'); $out = []; foreach ($vals as $v) { $s = trim((string) $v); if ($s !== '') { $out[$s] = true; } } return array_keys($out); } /** @var bool|null 进程内缓存:doctor_appointment 是否已加 channel_source 列 */ private static ?bool $doctorAppointmentHasChannelSourceColumn = null; private static function doctorAppointmentHasChannelSourceColumn(): bool { if (self::$doctorAppointmentHasChannelSourceColumn !== null) { return self::$doctorAppointmentHasChannelSourceColumn; } try { $fields = Db::name('doctor_appointment')->getTableFields(); self::$doctorAppointmentHasChannelSourceColumn = is_array($fields) && in_array('channel_source', $fields, true); } catch (\Throwable) { self::$doctorAppointmentHasChannelSourceColumn = false; } return self::$doctorAppointmentHasChannelSourceColumn; } /** @var bool|null 进程内缓存:doctor_appointment 是否存在 channels 列 */ private static ?bool $doctorAppointmentHasChannelsColumnCache = null; private static function doctorAppointmentHasChannelsColumn(): bool { if (self::$doctorAppointmentHasChannelsColumnCache !== null) { return self::$doctorAppointmentHasChannelsColumnCache; } try { $fields = Db::name('doctor_appointment')->getTableFields(); self::$doctorAppointmentHasChannelsColumnCache = is_array($fields) && in_array('channels', $fields, true); } catch (\Throwable) { self::$doctorAppointmentHasChannelsColumnCache = false; } return self::$doctorAppointmentHasChannelsColumnCache; } /** * 选定 dict(channels) 渠道时:患者存在已完成挂号,且挂号助理为医助(role_id=2),且 (ap.channels 或 ap.channel_source) 命中配置, * 与 {@see self::applyYejiDrawerChannelFilterToPrescriptionOrderQuery} 一致。 * * @param string $patientIdExpr 与 ap.patient_id 比较的 SQL 表达式,如 o.diagnosis_id、dg.id * * @return array{0: string, 1: array}|null 无可用工件列时返回 null */ private static function buildChannelScopedAppointmentExistsSqlAndBindings( string $patientIdExpr, array $normInts, ?array $channelInfo ): ?array { $normInts = self::normalizeAppointmentChannelValues($normInts); $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normInts))); $srcFromInfo = self::appointmentChannelDictStringValuesForDrawer($channelInfo); $allSrcStrings = array_values(array_unique(array_filter( array_merge($strVals, $srcFromInfo), static fn (string $s): bool => $s !== '' ))); $hasCh = self::doctorAppointmentHasChannelsColumn(); $hasSrc = self::doctorAppointmentHasChannelSourceColumn(); $parts = []; $bind = []; if ($hasCh && $strVals !== []) { $ph = implode(',', array_fill(0, count($strVals), '?')); $parts[] = "ap.channels IN ({$ph})"; foreach ($strVals as $sv) { $bind[] = $sv; } } if ($hasSrc && $allSrcStrings !== []) { $ph = implode(',', array_fill(0, count($allSrcStrings), '?')); $parts[] = "ap.channel_source IN ({$ph})"; foreach ($allSrcStrings as $s) { $bind[] = $s; } } if ($parts === []) { return null; } $apTable = self::tableWithPrefix('doctor_appointment'); $adminRoleTable = self::tableWithPrefix('admin_role'); $innerOr = implode(' OR ', $parts); $sql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar " . "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = {$patientIdExpr} " . "AND ap.status = 3 AND ({$innerOr}))"; return [$sql, $bind]; } /** * 业绩看板业务订单侧栏:按 channel_code 收窄(与 overview「{渠道}业绩」同患者判定;含 ap.channels 与 ap.channel_source); * **二中心及其组织下级**展示部门与看板一致:选定渠道收窄时本条无渠道订单(表中渠道业绩已为 0)。 * * @param Query $query PrescriptionOrder 主表查询 * @param array $params */ public static function applyYejiDrawerChannelFilterToPrescriptionOrderQuery(Query $query, array $params): void { if ((int) ($params['yeji_order_drawer'] ?? 0) !== 1) { return; } $channelCode = trim((string) ($params['channel_code'] ?? '')); if ($channelCode === '') { return; } /** @see excludeErCenterSubtreeFromChannelMetrics 看板选定渠道时对二中心子树渠道列置 0,侧栏须同步 */ $assistantDeptForDrawer = (int) ($params['assistant_dept_id'] ?? 0); if ($assistantDeptForDrawer > 0) { $erCenterSubtree = DeptLogic::getErCenterSubtreeDeptIdSet(); if (isset($erCenterSubtree[$assistantDeptForDrawer])) { $query->whereRaw('0 = 1'); return; } } $startDate = ''; $endDate = ''; $st = trim((string) ($params['start_time'] ?? '')); if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $st, $m)) { $startDate = $m[1]; } $et = trim((string) ($params['end_time'] ?? '')); if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $et, $m)) { $endDate = $m[1]; } if ($startDate === '' || $endDate === '') { return; } $ctx = self::resolveYejiContext([ 'start_date' => $startDate, 'end_date' => $endDate, 'channel_code' => $channelCode, 'dept_ids' => null, ]); if (!$ctx['channelFilterActive'] || $ctx['channelInfo'] === null) { $query->whereRaw('0 = 1'); return; } $poTbl = (new PrescriptionOrder())->getTable(); $apTable = self::tableWithPrefix('doctor_appointment'); $adminRoleTable = self::tableWithPrefix('admin_role'); $norm = self::normalizeAppointmentChannelValues($ctx['appointmentChannelValues']); $srcStrings = self::appointmentChannelDictStringValuesForDrawer($ctx['channelInfo']); if ($norm !== [] || $srcStrings !== []) { $subParts = []; $subBind = []; if ($norm !== []) { $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm))); $ph = implode(',', array_fill(0, count($strVals), '?')); $subParts[] = "ap.channels IN ({$ph})"; $subBind = array_merge($subBind, $strVals); } if ($srcStrings !== [] && self::doctorAppointmentHasChannelSourceColumn()) { $ph = implode(',', array_fill(0, count($srcStrings), '?')); $subParts[] = "ap.channel_source IN ({$ph})"; $subBind = array_merge($subBind, $srcStrings); } if ($subParts !== []) { $innerOr = implode(' OR ', $subParts); $existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar " . "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = `{$poTbl}`.`diagnosis_id` " . "AND ap.status = 3 AND ({$innerOr}))"; $query->whereRaw($existsSql, $subBind); return; } } $tagDiagIds = $ctx['tagDiagIds']; $tagAssistantIds = $ctx['tagAssistantIds']; $tagFallback = $ctx['tagFallback']; $tagFilterId = (string) ($ctx['tagFilterId'] ?? ''); if ($tagFilterId === '') { $query->whereRaw('0 = 1'); return; } if ($tagDiagIds !== null && $tagDiagIds === []) { $query->whereRaw('0 = 1'); return; } if ($tagAssistantIds !== null && $tagAssistantIds === [] && $tagFallback) { $query->whereRaw('0 = 1'); return; } if ($tagDiagIds !== null) { $query->whereIn('diagnosis_id', $tagDiagIds); } if ($tagFallback && $tagAssistantIds !== null && $tagAssistantIds !== []) { $query->whereIn('creator_id', array_map('intval', array_keys($tagAssistantIds))); } if ($tagDiagIds === null && !($tagFallback && $tagAssistantIds !== null && $tagAssistantIds !== [])) { $query->whereRaw('0 = 1'); } } /** * 业绩看板业务订单侧栏:在「渠道筛选」模式下,为每个诊单挑一条用于「关联挂号」展示的预约行—— * 须为已完成(status=3)、挂号助理为医助(role_id=2),且 channels/channel_source 与当前 channel_code 同源命中(与 {@see applyYejiDrawerChannelFilterToPrescriptionOrderQuery} 的 dict 收窄一致)。 * 若为 tag 等未走 channels 表达式的情况则返回空图,交由列表沿用处方/最近挂号。 * * @param int[] $diagnosisIds doctor_appointment.patient_id(与本系统诊单 id 一致) * @param array $params 与列表同源:yeji_order_drawer、channel_code、start_time、end_time * * @return array diagnosis_id => appointment_id */ public static function batchResolveYejiChannelDrawerHighlightAppointmentIds(array $diagnosisIds, array $params): array { $diagnosisIds = array_values(array_unique(array_filter(array_map('intval', $diagnosisIds), static fn (int $x): bool => $x > 0))); if ($diagnosisIds === []) { return []; } if ((int) ($params['yeji_order_drawer'] ?? 0) !== 1) { return []; } $channelCode = trim((string) ($params['channel_code'] ?? '')); if ($channelCode === '') { return []; } $startDate = ''; $endDate = ''; $st = trim((string) ($params['start_time'] ?? '')); if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $st, $m)) { $startDate = $m[1]; } $et = trim((string) ($params['end_time'] ?? '')); if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $et, $m)) { $endDate = $m[1]; } if ($startDate === '' || $endDate === '') { return []; } $ctx = self::resolveYejiContext([ 'start_date' => $startDate, 'end_date' => $endDate, 'channel_code' => $channelCode, 'dept_ids' => null, ]); if (!$ctx['channelFilterActive'] || $ctx['channelInfo'] === null) { return []; } $norm = self::normalizeAppointmentChannelValues($ctx['appointmentChannelValues']); $srcStrings = self::appointmentChannelDictStringValuesForDrawer($ctx['channelInfo']); if ($norm === [] && $srcStrings === []) { return []; } $apTable = self::tableWithPrefix('doctor_appointment'); $arTable = self::tableWithPrefix('admin_role'); $hasCh = self::doctorAppointmentHasChannelsColumn(); $orParts = []; $channelBind = []; if ($norm !== [] && $hasCh) { $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm))); $ph = implode(',', array_fill(0, count($strVals), '?')); $orParts[] = "ap.`channels` IN ({$ph})"; foreach ($strVals as $sv) { $channelBind[] = $sv; } } if ($srcStrings !== [] && self::doctorAppointmentHasChannelSourceColumn()) { $ph = implode(',', array_fill(0, count($srcStrings), '?')); $orParts[] = "ap.`channel_source` IN ({$ph})"; foreach ($srcStrings as $s) { $channelBind[] = $s; } } if ($orParts === []) { return []; } $inPh = implode(',', array_fill(0, count($diagnosisIds), '?')); $bind = array_merge($diagnosisIds, $channelBind); $orSql = '(' . implode(' OR ', $orParts) . ')'; $sql = "SELECT ap.`patient_id` AS dg, MAX(ap.`id`) AS aid FROM `{$apTable}` ap " . "INNER JOIN `{$arTable}` ar ON ar.`admin_id` = ap.`assistant_id` AND ar.`role_id` = 2 " . "WHERE ap.`patient_id` IN ({$inPh}) AND ap.`status` = 3 AND {$orSql} " . 'GROUP BY ap.`patient_id`'; try { $rows = Db::query($sql, $bind); } catch (\Throwable) { return []; } $out = []; foreach ($rows as $r) { $dg = (int) ($r['dg'] ?? $r['DG'] ?? 0); $aid = (int) ($r['aid'] ?? $r['AID'] ?? 0); if ($dg > 0 && $aid > 0) { $out[$dg] = $aid; } } return $out; } /** * 提成结算统计:渠道收窄条件应用到业务订单查询(与 sumPerformance「scoped」同源)。 */ private static function applyCommissionSettlementChannelFilter(Query $query, array $c): void { if (empty($c['channelFilterActive'])) { return; } /** @var array $appointmentChannelValues */ $appointmentChannelValues = $c['appointmentChannelValues'] ?? []; $tagDiagIds = $c['tagDiagIds'] ?? null; $tagAssistantIds = $c['tagAssistantIds'] ?? null; $tagFallback = !empty($c['tagFallback']); $channelInfo = $c['channelInfo'] ?? null; if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); if ($pack === null) { $query->whereRaw('0 = 1'); return; } $query->whereRaw($pack[0], $pack[1]); return; } if ($tagDiagIds !== null && $tagDiagIds === []) { $query->whereRaw('0 = 1'); return; } if ($tagAssistantIds !== null && $tagAssistantIds === [] && $tagFallback) { $query->whereRaw('0 = 1'); return; } $att = self::sqlPerformanceAttributionAdminExpr(); $narrowed = false; if ($tagDiagIds !== null) { $idsSan = array_values(array_unique(array_filter(array_map('intval', $tagDiagIds), static fn (int $x): bool => $x > 0))); if ($idsSan === []) { $query->whereRaw('0 = 1'); return; } $narrowed = true; $chunks = array_chunk($idsSan, 800); if (\count($chunks) === 1) { $query->whereIn('o.diagnosis_id', $chunks[0]); } else { $query->where(function ($qq) use ($chunks): void { foreach ($chunks as $i => $chunk) { if ($i === 0) { $qq->whereIn('o.diagnosis_id', $chunk); } else { $qq->whereOr(function ($q2) use ($chunk): void { $q2->whereIn('o.diagnosis_id', $chunk); }); } } }); } } if ($tagAssistantIds !== null) { $ids = array_map('intval', array_keys($tagAssistantIds)); if ($ids === []) { $query->whereRaw('0 = 1'); return; } $in = implode(',', $ids); $query->whereRaw("({$att}) IN ({$in})"); $narrowed = true; } if (!$narrowed) { $query->whereRaw('0 = 1'); } } /** * 提成结算:部门筛选规范化键(与确认/结转 scope 一致)。 * * @param int[]|string|null $deptIds */ private static function commissionSettlementDeptIdsKey($deptIds): string { if ($deptIds === null || $deptIds === '' || $deptIds === []) { return '*'; } if (is_array($deptIds)) { $ids = array_map('intval', $deptIds); } else { $ids = array_map('intval', explode(',', (string) $deptIds)); } $ids = array_values(array_unique(array_filter($ids, static function (int $x): bool { return $x > 0; }))); sort($ids); return $ids === [] ? '*' : implode(',', $ids); } /** * @return int[] */ private static function commissionSettlementCarryOrderIdsForTarget(string $targetSettlementMonth, string $scopeChannelCode, string $scopeDeptKey): array { $ids = Db::name('commission_settlement_carry') ->where('target_settlement_month', $targetSettlementMonth) ->where('scope_channel_code', $scopeChannelCode) ->where('scope_dept_ids_key', $scopeDeptKey) ->column('prescription_order_id'); return array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $x): bool { return $x > 0; }))); } private static function commissionSettlementNextMonth(string $monthRaw): string { $t = strtotime($monthRaw . '-01 12:00:00'); return date('Y-m', strtotime('+1 month', $t !== false ? $t : time())); } /** * @return array|null */ private static function commissionSettlementGetConfirmRow(string $settlementMonth, string $scopeChannelCode, string $scopeDeptKey): ?array { $row = Db::name('commission_settlement_confirm') ->where('settlement_month', $settlementMonth) ->where('scope_channel_code', $scopeChannelCode) ->where('scope_dept_ids_key', $scopeDeptKey) ->find(); if (!$row) { return null; } $r = is_array($row) ? $row : $row->toArray(); $st = (int) ($r['status'] ?? 0); return [ 'status' => $st, 'reconcile_note' => (string) ($r['reconcile_note'] ?? ''), 'confirmed_admin_id' => (int) ($r['confirmed_admin_id'] ?? 0), 'confirmed_admin_name' => (string) ($r['confirmed_admin_name'] ?? ''), 'confirmed_at' => (int) ($r['confirmed_at'] ?? 0), 'confirmed_at_text' => !empty($r['confirmed_at']) ? date('Y-m-d H:i:s', (int) $r['confirmed_at']) : '', 'totals_json' => (string) ($r['totals_json'] ?? ''), 'revoked_at' => (int) ($r['revoked_at'] ?? 0), 'revoked_admin_id' => (int) ($r['revoked_admin_id'] ?? 0), 'revoked_admin_name' => (string) ($r['revoked_admin_name'] ?? ''), 'revoked_at_text' => !empty($r['revoked_at']) ? date('Y-m-d H:i:s', (int) $r['revoked_at']) : '', // 仅 status=1 锁定,禁止重复确定;status=2 已撤回可再核对/确定 'is_confirmed_locked' => $st === 1, ]; } /** * @return array */ private static function commissionSettlementResolveBasePack(array $params, int $viewerAdminId, array $viewerAdminInfo): array { $monthRaw = trim((string) ($params['settlement_month'] ?? '')); if (!preg_match('/^\d{4}-\d{2}$/', $monthRaw)) { throw new \think\Exception('请传入有效的结算月 settlement_month(格式 YYYY-MM)'); } $anchor = strtotime($monthRaw . '-01 12:00:00'); if ($anchor === false) { throw new \think\Exception('结算月日期无效'); } $prevMonthAnchor = strtotime('-1 month', $anchor); if ($prevMonthAnchor === false) { throw new \think\Exception('结算月日期无效'); } $stRaw = trim((string) ($params['start_time'] ?? '')); $etRaw = trim((string) ($params['end_time'] ?? '')); $explicitOrderTimeWindow = ($stRaw !== '' && $etRaw !== ''); if ($explicitOrderTimeWindow) { $t0 = strtotime($stRaw); $t1 = strtotime($etRaw); if ($t0 === false || $t1 === false) { throw new \think\Exception('订单创建时间 start_time / end_time 无效,请与处方订单列表一致传入(如 2026-04-02 00:00:00)'); } $orderCreateStartTs = (int) $t0; $orderCreateEndTs = (int) $t1; if ($orderCreateStartTs > $orderCreateEndTs) { throw new \think\Exception('订单开始时间不能晚于结束时间'); } $orderMonthLabel = date('Y-m-d H:i:s', $orderCreateStartTs) . ' ~ ' . date('Y-m-d H:i:s', $orderCreateEndTs); } else { $orderCreateStartTs = (int) strtotime(date('Y-m-01 00:00:00', $prevMonthAnchor)); $orderCreateEndTs = (int) strtotime(date('Y-m-t 23:59:59', $prevMonthAnchor)); $orderMonthLabel = date('Y-m', $prevMonthAnchor); } $cutoffTs = (int) strtotime(date('Y-m-07 23:59:59', $anchor)); $fulfillmentStatus = (int) ($params['fulfillment_status'] ?? 3); if ($fulfillmentStatus < 0) { $fulfillmentStatus = 3; } if ($explicitOrderTimeWindow) { if (array_key_exists('require_system_auto_prescription', $params)) { $v = $params['require_system_auto_prescription']; $requireSystemAutoEffective = $v === true || $v === 1 || $v === '1'; } else { $requireSystemAutoEffective = false; } $applyListSqlVisibility = true; $skipPhpDataScopeAttributionFilter = true; } else { $requireSystemAutoEffective = (bool) Config::get('project.commission_settlement.require_system_auto_prescription', true); $applyListSqlVisibility = false; $skipPhpDataScopeAttributionFilter = false; } $ctxParams = array_merge($params, [ 'start_date' => date('Y-m-d', $orderCreateStartTs), 'end_date' => date('Y-m-d', $orderCreateEndTs), ]); $c = self::resolveYejiContext($ctxParams); if ($viewerAdminId > 0) { self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo); } $deptKey = self::commissionSettlementDeptIdsKey($params['dept_ids'] ?? null); $scopeChannelCode = (string) ($c['channelCode'] ?? ''); $carryOrderIds = self::commissionSettlementCarryOrderIdsForTarget($monthRaw, $scopeChannelCode, $deptKey); $diagTable = self::tableWithPrefix('tcm_diagnosis'); $rxTable = self::tableWithPrefix('tcm_prescription'); $att = self::sqlPerformanceAttributionAdminExpr(); $assistAttributionRestrictSelfId = 0; if ($viewerAdminId > 0 && PrescriptionOrderLogic::statsRestrictedToOwnAssistantPerformanceDomain($viewerAdminInfo)) { $assistAttributionRestrictSelfId = $viewerAdminId; } return [ 'monthRaw' => $monthRaw, 'cutoffTs' => $cutoffTs, 'orderCreateStartTs' => $orderCreateStartTs, 'orderCreateEndTs' => $orderCreateEndTs, 'orderMonthLabel' => $orderMonthLabel, 'orderStartTimeText' => date('Y-m-d H:i:s', $orderCreateStartTs), 'orderEndTimeText' => date('Y-m-d H:i:s', $orderCreateEndTs), 'nextSettlementMonth' => self::commissionSettlementNextMonth($monthRaw), 'deptKey' => $deptKey, 'scopeChannelCode' => $scopeChannelCode, 'carryOrderIds' => $carryOrderIds, 'c' => $c, 'tableRowDeptIds' => $c['tableRowDeptIds'], 'deptById' => $c['deptById'], 'adminToPrimary' => $c['adminToPrimary'], 'diagTable' => $diagTable, 'rxTable' => $rxTable, 'att' => $att, 'assistAttributionRestrictSelfId' => $assistAttributionRestrictSelfId, 'fulfillmentStatus' => $fulfillmentStatus, 'explicitOrderTimeWindow' => $explicitOrderTimeWindow, 'requireSystemAutoEffective' => $requireSystemAutoEffective, 'applyListSqlVisibility' => $applyListSqlVisibility, 'skipPhpDataScopeAttributionFilter' => $skipPhpDataScopeAttributionFilter, 'viewerAdminId' => $viewerAdminId, 'viewerAdminInfo' => $viewerAdminInfo, 'commissionRequestParams' => $params, ]; } /** * @param int[] $ids * * @return array */ private static function commissionSettlementBatchAdminNames(array $ids): array { $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); if ($ids === []) { return []; } $rows = Db::name('admin') ->whereIn('id', $ids) ->whereNull('delete_time') ->column('name', 'id'); $out = []; foreach ($rows as $id => $name) { $out[(int) $id] = (string) $name; } return $out; } /** * 业务订单关联挂号渠道 SQL:与 PrescriptionOrderLogic 列表/详情一致(处方 appointment_id 优先,否则诊单下 MAX(挂号.id))。 */ private static function commissionSettlementSqlOrderLinkedApptChannelExpr(): string { $rxTable = self::tableWithPrefix('tcm_prescription'); $daTable = self::tableWithPrefix('doctor_appointment'); return 'COALESCE(' . '(SELECT `da_rx`.`channels` FROM `' . $rxTable . '` `rx_lnk` ' . 'INNER JOIN `' . $daTable . '` `da_rx` ON `da_rx`.`id` = `rx_lnk`.`appointment_id` ' . 'WHERE `rx_lnk`.`id` = `o`.`prescription_id` AND `rx_lnk`.`delete_time` IS NULL ' . 'AND IFNULL(`rx_lnk`.`appointment_id`, 0) > 0 AND IFNULL(`da_rx`.`channels`, 0) > 0), ' . '(SELECT `da_fb`.`channels` FROM `' . $daTable . '` `da_fb` ' . 'WHERE `da_fb`.`id` = (SELECT MAX(`da_m`.`id`) FROM `' . $daTable . '` `da_m` WHERE `da_m`.`patient_id` = `o`.`diagnosis_id`) ' . 'AND IFNULL(`da_fb`.`channels`, 0) > 0)' . ')'; } /** * 与 PrescriptionOrderLogic 列表/详情一致:按业务订单解析关联挂号(处方 appointment_id 优先,否则诊单下 MAX(挂号.id))。 * * @param list $rows * * @return array */ private static function commissionSettlementBatchResolveLinkedAppointmentsByOrders(array $rows): array { if ($rows === []) { return []; } $rxIds = []; $diagNeedFallback = []; foreach ($rows as $row) { $oid = (int) ($row['id'] ?? 0); if ($oid <= 0) { continue; } $rxId = (int) ($row['prescription_id'] ?? 0); $dgId = (int) ($row['diagnosis_id'] ?? 0); if ($rxId > 0) { $rxIds[$rxId] = true; } if ($rxId <= 0 && $dgId > 0) { $diagNeedFallback[$dgId] = true; } } $rxApptByRx = []; if ($rxIds !== []) { $rxApptByRx = Db::name('tcm_prescription') ->whereIn('id', array_keys($rxIds)) ->whereNull('delete_time') ->column('appointment_id', 'id'); foreach ($rxApptByRx as $rxId => $apptId) { $rxApptByRx[(int) $rxId] = (int) $apptId; } } $latestApptByDiag = []; $fallbackDiagIds = array_keys($diagNeedFallback); if ($fallbackDiagIds !== []) { foreach (array_chunk($fallbackDiagIds, 800) as $chunk) { $agg = Db::name('doctor_appointment') ->whereIn('patient_id', $chunk) ->fieldRaw('patient_id, MAX(id) AS max_id') ->group('patient_id') ->select() ->toArray(); foreach ($agg as $r) { $pid = (int) ($r['patient_id'] ?? 0); $mid = (int) ($r['max_id'] ?? 0); if ($pid > 0 && $mid > 0) { $latestApptByDiag[$pid] = $mid; } } } } $resolvedApptByOrder = []; foreach ($rows as $row) { $oid = (int) ($row['id'] ?? 0); if ($oid <= 0) { continue; } $rxId = (int) ($row['prescription_id'] ?? 0); $dgId = (int) ($row['diagnosis_id'] ?? 0); $rxApptId = $rxId > 0 ? (int) ($rxApptByRx[$rxId] ?? 0) : 0; $resolved = $rxApptId; if ($resolved <= 0 && $dgId > 0) { $resolved = (int) ($latestApptByDiag[$dgId] ?? 0); } if ($resolved > 0) { $resolvedApptByOrder[$oid] = $resolved; } } if ($resolvedApptByOrder === []) { return []; } $apptIds = array_values(array_unique(array_map('intval', $resolvedApptByOrder))); $apptRows = []; foreach (array_chunk($apptIds, 800) as $chunk) { $part = Db::name('doctor_appointment') ->whereIn('id', $chunk) ->field(['id', 'channels', 'appointment_date', 'status']) ->select() ->toArray(); foreach ($part as $r) { $aid = (int) ($r['id'] ?? 0); if ($aid > 0) { $apptRows[$aid] = $r; } } } $out = []; foreach ($resolvedApptByOrder as $oid => $apptId) { $apptId = (int) $apptId; $r = $apptRows[$apptId] ?? null; if (!is_array($r)) { continue; } $out[(int) $oid] = [ 'appointment_id' => $apptId, 'channels' => (int) ($r['channels'] ?? 0), 'appointment_date' => trim((string) ($r['appointment_date'] ?? '')), 'appointment_status' => (int) ($r['status'] ?? 0), ]; } return $out; } /** * @param list> $rows * * @return array{0: array>, 1: array} */ private static function commissionSettlementLoadApptContextForOrders(array $rows): array { $linkedApptByOrderId = self::commissionSettlementBatchResolveLinkedAppointmentsByOrders($rows); $chVals = []; foreach ($linkedApptByOrderId as $ap) { $chVals[] = (int) ($ap['channels'] ?? 0); } return [$linkedApptByOrderId, self::commissionSettlementApptChannelsDictMap($chVals)]; } /** * 挂号字典 channels:value(int) => name(string)。仅取本次涉及到的 value 集合,减少 dict 全量读取。 * * @param int[] $values * * @return array */ private static function commissionSettlementApptChannelsDictMap(array $values): array { $values = array_values(array_unique(array_filter(array_map('intval', $values), static fn (int $x): bool => $x > 0))); if ($values === []) { return []; } $rows = Db::name('dict_data') ->where('type_value', 'channels') ->whereIn('value', array_map('strval', $values)) ->field(['value', 'name']) ->select() ->toArray(); $out = []; foreach ($rows as $r) { $v = (int) ($r['value'] ?? 0); $n = trim((string) ($r['name'] ?? '')); if ($v > 0 && $n !== '') { $out[$v] = $n; } } return $out; } /** * 渠道值 → 展示标签。未关联 / 字典缺失时统一标签为「未匹配挂号渠道」。 * * @param array $dictMap */ private static function commissionSettlementApptChannelLabel(int $channelValue, array $dictMap): string { if ($channelValue <= 0) { return '未匹配挂号渠道'; } $name = trim((string) ($dictMap[$channelValue] ?? '')); if ($name === '') { return '未匹配挂号渠道·#' . $channelValue; } return $name; } /** * 「本期提成 / 顺延下期」单笔判定(与 overview / orderLines / 确定结转一致)。 * * @return array{eligible: bool, defer_code: string, defer_text: string} */ private static function commissionSettlementBucketEligible(int $signTs, int $payTs, int $cutoffTs): array { $deadlineText = date('Y-m-d H:i:s', $cutoffTs); if ($signTs <= 0) { return [ 'eligible' => false, 'defer_code' => 'missing_sign', 'defer_text' => '库内无有效物流签收时间(轨迹未入库或运单号与物流主表不匹配)', ]; } if ($payTs <= 0) { return [ 'eligible' => false, 'defer_code' => 'missing_pay', 'defer_text' => '无已支付关联单或未能解析尾款支付时间', ]; } if ($signTs > $cutoffTs) { return [ 'eligible' => false, 'defer_code' => 'sign_after_cutoff', 'defer_text' => '物流签收晚于结算截止(' . $deadlineText . ')', ]; } if ($payTs > $cutoffTs) { return [ 'eligible' => false, 'defer_code' => 'pay_after_cutoff', 'defer_text' => '尾款支付(' . date('Y-m-d H:i:s', $payTs) . ')晚于结算截止(' . $deadlineText . '),仅此一项也会导致顺延', ]; } return ['eligible' => true, 'defer_code' => '', 'defer_text' => '']; } /** * 单条 express_tracking 的有效签收时间 SQL 表达式(与 ExpressTrackingService::effectiveSignUnixFromTrackingDetail 对齐)。 */ private static function commissionSettlementExpressRowSignExpr(string $etTable, string $traceTable): string { // 与 ExpressTrackingService::contextLooksSigned 对齐: // 正向「签收/妥投/送达/本人签收/已放在」(顺丰末端 status=送达、context=快件已放在…), // 先排除「准备签收/待签收/即将送达/预计」等「尚未签收」文案(含「签收」「送达」二字易误判)。 $sigByCode = "`tr_sig`.`status_code` IN ('3','301','302','304')"; $sigByText = "(`tr_sig`.`status` LIKE '%签收%' OR `tr_sig`.`status` LIKE '%送达%' " . "OR `tr_sig`.`trace_context` LIKE '%签收%' OR `tr_sig`.`trace_context` LIKE '%妥投%' " . "OR `tr_sig`.`trace_context` LIKE '%送达%' OR `tr_sig`.`trace_context` LIKE '%本人签收%' " . "OR `tr_sig`.`trace_context` LIKE '%已放在%')"; $sigNotText = "(`tr_sig`.`trace_context` LIKE '%准备签收%' OR `tr_sig`.`trace_context` LIKE '%待签收%' " . "OR `tr_sig`.`trace_context` LIKE '%等待签收%' OR `tr_sig`.`trace_context` LIKE '%即将送达%' " . "OR `tr_sig`.`trace_context` LIKE '%预计%')"; $sigTraceCond = $sigByCode . ' OR (' . $sigByText . ' AND NOT ' . $sigNotText . ')'; // 与 effectiveSignUnixFromTrackingDetail 一致:轨迹时间最权威,sign_time 列仅兜底。 // 用 COALESCE(NULLIF(...,0)) 实现「优先签收轨迹 → 已签收态最新轨迹 → sign_time 列」的取值顺序, // 避免历史同步写入 sign_time 的「拉取当天」把签收时间顶高(旧 GREATEST 会取最大值)。 return 'COALESCE(' . 'NULLIF((SELECT MAX(`tr_sig`.`trace_time_stamp`) FROM `' . $traceTable . '` `tr_sig` ' . 'WHERE `tr_sig`.`tracking_id` = `et`.`id` AND IFNULL(`tr_sig`.`trace_time_stamp`, 0) > 0 AND (' . $sigTraceCond . ')), 0), ' . 'NULLIF(IF((`et`.`current_state` = \'3\' OR IFNULL(`et`.`is_signed`, 0) = 1), ' . 'IFNULL((SELECT MAX(`tr_any`.`trace_time_stamp`) FROM `' . $traceTable . '` `tr_any` ' . 'WHERE `tr_any`.`tracking_id` = `et`.`id` AND IFNULL(`tr_any`.`trace_time_stamp`, 0) > 0), 0), ' . '0), 0), ' . 'NULLIF(IF(IFNULL(`et`.`sign_time`, 0) > 0, `et`.`sign_time`, 0), 0), ' . '0' . ')'; } /** * 签收 / 尾款时间:对物流与支付关联做 GROUP BY 后 LEFT JOIN。 * 签收时间不仅用 express_tracking.sign_time:历史数据或部分同步路径下 sign_time 仍为 0, * 但 express_trace 已有「签收」语义轨迹(与 logisticsTrace 展示同源),此处用轨迹时间戳回填,避免误算为「无签收」而全部顺延。 */ private static function commissionSettlementAttachExpressPayAggregates(Query $query): void { $etTable = self::tableWithPrefix('express_tracking'); $traceTable = self::tableWithPrefix('express_trace'); $linkTbl = self::tableWithPrefix('tcm_prescription_order_pay_order'); $payTbl = self::tableWithPrefix('order'); $rowSignExpr = self::commissionSettlementExpressRowSignExpr($etTable, $traceTable); $poTable = self::tableWithPrefix('tcm_prescription_order'); // tracking_number:TRIM 避免空格导致匹配失败;collation 统一避免 HY000 1267 $trackingJoinOn = 'TRIM(`po`.`tracking_number`) COLLATE utf8mb4_unicode_ci = TRIM(`et`.`tracking_number`) COLLATE utf8mb4_unicode_ci'; // 与 ExpressTrackingService::getDetailByTrackingNumber 一致:按单号可查即应能统计签收;勿再依赖 et.order_type(历史数据可能空串/未回写)。 // 口径一:express_tracking.order_id 指向处方业务单(用业务单表限定,而非 et.order_type 字符串)。 // 口径二:order_id 未绑定或漂移时,用处方单运单号与物流主表 TRIM 后等值关联。 $etAggSql = '(SELECT `u`.`order_id`, MAX(`u`.`cs_row_sign`) AS `cs_sign_ts` FROM (' . 'SELECT `et`.`order_id` AS `order_id`, ' . $rowSignExpr . ' AS `cs_row_sign` FROM `' . $etTable . '` `et` ' . 'INNER JOIN `' . $poTable . '` `po_by_id` ON `po_by_id`.`id` = `et`.`order_id` AND `po_by_id`.`delete_time` IS NULL ' . 'WHERE `et`.`delete_time` IS NULL AND IFNULL(`et`.`order_id`, 0) > 0 ' . 'UNION ALL ' . 'SELECT `po`.`id` AS `order_id`, ' . $rowSignExpr . ' AS `cs_row_sign` FROM `' . $etTable . '` `et` ' . 'INNER JOIN `' . $poTable . '` `po` ON ' . $trackingJoinOn . " AND LENGTH(TRIM(IFNULL(`po`.`tracking_number`, ''))) > 0 " . ' AND `po`.`delete_time` IS NULL ' . 'WHERE `et`.`delete_time` IS NULL' . ') AS `u` GROUP BY `u`.`order_id`) `cs_et_sig`'; $payAggSql = "(SELECT l.prescription_order_id AS cs_pay_oid, " . 'MAX(CASE WHEN pay.order_type IN (5, 7) THEN UNIX_TIMESTAMP(pay.payment_time) END) AS cs_pay_tail_ts, ' . 'MAX(UNIX_TIMESTAMP(pay.payment_time)) AS cs_pay_any_ts ' . "FROM {$linkTbl} l INNER JOIN {$payTbl} pay ON pay.id = l.pay_order_id AND pay.delete_time IS NULL " . 'WHERE pay.status = 2 GROUP BY l.prescription_order_id) cs_pay_agg'; // 须传入带子查询别名的字符串;ThinkPHP parseJoin 对 Db::raw 整段 JOIN 表会直接传给 parseTable,触发类型错误 $query->leftJoin($etAggSql, 'cs_et_sig.order_id = o.id'); $query->leftJoin($payAggSql, 'cs_pay_agg.cs_pay_oid = o.id'); } /** * @return array */ private static function commissionSettlementSignPayFieldRawList(): array { return [ Db::raw('IFNULL(cs_et_sig.cs_sign_ts, 0) AS sign_ts'), Db::raw('COALESCE(cs_pay_agg.cs_pay_tail_ts, cs_pay_agg.cs_pay_any_ts, 0) AS pay_ts'), ]; } /** * @param int[] $orderIds * * @return array order_id => pay unix */ private static function commissionSettlementBatchPayTsMap(array $orderIds): array { $orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds), static fn (int $x): bool => $x > 0))); if ($orderIds === []) { return []; } $linkTbl = self::tableWithPrefix('tcm_prescription_order_pay_order'); $payTbl = self::tableWithPrefix('order'); $out = []; foreach (array_chunk($orderIds, 800) as $chunk) { $in = implode(',', $chunk); $sql = "SELECT l.prescription_order_id AS cs_oid, " . 'MAX(CASE WHEN pay.order_type IN (5, 7) THEN UNIX_TIMESTAMP(pay.payment_time) END) AS cs_pay_tail_ts, ' . 'MAX(UNIX_TIMESTAMP(pay.payment_time)) AS cs_pay_any_ts ' . "FROM {$linkTbl} l INNER JOIN {$payTbl} pay ON pay.id = l.pay_order_id AND pay.delete_time IS NULL " . 'WHERE pay.status = 2 AND l.prescription_order_id IN (' . $in . ') ' . 'GROUP BY l.prescription_order_id'; $rows = Db::query($sql); if (!is_array($rows)) { continue; } foreach ($rows as $r) { $oid = (int) ($r['cs_oid'] ?? 0); if ($oid <= 0) { continue; } $tail = (int) ($r['cs_pay_tail_ts'] ?? 0); $any = (int) ($r['cs_pay_any_ts'] ?? 0); $out[$oid] = $tail > 0 ? $tail : $any; } } return $out; } /** * 按候选业务单 id 批量解析签收时间(限定 IN 范围,供 orderLines 分页/bucket 分块使用)。 * * @param int[] $orderIds * * @return array order_id => sign unix */ private static function commissionSettlementBatchSignTsMapForOrderIds(array $orderIds): array { $orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds), static fn (int $x): bool => $x > 0))); if ($orderIds === []) { return []; } $etTable = self::tableWithPrefix('express_tracking'); $traceTable = self::tableWithPrefix('express_trace'); $poTable = self::tableWithPrefix('tcm_prescription_order'); $rowSignExpr = self::commissionSettlementExpressRowSignExpr($etTable, $traceTable); $trackingJoinOn = 'TRIM(`po`.`tracking_number`) COLLATE utf8mb4_unicode_ci = TRIM(`et`.`tracking_number`) COLLATE utf8mb4_unicode_ci'; $out = []; foreach (array_chunk($orderIds, 400) as $chunk) { $in = implode(',', $chunk); $sql = 'SELECT `u`.`order_id`, MAX(`u`.`cs_row_sign`) AS `cs_sign_ts` FROM (' . 'SELECT `et`.`order_id` AS `order_id`, ' . $rowSignExpr . ' AS `cs_row_sign` FROM `' . $etTable . '` `et` ' . 'INNER JOIN `' . $poTable . '` `po_by_id` ON `po_by_id`.`id` = `et`.`order_id` AND `po_by_id`.`delete_time` IS NULL ' . 'WHERE `et`.`delete_time` IS NULL AND IFNULL(`et`.`order_id`, 0) > 0 AND `et`.`order_id` IN (' . $in . ') ' . 'UNION ALL ' . 'SELECT `po`.`id` AS `order_id`, ' . $rowSignExpr . ' AS `cs_row_sign` FROM `' . $etTable . '` `et` ' . 'INNER JOIN `' . $poTable . '` `po` ON ' . $trackingJoinOn . ' AND `po`.`id` IN (' . $in . ') AND `po`.`delete_time` IS NULL ' . " AND LENGTH(TRIM(IFNULL(`po`.`tracking_number`, ''))) > 0 " . 'WHERE `et`.`delete_time` IS NULL' . ') AS `u` GROUP BY `u`.`order_id`'; $rows = Db::query($sql); if (!is_array($rows)) { continue; } foreach ($rows as $r) { $oid = (int) ($r['order_id'] ?? 0); $ts = (int) ($r['cs_sign_ts'] ?? 0); if ($oid > 0 && $ts > 0) { $out[$oid] = $ts; } } } return $out; } /** * 为订单行批量补齐 sign_ts / pay_ts。默认走「候选单批量查库」,避免全表物流 LEFT JOIN 与 overview 逐单 HTTP。 * * @param list> $rows * * @return list> */ private static function commissionSettlementHydrateSignPay(array $rows, bool $allowKuaidiFallback = false): array { if ($rows === []) { return $rows; } $orderIds = []; foreach ($rows as $r) { $oid = (int) ($r['id'] ?? 0); if ($oid <= 0) { continue; } $orderIds[] = $oid; } $payMap = self::commissionSettlementBatchPayTsMap($orderIds); $signItems = []; foreach ($rows as $r) { $oid = (int) ($r['id'] ?? 0); if ($oid <= 0) { continue; } $signItems[] = [ 'order_id' => $oid, 'tracking_number' => trim((string) ($r['tracking_number'] ?? '')), ]; } // 与快递单详情 / logisticsTrace 同源:PHP 推导含 trace_time 字符串回填,避免 SQL 仅认 trace_time_stamp 导致误为空 $signMap = ExpressTrackingService::batchResolveSignUnixFromDbForOrders($signItems); foreach ($rows as &$r) { $oid = (int) ($r['id'] ?? 0); if ($oid <= 0) { continue; } $signTs = (int) ($signMap[$oid] ?? 0); if ($signTs <= 0) { if ($allowKuaidiFallback && $oid > 0) { // 与 logisticsTrace 一致:库内无轨迹时 queryAndUpdate 落库后再解析 $signTs = ExpressTrackingService::syncSignUnixFromLogisticsForPrescriptionOrder($oid, true); } else { $tn = trim((string) ($r['tracking_number'] ?? '')); if ($tn !== '') { $signTs = ExpressTrackingService::resolveSignUnixTimeForCommission( $tn, (string) ($r['express_company'] ?? 'auto'), (string) ($r['recipient_phone'] ?? ''), false ); } } } $r['sign_ts'] = $signTs; $r['pay_ts'] = (int) ($payMap[$oid] ?? 0); } unset($r); return $rows; } /** * @param array $pack commissionSettlementResolveBasePack 返回值 */ private static function commissionSettlementBuildFilteredOrderQuery(array $pack, bool $attachSignPayJoin = false): Query { $c = $pack['c']; $diagTable = $pack['diagTable']; $rxTable = $pack['rxTable']; $att = $pack['att']; $requireSa = !empty($pack['requireSystemAutoEffective']); $fulfillmentStatus = (int) ($pack['fulfillmentStatus'] ?? 3); $rxJoinCond = 'rx.id = o.prescription_id AND rx.delete_time IS NULL'; if ($requireSa) { $rxJoinCond .= ' AND IFNULL(rx.is_system_auto, 0) = 1'; } $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER'); if ($requireSa) { $query->join("{$rxTable} rx", $rxJoinCond, 'INNER'); } else { $query->join("{$rxTable} rx", $rxJoinCond, 'LEFT'); } if ($attachSignPayJoin) { self::commissionSettlementAttachExpressPayAggregates($query); } $query->whereNull('o.delete_time') ->where('o.fulfillment_status', $fulfillmentStatus) ->where('o.diagnosis_id', '>', 0); $query->where(function ($qq) use ($pack) { $qq->whereBetween('o.create_time', [$pack['orderCreateStartTs'], $pack['orderCreateEndTs']]); if ($pack['carryOrderIds'] !== []) { $qq->whereOr(function ($q2) use ($pack) { $q2->whereIn('o.id', $pack['carryOrderIds']); }); } }); self::applyCommissionSettlementChannelFilter($query, $c); $viewerAdminId = (int) ($pack['viewerAdminId'] ?? 0); $viewerAdminInfo = $pack['viewerAdminInfo'] ?? []; if (!empty($pack['applyListSqlVisibility']) && $viewerAdminId > 0 && \is_array($viewerAdminInfo)) { $reqParams = $pack['commissionRequestParams'] ?? []; PrescriptionOrderLogic::applyPrescriptionOrderListAccessForCommissionQuery( $query, 'o', $viewerAdminId, $viewerAdminInfo, \is_array($reqParams) ? $reqParams : [] ); } $selfAid = (int) ($pack['assistAttributionRestrictSelfId'] ?? 0); if ($selfAid > 0) { $query->whereRaw("({$att}) = ?", [$selfAid]); } return $query; } /** * 提成结算业绩看板:上月(相对结算月)系统处方对应的已完成业务单,按签收与尾款支付是否在结算月 7 日 24 点前拆分「计入本期提成 / 顺延下期」。 * * 规则摘要: * - 订单池:结算月上一自然月内创建;关联处方 is_system_auto=1(系统代开);履约已完成 fulfillment_status=3;未软删。 * - 签收时间:express_tracking 与处方单关联(order_id 命中业务单 **或** 运单号 TRIM 后一致);sign_time / express_trace 轨迹回填,与 logisticsTrace(getDetailByTrackingNumber) 同源,不依赖 et.order_type 字符串(历史数据可能未写入)。 * - 尾款支付时间:关联支付单 status=2,优先 order_type∈(5,7),否则回退为任意已支付关联单的 MAX(payment_time)。 * - 计入本期:签收与尾款时间均已成立,且**两者均**不晚于结算月 7 日 23:59:59(仅签收在截止前但尾款更晚→仍顺延)。 * - 其余已完成单计入「顺延下期」(含签收或支付缺失、或晚于截止)。 * * @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string, tag_id?: string} $params * * @return array */ public static function commissionSettlementOverview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo); $monthRaw = $pack['monthRaw']; $cutoffTs = $pack['cutoffTs']; $orderMonthLabel = $pack['orderMonthLabel']; $c = $pack['c']; $tableRowDeptIds = $pack['tableRowDeptIds']; $deptById = $pack['deptById']; $adminToPrimary = $pack['adminToPrimary']; $carryFlip = array_flip($pack['carryOrderIds']); $confirm = self::commissionSettlementGetConfirmRow($monthRaw, $pack['scopeChannelCode'], $pack['deptKey']); $requireSysAutoPo = !empty($pack['requireSystemAutoEffective']); $explicitOrderTimeWindow = !empty($pack['explicitOrderTimeWindow']); $emptyTotal = [ 'completed_order_count' => 0, 'completed_order_amount' => 0.0, 'current_period_count' => 0, 'current_period_amount' => 0.0, 'deferred_period_count' => 0, 'deferred_period_amount' => 0.0, 'carry_in_order_count' => 0, ]; if ($tableRowDeptIds === [] || $adminToPrimary === []) { $ruleLegacy = '订单创建月为结算月上一个月的自然月整段;或未传 start/end 时为整月时间段。签收与尾款均在结算月 7 日前完成的计入本期,否则顺延。上期「确定业绩」产生的顺延订单会并入本期。'; $ruleAligned = '传 start_time+end_time 时订单池与处方订单列表一致(创建时间与 fulfillment_status);默认含手动与系统处方,可见性与列表同源。结转与签收/尾款截止日期规则同上。'; return [ 'settlement_month' => $monthRaw, 'order_month' => $orderMonthLabel, 'order_start_time' => $pack['orderStartTimeText'], 'order_end_time' => $pack['orderEndTimeText'], 'fulfillment_status' => $pack['fulfillmentStatus'], 'explicit_order_time_window' => $explicitOrderTimeWindow, 'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs), 'rule_note' => $explicitOrderTimeWindow ? $ruleAligned : $ruleLegacy, 'channel_code' => $c['channelCode'], 'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '', 'scope_dept_ids_key' => $pack['deptKey'], 'carry_in_order_count' => 0, 'confirm' => $confirm, 'rows' => [], 'assistant_rows' => [], 'doctor_rows' => [], 'assistant_channel_rows' => [], 'doctor_channel_rows' => [], 'total' => $emptyTotal, 'require_system_auto_prescription' => $requireSysAutoPo, ]; } $query = self::commissionSettlementBuildFilteredOrderQuery($pack, false); $att = $pack['att']; $rowsRaw = $query->field([ 'o.id', 'o.order_no', 'o.prescription_id', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.tracking_number', 'o.express_company', 'o.recipient_phone', Db::raw("({$att}) AS assistant_id"), Db::raw('IFNULL(rx.creator_id, 0) AS doctor_id'), ])->select()->toArray(); $rowsRaw = self::commissionSettlementHydrateSignPay($rowsRaw, false); $linkedApptByOrderId = self::commissionSettlementBatchResolveLinkedAppointmentsByOrders($rowsRaw); $chValsForDict = []; foreach ($linkedApptByOrderId as $ap) { $chValsForDict[] = (int) ($ap['channels'] ?? 0); } $apptChannelsDictMap = self::commissionSettlementApptChannelsDictMap($chValsForDict); $curAgg = []; $defAgg = []; $totAgg = []; $cntByAsst = []; $curCntByAsst = []; $defCntByAsst = []; $carryInCnt = 0; $carryInByAssist = []; $carryInByDoctor = []; $totByDoctor = []; $curByDoctor = []; $defByDoctor = []; $cntDoctorTot = []; $cntDoctorCur = []; $cntDoctorDef = []; /** 按 (业绩归属医助 × 挂号渠道值) / (开方医生 × 挂号渠道值) 拆分聚合,渠道取各业务订单绑定挂号(与处方订单列表同源) */ $assistChTot = []; $assistChCur = []; $assistChDef = []; $assistChCnt = []; $assistChCurCnt = []; $assistChDefCnt = []; $assistChCarry = []; $doctorChTot = []; $doctorChCur = []; $doctorChDef = []; $doctorChCnt = []; $doctorChCurCnt = []; $doctorChDefCnt = []; $doctorChCarry = []; $assistScopeFlip = null; if (!empty($c['dataScopeRestricted']) && isset($c['dataScopeVisibleAdminIds']) && $c['dataScopeVisibleAdminIds'] !== []) { $assistScopeFlip = array_flip(array_map('intval', $c['dataScopeVisibleAdminIds'])); } $selfOnlyAttributionAid = (int) ($pack['assistAttributionRestrictSelfId'] ?? 0); if ($selfOnlyAttributionAid > 0) { $assistScopeFlip = [$selfOnlyAttributionAid => true]; } $skipPhpDs = !empty($pack['skipPhpDataScopeAttributionFilter']); foreach ($rowsRaw as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if (!$skipPhpDs && $assistScopeFlip !== null && ($aid <= 0 || !isset($assistScopeFlip[$aid]))) { continue; } // 展示部门有勾选时:只统计可归入当前部门上下文(adminToPrimary)的医助,与部门汇总口径一致;否则「按医助下发」会混入部门外的业绩医助。 if (!empty($c['explicitDeptFilter']) && ($aid <= 0 || !isset($adminToPrimary[$aid]))) { continue; } $oid = (int) ($r['id'] ?? 0); $isCarry = $oid > 0 && isset($carryFlip[$oid]); if ($isCarry) { $carryInCnt++; } $doctorId = (int) ($r['doctor_id'] ?? 0); $signTs = (int) ($r['sign_ts'] ?? 0); $payTs = (int) ($r['pay_ts'] ?? 0); $amt = round((float) ($r['amount'] ?? 0), 2); $apCh = (int) (($linkedApptByOrderId[$oid] ?? [])['channels'] ?? 0); if ($aid <= 0 || $amt < 0) { continue; } $totAgg[$aid] = ($totAgg[$aid] ?? 0.0) + $amt; $cntByAsst[$aid] = ($cntByAsst[$aid] ?? 0) + 1; $eligible = self::commissionSettlementBucketEligible($signTs, $payTs, $cutoffTs)['eligible']; if ($eligible) { $curAgg[$aid] = ($curAgg[$aid] ?? 0.0) + $amt; $curCntByAsst[$aid] = ($curCntByAsst[$aid] ?? 0) + 1; } else { $defAgg[$aid] = ($defAgg[$aid] ?? 0.0) + $amt; $defCntByAsst[$aid] = ($defCntByAsst[$aid] ?? 0) + 1; } if ($isCarry) { $carryInByAssist[$aid] = ($carryInByAssist[$aid] ?? 0) + 1; $carryInByDoctor[$doctorId] = ($carryInByDoctor[$doctorId] ?? 0) + 1; } $totByDoctor[$doctorId] = ($totByDoctor[$doctorId] ?? 0.0) + $amt; $cntDoctorTot[$doctorId] = ($cntDoctorTot[$doctorId] ?? 0) + 1; if ($eligible) { $curByDoctor[$doctorId] = ($curByDoctor[$doctorId] ?? 0.0) + $amt; $cntDoctorCur[$doctorId] = ($cntDoctorCur[$doctorId] ?? 0) + 1; } else { $defByDoctor[$doctorId] = ($defByDoctor[$doctorId] ?? 0.0) + $amt; $cntDoctorDef[$doctorId] = ($cntDoctorDef[$doctorId] ?? 0) + 1; } $assistChTot[$aid][$apCh] = ($assistChTot[$aid][$apCh] ?? 0.0) + $amt; $assistChCnt[$aid][$apCh] = ($assistChCnt[$aid][$apCh] ?? 0) + 1; if ($eligible) { $assistChCur[$aid][$apCh] = ($assistChCur[$aid][$apCh] ?? 0.0) + $amt; $assistChCurCnt[$aid][$apCh] = ($assistChCurCnt[$aid][$apCh] ?? 0) + 1; } else { $assistChDef[$aid][$apCh] = ($assistChDef[$aid][$apCh] ?? 0.0) + $amt; $assistChDefCnt[$aid][$apCh] = ($assistChDefCnt[$aid][$apCh] ?? 0) + 1; } if ($isCarry) { $assistChCarry[$aid][$apCh] = ($assistChCarry[$aid][$apCh] ?? 0) + 1; } $doctorChTot[$doctorId][$apCh] = ($doctorChTot[$doctorId][$apCh] ?? 0.0) + $amt; $doctorChCnt[$doctorId][$apCh] = ($doctorChCnt[$doctorId][$apCh] ?? 0) + 1; if ($eligible) { $doctorChCur[$doctorId][$apCh] = ($doctorChCur[$doctorId][$apCh] ?? 0.0) + $amt; $doctorChCurCnt[$doctorId][$apCh] = ($doctorChCurCnt[$doctorId][$apCh] ?? 0) + 1; } else { $doctorChDef[$doctorId][$apCh] = ($doctorChDef[$doctorId][$apCh] ?? 0.0) + $amt; $doctorChDefCnt[$doctorId][$apCh] = ($doctorChDefCnt[$doctorId][$apCh] ?? 0) + 1; } if ($isCarry) { $doctorChCarry[$doctorId][$apCh] = ($doctorChCarry[$doctorId][$apCh] ?? 0) + 1; } } $foldAmt = static function (array $byAssistant): array { $outRows = []; foreach ($byAssistant as $aid => $amt) { $aid = (int) $aid; if ($aid <= 0) { continue; } $outRows[] = ['assistant_id' => $aid, 'amt' => round((float) $amt, 2)]; } return $outRows; }; $foldCnt = static function (array $byAssistant): array { $outRows = []; foreach ($byAssistant as $aid => $v) { $aid = (int) $aid; if ($aid <= 0) { continue; } $outRows[] = ['assistant_id' => $aid, 'cnt' => (int) $v]; } return $outRows; }; $byDeptAmtTot = self::foldPerformanceRowsToDeptByTable($foldAmt($totAgg), $adminToPrimary, $tableRowDeptIds, $deptById, 'amt'); $byDeptAmtCur = self::foldPerformanceRowsToDeptByTable($foldAmt($curAgg), $adminToPrimary, $tableRowDeptIds, $deptById, 'amt'); $byDeptAmtDef = self::foldPerformanceRowsToDeptByTable($foldAmt($defAgg), $adminToPrimary, $tableRowDeptIds, $deptById, 'amt'); $byDeptCntTot = self::foldPerformanceRowsToDeptByTable($foldCnt($cntByAsst), $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt'); $byDeptCntCur = self::foldPerformanceRowsToDeptByTable($foldCnt($curCntByAsst), $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt'); $byDeptCntDef = self::foldPerformanceRowsToDeptByTable($foldCnt($defCntByAsst), $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt'); $rows = []; $sumCompletedCnt = 0; $sumCompletedAmt = 0.0; $sumCurCnt = 0; $sumCurAmt = 0.0; $sumDefCnt = 0; $sumDefAmt = 0.0; foreach ($tableRowDeptIds as $deptId) { $deptId = (int) $deptId; $cCnt = (int) ($byDeptCntTot[$deptId] ?? 0); $cAmt = round((float) ($byDeptAmtTot[$deptId] ?? 0), 2); $kCnt = (int) ($byDeptCntCur[$deptId] ?? 0); $kAmt = round((float) ($byDeptAmtCur[$deptId] ?? 0), 2); $dCnt = (int) ($byDeptCntDef[$deptId] ?? 0); $dAmt = round((float) ($byDeptAmtDef[$deptId] ?? 0), 2); $rows[] = [ 'dept_id' => $deptId, 'dept_name' => self::formatYejiDeptRowDisplayName($deptId, $deptById), 'completed_order_count' => $cCnt, 'completed_order_amount' => $cAmt, 'current_period_count' => $kCnt, 'current_period_amount' => $kAmt, 'deferred_period_count' => $dCnt, 'deferred_period_amount' => $dAmt, ]; $sumCompletedCnt += $cCnt; $sumCompletedAmt += $cAmt; $sumCurCnt += $kCnt; $sumCurAmt += $kAmt; $sumDefCnt += $dCnt; $sumDefAmt += $dAmt; } $assistantRows = []; foreach (array_keys($totAgg) as $aid) { $aid = (int) $aid; if ($aid <= 0) { continue; } $pid = (int) ($adminToPrimary[$aid] ?? 0); $assistantRows[] = [ 'assistant_id' => $aid, 'assistant_name' => '', 'primary_dept_id' => $pid, 'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '', 'carry_in_order_count' => (int) ($carryInByAssist[$aid] ?? 0), 'completed_order_count' => (int) ($cntByAsst[$aid] ?? 0), 'completed_order_amount' => round((float) ($totAgg[$aid] ?? 0), 2), 'current_period_count' => (int) ($curCntByAsst[$aid] ?? 0), 'current_period_amount' => round((float) ($curAgg[$aid] ?? 0), 2), 'deferred_period_count' => (int) ($defCntByAsst[$aid] ?? 0), 'deferred_period_amount' => round((float) ($defAgg[$aid] ?? 0), 2), ]; } $assistNameMap = self::commissionSettlementBatchAdminNames(array_column($assistantRows, 'assistant_id')); foreach ($assistantRows as &$ar) { $aid = (int) ($ar['assistant_id'] ?? 0); $ar['assistant_name'] = (string) ($assistNameMap[$aid] ?? ('#' . $aid)); } unset($ar); usort($assistantRows, static function (array $a, array $b): int { $cmp = ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0); return $cmp !== 0 ? $cmp : (($b['assistant_id'] ?? 0) <=> ($a['assistant_id'] ?? 0)); }); $doctorRows = []; foreach (array_keys($totByDoctor) as $did) { $did = (int) $did; $pid = $did > 0 ? (int) ($adminToPrimary[$did] ?? 0) : 0; $doctorRows[] = [ 'doctor_id' => $did, 'doctor_name' => '', 'primary_dept_id' => $pid, 'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '', 'carry_in_order_count' => (int) ($carryInByDoctor[$did] ?? 0), 'completed_order_count' => (int) ($cntDoctorTot[$did] ?? 0), 'completed_order_amount' => round((float) ($totByDoctor[$did] ?? 0), 2), 'current_period_count' => (int) ($cntDoctorCur[$did] ?? 0), 'current_period_amount' => round((float) ($curByDoctor[$did] ?? 0), 2), 'deferred_period_count' => (int) ($cntDoctorDef[$did] ?? 0), 'deferred_period_amount' => round((float) ($defByDoctor[$did] ?? 0), 2), ]; } $doctorNameIds = array_values(array_filter(array_column($doctorRows, 'doctor_id'), static fn ($id) => (int) $id > 0)); $doctorNameMap = self::commissionSettlementBatchAdminNames($doctorNameIds); foreach ($doctorRows as &$dr) { $did = (int) ($dr['doctor_id'] ?? 0); $dr['doctor_name'] = $did > 0 ? (string) ($doctorNameMap[$did] ?? ('#' . $did)) : '未关联开方人'; } unset($dr); usort($doctorRows, static function (array $a, array $b): int { $za = (int) ($a['doctor_id'] ?? 0) === 0 ? 1 : 0; $zb = (int) ($b['doctor_id'] ?? 0) === 0 ? 1 : 0; if ($za !== $zb) { return $za <=> $zb; } $cmp = ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0); return $cmp !== 0 ? $cmp : (($b['doctor_id'] ?? 0) <=> ($a['doctor_id'] ?? 0)); }); /** 按 (业绩归属医助 × 挂号渠道) 拆分行:复诊2 / 复诊3 / 自媒体1 等每个 channels 值各自独立一行 */ $assistantChannelRows = []; foreach ($assistChTot as $aid => $byCh) { $aid = (int) $aid; if ($aid <= 0) { continue; } $pid = (int) ($adminToPrimary[$aid] ?? 0); foreach ($byCh as $ch => $amt) { $ch = (int) $ch; $assistantChannelRows[] = [ 'assistant_id' => $aid, 'assistant_name' => (string) ($assistNameMap[$aid] ?? ('#' . $aid)), 'primary_dept_id' => $pid, 'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '', 'appt_channel_value' => $ch, 'appt_channel_name' => self::commissionSettlementApptChannelLabel($ch, $apptChannelsDictMap), 'carry_in_order_count' => (int) ($assistChCarry[$aid][$ch] ?? 0), 'completed_order_count' => (int) ($assistChCnt[$aid][$ch] ?? 0), 'completed_order_amount' => round((float) $amt, 2), 'current_period_count' => (int) ($assistChCurCnt[$aid][$ch] ?? 0), 'current_period_amount' => round((float) ($assistChCur[$aid][$ch] ?? 0), 2), 'deferred_period_count' => (int) ($assistChDefCnt[$aid][$ch] ?? 0), 'deferred_period_amount' => round((float) ($assistChDef[$aid][$ch] ?? 0), 2), ]; } } /** 同医助按总业绩 desc 排序,组内按当前行金额 desc;保证同医助行连续,方便前端合并单元格 */ usort($assistantChannelRows, static function (array $a, array $b) use ($totAgg): int { $aa = (int) ($a['assistant_id'] ?? 0); $bb = (int) ($b['assistant_id'] ?? 0); if ($aa !== $bb) { $aTot = (float) ($totAgg[$aa] ?? 0); $bTot = (float) ($totAgg[$bb] ?? 0); if ($aTot !== $bTot) { return $bTot <=> $aTot; } return $bb <=> $aa; } return ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0); }); /** 按 (开方医生 × 挂号渠道) 拆分行;未关联开方人(0) 行也保留,沉到末尾 */ $doctorChannelRows = []; foreach ($doctorChTot as $did => $byCh) { $did = (int) $did; $pid = $did > 0 ? (int) ($adminToPrimary[$did] ?? 0) : 0; foreach ($byCh as $ch => $amt) { $ch = (int) $ch; $doctorChannelRows[] = [ 'doctor_id' => $did, 'doctor_name' => $did > 0 ? (string) ($doctorNameMap[$did] ?? ('#' . $did)) : '未关联开方人', 'primary_dept_id' => $pid, 'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '', 'appt_channel_value' => $ch, 'appt_channel_name' => self::commissionSettlementApptChannelLabel($ch, $apptChannelsDictMap), 'carry_in_order_count' => (int) ($doctorChCarry[$did][$ch] ?? 0), 'completed_order_count' => (int) ($doctorChCnt[$did][$ch] ?? 0), 'completed_order_amount' => round((float) $amt, 2), 'current_period_count' => (int) ($doctorChCurCnt[$did][$ch] ?? 0), 'current_period_amount' => round((float) ($doctorChCur[$did][$ch] ?? 0), 2), 'deferred_period_count' => (int) ($doctorChDefCnt[$did][$ch] ?? 0), 'deferred_period_amount' => round((float) ($doctorChDef[$did][$ch] ?? 0), 2), ]; } } usort($doctorChannelRows, static function (array $a, array $b) use ($totByDoctor): int { $aa = (int) ($a['doctor_id'] ?? 0); $bb = (int) ($b['doctor_id'] ?? 0); /** 「未关联开方人」(doctor_id=0) 一律置末尾 */ $za = $aa === 0 ? 1 : 0; $zb = $bb === 0 ? 1 : 0; if ($za !== $zb) { return $za <=> $zb; } if ($aa !== $bb) { $aTot = (float) ($totByDoctor[$aa] ?? 0); $bTot = (float) ($totByDoctor[$bb] ?? 0); if ($aTot !== $bTot) { return $bTot <=> $aTot; } return $bb <=> $aa; } return ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0); }); if ($explicitOrderTimeWindow) { $ruleNoteFull = '订单池与「处方订单列表」对齐:create_time 介于 start_time~end_time(与列表 between_time 同源),' . ('fulfillment_status=' . (string) (int) ($pack['fulfillmentStatus'] ?? 3) . '。') . ($requireSysAutoPo ? '当前已开启仅统计关联处方 is_system_auto=1(系统代开)的订单;与未限制开方类型的列表条数可能不一致。' : '未限制仅系统代开处方(含手动),与列表在同一时段、同一状态下条数应一致(另受本页展示部门/渠道筛选影响)。') . ' 签收与尾款时间不晚于结算月 7 日 24 点的计入「本期提成」,其余计入「顺延下期」。' . '上期同渠道同部门范围内「确定业绩」产生的顺延订单并入本期。' . '「按医助」「按医生」拆分按 (人 × 挂号渠道):挂号渠道取各业务订单绑定挂号(处方 appointment_id 优先,否则诊单最近一条);未关联展示为「未匹配挂号渠道」。'; } else { $ruleNoteFull = ($requireSysAutoPo ? '订单创建月为结算月的上一自然月;仅统计系统代开处方(is_system_auto=1)对应的履约已完成(默认 fulfillment_status=3)业务订单。 ' : '订单创建月为结算月的上一自然月;当前配置同时包含手动与系统代开关联处方(订单池更接近列表;财务请确认口径)。 ') . '签收时间取物流签收最大值;尾款取关联支付单已支付时间(优先尾款/全部)。签收与尾款均不晚于结算月 7 日 24 点计入「本期提成」,其余计入「顺延下期」。结转规则同上。'; } return [ 'settlement_month' => $monthRaw, 'order_month' => $orderMonthLabel, 'order_start_time' => $pack['orderStartTimeText'], 'order_end_time' => $pack['orderEndTimeText'], 'fulfillment_status' => $pack['fulfillmentStatus'], 'explicit_order_time_window' => $explicitOrderTimeWindow, 'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs), 'rule_note' => $ruleNoteFull, 'channel_code' => $c['channelCode'], 'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '', 'scope_dept_ids_key' => $pack['deptKey'], 'carry_in_order_count' => $carryInCnt, 'confirm' => $confirm, 'rows' => $rows, 'assistant_rows' => $assistantRows, 'doctor_rows' => $doctorRows, 'assistant_channel_rows' => $assistantChannelRows, 'doctor_channel_rows' => $doctorChannelRows, 'total' => [ 'completed_order_count' => $sumCompletedCnt, 'completed_order_amount' => round($sumCompletedAmt, 2), 'current_period_count' => $sumCurCnt, 'current_period_amount' => round($sumCurAmt, 2), 'deferred_period_count' => $sumDefCnt, 'deferred_period_amount' => round($sumDefAmt, 2), 'carry_in_order_count' => $carryInCnt, ], 'require_system_auto_prescription' => $requireSysAutoPo, ]; } /** * orderLines 明细查询:在订单池 query 上叠加医助/医生/部门/挂号渠道等 SQL 筛选。 * * @param array|null $linesAssistScopeFlip */ private static function commissionSettlementApplyOrderLinesSqlFilters( Query $query, array $pack, array $params, ?array $linesAssistScopeFlip, bool $linesSkipPhpDs ): void { $att = $pack['att']; $cPack = $pack['c']; $adminToPrimary = $pack['adminToPrimary']; $filterAssistantId = max(0, (int) ($params['assistant_id'] ?? 0)); $filterDoctorId = max(0, (int) ($params['doctor_id'] ?? 0)); $filterApptChannelActive = array_key_exists('appt_channel_value', $params) && $params['appt_channel_value'] !== null && $params['appt_channel_value'] !== ''; $tableRowDeptId = max(0, (int) ($params['table_row_dept_id'] ?? 0)); if ($filterAssistantId > 0) { $query->where('o.creator_id', $filterAssistantId); } if ($filterDoctorId > 0) { $query->where('rx.creator_id', $filterDoctorId); } if (!$linesSkipPhpDs && $linesAssistScopeFlip !== null) { if ($filterAssistantId > 0) { if (!isset($linesAssistScopeFlip[$filterAssistantId])) { $query->whereRaw('0 = 1'); } } else { $ids = array_values(array_unique(array_map('intval', array_keys($linesAssistScopeFlip)))); $ids = array_values(array_filter($ids, static fn (int $x): bool => $x > 0)); if ($ids === []) { $query->whereRaw('0 = 1'); } elseif (\count($ids) === 1) { $query->whereRaw("({$att}) = ?", [$ids[0]]); } else { $query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')'); } } } if ($tableRowDeptId > 0) { $creatorIds = self::collectAdminIdsAttributedToYejiTableRow( $tableRowDeptId, $params['dept_ids'] ?? null ); if ($creatorIds === []) { $query->whereRaw('0 = 1'); } elseif (\count($creatorIds) === 1) { $query->where('o.creator_id', (int) $creatorIds[0]); } else { $query->whereIn('o.creator_id', $creatorIds); } } elseif (!empty($cPack['explicitDeptFilter'])) { if ($filterAssistantId > 0) { if (!isset($adminToPrimary[$filterAssistantId])) { $query->whereRaw('0 = 1'); } } else { $deptAdminIds = array_values(array_unique(array_map('intval', array_keys($adminToPrimary)))); $deptAdminIds = array_values(array_filter($deptAdminIds, static fn (int $x): bool => $x > 0)); if ($deptAdminIds === []) { $query->whereRaw('0 = 1'); } elseif (\count($deptAdminIds) === 1) { $query->whereRaw("({$att}) = ?", [$deptAdminIds[0]]); } else { $query->whereRaw("({$att}) IN (" . implode(',', $deptAdminIds) . ')'); } } } if ($filterApptChannelActive) { self::commissionSettlementApplyApptChannelSqlFilter( $query, (int) $params['appt_channel_value'] ); } } /** * 挂号渠道筛选:与 PrescriptionOrderLogic 列表/详情一致,按业务订单绑定挂号解析 channels。 */ private static function commissionSettlementApplyApptChannelSqlFilter(Query $query, int $channelValue): void { $expr = self::commissionSettlementSqlOrderLinkedApptChannelExpr(); if ($channelValue > 0) { $query->whereRaw('IFNULL(' . $expr . ', 0) = ?', [$channelValue]); return; } $query->whereRaw('IFNULL(' . $expr . ', 0) = 0'); } /** * @param array|null $linesAssistScopeFlip */ private static function commissionSettlementBuildOrderLinesQuery( array $pack, array $params, ?array $linesAssistScopeFlip, bool $linesSkipPhpDs ): Query { $query = self::commissionSettlementBuildFilteredOrderQuery($pack, false); self::commissionSettlementApplyOrderLinesSqlFilters( $query, $pack, $params, $linesAssistScopeFlip, $linesSkipPhpDs ); return $query; } /** * @return array */ private static function commissionSettlementOrderLinesSelectFields(string $att): array { return [ 'o.id', 'o.order_no', 'o.prescription_id', 'o.tracking_number', 'o.express_company', 'o.recipient_phone', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'dg.patient_name', 'dg.phone AS patient_phone', 'dg.gender AS patient_gender', Db::raw("({$att}) AS assistant_id"), Db::raw('IFNULL(rx.creator_id, 0) AS doctor_id'), ]; } /** * @param list> $rows * * @return list> */ private static function commissionSettlementFormatOrderLineRows( array $rows, array $pack, array $carryFlip, int $cutoffTs, array $linkedApptByOrderId, array $apptChannelsDictMap, array $adminNames ): array { $tableRowDeptIds = $pack['tableRowDeptIds']; $deptById = $pack['deptById']; $adminToPrimary = $pack['adminToPrimary']; $aids = []; foreach ($rows as $r) { $aid = (int) ($r['assistant_id'] ?? 0); if ($aid > 0) { $aids[] = $aid; } } $adminToTable = ($tableRowDeptIds !== [] && $deptById !== [] && $aids !== []) ? self::batchMapPerformanceAdminsToTableDept( array_values(array_unique($aids)), $tableRowDeptIds, $deptById ) : []; $out = []; foreach ($rows as $r) { $oid = (int) ($r['id'] ?? 0); $aid = (int) ($r['assistant_id'] ?? 0); $doctorId = (int) ($r['doctor_id'] ?? 0); $dgId = (int) ($r['diagnosis_id'] ?? 0); $signTs = (int) ($r['sign_ts'] ?? 0); $payTs = (int) ($r['pay_ts'] ?? 0); $bucketMeta = self::commissionSettlementBucketEligible($signTs, $payTs, $cutoffTs); $eligible = $bucketMeta['eligible']; $b = $eligible ? 'current' : 'deferred'; $apDet = $linkedApptByOrderId[$oid] ?? null; $chForLabel = \is_array($apDet) ? (int) ($apDet['channels'] ?? 0) : 0; $apptDateRaw = \is_array($apDet) ? trim((string) ($apDet['appointment_date'] ?? '')) : ''; $apptStatus = \is_array($apDet) ? (int) ($apDet['appointment_status'] ?? 0) : 0; $trackingNo = trim((string) ($r['tracking_number'] ?? '')); $primaryDeptId = (int) ($adminToTable[$aid] ?? 0); if ($primaryDeptId <= 0) { $primaryDeptId = (int) ($adminToPrimary[$aid] ?? 0); } $patientName = trim((string) ($r['patient_name'] ?? '')); $patientPhone = trim((string) ($r['patient_phone'] ?? '')); $patientGender = (int) ($r['patient_gender'] ?? 0); $patientGenderText = $patientGender === 1 ? '男' : ($patientGender === 2 ? '女' : ''); $out[] = [ 'id' => $oid, 'order_no' => (string) ($r['order_no'] ?? ''), 'tracking_number' => $trackingNo, 'express_company' => trim((string) ($r['express_company'] ?? '')), 'diagnosis_id' => $dgId, 'patient_name' => $patientName, 'patient_phone' => $patientPhone, 'patient_gender' => $patientGender, 'patient_gender_text' => $patientGenderText, 'amount' => round((float) ($r['amount'] ?? 0), 2), 'create_time' => (int) ($r['create_time'] ?? 0), 'create_time_text' => !empty($r['create_time']) ? date('Y-m-d H:i:s', (int) $r['create_time']) : '', 'sign_ts' => $signTs, 'sign_time_text' => $signTs > 0 ? date('Y-m-d H:i:s', $signTs) : '', 'pay_ts' => $payTs, 'pay_time_text' => $payTs > 0 ? date('Y-m-d H:i:s', $payTs) : '', 'assistant_id' => $aid, 'doctor_id' => $doctorId, 'assistant_name' => $aid > 0 ? (string) ($adminNames[$aid] ?? ('#' . $aid)) : '—', 'doctor_name' => $doctorId > 0 ? (string) ($adminNames[$doctorId] ?? ('#' . $doctorId)) : '未关联开方人', 'bucket' => $b, 'bucket_defer_code' => $eligible ? '' : (string) ($bucketMeta['defer_code'] ?? ''), 'bucket_defer_text' => $eligible ? '' : (string) ($bucketMeta['defer_text'] ?? ''), 'is_carry_in' => $oid > 0 && isset($carryFlip[$oid]) ? 1 : 0, 'primary_dept_id' => $primaryDeptId, 'primary_dept_name' => $primaryDeptId > 0 ? self::formatYejiDeptRowDisplayName($primaryDeptId, $deptById) : '', 'appointment_id' => \is_array($apDet) ? (int) ($apDet['appointment_id'] ?? 0) : 0, 'appointment_date' => $apptDateRaw, 'appointment_date_text' => $apptDateRaw !== '' ? $apptDateRaw : '', 'appointment_status' => $apptStatus, 'appt_channel_value' => $chForLabel, 'appt_channel_name' => self::commissionSettlementApptChannelLabel($chForLabel, $apptChannelsDictMap), ]; } return $out; } /** * bucket=current|deferred:分块拉取候选单 id 并批量补签收/尾款(仅库表),避免一次性加载全量导致 504。 * * @return array{0: int[], 1: array} */ private static function commissionSettlementCollectBucketMatchingOrders( array $pack, array $params, ?array $linesAssistScopeFlip, bool $linesSkipPhpDs, int $cutoffTs, string $bucket, int $chunkSize = 400 ): array { $matchingIds = []; $signPayById = []; $cursor = PHP_INT_MAX; while (true) { $chunkQuery = self::commissionSettlementBuildOrderLinesQuery( $pack, $params, $linesAssistScopeFlip, $linesSkipPhpDs ); $chunkRows = $chunkQuery ->where('o.id', '<', $cursor) ->field([ 'o.id', 'o.tracking_number', 'o.express_company', 'o.recipient_phone', ]) ->order('o.id', 'desc') ->limit($chunkSize) ->select() ->toArray(); if ($chunkRows === []) { break; } $minId = PHP_INT_MAX; foreach ($chunkRows as $r) { $oid = (int) ($r['id'] ?? 0); if ($oid > 0 && $oid < $minId) { $minId = $oid; } } if ($minId === PHP_INT_MAX) { break; } $cursor = $minId; $chunkRows = self::commissionSettlementHydrateSignPay($chunkRows, false); foreach ($chunkRows as $r) { $oid = (int) ($r['id'] ?? 0); if ($oid <= 0) { continue; } $signTs = (int) ($r['sign_ts'] ?? 0); $payTs = (int) ($r['pay_ts'] ?? 0); $eligible = self::commissionSettlementBucketEligible($signTs, $payTs, $cutoffTs)['eligible']; $b = $eligible ? 'current' : 'deferred'; if ($bucket === 'current' && $b !== 'current') { continue; } if ($bucket === 'deferred' && $b !== 'deferred') { continue; } $matchingIds[] = $oid; $signPayById[$oid] = ['sign_ts' => $signTs, 'pay_ts' => $payTs]; } } return [$matchingIds, $signPayById]; } /** * 提成核对:订单明细(分页)。 * * @param array{settlement_month?: string, dept_ids?: int[]|string, table_row_dept_id?: int|string, channel_code?: string, page?: int|string, page_size?: int|string, bucket?: string, assistant_id?: int|string, doctor_id?: int|string, appt_channel_value?: int|string|null} $params table_row_dept_id:点击部门行下钻时传入,与 overview foldPerformanceRowsToDeptByTable 同源;dept_ids 仍表示当前页部门筛选上下文(父级钻取),勿仅用叶子部门 id 替代 * * @return array */ public static function commissionSettlementOrderLines(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo); $cutoffTs = $pack['cutoffTs']; $tableRowDeptIds = $pack['tableRowDeptIds']; $adminToPrimary = $pack['adminToPrimary']; $carryFlip = array_flip($pack['carryOrderIds']); $att = $pack['att']; $bucket = trim((string) ($params['bucket'] ?? '')); $page = max(1, (int) ($params['page'] ?? 1)); $pageSize = min(100, max(10, (int) ($params['page_size'] ?? 20))); if ($tableRowDeptIds === [] || $adminToPrimary === []) { return ['list' => [], 'count' => 0, 'page' => $page, 'page_size' => $pageSize]; } $cPack = $pack['c']; $linesAssistScopeFlip = null; if (!empty($cPack['dataScopeRestricted']) && isset($cPack['dataScopeVisibleAdminIds']) && $cPack['dataScopeVisibleAdminIds'] !== []) { $linesAssistScopeFlip = array_flip(array_map('intval', $cPack['dataScopeVisibleAdminIds'])); } $linesSelfAid = (int) ($pack['assistAttributionRestrictSelfId'] ?? 0); if ($linesSelfAid > 0) { $linesAssistScopeFlip = [$linesSelfAid => true]; } $linesSkipPhpDs = !empty($pack['skipPhpDataScopeAttributionFilter']); $bucketNeedsPhp = ($bucket === 'current' || $bucket === 'deferred'); $selectFields = self::commissionSettlementOrderLinesSelectFields($att); if (!$bucketNeedsPhp) { $total = (int) self::commissionSettlementBuildOrderLinesQuery( $pack, $params, $linesAssistScopeFlip, $linesSkipPhpDs )->count(); if ($total <= 0) { return ['list' => [], 'count' => 0, 'page' => $page, 'page_size' => $pageSize]; } $pageRows = self::commissionSettlementBuildOrderLinesQuery( $pack, $params, $linesAssistScopeFlip, $linesSkipPhpDs ) ->field($selectFields) ->order('o.id', 'desc') ->page($page, $pageSize) ->select() ->toArray(); $pageRows = self::commissionSettlementHydrateSignPay($pageRows, true); [$linkedApptByOrderId, $apptChannelsDictMap] = self::commissionSettlementLoadApptContextForOrders($pageRows); $nameIds = []; foreach ($pageRows as $r) { $na = (int) ($r['assistant_id'] ?? 0); if ($na > 0) { $nameIds[$na] = true; } $nd = (int) ($r['doctor_id'] ?? 0); if ($nd > 0) { $nameIds[$nd] = true; } } $adminNames = self::commissionSettlementBatchAdminNames(array_map('intval', array_keys($nameIds))); $list = self::commissionSettlementFormatOrderLineRows( $pageRows, $pack, $carryFlip, $cutoffTs, $linkedApptByOrderId, $apptChannelsDictMap, $adminNames ); return [ 'list' => $list, 'count' => $total, 'page' => $page, 'page_size' => $pageSize, ]; } // bucket=current|deferred:分块判定签收/尾款后再 PHP 分页 [$matchingIds] = self::commissionSettlementCollectBucketMatchingOrders( $pack, $params, $linesAssistScopeFlip, $linesSkipPhpDs, $cutoffTs, $bucket ); if ($matchingIds === []) { return ['list' => [], 'count' => 0, 'page' => $page, 'page_size' => $pageSize]; } $total = \count($matchingIds); $pageIds = array_slice($matchingIds, ($page - 1) * $pageSize, $pageSize); if ($pageIds === []) { return ['list' => [], 'count' => $total, 'page' => $page, 'page_size' => $pageSize]; } $pageQuery = self::commissionSettlementBuildOrderLinesQuery( $pack, $params, $linesAssistScopeFlip, $linesSkipPhpDs ); $pageRows = $pageQuery ->whereIn('o.id', $pageIds) ->field($selectFields) ->order('o.id', 'desc') ->select() ->toArray(); // 明细展示:库内解析后仍缺签收时,与 logisticsTrace 一致补查快递100(仅当前页) $pageRows = self::commissionSettlementHydrateSignPay($pageRows, true); [$linkedApptByOrderId, $apptChannelsDictMap] = self::commissionSettlementLoadApptContextForOrders($pageRows); $nameIds = []; foreach ($pageRows as $r) { $na = (int) ($r['assistant_id'] ?? 0); if ($na > 0) { $nameIds[$na] = true; } $nd = (int) ($r['doctor_id'] ?? 0); if ($nd > 0) { $nameIds[$nd] = true; } } $adminNames = self::commissionSettlementBatchAdminNames(array_map('intval', array_keys($nameIds))); $list = self::commissionSettlementFormatOrderLineRows( $pageRows, $pack, $carryFlip, $cutoffTs, $linkedApptByOrderId, $apptChannelsDictMap, $adminNames ); return [ 'list' => $list, 'count' => $total, 'page' => $page, 'page_size' => $pageSize, ]; } /** * 当前筛选条件下的核对 / 确定状态。 * * @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string} $params * * @return array */ public static function commissionSettlementConfirmStatus(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { $pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo); $row = self::commissionSettlementGetConfirmRow($pack['monthRaw'], $pack['scopeChannelCode'], $pack['deptKey']); return [ 'settlement_month' => $pack['monthRaw'], 'scope_channel_code' => $pack['scopeChannelCode'], 'scope_dept_ids_key' => $pack['deptKey'], 'confirm' => $row, ]; } /** * 保存核对备注(未确定前可反复保存)。 * * @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string, reconcile_note?: string} $params */ public static function commissionSettlementSaveReconcile(array $params, int $viewerAdminId, array $viewerAdminInfo): array { if ($viewerAdminId <= 0) { throw new \think\Exception('未登录'); } $pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo); $note = mb_substr(trim((string) ($params['reconcile_note'] ?? '')), 0, 500); $t = time(); $exists = Db::name('commission_settlement_confirm') ->where('settlement_month', $pack['monthRaw']) ->where('scope_channel_code', $pack['scopeChannelCode']) ->where('scope_dept_ids_key', $pack['deptKey']) ->find(); if ($exists) { $ex = is_array($exists) ? $exists : $exists->toArray(); if ((int) ($ex['status'] ?? 0) === 1) { throw new \think\Exception('当前为「已确定业绩」锁定状态,不可修改备注;请先「撤回确定」后再编辑'); } Db::name('commission_settlement_confirm')->where('id', (int) $ex['id'])->update([ 'reconcile_note' => $note, 'update_time' => $t, ]); } else { Db::name('commission_settlement_confirm')->insert([ 'settlement_month' => $pack['monthRaw'], 'scope_channel_code' => $pack['scopeChannelCode'], 'scope_dept_ids_key' => $pack['deptKey'], 'status' => 0, 'reconcile_note' => $note, 'totals_json' => '', 'confirmed_admin_id' => 0, 'confirmed_admin_name' => '', 'confirmed_at' => 0, 'create_time' => $t, 'update_time' => $t, ]); } return ['ok' => true]; } /** * 确定本期业绩:写入快照,并将「顺延下期」订单结转至下一结算月同 scope。 * * @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string, reconcile_note?: string} $params */ public static function commissionSettlementConfirmFinalize(array $params, int $viewerAdminId, array $viewerAdminInfo): array { if ($viewerAdminId <= 0) { throw new \think\Exception('未登录'); } $pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo); $tableRowDeptIds = $pack['tableRowDeptIds']; $adminToPrimary = $pack['adminToPrimary']; if ($tableRowDeptIds === [] || $adminToPrimary === []) { throw new \think\Exception('当前账号无可操作的展示部门,无法确定业绩'); } $exists = Db::name('commission_settlement_confirm') ->where('settlement_month', $pack['monthRaw']) ->where('scope_channel_code', $pack['scopeChannelCode']) ->where('scope_dept_ids_key', $pack['deptKey']) ->find(); if ($exists) { $ex = is_array($exists) ? $exists : $exists->toArray(); if ((int) ($ex['status'] ?? 0) === 1) { throw new \think\Exception('该结算月在同渠道同部门筛选下已确定业绩,请勿重复提交'); } } $overview = self::commissionSettlementOverview($params, $viewerAdminId, $viewerAdminInfo); $total = $overview['total'] ?? []; $query = self::commissionSettlementBuildFilteredOrderQuery($pack, false); $att = $pack['att']; $cutoffTs = $pack['cutoffTs']; $rowsRaw = $query->field([ 'o.id', 'o.create_time', 'o.tracking_number', 'o.express_company', 'o.recipient_phone', Db::raw("({$att}) AS assistant_id"), ])->select()->toArray(); $rowsRaw = self::commissionSettlementHydrateSignPay($rowsRaw, false); $deferredIds = []; foreach ($rowsRaw as $r) { $signTs = (int) ($r['sign_ts'] ?? 0); $payTs = (int) ($r['pay_ts'] ?? 0); if (!self::commissionSettlementBucketEligible($signTs, $payTs, $cutoffTs)['eligible']) { $deferredIds[] = (int) ($r['id'] ?? 0); } } $deferredIds = array_values(array_unique(array_filter($deferredIds, static fn (int $x): bool => $x > 0))); $adminName = (string) (Db::name('admin')->where('id', $viewerAdminId)->whereNull('delete_time')->value('name') ?: ''); $note = mb_substr(trim((string) ($params['reconcile_note'] ?? '')), 0, 500); $t = time(); $next = $pack['nextSettlementMonth']; $totalsJson = json_encode([ 'total' => $total, 'deferred_ids' => $deferredIds, 'next_carry_month' => $next, ], JSON_UNESCAPED_UNICODE); Db::startTrans(); try { Db::name('commission_settlement_carry') ->where('source_settlement_month', $pack['monthRaw']) ->where('scope_channel_code', $pack['scopeChannelCode']) ->where('scope_dept_ids_key', $pack['deptKey']) ->delete(); foreach ($deferredIds as $poId) { $dup = Db::name('commission_settlement_carry') ->where('prescription_order_id', $poId) ->where('target_settlement_month', $next) ->where('scope_channel_code', $pack['scopeChannelCode']) ->where('scope_dept_ids_key', $pack['deptKey']) ->find(); if ($dup) { continue; } Db::name('commission_settlement_carry')->insert([ 'prescription_order_id' => $poId, 'target_settlement_month' => $next, 'source_settlement_month' => $pack['monthRaw'], 'scope_channel_code' => $pack['scopeChannelCode'], 'scope_dept_ids_key' => $pack['deptKey'], 'create_time' => $t, ]); } if ($exists) { $ex = is_array($exists) ? $exists : $exists->toArray(); Db::name('commission_settlement_confirm')->where('id', (int) $ex['id'])->update([ 'status' => 1, 'reconcile_note' => $note !== '' ? $note : (string) ($ex['reconcile_note'] ?? ''), 'totals_json' => (string) $totalsJson, 'confirmed_admin_id' => $viewerAdminId, 'confirmed_admin_name' => mb_substr($adminName, 0, 64), 'confirmed_at' => $t, 'revoked_at' => 0, 'revoked_admin_id' => 0, 'revoked_admin_name' => '', 'update_time' => $t, ]); } else { Db::name('commission_settlement_confirm')->insert([ 'settlement_month' => $pack['monthRaw'], 'scope_channel_code' => $pack['scopeChannelCode'], 'scope_dept_ids_key' => $pack['deptKey'], 'status' => 1, 'reconcile_note' => $note, 'totals_json' => (string) $totalsJson, 'confirmed_admin_id' => $viewerAdminId, 'confirmed_admin_name' => mb_substr($adminName, 0, 64), 'confirmed_at' => $t, 'revoked_at' => 0, 'revoked_admin_id' => 0, 'revoked_admin_name' => '', 'create_time' => $t, 'update_time' => $t, ]); } Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; } return [ 'ok' => true, 'next_settlement_month' => $next, 'deferred_carry_count' => count($deferredIds), ]; } /** * 撤回「确定本期业绩」:status 1→2,并删除本 scope 下因该次确定写入的顺延结转,避免下期重复汇入。 * 仅 status=1 时可撤回;撤回后可再次保存备注并重新确定(仍受 uk_scope 唯一行约束)。 * * @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string} $params * * @return array{ok: bool} */ public static function commissionSettlementConfirmRevoke(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { if ($viewerAdminId <= 0) { throw new \think\Exception('未登录'); } $pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo); $tableRowDeptIds = $pack['tableRowDeptIds']; $adminToPrimary = $pack['adminToPrimary']; if ($tableRowDeptIds === [] || $adminToPrimary === []) { throw new \think\Exception('当前账号无可操作的展示部门'); } $exists = Db::name('commission_settlement_confirm') ->where('settlement_month', $pack['monthRaw']) ->where('scope_channel_code', $pack['scopeChannelCode']) ->where('scope_dept_ids_key', $pack['deptKey']) ->find(); if (!$exists) { throw new \think\Exception('当前筛选下无核对记录,无需撤回'); } $ex = is_array($exists) ? $exists : $exists->toArray(); if ((int) ($ex['status'] ?? 0) !== 1) { throw new \think\Exception('仅「已确定业绩」状态可撤回;当前为草稿、或已撤回状态'); } $adminName = (string) (Db::name('admin')->where('id', $viewerAdminId)->whereNull('delete_time')->value('name') ?: ''); $t = time(); Db::startTrans(); try { Db::name('commission_settlement_carry') ->where('source_settlement_month', $pack['monthRaw']) ->where('scope_channel_code', $pack['scopeChannelCode']) ->where('scope_dept_ids_key', $pack['deptKey']) ->delete(); Db::name('commission_settlement_confirm')->where('id', (int) $ex['id'])->update([ 'status' => 2, 'revoked_at' => $t, 'revoked_admin_id' => $viewerAdminId, 'revoked_admin_name' => mb_substr($adminName, 0, 64), 'update_time' => $t, ]); Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; } return ['ok' => true]; } }