getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); exit(1); }); $manager = new think\DbManager(); $manager->setConfig(['default' => 'isolated', 'connections' => ['isolated' => ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'test_', 'fields_strict' => true]]]); Container::getInstance()->instance('think\DbManager', $manager); think\Model::setDb($manager); $config = new think\Config(); $config->set(['enabled' => false, 'daily_contact_enabled' => true, 'daily_contact_actor_id' => 13, 'daily_contact_interval_seconds' => 60, 'daily_contact_batch_size' => 500], 'assistant_service'); $config->set(['data_scope' => ['enabled' => true, 'no_dept_fallback_self' => true]], 'project'); Container::getInstance()->instance('config', $config); $tables = [ 'admin' => 'id INTEGER PRIMARY KEY,name TEXT,root INTEGER DEFAULT 0,disable INTEGER DEFAULT 0,delete_time INTEGER NULL', 'admin_role' => 'admin_id INTEGER,role_id INTEGER', 'admin_dept' => 'admin_id INTEGER,dept_id INTEGER', 'dept' => 'id INTEGER PRIMARY KEY,pid INTEGER,status INTEGER DEFAULT 1,delete_time INTEGER NULL', 'system_role' => 'id INTEGER PRIMARY KEY,name TEXT,data_scope INTEGER,delete_time INTEGER NULL', 'system_role_menu' => 'role_id INTEGER,menu_id INTEGER', 'system_menu' => 'id INTEGER PRIMARY KEY,perms TEXT,is_disable INTEGER DEFAULT 0', 'doctor_appointment' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,doctor_id INTEGER,status INTEGER', 'tcm_diagnosis' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,assistant_id INTEGER,patient_name TEXT,phone TEXT,status INTEGER DEFAULT 1,delete_time INTEGER NULL', 'tcm_prescription_order' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,fulfillment_status INTEGER DEFAULT 1,delete_time INTEGER NULL', 'tcm_diagnosis_revisit_due' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,assistant_id INTEGER,source_order_id INTEGER,due_date TEXT,promised_date TEXT NULL,closed_state INTEGER DEFAULT 0,appointment_state INTEGER DEFAULT 0', 'tcm_diagnosis_revisit_due_log' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,assistant_id INTEGER,source_order_id INTEGER,event INTEGER,operator_id INTEGER,create_time INTEGER', 'tcm_im_chat_message' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,from_account TEXT COLLATE NOCASE,to_account TEXT,msg_type TEXT,text TEXT,msg_time INTEGER', 'assistant_service_job' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,patient_id INTEGER', 'assistant_service_action' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,job_id INTEGER,assignee_id INTEGER,due_at INTEGER,business_state TEXT DEFAULT "pending",completed_at INTEGER DEFAULT 0,completed_by INTEGER DEFAULT 0,contact_required INTEGER DEFAULT 0', 'assistant_service_daily_contact' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,business_date TEXT,assistant_id INTEGER,diagnosis_id INTEGER,patient_id INTEGER,source_type TEXT,source_id INTEGER,source_order_id INTEGER DEFAULT 0,reason TEXT,due_at INTEGER,state TEXT DEFAULT "active",exempt_reason TEXT DEFAULT "",created_at INTEGER,updated_at INTEGER,UNIQUE(business_date,assistant_id,source_type,source_id,source_order_id)', ]; foreach ($tables as $name => $schema) { Db::execute('CREATE TABLE test_' . $name . ' (' . $schema . ')'); } Db::name('admin')->insertAll([['id' => 1, 'name' => 'root', 'root' => 1], ['id' => 11, 'name' => '甲', 'root' => 0], ['id' => 12, 'name' => '域外', 'root' => 0], ['id' => 13, 'name' => '主管', 'root' => 0], ['id' => 15, 'name' => '乙', 'root' => 0], ['id' => 14, 'name' => '医生', 'root' => 0]]); Db::name('system_role')->insertAll([['id' => 1, 'name' => '医生', 'data_scope' => 1], ['id' => 2, 'name' => '医助', 'data_scope' => 4], ['id' => 3, 'name' => '经理', 'data_scope' => 3]]); Db::name('admin_role')->insertAll([['admin_id' => 11, 'role_id' => 2], ['admin_id' => 12, 'role_id' => 2], ['admin_id' => 13, 'role_id' => 3], ['admin_id' => 15, 'role_id' => 2], ['admin_id' => 14, 'role_id' => 1]]); Db::name('dept')->insertAll([['id' => 10, 'pid' => 0], ['id' => 20, 'pid' => 0]]); Db::name('admin_dept')->insertAll([['admin_id' => 11, 'dept_id' => 10], ['admin_id' => 12, 'dept_id' => 20], ['admin_id' => 13, 'dept_id' => 10], ['admin_id' => 15, 'dept_id' => 10]]); Db::name('system_menu')->insert(['id' => 1, 'perms' => 'tcm.assistantService/view']); Db::name('system_role_menu')->insertAll([['role_id' => 1, 'menu_id' => 1], ['role_id' => 2, 'menu_id' => 1], ['role_id' => 3, 'menu_id' => 1]]); $clock = (new DateTimeImmutable('2026-11-10 12:00:00', new DateTimeZone('Asia/Shanghai')))->getTimestamp(); $day = Daily::day($clock); $temporary = sys_get_temp_dir() . '/daily-contact-test-' . bin2hex(random_bytes(6)); $daily = new Daily($temporary, static function () use (&$clock): int { return $clock; }); $checks = 0; $ok = static function (bool $pass, string $message) use (&$checks): void { if (!$pass) { throw new RuntimeException($message); } $checks++; }; $deny = static function (callable $call, string $message) use ($ok): void { try { $call(); } catch (DomainException $e) { $ok(true, $message); return; } $ok(false, $message); }; $team = static function (int $admin = 13, ?array $ids = null) use ($daily): array { $actor = Access::actor($admin); $staff = Access::assistantRows($admin, $actor); if ($ids !== null) { $staff = array_values(array_filter($staff, static fn (array $s): bool => in_array($s['id'], $ids, true))); } return $daily->teamReport($staff, $admin, $actor); }; $patients = static fn (array $p = [], int $admin = 13): array => $daily->patientReport($p + ['assistant_id' => 11, 'status' => 'all', 'page_no' => 1, 'page_size' => 50], $admin); $tick = static function () use ($daily, &$clock): array { $clock += 61; return $daily->tick(); }; $diagnosis = static function (int $id, int $patient = -1, int $assistant = 11): void { Db::name('tcm_diagnosis')->insert(['id' => $id, 'patient_id' => $patient < 0 ? $id + 1000 : $patient, 'assistant_id' => $assistant, 'patient_name' => '合成' . $id, 'phone' => '']); }; $revisit = static function (int $id, ?string $due = '2026-11-10', ?string $promised = null, int $appointment = 0): void { $d = Db::name('tcm_diagnosis')->where('id', $id)->find(); Db::name('tcm_prescription_order')->insert(['id' => $id * 10, 'diagnosis_id' => $id]); Db::name('tcm_diagnosis_revisit_due')->insert(['id' => $id, 'diagnosis_id' => $id, 'assistant_id' => $d['assistant_id'], 'source_order_id' => $id * 10, 'due_date' => $due, 'promised_date' => $promised, 'appointment_state' => $appointment]); }; $action = static function (int $id, int $diagnosisId, array $extra = []) use (&$clock): void { $d = Db::name('tcm_diagnosis')->where('id', $diagnosisId)->find(); Db::name('assistant_service_job')->insert(['id' => $id, 'diagnosis_id' => $diagnosisId, 'patient_id' => $d['patient_id']]); Db::name('assistant_service_action')->insert($extra + ['id' => $id, 'diagnosis_id' => $diagnosisId, 'job_id' => $id, 'assignee_id' => $d['assistant_id'], 'due_at' => $clock - 60, 'contact_required' => 1]); }; $im = static function (int $id, int $patient, int $at, string $from = 'doctor_11', ?string $to = null, string $text = '合成联系', string $type = 'text'): void { Db::name('tcm_im_chat_message')->insert(['id' => $id, 'patient_id' => $patient, 'from_account' => $from, 'to_account' => $to ?? 'patient_' . $patient, 'text' => $text, 'msg_type' => $type, 'msg_time' => $at]); }; $log = static function (int $id, int $diagnosis, int $operator, int $at, int $event = 2, ?int $order = null): void { Db::name('tcm_diagnosis_revisit_due_log')->insert(['id' => $id, 'diagnosis_id' => $diagnosis, 'assistant_id' => 11, 'source_order_id' => $order ?? $diagnosis * 10, 'operator_id' => $operator, 'event' => $event, 'create_time' => $at]); }; try { $ok($team()['meta']['state'] === 'waiting' && $team()['summary']['required'] === null, '无今日扫描不能伪装0任务'); $ok($daily->tick()['state'] === 'ready', '禁用AI仍可生成今日计划'); $ok($team()['summary']['state'] === 'no_tasks' && $team()['summary']['required'] === 0, '真实空来源明确无任务'); $ok($team()['by_staff'][15]['state'] === 'no_tasks', '每个输入医助均返回明确计数'); $deny(fn () => $patients(['assistant_id' => 12]), '明细不能越员工域'); $deny(fn () => $patients(['assistant_id' => 11], 14), '普通医生不能查看其他员工'); foreach ([101, 102, 103, 104, 105, 106, 107, 108, 109] as $id) { $diagnosis($id); } $revisit(101); $revisit(102, '2026-11-01', '2026-11-10'); $revisit(103, '2026-11-01', '2026-11-11'); $revisit(104, null); $revisit(105, null, null, 4); $revisit(106, '2026-11-11'); $revisit(107); Db::name('tcm_diagnosis_revisit_due')->where('id', 107)->update(['closed_state' => 4]); $revisit(108); Db::name('tcm_diagnosis_revisit_due')->where('id', 108)->update(['assistant_id' => 12]); $revisit(109); Db::name('tcm_prescription_order')->where('id', 1090)->update(['fulfillment_status' => 9]); $diagnosis(110, 1101); $revisit(110); // 与101同患者 $diagnosis(111, 0); $revisit(111); $diagnosis(112, 0); $revisit(112); $diagnosis(113, -1, 12); $revisit(113); $action(1, 101); $action(2, 104, ['contact_required' => 0]); $action(3, 106, ['due_at' => $day['end']]); $action(4, 107, ['business_state' => 'cancelled', 'completed_at' => $clock]); $action(5, 108, ['business_state' => 'completed', 'completed_at' => $day['start'] - 1, 'completed_by' => 11]); $action(6, 109, ['business_state' => 'completed', 'completed_at' => $clock, 'completed_by' => 11]); $ok($tick()['created'] === 8, '来源含承诺今天/过号,排未来承诺、无日期、关闭、错归属、无有效订单和旧泛化动作'); $report = $team(); $ok($report['summary']['required'] === 6 && $report['summary']['reported'] === 1, '同患者多来源去重,patient_id0按诊单区分'); $ok($patients(['page_size' => 1])['summary'] === $patients()['summary'] && count($patients(['page_size' => 1])['lists']) === 1, '分页不缩小汇总'); $ok($patients(['status' => 'pending'])['summary'] === $patients()['summary'], '状态筛选不缩小汇总'); $ok($team(1)['meta']['state'] === 'partial' && $team(1)['summary']['required'] === null, '读者范围超出扫描主管时不能宣称完整'); $before = Db::name('assistant_service_daily_contact')->count(); $tick(); $ok(Db::name('assistant_service_daily_contact')->count() === $before, '重扫幂等不重复当日来源'); Db::name('tcm_diagnosis_revisit_due')->whereIn('id', [101, 102])->update(['promised_date' => '2026-11-20']); $tick(); $ok($team()['summary']['required'] === 6, '入计划后新增未来承诺不能缩小分母'); $im(1, 1101, $day['start'] - 1); $im(2, 1101, $day['end']); $im(3, 1101, $clock + 1000); $im(4, 1101, $clock, 'patient_1101', 'doctor_11'); $im(5, 1101, $clock, 'doctor_12'); $im(6, 1101, $clock, 'doctor_11', 'patient_999'); $im(7, 1101, $clock, 'doctor_11', null, ' '); $im(8, 1101, $clock, 'doctor_11', null, '图片', 'image'); $ok($team()['summary']['contacted'] === 0, '入站/域外医助/错配/空文字/图片/昨日/次日/未来记录均不计联系'); $im(11, 1101, $clock, 'DOCTOR_11'); $im(12, 1101, $clock, 'doctor_011'); $ok($team()['summary']['contacted'] === 0, '大小写不敏感归档也必须严格匹配规范发件账号'); $im(9, 1101, $day['start']); $im(10, 1101, $clock); $ok($team()['summary']['contacted'] === 1, '上海零点包含且重复证据只计一个患者'); $row = $patients(['status' => 'contacted'])['lists'][0]; $ok(count($row['evidence']) === 1 && $row['last_contact_at'] === $clock && !isset($row['evidence'][0]['text']), '只展示最近发出事实,不返回消息正文'); $log(1, 102, 13, $clock); $log(2, 102, 11, $clock, 8); $log(3, 102, 11, $clock, 2, 999); $ok($team()['summary']['reported'] === 1, '主管代录/推送/旧周期不冒充医助联系'); $log(4, 102, 11, $clock); $ok($team()['summary']['reported'] === 2 && $team()['summary']['outstanding'] === 5, '本人复诊登记计待核实仍在未完成中'); $action(7, 105, ['business_state' => 'completed', 'completed_at' => $clock, 'completed_by' => 13]); $tick(); $ok($team()['summary']['reported'] === 2, '主管代完成联系动作不冒充本人登记'); Db::name('assistant_service_action')->where('id', 7)->update(['completed_by' => 11]); $ok($team()['summary']['reported'] === 3, '明确联系动作本人今日登记计待核实'); $summary = $team()['summary']; $ok($summary['required'] === $summary['contacted'] + $summary['reported'] + $summary['pending'] && $summary['outstanding'] === $summary['reported'] + $summary['pending'], '人数恒等式成立'); Db::name('tcm_diagnosis_revisit_due')->where('id', 111)->update(['closed_state' => 4]); $ok($team()['summary']['exempt'] === 1 && $team()['summary']['required'] === 5, 'GET实时关闭豁免,不待下轮也不算完成'); $tick(); $ok(Db::name('assistant_service_daily_contact')->where('diagnosis_id', 111)->value('state') === 'exempt', '扫描持久保存关闭豁免'); Db::name('tcm_diagnosis_revisit_due')->where('id', 111)->update(['closed_state' => 0]); $tick(); $restored = Db::name('assistant_service_daily_contact')->where('diagnosis_id', 111)->find(); $ok($restored['state'] === 'active' && $restored['exempt_reason'] !== '' && $team()['summary']['required'] === 6, '同周期恢复有效责任使用原计划并保留上次豁免原因'); Db::name('tcm_diagnosis_revisit_due')->where('id', 111)->update(['closed_state' => 4]); $tick(); Db::name('tcm_diagnosis')->where('id', 112)->update(['patient_id' => 9999]); $ok(!in_array(112, array_column($patients()['lists'], 'diagnosis_id'), true), '换绑立即隐藏旧计划患者'); $tick(); $ok(Db::name('assistant_service_daily_contact')->where('diagnosis_id', 112)->value('state') === 'exempt', '换绑保留豁免审计'); Db::name('tcm_diagnosis')->where('id', 102)->update(['assistant_id' => 15]); $ok($patients(['status' => 'exempt'])['count'] === 2, '可见范围内转医助旧责任显示豁免'); $tick(); Db::name('tcm_diagnosis_revisit_due')->where('id', 102)->update(['assistant_id' => 15, 'promised_date' => null]); $tick(); $ok($team()['by_staff'][15]['required'] === 1 && $team()['by_staff'][15]['contacted'] === 0, '转医助新责任不继承别人的IM/登记'); Db::name('tcm_prescription_order')->insert(['id' => 99999, 'diagnosis_id' => 110]); $ok($patients(['status' => 'contacted'])['count'] === 1, '同患者其他有效理由仍保留已联系,旧周期源不额外计人数'); $tick(); $ok(Db::name('assistant_service_daily_contact')->where('source_type', 'revisit')->where('source_id', 110)->value('state') === 'exempt', '真实新订单立即使旧快照周期豁免'); Db::name('admin_dept')->where('admin_id', 15)->update(['dept_id' => 20]); $ok(!isset($team()['by_staff'][15]), '实时员工范围移除域外员工'); $deny(fn () => $patients(['assistant_id' => 15]), '明细重新检查员工域'); Db::name('admin_dept')->where('admin_id', 15)->update(['dept_id' => 10]); $tick(); Db::name('admin')->where('id', 11)->update(['disable' => 1]); $ok(!isset($team()['by_staff'][11]), '禁用账号不计在医助汇总'); Db::name('admin')->where('id', 11)->update(['disable' => 0]); $tick(); $before = Db::name('assistant_service_daily_contact')->select()->toArray(); $team(); $patients(); $ok($before === Db::name('assistant_service_daily_contact')->select()->toArray(), 'GET只读不写计划'); $state = json_decode(file_get_contents($temporary . '/scan.json'), true); $ok(!str_contains(json_encode($state), 'patient_id') && !str_contains(json_encode($state), 'diagnosis_id') && !str_contains(json_encode($state), '合成'), 'runtime只存进度/心跳/域指纹,不保存无权限患者清单'); $clock += 181; $ok($team()['meta']['state'] === 'waiting' && $team()['summary']['required'] === null, '过期心跳不能宣布完成'); $tick(); $config->set(['daily_contact_actor_id' => 11], 'assistant_service'); $ok($tick()['state'] === 'error' && $team()['summary']['required'] === null, '不允许一线员工冒充扫描主管'); $config->set(['daily_contact_actor_id' => 13], 'assistant_service'); $tick(); Db::name('system_role_menu')->where('role_id', 3)->delete(); $ok($tick()['state'] === 'error', '每轮重新检查view授权'); $deny(fn () => $team(), '读取也重新检查view授权'); Db::name('system_role_menu')->insert(['role_id' => 3, 'menu_id' => 1]); $tick(); // 单轮预算与多次重启公平游标;既有计划数量大于batch也不阻止新来源发现。 $config->set(['daily_contact_batch_size' => 2], 'assistant_service'); for ($i = 200; $i < 211; $i++) { $diagnosis($i); $revisit($i); } $first = $tick(); $ok($first['scanned'] <= 4 && $team()['meta']['state'] === 'partial', '首轮有界且扫描未完成不提供完整人数'); for ($i = 0; $i < 15; $i++) { // 持续新增诊单改变域指纹,也不得让旧候选永远困在第一页。 $diagnosis(300 + $i); $replacement = new Daily($temporary, static function () use (&$clock): int { return $clock; }); $clock += 61; $replacement->tick(); } $ok(Db::name('assistant_service_daily_contact')->whereIn('diagnosis_id', range(200, 210))->count() === 11, '重启恢复游标并覆盖第一批之后全部患者'); $ok($team()['meta']['state'] === 'partial', '持续新增权限域时保持不完整,游标仍公平前进'); for ($i = 0; $i < 15; $i++) { $tick(); } $ok($team()['meta']['state'] === 'ready', '完整一轮后才可ready'); $lock = fopen($temporary . '/scan.lock', 'c+b'); flock($lock, LOCK_EX | LOCK_NB); $clock += 61; $ok($daily->tick()['state'] === 'waiting', '真实flock排斥并发扫描者'); flock($lock, LOCK_UN); fclose($lock); $beforeDayCount = Db::name('assistant_service_daily_contact')->count(); $clock = $day['end']; $ok($team()['meta']['business_date'] === '2026-11-11' && $team()['summary']['required'] === null, '上海午夜立即切换日界,不展示旧日完整统计'); $config->set(['daily_contact_batch_size' => 500], 'assistant_service'); $daily->tick(); $ok(Db::name('assistant_service_daily_contact')->count() > $beforeDayCount && $team()['meta']['state'] === 'ready', '新自然日独立生成保留昨日快照'); $ok($team()['summary']['contacted'] === 1, '昨日边界排除的次日零点IM只在次日生效'); $oldTimezone = date_default_timezone_get(); date_default_timezone_set('UTC'); $ok(Daily::day($day['end'] - 1)['date'] === '2026-11-10' && Daily::day($day['end'])['date'] === '2026-11-11', '上海日期不依赖默认PHP时区'); date_default_timezone_set($oldTimezone); Db::execute('DROP TABLE test_tcm_im_chat_message'); $ok($team()['meta']['state'] === 'partial' && $team()['summary']['required'] === null, '缺IM表不降成零完成'); Db::execute('CREATE TABLE test_tcm_im_chat_message (id INTEGER PRIMARY KEY,patient_id INTEGER,to_account TEXT,msg_type TEXT,text TEXT,msg_time INTEGER)'); $ok($team()['meta']['state'] === 'partial', '缺可靠账号字段明确不完整'); Db::execute('ALTER TABLE test_tcm_im_chat_message ADD COLUMN from_account TEXT'); $ok($team()['summary']['contacted'] === 0, '真实空档案可以为0'); Db::execute('DROP TABLE test_assistant_service_action'); $ok($team()['summary']['required'] === null && $tick()['state'] === 'partial', '动作来源不可读不能伪装来源为空或将计划豁免'); Db::execute('DROP TABLE test_assistant_service_daily_contact'); $ok($team()['meta']['state'] === 'unavailable' && $team()['summary']['required'] === null, '计划表缺失返回明确不可用'); file_put_contents($temporary . '/scan.json', '{broken'); $config->set(['daily_contact_enabled' => false], 'assistant_service'); $ok($daily->tick()['state'] === 'disabled' && $team()['summary']['required'] === null, '关闭时不触发扫描且不伪装已完成'); echo 'ASSISTANT_SERVICE_DAILY_CONTACT assertions=' . $checks . ' PASS sqlite=memory real_access=1 flock=1 model=0 network=0' . PHP_EOL; } finally { foreach (['scan.json', 'scan.lock'] as $name) { $file = $temporary . '/' . $name; if (is_file($file)) { unlink($file); } } if (is_dir($temporary)) { rmdir($temporary); } }