0, * **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合; * 同一诊单当月被多次指派给同一医助只计 1 次。 * - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔; * 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。 * - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**—— * 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属; * 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。 * - 当月 N 诊接诊率 = 当月 N 诊单数 ÷ 当月被指派总数;往月指派当月成交会推高分子,故比率可能超过 100%; * 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。 * - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据), * 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。 * - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;部门筛选(dept_ids,含组织下级)按该归属部门过滤。 * - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);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} $params * * @return array{ * month:string,start_date:string,end_date: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'], 'slots' => $minSlots, 'total' => self::buildMetricPack(0, [], $minSlots), 'rows' => [], ]; } // 分档:2 ~ max(4, 当月命中的最大诊次) $maxHitSlot = 0; foreach ($ctx['slotOrdersByAssistant'] as $slotMap) { foreach ($slotMap as $slot => $_) { if ((int) $slot > $maxHitSlot) { $maxHitSlot = (int) $slot; } } } $slots = range(2, max(self::MIN_VISIT_SLOT_CEILING, $maxHitSlot)); $nameMap = Db::name('admin') ->whereIn('id', array_keys($universe)) ->column('name', 'id'); // 医助行(按部门分组收集) /** @var array>> $assistantRowsByDept */ $assistantRowsByDept = []; /** @var array> $deptDiagSet 部门 => 去重被指派诊单集合 */ $deptDiagSet = []; /** @var array> $deptSlotCounts 部门 => [slot => 订单数] */ $deptSlotCounts = []; /** @var array $totalDiagSet */ $totalDiagSet = []; /** @var array $totalSlotCounts */ $totalSlotCounts = []; foreach ($universe as $aid => $_) { $deptId = (int) ($ctx['assistantDept'][$aid] ?? 0); $diagSet = $ctx['diagsByAssistant'][$aid] ?? []; foreach ($diagSet as $did => $_d) { $deptDiagSet[$deptId][$did] = true; $totalDiagSet[$did] = true; } $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), $slotCounts, $slots); } // 部门行 + 子行 $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; $rows[] = [ 'row_key' => 'd' . $deptId, 'is_dept' => 1, 'dept_id' => (int) $deptId, 'dept_name' => $deptName, 'assistant_count' => \count($children), 'children' => $children, ] + self::buildMetricPack(\count($deptDiagSet[$deptId] ?? []), $deptSlotCounts[$deptId] ?? [], $slots); } 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']); }); return [ 'month' => $month, 'start_date' => $ctx['startDate'], 'end_date' => $ctx['endDate'], 'slots' => $slots, 'total' => self::buildMetricPack(\count($totalDiagSet), $totalSlotCounts, $slots), 'rows' => $rows, ]; } /** * 被指派明细(按诊单聚合,与「被指派数」同口径可对账)。 * scope:assistant_id(医助行)/ dept_id(部门行,含 0=未分配部门)/ 都不传 = 当前部门筛选下合计。 * * @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params * * @return array{month:string,count:int,rows:list>} */ public static function assignLines(array $params): array { $ctx = self::buildStatsCore($params); $assistantSet = self::applyRowScope($ctx, $params); /** @var array, assign_count: int, last_time: int}> $byDiag */ $byDiag = []; foreach ($ctx['pairsRaw'] as $p) { $aid = (int) $p['to_assistant_id']; $did = (int) $p['diagnosis_id']; if (!isset($assistantSet[$aid])) { continue; } if (!isset($byDiag[$did])) { $byDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0]; } $byDiag[$did]['assistants'][$aid] = true; $byDiag[$did]['assign_count']++; $byDiag[$did]['last_time'] = max($byDiag[$did]['last_time'], (int) $p['create_time']); } if ($byDiag === []) { return ['month' => $ctx['month'], 'count' => 0, 'rows' => []]; } $diagInfo = self::fetchDiagnosisInfo(array_keys($byDiag)); $assistantIds = []; foreach ($byDiag as $d) { foreach ($d['assistants'] as $aid => $_) { $assistantIds[$aid] = true; } } $nameMap = Db::name('admin') ->whereIn('id', array_keys($assistantIds)) ->column('name', 'id'); $rows = []; foreach ($byDiag 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']) : '', ]; } usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']); return ['month' => $ctx['month'], 'count' => \count($rows), 'rows' => $rows]; } /** * N 诊订单明细:scope 内当月下单、全局序号 = slot 的具体订单(归属持有医助),与 visit{slot}_count 同口径可对账。 * * @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|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 < 2 || $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 => $_) { foreach ($ctx['slotOrdersByAssistant'][$aid][$slot] ?? [] as $r) { $r['holder_assistant_id'] = (int) $aid; $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)) : '—', ]; } 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]; } /** * 部门下拉(全量未删除部门,前端组树)。 * * @return array{rows: list} */ public static function deptOptions(): array { $rows = Db::name('dept') ->whereNull('delete_time') ->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. 分母:当月非继承指派的「医助 × 诊单」; * 3. 分子:曾被指派诊单的当月订单按全局序号 ≥2 归属持有医助; * 4. 应用部门筛选(含组织下级)。 * * @param array{month?:string,dept_ids?:int[]|string} $params * * @return array{ * month:string,startDate:string,endDate:string,startTs:int,endTs:int, * pairsRaw:list, * diagsByAssistant:array>, * slotOrdersByAssistant:array>>>, * 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'); // 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线 $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.to_assistant_id', 'lg.create_time', 'lg.is_inherit']) ->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc']) ->select() ->toArray(); /** @var array> $timeline 诊单 => 持有变更时间线(升序) */ $timeline = []; $pairsRaw = []; /** @var array> $diagsByAssistant 分母:医助 => 诊单集合 */ $diagsByAssistant = []; /** @var array $candidateDiagSet 曾被指派(to>0,含继承)的诊单 */ $candidateDiagSet = []; foreach ($logRows as $r) { $did = (int) ($r['diagnosis_id'] ?? 0); $aid = (int) ($r['to_assistant_id'] ?? 0); $t = (int) ($r['create_time'] ?? 0); $timeline[$did][] = ['t' => $t, 'to' => $aid]; if ($aid > 0) { $candidateDiagSet[$did] = true; if ((int) ($r['is_inherit'] ?? 0) === 0 && $t >= $startTs && $t <= $endTs) { $pairsRaw[] = ['diagnosis_id' => $did, 'to_assistant_id' => $aid, 'create_time' => $t]; $diagsByAssistant[$aid][$did] = true; } } } // 分子:曾被指派诊单的当月订单(全局序号 ≥2),归属下单时点的持有医助 /** @var array>>> $slotOrdersByAssistant */ $slotOrdersByAssistant = []; foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) { $orderRows = self::fetchOrderSeqRows( $chunk, ['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id'] ); $curDid = 0; $seq = 0; $ptr = 0; $holder = 0; foreach ($orderRows as $r) { $did = (int) ($r['diagnosis_id'] ?? 0); if ($did <= 0) { continue; } if ($did !== $curDid) { $curDid = $did; $seq = 0; $ptr = 0; $holder = 0; } $seq++; $ct = (int) ($r['create_time'] ?? 0); // 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人 $tl = $timeline[$did] ?? []; $tlCount = \count($tl); while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) { $holder = (int) $tl[$ptr]['to']; $ptr++; } if ($seq < 2 || $seq > self::MAX_VISIT_SLOT) { continue; } if ($ct < $startTs || $ct > $endTs) { continue; } if ($holder > 0) { $slotOrdersByAssistant[$holder][$seq][] = $r; } } } // 医助归属部门 + 部门筛选(含组织下级) $universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant); [$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds); $deptFilterIds = self::parseDeptIds($params['dept_ids'] ?? null); if ($deptFilterIds !== []) { $subtreeSet = self::expandDeptSubtreeSet($deptFilterIds); foreach ($universeIds as $aid) { $deptId = (int) ($assistantDept[$aid] ?? 0); if ($deptId <= 0 || !isset($subtreeSet[$deptId])) { unset($diagsByAssistant[$aid], $slotOrdersByAssistant[$aid]); } } } return [ 'month' => $month, 'startDate' => date('Y-m-d', $startTs), 'endDate' => date('Y-m-d', $endTs), 'startTs' => $startTs, 'endTs' => $endTs, 'pairsRaw' => $pairsRaw, 'diagsByAssistant' => $diagsByAssistant, 'slotOrdersByAssistant' => $slotOrdersByAssistant, 'assistantDept' => $assistantDept, 'deptNames' => $deptNames, ]; } /** * 统计涉及的医助全集:分母(被指派)∪ 分子(持有成交)。 * * @param array{diagsByAssistant:array>,slotOrdersByAssistant:array>>>} $ctx * * @return array */ private static function assistantUniverse(array $ctx): array { $set = []; foreach (array_keys($ctx['diagsByAssistant']) as $aid) { $set[(int) $aid] = true; } foreach (array_keys($ctx['slotOrdersByAssistant']) 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]; } /** * @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; } /** * 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。 * * @param list $diagIds * @param list $fields * * @return list> */ private static function fetchOrderSeqRows(array $diagIds, array $fields): array { $q = Db::name('tcm_prescription_order') ->alias('o') ->whereIn('o.diagnosis_id', $diagIds) ->whereNull('o.delete_time'); YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o'); return $q ->field($fields) ->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc']) ->select() ->toArray(); } /** * @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; } /** * @param array $slotCounts [slot => n] * @param list $slots 需输出的分档列表(保证各行键齐全) * * @return array */ private static function buildMetricPack(int $assigned, array $slotCounts, array $slots): array { $pack = ['assigned_count' => $assigned]; foreach ($slots as $slot) { $cnt = (int) ($slotCounts[$slot] ?? 0); $pack['visit' . $slot . '_count'] = $cnt; $pack['visit' . $slot . '_rate'] = $assigned > 0 ? round($cnt / $assigned * 100, 2) : null; } return $pack; } /** 归一化月份参数为 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'); } }