This commit is contained in:
gr
2026-10-15 11:03:54 +08:00
parent 70ddc1a70a
commit bb28d93cec
64 changed files with 12747 additions and 420 deletions
+259
View File
@@ -0,0 +1,259 @@
<?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";