0, * **is_inherit=0**,诊单未删除)去重后的「医助 × 诊单」组合(NI);同一诊单当月多次指派给同一医助只计 1 次; * **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单(R,不限月份)。 * - 继承数 inherit_count = 当月 **is_inherit=1**、to>0、from≠to 的「医助 × 诊单」(IH),同一 (医助, 诊单) 多条继承取最早; * 同月既新指派又继承给同一医助只算新指派;剔除 R。历史「回退自动分配」行(auto_assign_log.rollback_time 匹配)按继承处理。 * 继承时诊次 k = 继承时刻之前(严格小于)的有效实单数 + 偏移;起算诊次 entry = max(2, k+1);entry>50 只进合计分母。 * - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔; * 诊次**跨月累计不重置**;诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;偏移 n 即在实单序号上叠加 n)。 * - 当月 N 诊单数 visit{N}_count = **当月内下单**且诊次为 N 的订单数,归属下单时点的**持有医助**—— * 按指派日志时间线取「订单时间之前(含同刻)最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;继承会转移持有人)。 * - 当月 N 诊分母 visit{N}_den = 新指派数 + 起算诊次 ≤ N 的继承数;接诊率 visit{N}_rate = count ÷ den(den=0 → null)。 * - 复诊合计:visit_all_count = Σ_{N≥2} visit{N}_count;visit_all_den = assigned_total_count = |NI ∪ IH|;visit_all_rate 可超过 100%。 * - 旧口径开关 inherit_mode=exclude|slot(默认 config project.revisit_rate.default_inherit_mode): * exclude 完全复现旧实现(IH=∅、不忽略 from==to、不豁免回退行,assigned_count/visit{N}_count/visit{N}_rate 与历史逐值一致); * 无论模式都输出 visit2_rate_new_assign = 二诊单数 ÷ 旧口径新指派数(供自动分配过渡期分档)。 * - 分档动态产出:N 从 2 起,至 max(4, 当月分子最大诊次, 最大起算诊次)(上限 MAX_VISIT_SLOT),返回 `slots` 供前端动态渲染。 * - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致); * 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。 * - 部门行 / 合计行:NI 按诊单去重;IH = (∪IH(A)) \ NI_dept,entry 取部门内最小值;N 诊单数为下级行求和(每笔订单唯一归属一名医助)。 */ class RevisitRateLogic { /** 至少展示到的复诊序号(二诊/三诊/四诊) */ private const MIN_VISIT_SLOT_CEILING = 4; /** 复诊序号统计上限(防御脏数据导致列爆炸;诊次跨月累计,上限放宽) */ private const MAX_VISIT_SLOT = 50; /** 未分配部门的占位分组 */ private const UNASSIGNED_DEPT_NAME = '未分配部门'; /** * @param array{month?:string,dept_ids?:int[]|string,inherit_mode?:string} $params * * @return array{ * month:string,start_date:string,end_date:string,inherit_mode:string, * slots:list, * total:array, * rows:list> * } */ public static function overview(array $params): array { $ctx = self::buildStatsCore($params); $month = $ctx['month']; $minSlots = range(2, self::MIN_VISIT_SLOT_CEILING); $universe = self::assistantUniverse($ctx); if ($universe === []) { return [ 'month' => $month, 'start_date' => $ctx['startDate'], 'end_date' => $ctx['endDate'], 'inherit_mode' => $ctx['inheritMode'], 'slots' => $minSlots, 'total' => self::buildMetricPack(0, [], [], $minSlots, 0), 'rows' => [], ]; } // 分档:2 ~ max(4, 当月命中的最大诊次, 最大起算诊次),上限 MAX_VISIT_SLOT $slots = RevisitRateCalculator::slotRange( $ctx['slotOrdersByAssistant'], (int) $ctx['maxEntry'], self::MIN_VISIT_SLOT_CEILING ); $nameMap = Db::name('admin') ->whereIn('id', array_keys($universe)) ->column('name', 'id'); // 医助行(按部门分组收集) /** @var array>> $assistantRowsByDept */ $assistantRowsByDept = []; /** @var array> $deptAssistants 部门 => 医助 id 列表(部门行按诊单去重用) */ $deptAssistants = []; /** @var array> $deptSlotCounts 部门 => [slot => 订单数] */ $deptSlotCounts = []; /** @var array $totalSlotCounts */ $totalSlotCounts = []; foreach ($universe as $aid => $_) { $deptId = (int) ($ctx['assistantDept'][$aid] ?? 0); $deptAssistants[$deptId][] = (int) $aid; $diagSet = $ctx['diagsByAssistant'][$aid] ?? []; $inheritEntries = $ctx['inheritEntryByAssistant'][$aid] ?? []; $excludeSet = $ctx['excludeDiagsByAssistant'][$aid] ?? []; $slotCounts = []; foreach ($ctx['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) { $cnt = \count($orders); $slotCounts[$slot] = $cnt; $deptSlotCounts[$deptId][$slot] = ($deptSlotCounts[$deptId][$slot] ?? 0) + $cnt; $totalSlotCounts[$slot] = ($totalSlotCounts[$slot] ?? 0) + $cnt; } $deptName = $deptId > 0 ? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId)) : self::UNASSIGNED_DEPT_NAME; $assistantRowsByDept[$deptId][] = [ 'row_key' => 'a' . $aid, 'is_dept' => 0, 'assistant_id' => (int) $aid, 'assistant_name' => (string) ($nameMap[$aid] ?? ('#' . $aid)), 'dept_id' => $deptId, 'dept_name' => $deptName, ] + self::buildMetricPack(\count($diagSet), $inheritEntries, $slotCounts, $slots, \count($excludeSet)); } // 部门行 + 子行(NI 按诊单去重;IH = ∪IH(A) \ NI_dept,entry 取最小) $rows = []; foreach ($assistantRowsByDept as $deptId => $children) { usort($children, static function (array $a, array $b): int { if ($a['assigned_count'] !== $b['assigned_count']) { return $b['assigned_count'] <=> $a['assigned_count']; } return strcmp((string) $a['assistant_name'], (string) $b['assistant_name']); }); $deptName = $deptId > 0 ? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId)) : self::UNASSIGNED_DEPT_NAME; $aids = $deptAssistants[$deptId] ?? []; [$deptNiSet, $deptIhEntries] = RevisitRateCalculator::mergeInheritEntries( $ctx['diagsByAssistant'], $ctx['inheritEntryByAssistant'], $aids ); $deptExcludeSet = RevisitRateCalculator::unionDiagSets($ctx['excludeDiagsByAssistant'], $aids); $rows[] = [ 'row_key' => 'd' . $deptId, 'is_dept' => 1, 'dept_id' => (int) $deptId, 'dept_name' => $deptName, 'assistant_count' => \count($children), 'children' => $children, ] + self::buildMetricPack( \count($deptNiSet), $deptIhEntries, $deptSlotCounts[$deptId] ?? [], $slots, \count($deptExcludeSet) ); } usort($rows, static function (array $a, array $b): int { if ($a['assigned_count'] !== $b['assigned_count']) { return $b['assigned_count'] <=> $a['assigned_count']; } return strcmp((string) $a['dept_name'], (string) $b['dept_name']); }); $allAids = array_map('intval', array_keys($universe)); [$totalNiSet, $totalIhEntries] = RevisitRateCalculator::mergeInheritEntries( $ctx['diagsByAssistant'], $ctx['inheritEntryByAssistant'], $allAids ); $totalExcludeSet = RevisitRateCalculator::unionDiagSets($ctx['excludeDiagsByAssistant'], $allAids); return [ 'month' => $month, 'start_date' => $ctx['startDate'], 'end_date' => $ctx['endDate'], 'inherit_mode' => $ctx['inheritMode'], 'slots' => $slots, 'total' => self::buildMetricPack( \count($totalNiSet), $totalIhEntries, $totalSlotCounts, $slots, \count($totalExcludeSet) ), 'rows' => $rows, ]; } /** * 医助本人「我的 N 诊接诊率」(个人业绩页卡片,方案 §10 D1-h)。 * * 复用 overview() 的全量结果(overview 不按个人数据范围过滤、仅限二中心子树),只从各部门行的 children 中 * 挑出 assistant_id 匹配的医助行返回,不暴露其他医助数据;本人不在二中心子树或当月无任何指派 / 成交时 row=null, * in_scope 用于区分两种情况(false=非二中心医助)。结果按 month / assistant / mode 缓存 600 秒,缓存不可用时直接计算。 * * @return array{month:string,inherit_mode:string,slots:list,in_scope:bool,row:array|null} */ public static function assistantOverview(int $assistantId, string $month, string $inheritMode = 'slot'): array { $month = self::normalizeMonth($month); $inheritMode = $inheritMode === 'exclude' ? 'exclude' : 'slot'; $cacheKey = sprintf('revisit_rate:assistant_overview:%s:%d:%s', $month, $assistantId, $inheritMode); try { $cached = \think\facade\Cache::get($cacheKey); if (\is_array($cached) && isset($cached['month'], $cached['slots'])) { return $cached; } } catch (\Throwable) { // 缓存驱动不可用:直接计算 } $overview = self::overview(['month' => $month, 'inherit_mode' => $inheritMode]); $row = null; if ($assistantId > 0) { foreach ($overview['rows'] as $deptRow) { foreach ((array) ($deptRow['children'] ?? []) as $child) { if ((int) ($child['assistant_id'] ?? 0) === $assistantId) { $row = $child; break 2; } } } } // 是否属于二中心子树:有行必然在子树内;无行时按人事部门判断,供前端区分「非二中心」与「当月无被指派患者」 $inScope = $row !== null; if (!$inScope && $assistantId > 0) { [$assistantDept] = self::buildAssistantDeptIndex([$assistantId]); $deptId = (int) ($assistantDept[$assistantId] ?? 0); $erSet = DeptLogic::getErCenterSubtreeDeptIdSet(); $inScope = $deptId > 0 && isset($erSet[$deptId]); } $result = [ 'month' => (string) $overview['month'], 'inherit_mode' => (string) $overview['inherit_mode'], 'slots' => array_values(array_map('intval', (array) $overview['slots'])), 'in_scope' => $inScope, 'row' => $row, ]; try { \think\facade\Cache::set($cacheKey, $result, 600); } catch (\Throwable) { // 写缓存失败不影响返回 } return $result; } /** * 被指派明细(按诊单聚合,与「新指派数 / 继承数 / 被指派合计」同口径可对账)。 * scope:assistant_id(医助行)/ dept_id(部门行,含 0=未分配部门)/ 都不传 = 当前部门筛选下合计。 * kind:new=仅新指派行 / inherit=仅继承行 / all(默认)=两者;行字段 assign_type(new|inherit)、 * inherit_visit_slot(继承时诊次 k,新指派为 null)、entry_slot(起算诊次;新指派恒为 2)。 * 部门/合计 scope 下与 overview 去重规则一致:诊单只要对 scope 内任一医助为新指派即记为 new, * 否则为 inherit 且 entry 取 scope 内最小值;故 kind=all 的「new 行数 + entry<=N 的 inherit 行数」= visit{N}_den。 * * @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string,inherit_mode?:string,kind?:string} $params * * @return array{month:string,inherit_mode:string,kind:string,count:int,rows:list>} */ public static function assignLines(array $params): array { $ctx = self::buildStatsCore($params); $assistantSet = self::applyRowScope($ctx, $params); $kind = strtolower(trim((string) ($params['kind'] ?? 'all'))); if (!\in_array($kind, ['new', 'inherit', 'all'], true)) { $kind = 'all'; } $minNext = self::inheritMinNextSlot(); /** @var array, assign_count: int, last_time: int}> $niByDiag */ $niByDiag = []; foreach ($ctx['pairsRaw'] as $p) { $aid = (int) $p['to_assistant_id']; $did = (int) $p['diagnosis_id']; if (!isset($assistantSet[$aid])) { continue; } if (!isset($niByDiag[$did])) { $niByDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0]; } $niByDiag[$did]['assistants'][$aid] = true; $niByDiag[$did]['assign_count']++; $niByDiag[$did]['last_time'] = max($niByDiag[$did]['last_time'], (int) $p['create_time']); } /** @var array, assign_count: int, last_time: int, k: int, entry: int, is_rollback: int}> $ihByDiag */ $ihByDiag = []; foreach ($ctx['inheritPairs'] as $p) { $aid = (int) $p['to_assistant_id']; $did = (int) $p['diagnosis_id']; if (!isset($assistantSet[$aid]) || isset($niByDiag[$did])) { continue; } $entry = (int) $p['entry_slot']; if (!isset($ihByDiag[$did])) { $ihByDiag[$did] = [ 'assistants' => [], 'assign_count' => 0, 'last_time' => 0, 'k' => (int) $p['inherit_visit_slot'], 'entry' => $entry, 'is_rollback' => (int) ($p['is_rollback'] ?? 0), ]; } elseif ($entry < $ihByDiag[$did]['entry']) { $ihByDiag[$did]['entry'] = $entry; $ihByDiag[$did]['k'] = (int) $p['inherit_visit_slot']; $ihByDiag[$did]['is_rollback'] = (int) ($p['is_rollback'] ?? 0); } $ihByDiag[$did]['assistants'][$aid] = true; $ihByDiag[$did]['assign_count'] += (int) ($p['assign_count'] ?? 1); $ihByDiag[$did]['last_time'] = max($ihByDiag[$did]['last_time'], (int) $p['create_time']); } if ($kind === 'new') { $ihByDiag = []; } elseif ($kind === 'inherit') { $niByDiag = []; } if ($niByDiag === [] && $ihByDiag === []) { return ['month' => $ctx['month'], 'inherit_mode' => $ctx['inheritMode'], 'kind' => $kind, 'count' => 0, 'rows' => []]; } $diagInfo = self::fetchDiagnosisInfo(array_values(array_unique(array_merge(array_keys($niByDiag), array_keys($ihByDiag))))); $assistantIds = []; foreach ([$niByDiag, $ihByDiag] as $map) { foreach ($map as $d) { foreach ($d['assistants'] as $aid => $_) { $assistantIds[$aid] = true; } } } $nameMap = Db::name('admin') ->whereIn('id', array_keys($assistantIds)) ->column('name', 'id'); $rows = []; foreach ($niByDiag as $did => $d) { $names = []; foreach ($d['assistants'] as $aid => $_) { $names[] = (string) ($nameMap[$aid] ?? ('#' . $aid)); } $rows[] = [ 'diagnosis_id' => (int) $did, 'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''), 'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''), 'assistant_names' => implode('、', $names), 'assign_count' => (int) $d['assign_count'], 'last_assign_time' => (int) $d['last_time'], 'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '', 'assign_type' => 'new', 'assign_type_text' => '新指派', 'inherit_visit_slot' => null, 'entry_slot' => $minNext, 'is_rollback' => 0, ]; } foreach ($ihByDiag as $did => $d) { $names = []; foreach ($d['assistants'] as $aid => $_) { $names[] = (string) ($nameMap[$aid] ?? ('#' . $aid)); } $rows[] = [ 'diagnosis_id' => (int) $did, 'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''), 'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''), 'assistant_names' => implode('、', $names), 'assign_count' => (int) $d['assign_count'], 'last_assign_time' => (int) $d['last_time'], 'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '', 'assign_type' => 'inherit', 'assign_type_text' => $d['is_rollback'] === 1 ? '继承(回退)' : '继承', 'inherit_visit_slot' => (int) $d['k'], 'entry_slot' => (int) $d['entry'], 'is_rollback' => (int) $d['is_rollback'], ]; } usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']); return ['month' => $ctx['month'], 'inherit_mode' => $ctx['inheritMode'], 'kind' => $kind, 'count' => \count($rows), 'rows' => $rows]; } /** * N 诊订单明细:scope 内当月下单、全局序号 = slot 的具体订单(归属持有医助),与 visit{slot}_count 同口径可对账。 * slot=0 表示全部 N>=2(与 visit_all_count 对账),行附 visit_slot。 * * @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string,inherit_mode?:string} $params * * @return array{month:string,slot:int,count:int,rows:list>} */ public static function visitOrderLines(array $params): array { $slot = (int) ($params['slot'] ?? 0); if ($slot < 0 || $slot === 1 || $slot > self::MAX_VISIT_SLOT) { return ['month' => self::normalizeMonth((string) ($params['month'] ?? '')), 'slot' => $slot, 'count' => 0, 'rows' => []]; } $ctx = self::buildStatsCore($params); $assistantSet = self::applyRowScope($ctx, $params); $orderRows = []; foreach ($assistantSet as $aid => $_) { $slotMap = $ctx['slotOrdersByAssistant'][$aid] ?? []; if ($slot > 0) { $slotMap = isset($slotMap[$slot]) ? [$slot => $slotMap[$slot]] : []; } foreach ($slotMap as $s => $orders) { foreach ($orders as $r) { $r['holder_assistant_id'] = (int) $aid; $r['visit_slot'] = (int) $s; $orderRows[] = $r; } } } if ($orderRows === []) { return ['month' => $ctx['month'], 'slot' => $slot, 'count' => 0, 'rows' => []]; } $diagIds = array_values(array_unique(array_map( static fn (array $r): int => (int) $r['diagnosis_id'], $orderRows ))); $diagInfo = self::fetchDiagnosisInfo($diagIds); $adminIds = []; foreach ($orderRows as $r) { if ((int) $r['creator_id'] > 0) { $adminIds[(int) $r['creator_id']] = true; } $adminIds[(int) $r['holder_assistant_id']] = true; } $adminNames = $adminIds !== [] ? Db::name('admin')->whereIn('id', array_keys($adminIds))->column('name', 'id') : []; $rows = []; foreach ($orderRows as $r) { $did = (int) $r['diagnosis_id']; $cid = (int) $r['creator_id']; $hid = (int) $r['holder_assistant_id']; $ct = (int) $r['create_time']; $rows[] = [ 'order_id' => (int) $r['id'], 'order_no' => (string) ($r['order_no'] ?? ''), 'diagnosis_id' => $did, 'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''), 'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''), 'amount' => round((float) ($r['amount'] ?? 0), 2), 'create_time' => $ct, 'create_time_text' => $ct > 0 ? date('Y-m-d H:i:s', $ct) : '', 'creator_id' => $cid, 'creator_name' => $cid > 0 ? (string) ($adminNames[$cid] ?? ('#' . $cid)) : '—', 'assistant_id' => $hid, 'assistant_name' => $hid > 0 ? (string) ($adminNames[$hid] ?? ('#' . $hid)) : '—', 'visit_slot' => (int) ($r['visit_slot'] ?? 0), ]; } usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']); return ['month' => $ctx['month'], 'slot' => $slot, 'count' => \count($rows), 'rows' => $rows]; } /** * 部门下拉:仅「二中心」及其组织下级(与业绩看板 ErCenter 子树一致)。 * * @return array{rows: list} */ public static function deptOptions(): array { $erSet = DeptLogic::getErCenterSubtreeDeptIdSet(); if ($erSet === []) { return ['rows' => []]; } $rows = Db::name('dept') ->whereNull('delete_time') ->whereIn('id', array_keys($erSet)) ->field(['id', 'pid', 'name']) ->order('sort', 'desc') ->order('id', 'asc') ->select() ->toArray(); return [ 'rows' => array_map(static fn (array $r): array => [ 'id' => (int) $r['id'], 'pid' => (int) $r['pid'], 'name' => (string) $r['name'], ], $rows), ]; } // ─────────────────────────── 内部实现 ─────────────────────────── /** * 核心统计上下文: * 1. 全量指派日志(≤ 月末)构建持有时间线; * 2. 分母:当月新指派 NI 与继承 IH 的「医助 × 诊单」(RevisitRateCalculator),再剔除名下存在拒收(9)/退款(10) 订单的诊单; * 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊); * 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内); * 继承集合与旧口径集合与分母/分子一并过滤(当月仅有继承行的医助不会静默丢失、也不会逃过二中心过滤)。 * * @param array{month?:string,dept_ids?:int[]|string,inherit_mode?:string} $params * * @return array{ * month:string,startDate:string,endDate:string,startTs:int,endTs:int,inheritMode:string, * pairsRaw:list, * diagsByAssistant:array>, * excludeDiagsByAssistant:array>, * inheritEntryByAssistant:array>, * inheritPairs:list>, * slotOrdersByAssistant:array>>>, * maxEntry:int, * assistantDept:array, * deptNames:array * } */ private static function buildStatsCore(array $params): array { $month = self::normalizeMonth((string) ($params['month'] ?? '')); $startTs = (int) strtotime($month . '-01 00:00:00'); $endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59'); $inheritMode = self::resolveInheritMode($params); // 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线 // inherit_visit_slot 为写入时快照,仅供明细展示;统计一律以实时重算为准 $logRows = Db::name('tcm_diagnosis_assign_log') ->alias('lg') ->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->where('lg.create_time', '<=', $endTs) ->where('lg.diagnosis_id', '>', 0) ->field([ 'lg.id', 'lg.diagnosis_id', 'lg.from_assistant_id', 'lg.to_assistant_id', 'lg.operator_admin_id', 'lg.create_time', 'lg.is_inherit', 'lg.inherit_visit_slot', ]) ->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc']) ->select() ->toArray(); // 分母剔除集合:当月被指派(含继承)诊单中名下存在拒收(9)/退款(10) 业务订单者(与明细 assignLines 同口径) $refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet( RevisitRateCalculator::monthAssignedDiagIds($logRows, $startTs, $endTs) ); // 历史「回退自动分配」行(slot 模式按继承处理) $rollbackKeySet = $inheritMode === RevisitRateCalculator::MODE_SLOT ? self::fetchRollbackKeySet($startTs, $endTs) : []; // 分子:曾被指派诊单的订单序列(分块生成器,同一诊单连续),统计诊次 = 实单全局序号 + 诊单偏移 $candidateDiagIds = RevisitRateCalculator::candidateDiagIds($logRows); $offsetMap = self::fetchRevisitSlotStartOffsetMap($candidateDiagIds); $orderRows = self::iterateOrderSeqRows( $candidateDiagIds, ['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id'] ); $calc = RevisitRateCalculator::compute( $logRows, $orderRows, $offsetMap, $refundRejectDiagSet, $rollbackKeySet, $startTs, $endTs, $inheritMode, self::inheritMinNextSlot() ); unset($logRows); $pairsRaw = $calc['pairsRaw']; $diagsByAssistant = $calc['diagsByAssistant']; $excludeDiagsByAssistant = $calc['excludeDiagsByAssistant']; $inheritEntryByAssistant = $calc['inheritEntryByAssistant']; $inheritPairs = $calc['inheritPairs']; $slotOrdersByAssistant = $calc['slotOrdersByAssistant']; // 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级) $universeIds = array_keys( $diagsByAssistant + $slotOrdersByAssistant + $inheritEntryByAssistant + $excludeDiagsByAssistant ); [$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds); $subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null); if ($subtreeSet === []) { // 无二中心部门时整表为空,避免误展示其它中心数据 $diagsByAssistant = []; $excludeDiagsByAssistant = []; $inheritEntryByAssistant = []; $slotOrdersByAssistant = []; } else { foreach ($universeIds as $aid) { $deptId = (int) ($assistantDept[$aid] ?? 0); if ($deptId <= 0 || !isset($subtreeSet[$deptId])) { unset( $diagsByAssistant[$aid], $excludeDiagsByAssistant[$aid], $inheritEntryByAssistant[$aid], $slotOrdersByAssistant[$aid] ); } } } // 列范围上界只看过滤后仍在表内的继承行 $maxEntry = 0; foreach ($inheritEntryByAssistant as $entries) { foreach ($entries as $entry) { if ((int) $entry <= self::MAX_VISIT_SLOT && (int) $entry > $maxEntry) { $maxEntry = (int) $entry; } } } return [ 'month' => $month, 'startDate' => date('Y-m-d', $startTs), 'endDate' => date('Y-m-d', $endTs), 'startTs' => $startTs, 'endTs' => $endTs, 'inheritMode' => $inheritMode, 'pairsRaw' => $pairsRaw, 'diagsByAssistant' => $diagsByAssistant, 'excludeDiagsByAssistant' => $excludeDiagsByAssistant, 'inheritEntryByAssistant' => $inheritEntryByAssistant, 'inheritPairs' => $inheritPairs, 'slotOrdersByAssistant' => $slotOrdersByAssistant, 'maxEntry' => $maxEntry, 'assistantDept' => $assistantDept, 'deptNames' => $deptNames, ]; } /** * 口径白名单:请求参数 inherit_mode=exclude|slot,缺省取 config project.revisit_rate.default_inherit_mode(再缺省 slot)。 */ private static function resolveInheritMode(array $params): string { $default = (string) (config('project.revisit_rate.default_inherit_mode') ?? RevisitRateCalculator::MODE_SLOT); return RevisitRateCalculator::normalizeMode($params['inherit_mode'] ?? '', $default); } /** 继承起算诊次下限(config project.revisit_rate.inherit_min_next_slot,默认 2,最小 2) */ private static function inheritMinNextSlot(): int { $v = (int) (config('project.revisit_rate.inherit_min_next_slot') ?? RevisitRateCalculator::DEFAULT_MIN_NEXT_SLOT); return max(RevisitRateCalculator::DEFAULT_MIN_NEXT_SLOT, $v); } /** * 当月「回退自动分配」键集合:auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id) 与 * assign_log(diagnosis_id, create_time, operator_admin_id) 一一对应(AutoAssignLogLogic::rollbackOne 同一事务同一 $now)。 * * @return array */ private static function fetchRollbackKeySet(int $startTs, int $endTs): array { $rows = Db::name('tcm_diagnosis_auto_assign_log') ->where('rollback_time', '>=', $startTs) ->where('rollback_time', '<=', $endTs) ->where('diagnosis_id', '>', 0) ->field(['diagnosis_id', 'rollback_time', 'rollback_admin_id']) ->select() ->toArray(); $out = []; foreach ($rows as $r) { $out[RevisitRateCalculator::rollbackKey( (int) ($r['diagnosis_id'] ?? 0), (int) ($r['rollback_time'] ?? 0), (int) ($r['rollback_admin_id'] ?? 0) )] = true; } return $out; } /** * 订单序列分块生成器:每块按诊单 id 切分(一个诊单的订单全部落在同一块),块内已按 (diagnosis_id, create_time, id) 升序。 * * @param list $diagIds * @param list $fields * * @return \Generator> */ private static function iterateOrderSeqRows(array $diagIds, array $fields): \Generator { foreach (array_chunk($diagIds, 2000) as $chunk) { foreach (self::fetchOrderSeqRows($chunk, $fields) as $r) { yield $r; } } } /** * 统计涉及的医助全集:新指派 ∪ 继承 ∪ 旧口径新指派 ∪ 分子(持有成交)。 * * @param array{diagsByAssistant:array>,excludeDiagsByAssistant:array>,inheritEntryByAssistant:array>,slotOrdersByAssistant:array>>>} $ctx * * @return array */ private static function assistantUniverse(array $ctx): array { $set = []; foreach (['diagsByAssistant', 'excludeDiagsByAssistant', 'inheritEntryByAssistant', 'slotOrdersByAssistant'] as $key) { foreach (array_keys($ctx[$key] ?? []) as $aid) { $set[(int) $aid] = true; } } return $set; } /** * 行级 scope:assistant_id(医助行)优先;其次 dept_id(部门归组行,0=未分配部门);都不传 = 全部(已含部门筛选)。 * * @return array scope 内医助集合 */ private static function applyRowScope(array $ctx, array $params): array { $assistantId = (int) ($params['assistant_id'] ?? 0); $hasDeptScope = isset($params['dept_id']) && $params['dept_id'] !== '' && $params['dept_id'] !== null; $deptScopeId = $hasDeptScope ? (int) $params['dept_id'] : -1; $assistantSet = []; foreach (self::assistantUniverse($ctx) as $aid => $_) { if ($assistantId > 0) { if ((int) $aid === $assistantId) { $assistantSet[$aid] = true; } continue; } if ($hasDeptScope) { if ((int) ($ctx['assistantDept'][$aid] ?? 0) === $deptScopeId) { $assistantSet[$aid] = true; } continue; } $assistantSet[$aid] = true; } return $assistantSet; } /** * 医助 → 归属部门 + 部门名称表。 * * @param list $adminIds * * @return array{0: array, 1: array} */ private static function buildAssistantDeptIndex(array $adminIds): array { if ($adminIds === []) { return [[], []]; } // admin_dept 为 (admin_id, dept_id) 联合主键、无自增 id;取最小 dept_id 作为归属部门保证确定性 $relRows = Db::name('admin_dept') ->whereIn('admin_id', $adminIds) ->order(['admin_id' => 'asc', 'dept_id' => 'asc']) ->field(['admin_id', 'dept_id']) ->select() ->toArray(); $deptNames = Db::name('dept') ->whereNull('delete_time') ->column('name', 'id'); $canonical = []; foreach ($relRows as $r) { $aid = (int) ($r['admin_id'] ?? 0); $deptId = (int) ($r['dept_id'] ?? 0); if ($aid <= 0 || $deptId <= 0 || isset($canonical[$aid])) { continue; } if (!isset($deptNames[$deptId])) { continue; } $canonical[$aid] = $deptId; } $names = []; foreach ($deptNames as $id => $name) { $names[(int) $id] = (string) $name; } return [$canonical, $names]; } /** * 部门筛选集合:始终落在「二中心」子树内。 * - 未传 dept_ids:整棵二中心子树 * - 已传:所选部门及其下级 ∩ 二中心子树(非法/非二中心 id 被忽略) * * @param mixed $raw * * @return array */ private static function resolveDeptFilterSet(mixed $raw): array { $erSet = DeptLogic::getErCenterSubtreeDeptIdSet(); if ($erSet === []) { return []; } $deptFilterIds = self::parseDeptIds($raw); if ($deptFilterIds === []) { return $erSet; } $allowedRoots = []; foreach ($deptFilterIds as $id) { if (isset($erSet[$id])) { $allowedRoots[] = $id; } } if ($allowedRoots === []) { return []; } $expanded = self::expandDeptSubtreeSet($allowedRoots); $out = []; foreach ($expanded as $id => $_) { if (isset($erSet[$id])) { $out[$id] = true; } } return $out; } /** * @param mixed $raw int[] | 逗号分隔字符串 * * @return list */ private static function parseDeptIds(mixed $raw): array { if ($raw === null || $raw === '' || $raw === []) { return []; } $list = \is_array($raw) ? $raw : explode(',', (string) $raw); return array_values(array_unique(array_filter( array_map('intval', $list), static fn (int $v): bool => $v > 0 ))); } /** * 选中部门 + 全部组织下级的 id 集合。 * * @param list $deptIds * * @return array */ private static function expandDeptSubtreeSet(array $deptIds): array { $rows = Db::name('dept') ->whereNull('delete_time') ->field(['id', 'pid']) ->select() ->toArray(); $childrenByPid = []; foreach ($rows as $r) { $childrenByPid[(int) $r['pid']][] = (int) $r['id']; } $set = []; $queue = $deptIds; while ($queue !== []) { $id = (int) array_shift($queue); if ($id <= 0 || isset($set[$id])) { continue; } $set[$id] = true; foreach ($childrenByPid[$id] ?? [] as $childId) { $queue[] = $childId; } } return $set; } /** * 名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单集合。 * 用于「当月被指派总数」分母过滤;不限订单创建月份。 * * @param list $diagIds * * @return array */ private static function fetchRefundOrRejectDiagnosisSet(array $diagIds): array { if ($diagIds === []) { return []; } $out = []; foreach (array_chunk($diagIds, 2000) as $chunk) { $ids = Db::name('tcm_prescription_order') ->whereIn('diagnosis_id', $chunk) ->whereNull('delete_time') ->whereIn('fulfillment_status', [9, 10]) ->group('diagnosis_id') ->column('diagnosis_id'); foreach ($ids as $id) { $out[(int) $id] = true; } } return $out; } /** * 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。 * * @param list $diagIds * @param list $fields * * @return list> */ private static function fetchOrderSeqRows(array $diagIds, array $fields): array { // 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff return RevisitVisitSlotService::fetchOrderSeqRows($diagIds, $fields); } /** * @param list $diagIds * * @return array diagnosis_id => revisit_slot_start_offset */ private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array { // 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff return RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds); } /** * 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移) * * @param array $offsetMap */ private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int { // 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff return RevisitVisitSlotService::resolveRevisitSlotStartOffset($diagId, $offsetMap); } /** * @param list $diagIds * * @return array */ private static function fetchDiagnosisInfo(array $diagIds): array { if ($diagIds === []) { return []; } $rows = Db::name('tcm_diagnosis') ->whereIn('id', $diagIds) ->field(['id', 'patient_name', 'phone']) ->select() ->toArray(); $out = []; foreach ($rows as $r) { $out[(int) $r['id']] = [ 'patient_name' => trim((string) ($r['patient_name'] ?? '')), 'phone' => trim((string) ($r['phone'] ?? '')), ]; } return $out; } /** * 指标包:assigned_count(新指派,语义不变)、inherit_count、assigned_total_count、 * visit{N}_count/_den/_rate、visit_all_count/_den/_rate、visit2_rate_new_assign(实现见 RevisitRateCalculator::metricPack)。 * * @param array $inheritEntryByDiag 诊单 => 起算诊次 * @param array $slotCounts [slot => n] * @param list $slots 需输出的分档列表(保证各行键齐全) * @param int $newAssignedExclude 旧口径(:380 现状)新指派数,供 visit2_rate_new_assign * * @return array */ private static function buildMetricPack( int $newAssigned, array $inheritEntryByDiag, array $slotCounts, array $slots, int $newAssignedExclude ): array { return RevisitRateCalculator::metricPack($newAssigned, $inheritEntryByDiag, $slotCounts, $slots, $newAssignedExclude); } /** 归一化月份参数为 YYYY-MM,非法时回退当前月 */ private static function normalizeMonth(string $month): string { $month = trim($month); if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) === 1) { return $month; } return date('Y-m'); } }