MAX_VISIT_SLOT 的继承行只进合计分母、不进任何诊次列; * - 分子 Num_s(A):当月下单、slot = s、2 <= s <= 50、下单时点持有人为 A 的订单(与原实现完全一致,不受模式影响); * - 无论模式都另行输出 NI_exclude(A)(:380 现状口径:仅 is_inherit=0,不忽略 from==to、不豁免回退行), * 供 visit2_rate_new_assign 使用。 * * inherit_mode: * - exclude:完全复现旧口径,IH = ∅,NI = NI_exclude; * - slot :继承按继承时诊次计入每列独立分母。 */ final class RevisitRateCalculator { public const MODE_EXCLUDE = 'exclude'; public const MODE_SLOT = 'slot'; /** 复诊序号统计上限(与 RevisitRateLogic::MAX_VISIT_SLOT 一致) */ public const MAX_VISIT_SLOT = 50; /** 继承起算诊次下限默认值(config project.revisit_rate.inherit_min_next_slot) */ public const DEFAULT_MIN_NEXT_SLOT = 2; /** 旧口径 :380 的字段名(供自动分配过渡期分档) */ public const FIELD_RATE_NEW_ASSIGN = 'visit2_rate_new_assign'; /** 口径白名单归一化;非法值回退 $default */ public static function normalizeMode(mixed $raw, string $default = self::MODE_SLOT): string { $mode = strtolower(trim((string) $raw)); if ($mode === self::MODE_EXCLUDE || $mode === self::MODE_SLOT) { return $mode; } $default = strtolower(trim($default)); return $default === self::MODE_EXCLUDE ? self::MODE_EXCLUDE : self::MODE_SLOT; } /** 历史回退行匹配键:auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id) ↔ assign_log(diagnosis_id, create_time, operator_admin_id) */ public static function rollbackKey(int $diagnosisId, int $time, int $operatorAdminId): string { return $diagnosisId . '|' . $time . '|' . $operatorAdminId; } /** * 当月被指派(to>0、t∈M,含继承 / from==to 行)的诊单 id 去重列表,供查询拒收/退款剔除集合。 * * @param list> $logRows * * @return list */ public static function monthAssignedDiagIds(array $logRows, int $startTs, int $endTs): array { $set = []; foreach ($logRows as $r) { $did = (int) ($r['diagnosis_id'] ?? 0); $aid = (int) ($r['to_assistant_id'] ?? 0); $t = (int) ($r['create_time'] ?? 0); if ($did > 0 && $aid > 0 && $t >= $startTs && $t <= $endTs) { $set[$did] = true; } } return array_keys($set); } /** * 曾被指派(to>0,任意时间,含继承)的诊单 id 列表:分子候选、订单序列拉取范围。 * * @param list> $logRows * * @return list */ public static function candidateDiagIds(array $logRows): array { $set = []; foreach ($logRows as $r) { $did = (int) ($r['diagnosis_id'] ?? 0); if ($did > 0 && (int) ($r['to_assistant_id'] ?? 0) > 0) { $set[$did] = true; } } return array_keys($set); } /** * 核心计算。 * * @param list> $logRows 指派日志(≤ 月末、诊单未删除),须按 (diagnosis_id, create_time, id) 升序; * 键:id, diagnosis_id, from_assistant_id, to_assistant_id, operator_admin_id, create_time, is_inherit * @param iterable> $orderRows 有效订单行,须按 (diagnosis_id, create_time, id) 升序且同一诊单连续(可分块生成器); * 键:id, diagnosis_id, create_time(其余字段原样进入 slotOrdersByAssistant) * @param array $offsetMap diagnosis_id => revisit_slot_start_offset(原始值) * @param array $refundRejectSet 名下有拒收/退款订单的诊单集合 R * @param array $rollbackKeySet 历史回退行键集合(rollbackKey()),仅 slot 模式使用 * @param string $inheritMode exclude | slot * @param int $inheritMinNextSlot 继承起算诊次下限(默认 2) * * @return array{ * inheritMode:string, * pairsRaw:list, * diagsByAssistant:array>, * excludeDiagsByAssistant:array>, * inheritEntryByAssistant:array>, * inheritPairs:list, * slotOrdersByAssistant:array>>>, * maxEntry:int * } */ public static function compute( array $logRows, iterable $orderRows, array $offsetMap, array $refundRejectSet, array $rollbackKeySet, int $startTs, int $endTs, string $inheritMode, int $inheritMinNextSlot = self::DEFAULT_MIN_NEXT_SLOT ): array { $inheritMode = self::normalizeMode($inheritMode); $slotMode = $inheritMode === self::MODE_SLOT; if ($inheritMinNextSlot < 2) { $inheritMinNextSlot = 2; } /** @var array> $timeline 诊单 => 持有变更时间线(升序,含释放/继承/from==to 行) */ $timeline = []; $pairsRaw = []; /** @var array> $diagsByAssistant 当前模式下的 NI */ $diagsByAssistant = []; /** @var array> $excludeDiagsByAssistant :380 现状口径 NI_exclude */ $excludeDiagsByAssistant = []; /** @var array>> $inheritFirst 医助 => 诊单 => 最早一条继承日志(附 assign_count) */ $inheritFirst = []; foreach ($logRows as $r) { $did = (int) ($r['diagnosis_id'] ?? 0); $aid = (int) ($r['to_assistant_id'] ?? 0); $from = (int) ($r['from_assistant_id'] ?? 0); $t = (int) ($r['create_time'] ?? 0); $isInherit = (int) ($r['is_inherit'] ?? 0) === 1; $logId = (int) ($r['id'] ?? 0); if ($did <= 0) { continue; } $timeline[$did][] = ['t' => $t, 'to' => $aid]; if ($aid <= 0 || $t < $startTs || $t > $endTs) { continue; } // 旧口径集合:仅看 is_inherit=0(不忽略 from==to、不豁免回退行) if (!$isInherit) { $excludeDiagsByAssistant[$aid][$did] = true; } if (!$slotMode) { if (!$isInherit) { $pairsRaw[] = [ 'diagnosis_id' => $did, 'to_assistant_id' => $aid, 'from_assistant_id' => $from, 'create_time' => $t, 'log_id' => $logId, ]; $diagsByAssistant[$aid][$did] = true; } continue; } // slot 模式:指派给自己(from == to)不计任何集合 if ($from === $aid) { continue; } $isRollback = $rollbackKeySet !== [] && isset($rollbackKeySet[self::rollbackKey($did, $t, (int) ($r['operator_admin_id'] ?? 0))]); if ($isInherit || $isRollback) { if (!isset($inheritFirst[$aid][$did])) { $inheritFirst[$aid][$did] = [ 'diagnosis_id' => $did, 'to_assistant_id' => $aid, 'from_assistant_id' => $from, 'create_time' => $t, 'log_id' => $logId, 'is_rollback' => $isRollback ? 1 : 0, 'inherit_visit_slot_snapshot' => (int) ($r['inherit_visit_slot'] ?? 0), 'assign_count' => 1, ]; } else { $inheritFirst[$aid][$did]['assign_count']++; } continue; } $pairsRaw[] = [ 'diagnosis_id' => $did, 'to_assistant_id' => $aid, 'from_assistant_id' => $from, 'create_time' => $t, 'log_id' => $logId, ]; $diagsByAssistant[$aid][$did] = true; } // D 同月既被非继承又被继承指派给同一 A 时只算 NI foreach ($inheritFirst as $aid => $diagMap) { foreach ($diagMap as $did => $_) { if (isset($diagsByAssistant[$aid][$did])) { unset($inheritFirst[$aid][$did]); } } if ($inheritFirst[$aid] === []) { unset($inheritFirst[$aid]); } } // 剔除名下存在拒收(9)/退款(10) 业务订单的诊单(NI / NI_exclude / IH 同时生效) if ($refundRejectSet !== []) { $strip = static function (array &$map) use ($refundRejectSet): void { foreach ($map as $aid => $diagMap) { foreach ($diagMap as $did => $_) { if (isset($refundRejectSet[$did])) { unset($map[$aid][$did]); } } if ($map[$aid] === []) { unset($map[$aid]); } } }; $strip($diagsByAssistant); $strip($excludeDiagsByAssistant); $strip($inheritFirst); $pairsRaw = array_values(array_filter( $pairsRaw, static fn (array $p): bool => !isset($refundRejectSet[$p['diagnosis_id']]) )); } /** @var array $inheritDiagSet 需要收集订单时间列表的诊单 */ $inheritDiagSet = []; foreach ($inheritFirst as $diagMap) { foreach ($diagMap as $did => $_) { $inheritDiagSet[$did] = true; } } /** @var array> $inheritOrderCts 继承诊单 => 有效订单 create_time 列表 */ $inheritOrderCts = []; // 分子:曾被指派诊单的订单,统计诊次 = 实单全局序号 + 诊单偏移;持有人 = 订单时间之前(含同刻)最近一次指派 /** @var array>>> $slotOrdersByAssistant */ $slotOrdersByAssistant = []; $curDid = 0; $seq = 0; $ptr = 0; $holder = 0; $offset = 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; $offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap); } $seq++; $effectiveSlot = $seq + $offset; $ct = (int) ($r['create_time'] ?? 0); if (isset($inheritDiagSet[$did])) { $inheritOrderCts[$did][] = $ct; } // 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人 $tl = $timeline[$did] ?? []; $tlCount = \count($tl); while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) { $holder = (int) $tl[$ptr]['to']; $ptr++; } if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) { continue; } if ($ct < $startTs || $ct > $endTs) { continue; } if ($holder > 0) { $slotOrdersByAssistant[$holder][$effectiveSlot][] = $r; } } // 继承时诊次 k 与起算诊次 entry /** @var array> $inheritEntryByAssistant */ $inheritEntryByAssistant = []; $inheritPairs = []; $maxEntry = 0; foreach ($inheritFirst as $aid => $diagMap) { foreach ($diagMap as $did => $row) { $k = RevisitVisitSlotService::slotBefore( $inheritOrderCts[$did] ?? [], RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap), (int) $row['create_time'] ); $entry = max($inheritMinNextSlot, $k + 1); $inheritEntryByAssistant[$aid][$did] = $entry; if ($entry <= self::MAX_VISIT_SLOT && $entry > $maxEntry) { $maxEntry = $entry; } $row['inherit_visit_slot'] = $k; $row['entry_slot'] = $entry; $inheritPairs[] = $row; } } return [ 'inheritMode' => $inheritMode, 'pairsRaw' => $pairsRaw, 'diagsByAssistant' => $diagsByAssistant, 'excludeDiagsByAssistant' => $excludeDiagsByAssistant, 'inheritEntryByAssistant' => $inheritEntryByAssistant, 'inheritPairs' => $inheritPairs, 'slotOrdersByAssistant' => $slotOrdersByAssistant, 'maxEntry' => $maxEntry, ]; } /** * 诊单集合按医助并集(部门行 / 合计行按诊单去重)。 * * @param array> $setsByAssistant * @param list $assistantIds * * @return array */ public static function unionDiagSets(array $setsByAssistant, array $assistantIds): array { $out = []; foreach ($assistantIds as $aid) { foreach ($setsByAssistant[(int) $aid] ?? [] as $did => $_) { $out[(int) $did] = true; } } return $out; } /** * 部门行 / 合计行的 NI 与 IH 合并:NI_dept = ∪NI(A);IH_dept = (∪IH(A)) \ NI_dept,entry 取各继承者中的最小值。 * * @param array> $diagsByAssistant * @param array> $inheritEntryByAssistant * @param list $assistantIds * * @return array{0: array, 1: array} [NI 去重集合, 诊单 => entry] */ public static function mergeInheritEntries(array $diagsByAssistant, array $inheritEntryByAssistant, array $assistantIds): array { $niSet = self::unionDiagSets($diagsByAssistant, $assistantIds); $ih = []; foreach ($assistantIds as $aid) { foreach ($inheritEntryByAssistant[(int) $aid] ?? [] as $did => $entry) { $did = (int) $did; if (isset($niSet[$did])) { continue; } $entry = (int) $entry; $ih[$did] = isset($ih[$did]) ? min($ih[$did], $entry) : $entry; } } return [$niSet, $ih]; } /** * 列范围:2 ~ min(MAX, max(minCeiling, 当月分子最大 slot, 最大起算诊次))。 * * @param array>>> $slotOrdersByAssistant * * @return list */ public static function slotRange(array $slotOrdersByAssistant, int $maxEntry, int $minCeiling = 4): array { $maxHitSlot = 0; foreach ($slotOrdersByAssistant as $slotMap) { foreach ($slotMap as $slot => $_) { if ((int) $slot > $maxHitSlot) { $maxHitSlot = (int) $slot; } } } $upper = max($minCeiling, $maxHitSlot, $maxEntry); $upper = min(self::MAX_VISIT_SLOT, max(2, $upper)); return range(2, $upper); } /** * 指标包: * assigned_count(新指派,语义不变)、inherit_count、assigned_total_count、 * visit{N}_count / _den / _rate(Den_s = NI + |{entry <= s}|)、visit_all_count / _den / _rate、visit2_rate_new_assign。 * * @param array $inheritEntryByDiag 诊单 => 起算诊次 * @param array $slotCounts [slot => n] * @param list $slots 需输出的分档列表(保证各行键齐全) * * @return array */ public static function metricPack( int $newAssigned, array $inheritEntryByDiag, array $slotCounts, array $slots, int $newAssignedExclude ): array { $inheritCount = \count($inheritEntryByDiag); /** @var array $byEntry entry => 继承诊单数 */ $byEntry = []; foreach ($inheritEntryByDiag as $entry) { $entry = (int) $entry; $byEntry[$entry] = ($byEntry[$entry] ?? 0) + 1; } ksort($byEntry); $pack = [ 'assigned_count' => $newAssigned, 'inherit_count' => $inheritCount, 'assigned_total_count' => $newAssigned + $inheritCount, ]; foreach ($slots as $slot) { $slot = (int) $slot; $cnt = (int) ($slotCounts[$slot] ?? 0); $den = $newAssigned; foreach ($byEntry as $entry => $n) { if ($entry > $slot) { break; } $den += $n; } $pack['visit' . $slot . '_count'] = $cnt; $pack['visit' . $slot . '_den'] = $den; $pack['visit' . $slot . '_rate'] = $den > 0 ? round($cnt / $den * 100, 2) : null; } $allCount = 0; foreach ($slotCounts as $slot => $cnt) { $slot = (int) $slot; if ($slot >= 2 && $slot <= self::MAX_VISIT_SLOT) { $allCount += (int) $cnt; } } $allDen = $newAssigned + $inheritCount; $pack['visit_all_count'] = $allCount; $pack['visit_all_den'] = $allDen; $pack['visit_all_rate'] = $allDen > 0 ? round($allCount / $allDen * 100, 2) : null; $num2 = (int) ($slotCounts[2] ?? 0); $pack[self::FIELD_RATE_NEW_ASSIGN] = $newAssignedExclude > 0 ? round($num2 / $newAssignedExclude * 100, 2) : null; return $pack; } }