50 只进合计分母、 * exclude 模式退化(IH=∅、不忽略 from==to、不豁免回退行、den=assigned_count)、visit2_rate_new_assign 两种模式一致。 */ require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\logic\stats\RevisitRateCalculator as C; $checks = 0; function expect(bool $condition, string $message): void { global $checks; if (!$condition) { fwrite(STDERR, "FAIL: {$message}\n"); exit(1); } $checks++; } $ts = static fn (string $dt): int => (int) strtotime($dt); $monthRange = static function (string $month) use ($ts): array { $start = $ts($month . '-01 00:00:00'); return [$start, $ts(date('Y-m-t', $start) . ' 23:59:59')]; }; /** 日志行构造:id 自增,默认 operator=100 */ $logSeq = 0; $log = static function (int $did, int $from, int $to, string $dt, int $inherit = 0, int $op = 100) use (&$logSeq, $ts): array { return [ 'id' => ++$logSeq, 'diagnosis_id' => $did, 'from_assistant_id' => $from, 'to_assistant_id' => $to, 'operator_admin_id' => $op, 'create_time' => $ts($dt), 'is_inherit' => $inherit, 'inherit_visit_slot' => 0, ]; }; $orderSeq = 0; $order = static function (int $did, string $dt) use (&$orderSeq, $ts): array { return ['id' => ++$orderSeq, 'order_no' => 'N' . $orderSeq, 'diagnosis_id' => $did, 'create_time' => $ts($dt), 'amount' => 100, 'creator_id' => 0]; }; /** 日志/订单按计算器要求排序 */ $sortLogs = static function (array $rows): array { usort($rows, static fn (array $a, array $b): int => [$a['diagnosis_id'], $a['create_time'], $a['id']] <=> [$b['diagnosis_id'], $b['create_time'], $b['id']]); return $rows; }; $sortOrders = static function (array $rows): array { usort($rows, static fn (array $a, array $b): int => [$a['diagnosis_id'], $a['create_time'], $a['id']] <=> [$b['diagnosis_id'], $b['create_time'], $b['id']]); return $rows; }; $slotCounts = static function (array $calc, int $aid): array { $out = []; foreach ($calc['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) { $out[$slot] = \count($orders); } return $out; }; // ───────────────────────── 示例计算(§1.1):A 持有 D 三单,09-10 继承给 B,09-25 B 名下第 4 单 ───────────────────────── $A = 1; $B = 2; $D = 10; [$s9, $e9] = $monthRange('2026-09'); $logs = $sortLogs([ $log($D, 0, $A, '2026-05-01 09:00:00'), $log($D, $A, $B, '2026-09-10 10:00:00', 1), ]); $orders = $sortOrders([ $order($D, '2026-05-03 10:00:00'), $order($D, '2026-06-12 10:00:00'), $order($D, '2026-08-20 10:00:00'), $order($D, '2026-09-25 10:00:00'), ]); $calc = C::compute($logs, $orders, [], [], [], $s9, $e9, C::MODE_SLOT); expect($calc['inheritMode'] === 'slot', '模式归一化'); expect(!isset($calc['diagsByAssistant'][$B]), 'B 无新指派'); expect(($calc['inheritEntryByAssistant'][$B][$D] ?? null) === 4, 'k=3 → entry=4'); expect(\count($calc['inheritPairs']) === 1 && $calc['inheritPairs'][0]['inherit_visit_slot'] === 3 && $calc['inheritPairs'][0]['entry_slot'] === 4, 'inheritPairs 带 k/entry'); expect(($slotCounts($calc, $B)[4] ?? 0) === 1, '09-25 第 4 单归 B 的 visit4'); expect(!isset($calc['slotOrdersByAssistant'][$A]), 'A 9 月无成交'); expect($calc['maxEntry'] === 4, 'maxEntry=4'); $slots = C::slotRange($calc['slotOrdersByAssistant'], $calc['maxEntry']); expect($slots === [2, 3, 4], 'slots 2~4'); $packB = C::metricPack(0, $calc['inheritEntryByAssistant'][$B], $slotCounts($calc, $B), $slots, 0); expect($packB['assigned_count'] === 0 && $packB['inherit_count'] === 1 && $packB['assigned_total_count'] === 1, 'B 新指派 0 / 继承 1 / 合计 1'); expect($packB['visit2_den'] === 0 && $packB['visit3_den'] === 0 && $packB['visit4_den'] === 1, 'visit2/3_den 不含 D,visit4_den 含 D'); expect($packB['visit2_rate'] === null && $packB['visit4_count'] === 1 && $packB['visit4_rate'] === 100.0, 'visit2_rate=null、visit4_rate=100'); expect($packB['visit_all_count'] === 1 && $packB['visit_all_den'] === 1 && $packB['visit_all_rate'] === 100.0, '合计列 1/1=100'); expect($packB[C::FIELD_RATE_NEW_ASSIGN] === null, 'B 旧口径新指派 0 → null'); // exclude 模式退化:B assigned_count=0、visit4_count=1、visit4_rate=null(与线上现状一致) $calcEx = C::compute($logs, $orders, [], [], [], $s9, $e9, C::MODE_EXCLUDE); expect($calcEx['inheritEntryByAssistant'] === [] && $calcEx['inheritPairs'] === [] && $calcEx['maxEntry'] === 0, 'exclude:IH=∅'); expect(($slotCounts($calcEx, $B)[4] ?? 0) === 1, 'exclude:分子不变'); $packBEx = C::metricPack(0, [], $slotCounts($calcEx, $B), C::slotRange($calcEx['slotOrdersByAssistant'], 0), 0); expect($packBEx['assigned_count'] === 0 && $packBEx['visit4_count'] === 1 && $packBEx['visit4_rate'] === null, 'exclude:B assigned=0、visit4_rate=null'); expect($packBEx['inherit_count'] === 0 && $packBEx['visit4_den'] === 0 && $packBEx['visit_all_den'] === 0, 'exclude:新键存在但为 0'); // 变体:继承时无任何有效订单(k=0)→ entry=2,与新指派等价 $calc0 = C::compute( $sortLogs([$log(11, 0, $A, '2026-08-01 09:00:00'), $log(11, $A, $B, '2026-09-10 10:00:00', 1)]), $sortOrders([$order(11, '2026-09-20 10:00:00')]), [], [], [], $s9, $e9, C::MODE_SLOT ); expect(($calc0['inheritEntryByAssistant'][$B][11] ?? null) === 2, 'k=0 → entry=2'); expect($calc0['inheritPairs'][0]['inherit_visit_slot'] === 0, 'k=0 记录'); // 变体:订单与继承同一秒 → 订单归 B 且不计入 k $calcSame = C::compute( $sortLogs([$log(12, 0, $A, '2026-05-01 09:00:00'), $log(12, $A, $B, '2026-09-10 10:00:00', 1)]), $sortOrders([$order(12, '2026-08-01 10:00:00'), $order(12, '2026-09-10 10:00:00')]), [], [], [], $s9, $e9, C::MODE_SLOT ); expect(($calcSame['inheritEntryByAssistant'][$B][12] ?? null) === 2, '同一秒:k=1(不含同秒订单)→ entry=2'); expect(($slotCounts($calcSame, $B)[2] ?? 0) === 1 && !isset($calcSame['slotOrdersByAssistant'][$A]), '同一秒:订单归新持有人 B'); // 偏移:offset=2,继承前 1 单 → k=3,entry=4 $calcOff = C::compute( $sortLogs([$log(13, 0, $A, '2026-05-01 09:00:00'), $log(13, $A, $B, '2026-09-10 10:00:00', 1)]), $sortOrders([$order(13, '2026-08-01 10:00:00')]), [13 => 2], [], [], $s9, $e9, C::MODE_SLOT ); expect(($calcOff['inheritEntryByAssistant'][$B][13] ?? null) === 4, '偏移 2 + 1 单 → k=3 → entry=4'); // ───────────────────────── from == to 忽略(仅 slot);exclude 不忽略 ───────────────────────── $logsSelf = $sortLogs([ $log(20, 0, $A, '2026-09-02 09:00:00'), $log(20, $A, $A, '2026-09-05 09:00:00'), // 指派给自己(非继承) $log(21, $B, $B, '2026-09-06 09:00:00', 1), // 继承给自己 ]); $calcSelf = C::compute($logsSelf, [], [], [], [], $s9, $e9, C::MODE_SLOT); expect(isset($calcSelf['diagsByAssistant'][$A][20]) && \count($calcSelf['pairsRaw']) === 1, 'slot:from==to 的非继承行忽略,不重复计 pairsRaw'); expect(!isset($calcSelf['inheritEntryByAssistant'][$B]), 'slot:from==to 的继承行忽略'); expect(isset($calcSelf['excludeDiagsByAssistant'][$A][20]) && !isset($calcSelf['excludeDiagsByAssistant'][$B]), '旧口径集合:is_inherit=0 计入、=1 不计'); $calcSelfEx = C::compute($logsSelf, [], [], [], [], $s9, $e9, C::MODE_EXCLUDE); expect(\count($calcSelfEx['pairsRaw']) === 2 && isset($calcSelfEx['diagsByAssistant'][$A][20]), 'exclude:from==to 行照旧计入 pairsRaw'); expect($calcSelfEx['diagsByAssistant'] === $calcSelfEx['excludeDiagsByAssistant'], 'exclude:NI === NI_exclude'); // ───────────────────────── 多次继承取最早;NI 与 IH 同诊单只算 NI ───────────────────────── $logsMulti = $sortLogs([ $log(30, 0, $A, '2026-05-01 09:00:00'), $log(30, $A, $B, '2026-09-03 09:00:00', 1), // 最早继承:此时 1 单 → k=1 → entry=2 $log(30, $B, $A, '2026-09-10 09:00:00'), $log(30, $A, $B, '2026-09-20 09:00:00', 1), // 第二次继承:此时 2 单 → k=2 → entry=3(应忽略) $log(31, 0, $B, '2026-09-04 09:00:00'), // 31 新指派给 B $log(31, $B, $A, '2026-09-05 09:00:00'), $log(31, $A, $B, '2026-09-06 09:00:00', 1), // 31 又继承给 B → 只算 NI ]); $ordersMulti = $sortOrders([$order(30, '2026-08-01 10:00:00'), $order(30, '2026-09-15 10:00:00')]); $calcMulti = C::compute($logsMulti, $ordersMulti, [], [], [], $s9, $e9, C::MODE_SLOT); expect(($calcMulti['inheritEntryByAssistant'][$B][30] ?? null) === 2, '多次继承取最早(entry=2 而非 3)'); $pair30 = array_values(array_filter($calcMulti['inheritPairs'], static fn (array $p): bool => $p['diagnosis_id'] === 30))[0]; expect($pair30['assign_count'] === 2 && $pair30['inherit_visit_slot'] === 1, '继承行 assign_count=2、k 取最早'); expect(isset($calcMulti['diagsByAssistant'][$B][31]) && !isset($calcMulti['inheritEntryByAssistant'][$B][31]), '既新指派又继承给同一医助 → 只算 NI'); expect(isset($calcMulti['diagsByAssistant'][$A][30]) && isset($calcMulti['diagsByAssistant'][$A][31]), 'A 的两条非继承指派计 NI(A)'); // 09-15 的第 2 单:持有人为 09-10 指派后的 A expect(($slotCounts($calcMulti, $A)[2] ?? 0) === 1 && !isset($calcMulti['slotOrdersByAssistant'][$B]), '持有人按时间线:09-15 订单归 A'); // ───────────────────────── 历史回退行按继承处理(仅 slot) ───────────────────────── $rbTime = '2026-09-12 09:00:00'; $logsRb = $sortLogs([ $log(40, 0, 5, '2026-05-01 09:00:00'), // 一中心 5 持有 $log(40, 5, $B, '2026-09-01 09:00:00', 0, 0), // 系统自动分配给 B $log(40, $B, 5, $rbTime, 0, 77), // 回退(历史行 is_inherit=0,operator=77) ]); $rbKeys = [C::rollbackKey(40, $ts($rbTime), 77) => true]; $ordersRb = $sortOrders([$order(40, '2026-06-01 10:00:00'), $order(40, '2026-07-01 10:00:00')]); $calcRb = C::compute($logsRb, $ordersRb, [], [], $rbKeys, $s9, $e9, C::MODE_SLOT); expect(isset($calcRb['diagsByAssistant'][$B][40]), '自动分配行计 NI(B)'); expect(!isset($calcRb['diagsByAssistant'][5]) && ($calcRb['inheritEntryByAssistant'][5][40] ?? null) === 3, '回退行按继承:k=2 → entry=3'); expect($calcRb['inheritPairs'][0]['is_rollback'] === 1, '回退行标记 is_rollback'); $calcRbEx = C::compute($logsRb, $ordersRb, [], [], $rbKeys, $s9, $e9, C::MODE_EXCLUDE); expect(isset($calcRbEx['diagsByAssistant'][5][40]) && $calcRbEx['inheritEntryByAssistant'] === [], 'exclude:回退行不豁免,照旧计 NI(5)'); $calcRbNoKey = C::compute($logsRb, $ordersRb, [], [], [], $s9, $e9, C::MODE_SLOT); expect(isset($calcRbNoKey['diagsByAssistant'][5][40]), '无回退键集合时按普通新指派'); // ───────────────────────── 拒收/退款剔除同时作用于 NI / IH / NI_exclude ───────────────────────── $calcR = C::compute($logs, $orders, [], [$D => true], [], $s9, $e9, C::MODE_SLOT); expect($calcR['inheritEntryByAssistant'] === [] && $calcR['inheritPairs'] === [], 'R 剔除继承诊单'); $calcR2 = C::compute($logsSelf, [], [], [20 => true], [], $s9, $e9, C::MODE_SLOT); expect($calcR2['diagsByAssistant'] === [] && $calcR2['excludeDiagsByAssistant'] === [] && $calcR2['pairsRaw'] === [], 'R 剔除 NI 与 NI_exclude'); // ───────────────────────── 部门去重与 entry 取 min ───────────────────────── $C_ = 3; $ni = [$A => [100 => true, 101 => true], $B => [101 => true, 102 => true], $C_ => []]; $ih = [$A => [200 => 5, 102 => 3], $B => [200 => 3, 201 => 4], $C_ => [100 => 2]]; [$niSet, $ihEntries] = C::mergeInheritEntries($ni, $ih, [$A, $B, $C_]); expect(\count($niSet) === 3, 'NI_dept 去重:100/101/102'); expect(!isset($ihEntries[102]) && !isset($ihEntries[100]), 'IH_dept 剔除已在 NI_dept 的诊单'); expect(($ihEntries[200] ?? null) === 3 && ($ihEntries[201] ?? null) === 4, 'entry 取部门内最小'); $deptPack = C::metricPack(\count($niSet), $ihEntries, [2 => 4, 3 => 2, 4 => 1, 5 => 1], [2, 3, 4, 5], 3); expect($deptPack['visit2_den'] === 3 && $deptPack['visit3_den'] === 4 && $deptPack['visit4_den'] === 5 && $deptPack['visit5_den'] === 5, '部门分母逐列累计'); expect($deptPack['assigned_total_count'] === 5 && $deptPack['visit_all_count'] === 8 && $deptPack['visit_all_rate'] === 160.0, '合计列:8/5=160%(可超 100%)'); expect($deptPack[C::FIELD_RATE_NEW_ASSIGN] === round(4 / 3 * 100, 2), 'visit2_rate_new_assign = Num_2 / NI_exclude'); // 子行之和 >= 部门去重 $packA = C::metricPack(2, $ih[$A], [], [2, 3, 4, 5], 2); $packBd = C::metricPack(2, $ih[$B], [], [2, 3, 4, 5], 2); $packC = C::metricPack(0, $ih[$C_], [], [2, 3, 4, 5], 0); expect($packA['visit5_den'] + $packBd['visit5_den'] + $packC['visit5_den'] >= $deptPack['visit5_den'], '部门 den <= 子行之和'); expect(C::unionDiagSets($ni, [$A, $B]) === [100 => true, 101 => true, 102 => true], 'unionDiagSets'); // ───────────────────────── slots 上界含 maxEntry;entry>50 只进合计分母 ───────────────────────── expect(C::slotRange([], 7) === range(2, 7), '只有继承到第 6 诊(entry=7)也显示到七诊列'); expect(C::slotRange([1 => [9 => [[]]]], 0) === range(2, 9), '分子最大 slot 决定上界'); expect(C::slotRange([], 0) === [2, 3, 4], '最少到四诊'); expect(C::slotRange([], 99) === range(2, 50), '上界封顶 50'); $big = C::compute( $sortLogs([$log(50, 0, $A, '2026-01-01 09:00:00'), $log(50, $A, $B, '2026-09-10 10:00:00', 1)]), $sortOrders([$order(50, '2026-08-01 10:00:00')]), [50 => 20], [], [], $s9, $e9, C::MODE_SLOT ); expect(($big['inheritEntryByAssistant'][$B][50] ?? null) === 22 && $big['maxEntry'] === 22, '偏移 20 + 1 单 → entry 22'); $packBig = C::metricPack(0, [50 => 60], [], [2, 3], 0); expect($packBig['visit2_den'] === 0 && $packBig['inherit_count'] === 1 && $packBig['visit_all_den'] === 1, 'entry>50:不进诊次列、只进合计分母'); // ───────────────────────── 辅助函数 ───────────────────────── expect(C::normalizeMode('EXCLUDE') === 'exclude' && C::normalizeMode('bogus') === 'slot' && C::normalizeMode('', 'exclude') === 'exclude', 'normalizeMode 白名单与默认'); expect(C::monthAssignedDiagIds($logsMulti, $s9, $e9) === [30, 31], 'monthAssignedDiagIds 含继承行、按月过滤'); expect(C::candidateDiagIds($logsRb) === [40], 'candidateDiagIds'); $packEmpty = C::metricPack(0, [], [], [2, 3, 4], 0); expect($packEmpty['visit2_rate'] === null && $packEmpty['visit_all_rate'] === null && $packEmpty[C::FIELD_RATE_NEW_ASSIGN] === null, '分母 0 → null'); echo "RevisitRateInheritSlotTest OK ({$checks} checks)\n";