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";