Files
zyt/server/tests/RevisitRateInheritSlotTest.php
2026-10-22 11:12:16 +08:00

260 lines
16 KiB
PHP
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* 复诊接诊率 v2 计算器(RevisitRateCalculator)纯函数单测,不连库。
* php server/tests/RevisitRateInheritSlotTest.php
* 覆盖(方案 §1.1 / §11 需求 1):
* 示例计算(9 月 B 继承 k=3 → entry=4)、k 计算与同一秒对偶、from==to 忽略、多次继承取最早、
* NI 与 IH 同诊单只算 NI、k=0 归二诊、历史回退行按继承处理、拒收/退款剔除同时作用于继承、
* 部门去重与 entry 取 min、slots 上界含 maxEntry、entry>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";