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
+235
View File
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
/**
* 复诊到期计算器(RevisitDueCalculator)纯函数单测,不连库。
* php server/tests/RevisitDueCalculatorTest.php
* 覆盖:锚点回退链三种、天数回落三种、15 天不归 14 档、computeFacts 失效/挂号选择、
* deriveState 九态与优先级(missed 优先于 overdue 且不受 due_date 限制、contacted、warning/pending 边界、no_days、none)、
* 人工关闭文案、escalationLevel、日期工具。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\tcm\RevisitDueCalculator as C;
$checks = 0;
function expect(bool $condition, string $message): void
{
global $checks;
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
$checks++;
}
$cfg = C::defaultConfig();
$ts = static fn (string $dt): int => (int) strtotime($dt);
/** 基础输入:订单 09-20 10:00 下单、28 天 */
$baseInput = static function (array $override = []) use ($ts): array {
return array_replace([
'diagnosis_id' => 1,
'assistant_id' => 7,
'patient_name' => '张三',
'latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => 28, 'prescription_id' => 55],
'valid_order_count' => 3,
'offset' => 0,
'rx_usage_days' => null,
'sign_ts' => 0,
'ship_ts' => 0,
'appointments' => [],
'first_touch_ts' => $ts('2026-03-02 09:00:00'),
'total_medication_days' => 84,
], $override);
};
// ---------- 锚点回退链 ----------
$f = C::computeFacts($baseInput(['sign_ts' => $ts('2026-09-22 15:30:00'), 'ship_ts' => $ts('2026-09-21 08:00:00')]), $cfg, '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_SIGN && $f['anchor_ts'] === $ts('2026-09-22 15:30:00'), '锚点:有签收优先签收');
expect($f['due_date'] === '2026-10-20', '锚点签收 09-22 + 28 天 = 10-20');
$f = C::computeFacts($baseInput(['ship_ts' => $ts('2026-09-21 08:00:00')]), $cfg, '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_SHIP && $f['due_date'] === '2026-10-19', '锚点:无签收回落发货 09-21 → 10-19');
$f = C::computeFacts($baseInput(), $cfg, '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_CREATE && $f['due_date'] === '2026-10-18', '锚点:都没有用下单日 09-20 → 10-18');
$f = C::computeFacts($baseInput(['sign_ts' => $ts('2026-09-22 15:30:00')]), ['anchor_chain' => ['create']], '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_CREATE, 'anchor_chain 收窄为 create 时忽略签收');
$f = C::computeFacts($baseInput(['sign_ts' => $ts('2026-09-22 15:30:00')]), ['anchor_chain' => ['ship']], '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_CREATE, '链耗尽无结果时兜底下单时间');
$f = C::computeFacts($baseInput(['sign_ts' => $ts('2026-09-22 15:30:00')]), ['anchor_chain' => 'ship,sign'], '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_SIGN, 'anchor_chain 支持逗号字符串');
// ---------- 天数回落 ----------
$f = C::computeFacts($baseInput(['rx_usage_days' => 14]), $cfg, '2026-10-01');
expect($f['medication_days'] === 28 && $f['days_src'] === C::DAYS_SRC_ORDER, '天数:订单有值用订单');
$f = C::computeFacts($baseInput(['latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => 0, 'prescription_id' => 55], 'rx_usage_days' => 14]), $cfg, '2026-10-01');
expect($f['medication_days'] === 14 && $f['days_src'] === C::DAYS_SRC_RX && $f['due_date'] === '2026-10-04', '天数:订单缺失回落处方 14');
$f = C::computeFacts($baseInput(['latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => null, 'prescription_id' => 55], 'rx_usage_days' => null]), $cfg, '2026-10-01');
expect($f['medication_days'] === 0 && $f['days_src'] === C::DAYS_SRC_NONE && $f['due_date'] === null, '天数:都缺失 → 0 且无 due_date');
expect($f['closed_state_auto'] === C::CLOSED_OPEN, '缺天数仍为进行中(由读取侧推导 no_days)');
$f = C::computeFacts($baseInput(['latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => 0, 'prescription_id' => 55], 'rx_usage_days' => 14]), ['days_fallback_rx' => false], '2026-10-01');
expect($f['medication_days'] === 0, 'days_fallback_rx=false 不回落处方');
$f = C::computeFacts($baseInput(['latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => 0, 'prescription_id' => 0]]), ['days_default' => 7], '2026-10-01');
expect($f['medication_days'] === 7 && $f['days_src'] === C::DAYS_SRC_NONE && $f['due_date'] === '2026-09-27', 'days_default>0 时兜底天数且 days_src 仍为 0');
// ---------- 档位 ----------
$f = C::computeFacts($baseInput(['latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => 15, 'prescription_id' => 55]]), $cfg, '2026-10-01');
expect($f['rule_days'] === 0 && $f['due_date'] === '2026-10-05', '15 天不归 14 档,到期仍按实际天数');
$f = C::computeFacts($baseInput(), $cfg, '2026-10-01');
expect($f['rule_days'] === 28, '28 天命中档位');
$f = C::computeFacts($baseInput(), ['grace_days' => 2], '2026-10-01');
expect($f['due_date'] === '2026-10-20', 'grace_days 顺延');
// ---------- 诊次 / 首次触达 / 失效 ----------
$f = C::computeFacts($baseInput(['offset' => 2]), $cfg, '2026-10-01');
expect($f['current_slot'] === 5 && $f['next_slot'] === 6, 'current_slot = 有效单 3 + 偏移 2');
expect($f['first_touch_date'] === '2026-03-02', 'first_touch_date 由时间戳转日期');
$f = C::computeFacts($baseInput(['offset' => 99]), $cfg, '2026-10-01');
expect($f['current_slot'] === 23, '偏移钳到 20');
$f = C::computeFacts($baseInput(['latest_order' => null, 'valid_order_count' => 0]), $cfg, '2026-10-01');
expect($f['closed_state_auto'] === C::CLOSED_INVALID && $f['source_order_id'] === 0 && $f['due_date'] === null, '无有效订单 → 失效');
$f = C::computeFacts($baseInput(['assistant_id' => 0]), $cfg, '2026-10-01');
expect($f['closed_state_auto'] === C::CLOSED_INVALID, '无医助 → 失效');
$f = C::computeFacts($baseInput(['diagnosis_active' => false]), $cfg, '2026-10-01');
expect($f['closed_state_auto'] === C::CLOSED_INVALID, '诊单删除/禁用 → 失效');
// ---------- 挂号选择 ----------
$appts = [
['id' => 1, 'status' => 3, 'appointment_date' => '2026-09-19', 'appointment_time' => '10:00:00'], // 订单日之前,忽略
['id' => 2, 'status' => 2, 'appointment_date' => '2026-10-21', 'appointment_time' => '10:00:00'], // 已取消,忽略
['id' => 3, 'status' => 1, 'appointment_date' => '2026-10-23', 'appointment_time' => ''],
['id' => 4, 'status' => 1, 'appointment_date' => '2026-10-23', 'appointment_time' => '09:30:00'],
];
$f = C::computeFacts($baseInput(['appointments' => $appts]), $cfg, '2026-10-21');
expect($f['appointment_state'] === 1 && $f['appointment_date'] === '2026-10-23' && $f['booked_appointment_id'] === 4, '挂号取锚点日之后最新一条(空时间视为 00:00:00,排在 09:30 之前)');
expect($f['closed_state_auto'] === C::CLOSED_BOOKED, '已约且日期未过 → 自动关闭 2');
$f = C::computeFacts($baseInput(['appointments' => $appts]), $cfg, '2026-10-24');
expect($f['appointment_state'] === 1 && $f['closed_state_auto'] === C::CLOSED_OPEN, '已约但日期已过(cron 未跑)→ 进行中');
$f = C::computeFacts($baseInput(['appointments' => [['id' => 9, 'status' => 3, 'appointment_date' => '2026-10-23', 'appointment_time' => '10:00:00']]]), $cfg, '2026-10-24');
expect($f['appointment_state'] === 3 && $f['closed_state_auto'] === C::CLOSED_VISITED && $f['booked_appointment_id'] === 9, '已面诊 → 自动关闭 3');
$f = C::computeFacts($baseInput(['appointments' => [['id' => 9, 'status' => 4, 'appointment_date' => '2026-10-23', 'appointment_time' => '10:00:00']]]), $cfg, '2026-10-23');
expect($f['appointment_state'] === 4 && $f['closed_state_auto'] === C::CLOSED_OPEN, '过号 → 进行中');
// 下单当天(及更早)的面诊是产生这笔药的那次面诊,不算复诊;次日起才算(appointment_min_gap_days 默认 1)
$f = C::computeFacts($baseInput(['appointments' => [['id' => 11, 'status' => 3, 'appointment_date' => '2026-09-20', 'appointment_time' => '09:30:00']]]), $cfg, '2026-09-21');
expect($f['appointment_state'] === 0 && $f['closed_state_auto'] === C::CLOSED_OPEN && $f['booked_appointment_id'] === 0, '下单当天的已面诊不计入,周期保持进行中');
$f = C::computeFacts($baseInput(['appointments' => [['id' => 12, 'status' => 3, 'appointment_date' => '2026-09-21', 'appointment_time' => '09:30:00']]]), $cfg, '2026-09-22');
expect($f['appointment_state'] === 3 && $f['closed_state_auto'] === C::CLOSED_VISITED, '下单次日的已面诊计入 → 自动关闭 3');
$f = C::computeFacts($baseInput(['appointments' => [['id' => 12, 'status' => 3, 'appointment_date' => '2026-09-21', 'appointment_time' => '09:30:00']]]), ['appointment_min_gap_days' => 2] + $cfg, '2026-09-22');
expect($f['appointment_state'] === 0, 'appointment_min_gap_days=2 时下单次日的面诊也不计入');
// ---------- deriveState ----------
$row = static function (array $override = []): array {
return array_replace([
'closed_state' => 0,
'assistant_id' => 7,
'source_order_id' => 100,
'valid_order_count' => 3,
'medication_days' => 28,
'due_date' => '2026-10-20',
'appointment_state' => 0,
'appointment_date' => null,
'promised_date' => null,
'snooze_count' => 0,
], $override);
};
$s = C::deriveState($row(['appointment_state' => 3, 'appointment_date' => '2026-10-23']), '2026-10-30', $cfg);
expect($s['state'] === C::STATE_VISITED && $s['state_text'] === '已面诊 10-23' && $s['severe'] === 0, 'visited 优先且不深红');
$s = C::deriveState($row(['appointment_state' => 1, 'appointment_date' => '2026-10-23']), '2026-10-21', $cfg);
expect($s['state'] === C::STATE_BOOKED && $s['state_text'] === '已约 10-23', 'booked');
$s = C::deriveState($row(['appointment_state' => 1, 'appointment_date' => '2026-10-21']), '2026-10-21', $cfg);
expect($s['state'] === C::STATE_BOOKED, 'booked:预约日 = today 仍为已约');
$s = C::deriveState($row(['appointment_state' => 4, 'appointment_date' => '2026-10-23']), '2026-10-23', $cfg);
expect($s['state'] === C::STATE_MISSED && $s['state_text'] === '过号 10-23', 'missed:status=4');
$s = C::deriveState($row(['appointment_state' => 1, 'appointment_date' => '2026-10-22']), '2026-10-23', $cfg);
expect($s['state'] === C::STATE_MISSED, 'missed:已约日期已过(cron 未跑兜底)');
$s = C::deriveState($row(['appointment_state' => 4, 'appointment_date' => '2026-10-02', 'due_date' => '2026-10-20']), '2026-10-02', $cfg);
expect($s['state'] === C::STATE_MISSED && $s['days_left'] === 18 && $s['overdue_days'] === 0, 'missed 不受 due_date 限制(到期前过号同样标红)');
$s = C::deriveState($row(['appointment_state' => 4, 'appointment_date' => '2026-10-23', 'promised_date' => '2026-10-30']), '2026-10-23', $cfg);
expect($s['state'] === C::STATE_MISSED, 'missed 优先于 contacted');
$s = C::deriveState($row(['appointment_state' => 4, 'appointment_date' => '2026-10-28']), '2026-10-28', $cfg);
expect($s['state'] === C::STATE_MISSED && $s['overdue_days'] === 8 && $s['severe'] === 1, 'missed 且超期 ≥7 天 → 深红');
$s = C::deriveState($row(['promised_date' => '2026-10-25']), '2026-10-22', $cfg);
expect($s['state'] === C::STATE_CONTACTED && $s['state_text'] === '已联系·约 10-25' && $s['severe'] === 0, 'contacted 覆盖 overdue');
$s = C::deriveState($row(['promised_date' => '2026-10-22']), '2026-10-22', $cfg);
expect($s['state'] === C::STATE_CONTACTED, 'contacted:承诺日 = today 仍为已联系');
$s = C::deriveState($row(['promised_date' => '2026-10-21']), '2026-10-22', $cfg);
expect($s['state'] === C::STATE_OVERDUE, '承诺日已过 → 回到 overdue');
$s = C::deriveState($row(), '2026-10-20', $cfg);
expect($s['state'] === C::STATE_OVERDUE && $s['state_text'] === '今日应挂号' && $s['overdue_days'] === 0 && $s['days_left'] === 0 && $s['severe'] === 0, 'overdue:到期日当天');
$s = C::deriveState($row(), '2026-10-26', $cfg);
expect($s['state'] === C::STATE_OVERDUE && $s['state_text'] === '超期 6 天' && $s['severe'] === 0, 'overdue 6 天未深红');
$s = C::deriveState($row(), '2026-10-27', $cfg);
expect($s['state'] === C::STATE_OVERDUE && $s['overdue_days'] === 7 && $s['severe'] === 1, 'overdue 7 天深红');
$s = C::deriveState($row(), '2026-10-27', ['severe_overdue_days' => [14, 21, 28, 30]]);
expect($s['severe'] === 0, 'severe 阶梯数组取最小阈值 14:7 天不深红');
$s = C::deriveState($row(), '2026-11-03', ['severe_overdue_days' => [14, 21, 28, 30]]);
expect($s['severe'] === 1, 'severe 阶梯数组:14 天深红');
$s = C::deriveState($row(), '2026-10-19', $cfg);
expect($s['state'] === C::STATE_WARNING && $s['state_text'] === '剩 1 天' && $s['days_left'] === 1, 'warning:剩 1 天');
$s = C::deriveState($row(), '2026-10-17', $cfg);
expect($s['state'] === C::STATE_WARNING && $s['days_left'] === 3, 'warning 边界:剩 3 天 = warn_days');
$s = C::deriveState($row(), '2026-10-16', $cfg);
expect($s['state'] === C::STATE_PENDING && $s['state_text'] === '剩 4 天' && $s['overdue_days'] === 0, 'pending 边界:剩 4 天');
$s = C::deriveState($row(), '2026-10-16', ['warn_days' => 5]);
expect($s['state'] === C::STATE_WARNING, 'warn_days 可配');
$s = C::deriveState($row(['due_date' => null, 'medication_days' => 0]), '2026-10-16', $cfg);
expect($s['state'] === C::STATE_NO_DAYS && $s['state_text'] === '缺开药天数' && $s['days_left'] === null && $s['overdue_days'] === null, 'no_days');
$s = C::deriveState($row(['due_date' => '', 'medication_days' => 0, 'appointment_state' => 4, 'appointment_date' => '2026-10-10']), '2026-10-10', $cfg);
expect($s['state'] === C::STATE_MISSED, '缺天数但过号 → missed 优先');
$s = C::deriveState($row(['valid_order_count' => 0, 'source_order_id' => 0]), '2026-10-16', $cfg);
expect($s['state'] === C::STATE_NONE && $s['state_text'] === '' && $s['days_left'] === null, 'none:无有效订单');
$s = C::deriveState($row(['assistant_id' => 0]), '2026-10-16', $cfg);
expect($s['state'] === C::STATE_NONE, 'none:无医助');
$s = C::deriveState($row(['closed_state' => 5]), '2026-10-16', $cfg);
expect($s['state'] === C::STATE_NONE, 'none:closed_state=5');
$s = C::deriveState($row(['closed_state' => 4]), '2026-10-27', $cfg);
expect($s['state'] === C::STATE_OVERDUE && $s['state_text'] === '已关闭·超期 7 天' && $s['severe'] === 0, '人工关闭保留事实状态、前缀文案、不深红');
$s = C::deriveState($row(['closed_state' => 4, 'appointment_state' => 3, 'appointment_date' => '2026-10-23']), '2026-10-27', $cfg);
expect($s['state'] === C::STATE_VISITED && $s['state_text'] === '已面诊 10-23', '人工关闭后已面诊文案不加前缀');
$s = C::deriveState($row(['closed_state' => 2, 'appointment_state' => 1, 'appointment_date' => '2026-10-23']), '2026-10-24', $cfg);
expect($s['state'] === C::STATE_MISSED, 'closed_state=2 但预约日已过 → 事实推导 missed');
// ---------- escalationLevel ----------
expect(C::escalationLevel(0) === 0, 'level 0');
expect(C::escalationLevel(1) === 1 && C::escalationLevel(2) === 1, 'level 1 (1~2)');
expect(C::escalationLevel(3) === 2 && C::escalationLevel(4) === 2, 'level 2 (3~4)');
expect(C::escalationLevel(5) === 3 && C::escalationLevel(99) === 3, 'level 3 (>=5)');
expect(C::escalationLevel(-1) === 0, '负数视为 0');
// ---------- 日期工具 ----------
expect(C::dayDiff('2026-10-20', '2026-10-27') === 7 && C::dayDiff('2026-10-27', '2026-10-20') === -7 && C::dayDiff('2026-10-20', '2026-10-20') === 0, 'dayDiff 正负零');
expect(C::dayDiff('2026-02-27', '2026-03-02') === 3, 'dayDiff 跨月(2026 非闰年)');
expect(C::addDays('2026-09-22', 28) === '2026-10-20' && C::addDays('2026-12-31', 1) === '2027-01-01' && C::addDays('2026-01-01', -1) === '2025-12-31', 'addDays 跨月跨年');
expect(C::dayDiff('bad', '2026-10-20') === 0 && C::addDays('bad', 3) === 'bad', '非法日期容错');
// ---------- normalizeConfig ----------
$n = C::normalizeConfig(['anchor_chain' => ['SIGN', 'x', 'create', 'create'], 'day_buckets' => ['14', 21, 0], 'warn_days' => '2']);
expect($n['anchor_chain'] === ['sign', 'create'] && $n['day_buckets'] === [14, 21] && $n['warn_days'] === 2, 'normalizeConfig 归一');
expect($n['severe_overdue_days'] === 7 && $n['days_fallback_rx'] === true, 'normalizeConfig 补默认值');
echo "RevisitDueCalculatorTest OK ({$checks} checks)\n";
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/**
* 复诊到期弹窗逻辑(RevisitDueLogic)纯函数单测,不连库、不读 config。
* php server/tests/RevisitDueLogicTest.php
* 覆盖:延迟间隔阶梯(4h→2h→1h→30min、default 回落、非法配置)、工作时段解析、
* 钳制(早于开始 → 当日开始;晚于结束 → 次日开始;边界 20:00:00 / 20:00:01)、
* nextRemindAfterSnooze 组合、承诺日 09:00、承诺日范围、提醒文案、升级等级、弹窗口径。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\RevisitDueLogic as L;
use app\common\service\tcm\RevisitDueCalculator as C;
date_default_timezone_set('Asia/Shanghai');
$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);
$fmt = static fn (int $t): string => date('Y-m-d H:i:s', $t);
$cfg = [
'snooze_intervals' => [1 => 14400, 2 => 7200, 3 => 3600, 'default' => 1800],
'work_hours' => ['09:00', '20:00'],
'promise_max_days' => 14,
'contact_max_per_cycle' => 2,
];
// ---------- snoozeInterval ----------
expect(L::snoozeInterval(1, $cfg['snooze_intervals']) === 14400, '第 1 次延迟 4h');
expect(L::snoozeInterval(2, $cfg['snooze_intervals']) === 7200, '第 2 次延迟 2h');
expect(L::snoozeInterval(3, $cfg['snooze_intervals']) === 3600, '第 3 次延迟 1h');
expect(L::snoozeInterval(4, $cfg['snooze_intervals']) === 1800, '第 4 次起回落 default 30min');
expect(L::snoozeInterval(99, $cfg['snooze_intervals']) === 1800, '第 99 次仍 30min');
expect(L::snoozeInterval(1, ['1' => '7200', 'default' => '600']) === 7200, '字符串配置可被归一');
expect(L::snoozeInterval(5, [1 => 100]) === L::DEFAULT_SNOOZE_SECONDS, '缺 default 时回落常量 1800');
expect(L::snoozeInterval(1, null) === L::DEFAULT_SNOOZE_SECONDS, '非数组配置回落 1800');
expect(L::snoozeInterval(1, [1 => 0, 'default' => -5]) === L::DEFAULT_SNOOZE_SECONDS, '非正数回落 1800');
expect(L::snoozeInterval(0, $cfg['snooze_intervals']) === 1800, 'n=0 取 default');
// ---------- parseWorkHours ----------
expect(L::parseWorkHours(['09:00', '20:00']) === [540, 1200], '09:00~20:00 → 540~1200 分钟');
expect(L::parseWorkHours(['08:30', '18:00']) === [510, 1080], '08:30~18:00');
expect(L::parseWorkHours(['09:00', '24:00']) === [540, 1440], '结束允许 24:00');
expect(L::parseWorkHours(['20:00', '09:00']) === [540, 1200], '开始不早于结束 → 默认');
expect(L::parseWorkHours(['9', '20']) === [540, 1200], '非 HH:mm → 默认');
expect(L::parseWorkHours(null) === [540, 1200], 'null → 默认');
expect(L::parseWorkHours(['09:00']) === [540, 1200], '少于两项 → 默认');
// ---------- clampToWorkHours ----------
$wh = $cfg['work_hours'];
expect(L::clampToWorkHours($ts('2026-10-13 14:00:00'), $wh) === $ts('2026-10-13 14:00:00'), '工作时段内原样返回');
expect(L::clampToWorkHours($ts('2026-10-13 20:30:00'), $wh) === $ts('2026-10-14 09:00:00'), '20:30 → 次日 09:00');
expect(L::clampToWorkHours($ts('2026-10-13 20:00:00'), $wh) === $ts('2026-10-13 20:00:00'), '恰好 20:00:00 不顺延');
expect(L::clampToWorkHours($ts('2026-10-13 20:00:01'), $wh) === $ts('2026-10-14 09:00:00'), '20:00:01 顺延次日 09:00');
expect(L::clampToWorkHours($ts('2026-10-13 03:00:00'), $wh) === $ts('2026-10-13 09:00:00'), '凌晨 → 当日 09:00');
expect(L::clampToWorkHours($ts('2026-10-13 08:59:59'), $wh) === $ts('2026-10-13 09:00:00'), '08:59:59 → 09:00:00');
expect(L::clampToWorkHours($ts('2026-10-13 09:00:00'), $wh) === $ts('2026-10-13 09:00:00'), '恰好 09:00 原样');
expect(L::clampToWorkHours($ts('2026-10-31 23:30:00'), $wh) === $ts('2026-11-01 09:00:00'), '跨月顺延');
expect(L::clampToWorkHours($ts('2026-12-31 21:00:00'), $wh) === $ts('2027-01-01 09:00:00'), '跨年顺延');
expect(L::clampToWorkHours($ts('2026-10-13 18:30:00'), ['08:30', '18:00']) === $ts('2026-10-14 08:30:00'), '自定义时段 18:30 → 次日 08:30');
expect(L::clampToWorkHours($ts('2026-10-13 23:59:59'), ['09:00', '24:00']) === $ts('2026-10-13 23:59:59'), '24:00 结束时深夜不顺延');
// ---------- nextRemindAfterSnooze ----------
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 09:30:00'), 1, $cfg)) === '2026-10-13 13:30:00', '09:30 第 1 次 → 13:30');
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 17:30:00'), 1, $cfg)) === '2026-10-14 09:00:00', '17:30 + 4h 越过 20:00 → 次日 09:00');
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 18:30:00'), 3, $cfg)) === '2026-10-13 19:30:00', '18:30 第 3 次 +1h → 19:30');
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 19:50:00'), 4, $cfg)) === '2026-10-14 09:00:00', '19:50 第 4 次 +30min → 次日 09:00');
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 20:30:00'), 1, $cfg)) === '2026-10-14 09:00:00', '20:30 点延迟 → 次日 09:00(验收 §11-4-2)');
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 19:00:00'), 5, $cfg)) === '2026-10-13 19:30:00', '第 5 次 30min 仍在时段内');
expect(L::nextRemindAfterSnooze($ts('2026-10-13 10:00:00'), 1, []) === $ts('2026-10-13 10:30:00'), '空配置:30min + 默认时段');
expect(L::nextRemindAfterSnooze($ts('2026-10-13 10:00:00'), 1, $cfg) > $ts('2026-10-13 10:00:00'), '下次提醒恒晚于 now(条件 UPDATE 幂等前提)');
// ---------- promisedRemindAt ----------
expect($fmt(L::promisedRemindAt('2026-10-16', $wh)) === '2026-10-16 09:00:00', '承诺日 09:00');
expect($fmt(L::promisedRemindAt('2026-10-16', ['08:30', '18:00'])) === '2026-10-16 08:30:00', '承诺日取时段开始');
expect(L::promisedRemindAt('2026-13-40', $wh) === 0, '非法日期 → 0');
expect(L::promisedRemindAt('', $wh) === 0, '空 → 0');
expect($fmt(L::promisedRemindAt('2026-10-16 12:00:00', $wh)) === '2026-10-16 09:00:00', '带时间的串只取日期');
// ---------- isPromisedDateInRange ----------
expect(L::isPromisedDateInRange('2026-10-13', '2026-10-13', 14), '今天可选');
expect(L::isPromisedDateInRange('2026-10-27', '2026-10-13', 14), '今天 +14 可选');
expect(!L::isPromisedDateInRange('2026-10-28', '2026-10-13', 14), '今天 +15 不可选');
expect(!L::isPromisedDateInRange('2026-10-12', '2026-10-13', 14), '昨天不可选');
expect(!L::isPromisedDateInRange('bad', '2026-10-13', 14), '非法日期不可选');
expect(!L::isPromisedDateInRange('2026-02-30', '2026-02-13', 30), '不存在的日期不可选');
expect(L::isPromisedDateInRange('2026-10-13', '2026-10-13', 0), 'max=0 仅今天');
// ---------- remindTimeText ----------
$now = $ts('2026-10-13 10:00:00');
expect(L::remindTimeText($ts('2026-10-13 14:30:00'), $now) === '14:30', '当日 HH:mm');
expect(L::remindTimeText($ts('2026-10-14 09:00:00'), $now) === '明日 09:00', '次日前缀');
expect(L::remindTimeText($ts('2026-10-16 09:00:00'), $now) === '10-16 09:00', '更远 MM-DD HH:mm');
expect(L::remindTimeText(0, $now) === '', '0 → 空');
// ---------- escalationLevel(与 Calculator 一致) ----------
expect(C::escalationLevel(1) === 1 && C::escalationLevel(2) === 1, '1~2 次 → level 1');
expect(C::escalationLevel(3) === 2 && C::escalationLevel(4) === 2, '3~4 次 → level 2');
expect(C::escalationLevel(5) === 3, '第 5 次 → level 3 居中模态');
// ---------- toastEligible ----------
$row = static fn (array $o = []): array => array_replace([
'closed_state' => 0, 'appointment_state' => 0, 'due_date' => '2026-10-10', 'promised_date' => null,
], $o);
expect(L::toastEligible($row(), '2026-10-13'), '到期未挂号进弹窗');
expect(L::toastEligible($row(['due_date' => '2026-10-13']), '2026-10-13'), '今日到期进弹窗');
expect(!L::toastEligible($row(['due_date' => '2026-10-14']), '2026-10-13'), '未到期不进');
expect(L::toastEligible($row(['appointment_state' => 4, 'due_date' => '2026-10-20']), '2026-10-13'), '过号不受到期日限制');
expect(L::toastEligible($row(['appointment_state' => 4, 'due_date' => null]), '2026-10-13'), '过号且缺天数仍进');
expect(!L::toastEligible($row(['due_date' => null]), '2026-10-13'), '缺天数不进');
expect(!L::toastEligible($row(['promised_date' => '2026-10-13']), '2026-10-13'), '承诺日=今天不进');
expect(!L::toastEligible($row(['promised_date' => '2026-10-16']), '2026-10-13'), '承诺日未到不进');
expect(L::toastEligible($row(['promised_date' => '2026-10-12']), '2026-10-13'), '承诺日已过再弹');
expect(!L::toastEligible($row(['closed_state' => 4]), '2026-10-13'), '人工关闭不进');
expect(!L::toastEligible($row(['closed_state' => 2]), '2026-10-13'), '已挂号不进');
expect(L::toastEligible($row(['promised_date' => '0000-00-00']), '2026-10-13'), '0000-00-00 视为无承诺');
echo "RevisitDueLogicTest OK ({$checks} checks)\n";
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
/**
* 复诊到期企微推送命令(RevisitDueNotify)纯函数单测,不连库、不读 config、不调企微。
* php server/tests/RevisitDueNotifyTest.php
* 覆盖:文案拼装(daily 汇总排序/截断/过号标签、self、supervisor)、每日上限与日切判断(selfSentToday / selfQuotaLeft)、
* 主管抄送去重判断(shouldCcSupervisor 阈值 + wecom_supervisor_at 日切、normalizeSupervisorIds 去重/剔除本人)、
* 工作时段判断、日切区间。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\command\RevisitDueNotify as N;
use app\common\service\tcm\RevisitDueCalculator as C;
date_default_timezone_set('Asia/Shanghai');
$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);
// ---------- 日切判断 ----------
expect(N::isSameDay($ts('2026-10-14 09:00:00'), $ts('2026-10-14 23:59:59')), '同日:同一自然日');
expect(!N::isSameDay($ts('2026-10-13 23:59:59'), $ts('2026-10-14 00:00:00')), '同日:跨午夜不同日');
expect(!N::isSameDay(0, $ts('2026-10-14 09:00:00')), '同日:0 视为从未发生');
[$s, $e] = N::dayRange('2026-10-14');
expect(date('Y-m-d H:i:s', $s) === '2026-10-14 00:00:00' && date('Y-m-d H:i:s', $e) === '2026-10-14 23:59:59', 'dayRange 当日起止');
[$s2] = N::dayRange('bad');
expect(date('Y-m-d', $s2) === date('Y-m-d'), 'dayRange 非法日期回落今天');
// ---------- 每日上限 ----------
$now = $ts('2026-10-14 10:30:00');
$row = ['wecom_self_at' => 0, 'wecom_self_count_day' => 0];
expect(N::selfSentToday($row, $now) === 0 && N::selfQuotaLeft($row, $now, 2) === 2, '从未推送:剩余 = cap');
$row = ['wecom_self_at' => $ts('2026-10-14 09:10:00'), 'wecom_self_count_day' => 1];
expect(N::selfSentToday($row, $now) === 1 && N::selfQuotaLeft($row, $now, 2) === 1, '今日已推 1 次:剩余 1');
$row = ['wecom_self_at' => $ts('2026-10-14 09:10:00'), 'wecom_self_count_day' => 2];
expect(N::selfQuotaLeft($row, $now, 2) === 0, '今日已推 2 次:达上限');
$row = ['wecom_self_at' => $ts('2026-10-13 19:50:00'), 'wecom_self_count_day' => 2];
expect(N::selfSentToday($row, $now) === 0 && N::selfQuotaLeft($row, $now, 2) === 2, '昨日推满:日切重置为 cap');
$row = ['wecom_self_at' => $ts('2026-10-14 09:10:00'), 'wecom_self_count_day' => 5];
expect(N::selfQuotaLeft($row, $now, 2) === 0, '计数超过 cap 不为负');
expect(N::selfQuotaLeft(['wecom_self_at' => 0, 'wecom_self_count_day' => 0], $now, 0) === 0, 'cap=0 不推');
// ---------- 主管抄送判断 ----------
$row = ['snooze_count' => 5, 'wecom_supervisor_at' => 0];
expect(N::shouldCcSupervisor($row, $now, 5), '首次达到 5 次且未抄送 → 抄送');
$row = ['snooze_count' => 4, 'wecom_supervisor_at' => 0];
expect(!N::shouldCcSupervisor($row, $now, 5), '4 次不抄送');
$row = ['snooze_count' => 7, 'wecom_supervisor_at' => $ts('2026-10-14 09:00:00')];
expect(!N::shouldCcSupervisor($row, $now, 5), '今日已抄送 → 不重复');
$row = ['snooze_count' => 7, 'wecom_supervisor_at' => $ts('2026-10-13 09:00:00')];
expect(N::shouldCcSupervisor($row, $now, 5), '昨日抄送过 → 今日再抄送一次');
$row = ['snooze_count' => 3, 'wecom_supervisor_at' => 0];
expect(N::shouldCcSupervisor($row, $now, 3), '阈值可配置(3)');
expect(!N::shouldCcSupervisor(['snooze_count' => 0, 'wecom_supervisor_at' => 0], $now, 0), '阈值 0 按 1 处理,0 次不抄送');
expect(N::normalizeSupervisorIds([3, '3', 0, -1, 'x', 5, 7], 5) === [3, 7], '主管名单:去重、剔除非正整数、剔除医助本人');
expect(N::normalizeSupervisorIds('3, 7,,7 9', 0) === [3, 7, 9], '主管名单:逗号/空格字符串');
expect(N::normalizeSupervisorIds(null) === [] && N::normalizeSupervisorIds([]) === [] && N::normalizeSupervisorIds([5], 5) === [], '主管名单:空 / 仅本人 → 空');
// ---------- 超期标签 ----------
expect(N::overdueLabel(['state' => C::STATE_OVERDUE, 'overdue_days' => 3]) === '超期3天', '标签:超期 3 天');
expect(N::overdueLabel(['state' => C::STATE_OVERDUE, 'overdue_days' => 0]) === '今日应挂号', '标签:今日应挂号');
expect(N::overdueLabel(['state' => C::STATE_MISSED, 'overdue_days' => null]) === '过号', '标签:过号无到期日');
expect(N::overdueLabel(['state' => C::STATE_MISSED, 'overdue_days' => 2]) === '过号·超期2天', '标签:过号且超期');
expect(N::overdueLabel(['state' => C::STATE_OVERDUE, 'overdue_days' => null]) === '到期', '标签:缺天数兜底');
// ---------- daily 文案 ----------
$items = [
['patient_name' => '李四', 'state' => C::STATE_OVERDUE, 'overdue_days' => 1],
['patient_name' => '张三', 'state' => C::STATE_OVERDUE, 'overdue_days' => 3],
['patient_name' => '王五', 'state' => C::STATE_MISSED, 'overdue_days' => null],
];
$text = N::buildDailyText($items, 5);
expect(str_starts_with($text, '【复诊到期提醒】您有 3 位患者到期未挂号,最久超期 3 天:'), 'daily:头部含人数与最久超期');
expect(str_contains($text, '张三(超期3天)、李四(超期1天)、王五(过号)'), 'daily:按超期天数降序,过号排末');
expect(!str_contains($text, '等 3 人') && str_ends_with($text, N::SIGNATURE), 'daily:未超出列数不加「等 N 人」,带落款');
$many = [];
for ($i = 1; $i <= 7; $i++) {
$many[] = ['patient_name' => '患者' . $i, 'state' => C::STATE_OVERDUE, 'overdue_days' => $i];
}
$text = N::buildDailyText($many, 5);
expect(str_contains($text, '您有 7 位患者') && str_contains($text, '最久超期 7 天'), 'daily:7 人统计');
expect(str_contains($text, '患者7(超期7天)、患者6(超期6天)、患者5(超期5天)、患者4(超期4天)、患者3(超期3天)…等 7 人'), 'daily:最多列 5 人并加「等 7 人」');
expect(!str_contains($text, '患者2('), 'daily:第 6 人起不列出');
$text = N::buildDailyText([['patient_name' => '', 'state' => C::STATE_OVERDUE, 'overdue_days' => 0]], 5);
expect(str_contains($text, '您有 1 位患者到期未挂号:患者(今日应挂号)') && !str_contains($text, '最久超期'), 'daily:空姓名兜底「患者」,无超期不写「最久超期」');
expect(N::buildDailyText([], 5) === '', 'daily:空列表返回空串');
expect(str_contains(N::buildDailyText($many, 0), '…等 7 人'), 'daily:listMax<=0 按 1 处理');
// ---------- self 文案 ----------
$row = ['patient_name' => '张三', 'current_slot' => 2, 'due_date' => '2026-10-10', 'snooze_count' => 5, 'assistant_id' => 7];
$st = ['state' => C::STATE_OVERDUE, 'overdue_days' => 4];
$text = N::buildSelfText($row, $st);
expect($text === '【复诊到期提醒】患者 张三(第2诊后,预计复诊 10-10)超期4天未挂号,您已延迟 5 次,请尽快联系患者挂号。' . N::SIGNATURE, 'self 文案');
$text = N::buildSelfText(['patient_name' => '', 'current_slot' => 0, 'due_date' => null, 'snooze_count' => 6], ['state' => C::STATE_MISSED, 'overdue_days' => null]);
expect($text === '【复诊到期提醒】患者 患者(待复诊)过号未挂号,您已延迟 6 次,请尽快联系患者挂号。' . N::SIGNATURE, 'self 文案:缺姓名/诊次/到期日');
// ---------- supervisor 文案 ----------
$row = ['patient_name' => '张三', 'current_slot' => 2, 'due_date' => '2026-10-10', 'snooze_count' => 5, 'contact_count' => 1, 'assistant_id' => 7];
$text = N::buildSupervisorText('李医助', $row, $st);
expect($text === '【复诊到期升级】医助 李医助 的患者 张三 超期4天未挂号(第2诊后,预计复诊 10-10),已延迟 5 次、已联系 1 次,请关注。' . N::SIGNATURE, 'supervisor 文案:含医助、患者、超期天数、延迟/联系次数');
$text = N::buildSupervisorText('', ['patient_name' => '张三', 'current_slot' => 1, 'due_date' => '2026-10-10', 'snooze_count' => 5, 'contact_count' => 0, 'assistant_id' => 7], $st);
expect(str_contains($text, '医助 #7 的患者') && !str_contains($text, '已联系'), 'supervisor 文案:医助缺名用 #id,无联系次数不写');
// ---------- 工作时段 ----------
$wh = ['09:00', '20:00'];
expect(N::withinWorkHours($ts('2026-10-14 09:00:00'), $wh), '工作时段:09:00 含起点');
expect(N::withinWorkHours($ts('2026-10-14 19:59:59'), $wh), '工作时段:19:59 在内');
expect(!N::withinWorkHours($ts('2026-10-14 20:00:00'), $wh), '工作时段:20:00 不含终点');
expect(!N::withinWorkHours($ts('2026-10-14 08:59:59'), $wh), '工作时段:08:59 在外');
expect(N::withinWorkHours($ts('2026-10-14 03:00:00'), ['00:00', '24:00']), '工作时段:全天配置');
expect(!N::withinWorkHours($ts('2026-10-14 21:00:00'), 'bad'), '工作时段:非法配置回落 09:00~20:00');
echo "RevisitDueNotifyTest OK ({$checks} checks)\n";
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
/**
* 复诊接诊率 v2 源码契约(ReflectionMethod 读源码,不连库)。
* php server/tests/RevisitRateContractTest.php
* 守住:
* - 分子订单过滤仍走 YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery(RevisitVisitSlotService::fetchOrderSeqRows);
* - RevisitRateLogic::buildStatsCore 经 RevisitRateCalculator::compute 计算,订单序列仍经 fetchOrderSeqRows 委托;
* - 计算器中 slot<2 / >50 跳过、持有人指针 t <= ct、继承诊次用 RevisitVisitSlotService::slotBefore;
* - 旧口径集合只看 is_inherit;exclude 模式不进入 from==to / 回退豁免分支;
* - DiagnosisLogic::assign 继承时在事务内写 inherit_visit_slot(slotAtTime);
* - AutoAssignLogLogic::rollbackOne 回退仍写 is_inherit = 0(原逻辑不变);
* - AutoAssignPendingDiagnosis 以 inherit_mode=slot 取 overview 并按配置字段分档。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\stats\AutoAssignLogLogic;
use app\adminapi\logic\stats\RevisitRateCalculator;
use app\adminapi\logic\stats\RevisitRateLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\command\AutoAssignPendingDiagnosis;
use app\common\service\tcm\RevisitVisitSlotService;
$checks = 0;
function expect(bool $condition, string $message): void
{
global $checks;
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
$checks++;
}
function methodSource(string $class, string $method): string
{
$ref = (new ReflectionClass($class))->getMethod($method);
$lines = file($ref->getFileName());
if (!is_array($lines)) {
throw new RuntimeException('method source must be readable: ' . $class . '::' . $method);
}
return implode('', array_slice($lines, $ref->getStartLine() - 1, $ref->getEndLine() - $ref->getStartLine() + 1));
}
// ---------- 分子过滤口径 ----------
$fetch = methodSource(RevisitVisitSlotService::class, 'fetchOrderSeqRows');
expect(str_contains($fetch, 'YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery'), '订单序列仍走业绩有效订单过滤');
expect(str_contains($fetch, "whereNull('o.delete_time')"), '订单序列剔除软删');
expect(str_contains($fetch, "'o.create_time' => 'asc', 'o.id' => 'asc'"), '订单序列按 (create_time, id) 升序');
$slotAt = methodSource(RevisitVisitSlotService::class, 'slotAtTime');
expect(str_contains($slotAt, 'self::fetchOrderSeqRows') && str_contains($slotAt, 'self::slotBefore'), 'slotAtTime 复用同一订单序列与 slotBefore');
$slotBefore = methodSource(RevisitVisitSlotService::class, 'slotBefore');
expect(str_contains($slotBefore, '(int) $ct < $atTs'), 'slotBefore 严格小于(与持有人 t <= ct 对偶)');
$logicFetch = methodSource(RevisitRateLogic::class, 'fetchOrderSeqRows');
expect(str_contains($logicFetch, 'RevisitVisitSlotService::fetchOrderSeqRows'), 'RevisitRateLogic::fetchOrderSeqRows 委托诊次服务');
$iter = methodSource(RevisitRateLogic::class, 'iterateOrderSeqRows');
expect(str_contains($iter, 'self::fetchOrderSeqRows'), '订单生成器经委托方法取数');
// ---------- buildStatsCore 经计算器 ----------
$core = methodSource(RevisitRateLogic::class, 'buildStatsCore');
expect(str_contains($core, 'RevisitRateCalculator::compute('), 'buildStatsCore 调用 RevisitRateCalculator::compute');
expect(str_contains($core, 'self::fetchRefundOrRejectDiagnosisSet'), '仍剔除拒收/退款诊单');
expect(str_contains($core, 'RevisitRateCalculator::monthAssignedDiagIds'), '拒收/退款剔除集合覆盖继承诊单');
expect(str_contains($core, 'self::fetchRollbackKeySet'), 'slot 模式读历史回退行');
expect(str_contains($core, "'lg.from_assistant_id'") && str_contains($core, "'lg.inherit_visit_slot'"), '字段列表含 from_assistant_id / inherit_visit_slot');
expect(str_contains($core, '$inheritEntryByAssistant[$aid]') && str_contains($core, '$excludeDiagsByAssistant[$aid]'), '二中心过滤同步 unset 继承集合与旧口径集合');
expect(str_contains($core, '$diagsByAssistant + $slotOrdersByAssistant + $inheritEntryByAssistant + $excludeDiagsByAssistant'), 'universe 并集含继承医助');
$universe = methodSource(RevisitRateLogic::class, 'assistantUniverse');
expect(str_contains($universe, "'inheritEntryByAssistant'"), 'assistantUniverse 含继承集合(当月仅有继承行的医助不丢行)');
// ---------- 计算器核心不变量 ----------
$compute = methodSource(RevisitRateCalculator::class, 'compute');
expect(str_contains($compute, '$effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT'), 'slot<2 或 >50 跳过');
expect(str_contains($compute, "\$tl[\$ptr]['t'] <= \$ct"), '持有人指针 t <= ct(同一秒归新持有人)');
expect(str_contains($compute, '$ct < $startTs || $ct > $endTs'), '分子按当月下单过滤');
expect(str_contains($compute, 'RevisitVisitSlotService::slotBefore('), '继承诊次 k 经 slotBefore 单点计算');
expect(str_contains($compute, 'RevisitVisitSlotService::resolveRevisitSlotStartOffset('), '偏移经诊次服务钳制');
expect(str_contains($compute, 'max($inheritMinNextSlot, $k + 1)'), 'entry = max(min_next_slot, k+1)');
expect(str_contains($compute, 'if (!$isInherit) {' . "\n" . ' $excludeDiagsByAssistant[$aid][$did] = true;'), '旧口径集合仅看 is_inherit=0');
expect(str_contains($compute, 'if (!$slotMode) {') && str_contains($compute, 'if ($from === $aid) {'), 'from==to 忽略仅在 slot 分支');
expect(str_contains($compute, 'isset($diagsByAssistant[$aid][$did])') && str_contains($compute, 'unset($inheritFirst[$aid][$did])'), 'NI 与 IH 同诊单只算 NI');
$pack = methodSource(RevisitRateCalculator::class, 'metricPack');
expect(str_contains($pack, "'assigned_count' => \$newAssigned") && str_contains($pack, "'inherit_count' => \$inheritCount"), '指标包键名');
expect(str_contains($pack, "'_den'") && str_contains($pack, "'visit_all_rate'") && str_contains($pack, 'self::FIELD_RATE_NEW_ASSIGN'), '新键 den / visit_all / visit2_rate_new_assign');
expect(str_contains($pack, '$den > 0 ? round($cnt / $den * 100, 2) : null'), 'den=0 → null(AutoAssign 依赖 null 语义)');
$logicPack = methodSource(RevisitRateLogic::class, 'buildMetricPack');
expect(str_contains($logicPack, 'RevisitRateCalculator::metricPack('), 'Logic 指标包委托计算器');
$overview = methodSource(RevisitRateLogic::class, 'overview');
expect(str_contains($overview, 'RevisitRateCalculator::slotRange(') && str_contains($overview, "\$ctx['maxEntry']"), 'slots 上界含 maxEntry');
expect(str_contains($overview, 'RevisitRateCalculator::mergeInheritEntries('), '部门/合计行按 §1.1 去重');
expect(str_contains($overview, "'inherit_mode' => \$ctx['inheritMode']"), '返回体含 inherit_mode');
expect(str_contains($overview, "\$b['assigned_count'] <=> \$a['assigned_count']"), '排序仍按 assigned_count');
$mode = methodSource(RevisitRateLogic::class, 'resolveInheritMode');
expect(str_contains($mode, "config('project.revisit_rate.default_inherit_mode')") && str_contains($mode, 'RevisitRateCalculator::normalizeMode('), '口径白名单读参数/配置');
$lines = methodSource(RevisitRateLogic::class, 'assignLines');
expect(str_contains($lines, "['new', 'inherit', 'all']") && str_contains($lines, "'assign_type' => 'inherit'") && str_contains($lines, "'entry_slot'"), 'assignLines 支持 kind 并输出 assign_type/entry_slot');
$visit = methodSource(RevisitRateLogic::class, 'visitOrderLines');
expect(str_contains($visit, '$slot < 0 || $slot === 1 || $slot > self::MAX_VISIT_SLOT'), 'visitOrderLines 允许 slot=0');
// ---------- 写入侧 ----------
$assign = methodSource(DiagnosisLogic::class, 'assign');
expect(str_contains($assign, 'RevisitVisitSlotService::slotAtTime($id, $now)') && str_contains($assign, "'inherit_visit_slot'") && str_contains($assign, "'inherit_visit_slot_calc_time'"), 'assign 继承时写诊次快照');
expect(strpos($assign, 'RevisitVisitSlotService::slotAtTime') > strpos($assign, 'Db::startTrans()') && strpos($assign, 'RevisitVisitSlotService::slotAtTime') < strpos($assign, 'Db::commit()'), '快照计算在事务内');
expect(str_contains($assign, 'RevisitDueHook::onAssistantChanged'), '保留复诊到期钩子');
$rollback = methodSource(AutoAssignLogLogic::class, 'rollbackOne');
// 用户要求复诊接诊率原逻辑不变:回退行保持 is_inherit=0(新口径下由 rollbackKeySet 匹配按继承处理)
expect(str_contains($rollback, "'is_inherit' => 0"), '回退写 is_inherit=0(原逻辑)');
expect(!str_contains($rollback, 'RevisitVisitSlotService::slotAtTime'), '回退不写 inherit_visit_slot 快照');
expect(str_contains($rollback, 'RevisitDueHook::onAssistantChanged'), '回退保留复诊到期钩子');
$exec = methodSource(AutoAssignPendingDiagnosis::class, 'execute');
expect(str_contains($exec, "'inherit_mode' => RevisitRateCalculator::MODE_SLOT"), '自动分配以 slot 口径取 overview');
$tierField = methodSource(AutoAssignPendingDiagnosis::class, 'resolveTierField');
expect(str_contains($tierField, "config('project.revisit_rate.auto_assign_tier_field')"), '分档字段读配置');
$logRow = methodSource(AutoAssignPendingDiagnosis::class, 'buildLogRow');
expect(str_contains($logRow, "'visit2_rate_new_assign'") && str_contains($logRow, "'rate_version' => \$this->rateVersion"), '自动指派日志写两套率与口径版本');
$apply = methodSource(AutoAssignPendingDiagnosis::class, 'applyAssignment');
expect(str_contains($apply, 'RevisitDueHook::onAssistantChanged'), '自动分配保留复诊到期钩子');
echo "RevisitRateContractTest OK ({$checks} checks)\n";
+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";
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* 诊次服务(RevisitVisitSlotService)纯函数单测,不连库。
* php server/tests/RevisitVisitSlotServiceTest.php
* 覆盖:slotBefore 严格小于边界(同一秒订单不计入)、偏移钳制、resolveRevisitSlotStartOffset、
* 与 RevisitRateLogic 持有人规则(t <= ct 归新持有人)的对偶关系。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\tcm\RevisitVisitSlotService as S;
$checks = 0;
function expect(bool $condition, string $message): void
{
global $checks;
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
$checks++;
}
$t = static fn (string $dt): int => (int) strtotime($dt);
$orders = [$t('2026-05-03 10:00:00'), $t('2026-06-12 10:00:00'), $t('2026-08-20 10:00:00')];
// ---------- slotBefore 基本 ----------
expect(S::slotBefore($orders, 0, $t('2026-09-10 10:00:00')) === 3, '三笔订单之后继承 → k=3');
expect(S::slotBefore($orders, 0, $t('2026-07-01 00:00:00')) === 2, '两笔之后 → k=2');
expect(S::slotBefore($orders, 0, $t('2026-05-03 09:59:59')) === 0, '首单之前 → k=0');
expect(S::slotBefore([], 0, $t('2026-09-10 10:00:00')) === 0, '无订单 → k=0');
expect(S::slotBefore([], 1, $t('2026-09-10 10:00:00')) === 1, '无订单有偏移 → k=offset');
// ---------- 严格小于:同一秒 ----------
expect(S::slotBefore($orders, 0, $t('2026-08-20 10:00:00')) === 2, '同一秒订单视为「指派之后」,不计入 k');
expect(S::slotBefore($orders, 0, $t('2026-08-20 10:00:01')) === 3, '晚一秒即计入');
// 对偶:持有人规则 t <= ct 归新持有人(RevisitRateLogic.php 指针 while tl[ptr].t <= ct)
$assignTs = $t('2026-08-20 10:00:00');
$orderCt = $t('2026-08-20 10:00:00');
expect(($assignTs <= $orderCt) && S::slotBefore([$orderCt], 0, $assignTs) === 0, '同一秒:订单归新持有人且不计入 k(对偶成立)');
// ---------- 偏移 ----------
expect(S::slotBefore($orders, 2, $t('2026-09-10 10:00:00')) === 5, '偏移叠加');
expect(S::slotBefore($orders, -3, $t('2026-09-10 10:00:00')) === 3, '负偏移钳到 0');
expect(S::slotBefore($orders, 99, $t('2026-09-10 10:00:00')) === 23, '偏移上限 20');
expect(S::slotBefore(['1778000000', 1779000000], 0, 1778500000) === 1, '字符串时间戳可用');
// ---------- resolveRevisitSlotStartOffset ----------
$map = [1 => 0, 2 => 1, 3 => -5, 4 => 25, 5 => '2'];
expect(S::resolveRevisitSlotStartOffset(1, $map) === 0, 'offset 0');
expect(S::resolveRevisitSlotStartOffset(2, $map) === 1, 'offset 1');
expect(S::resolveRevisitSlotStartOffset(3, $map) === 0, '负值钳 0');
expect(S::resolveRevisitSlotStartOffset(4, $map) === S::MAX_OFFSET, '超限钳 20');
expect(S::resolveRevisitSlotStartOffset(5, $map) === 2, '字符串转 int');
expect(S::resolveRevisitSlotStartOffset(999, $map) === 0, '缺键默认 0');
// ---------- current_slot / next_slot 口径(与 batchCurrentSlot 组装一致) ----------
$validCount = \count($orders);
$offset = S::resolveRevisitSlotStartOffset(2, $map);
$currentSlot = $validCount + $offset;
expect($currentSlot === 4 && $currentSlot + 1 === 5, 'current_slot = |E(D)| + off(D),next_slot = current_slot + 1');
expect(S::slotBefore($orders, $offset, PHP_INT_MAX) === $currentSlot, '未来时刻的 slot_at 等于 current_slot');
echo "RevisitVisitSlotServiceTest OK ({$checks} checks)\n";