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' => true, 'profile' => 'qwen', 'timeout' => 120, 'daily_job_limit' => 100, 'max_source_rows' => 200, 'max_source_chars' => 30000, 'max_manual_chars' => 10000, 'max_knowledge_rows' => 40], 'assistant_service'); $config->set(['enable' => true, 'base_url' => 'https://example.invalid', 'models' => [ 'qwen' => ['name' => 'synthetic', 'api_key' => 'synthetic-no-network']]], 'prescription_ai'); $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', 'tracking_note' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,admin_id INTEGER,note_date TEXT,content TEXT,create_time INTEGER,update_time INTEGER,delete_time INTEGER NULL', 'tcm_im_chat_message' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,diagnosis_id INTEGER,from_account TEXT,to_account TEXT,doctor_peer_account TEXT,msg_type TEXT,text TEXT,msg_time INTEGER', 'tcm_diagnosis_todo' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,content TEXT,remind_time INTEGER,status INTEGER,creator_id INTEGER,creator_name TEXT,create_time INTEGER,update_time INTEGER,notified_at INTEGER DEFAULT 0,cancelled_at INTEGER DEFAULT 0,cancelled_by INTEGER DEFAULT 0,delete_time INTEGER NULL', 'assistant_service_record' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,actor_id INTEGER,content TEXT,record_type TEXT,speaker_confirmed INTEGER,occurred_at INTEGER,created_at INTEGER,delete_time INTEGER NULL', 'assistant_service_job' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,actor_id INTEGER,type TEXT,scenario TEXT,days INTEGER,status TEXT,version INTEGER,dedupe_key TEXT UNIQUE,source_hash TEXT,scope_hash TEXT,payload_json TEXT,result_json TEXT,claim_token TEXT DEFAULT "",lease_until INTEGER DEFAULT 0,error_code TEXT DEFAULT "",error_message TEXT DEFAULT "",upstream_id TEXT DEFAULT "",created_at INTEGER,updated_at INTEGER,finished_at INTEGER DEFAULT 0', 'assistant_service_review' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,job_id INTEGER,item_key TEXT,reviewer_id INTEGER,decision TEXT,note TEXT,created_at INTEGER', 'assistant_service_action' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,job_id INTEGER,action_key TEXT,todo_id INTEGER,assignee_id INTEGER,content TEXT,due_at INTEGER,business_state TEXT,completion_note TEXT DEFAULT "",completed_at INTEGER DEFAULT 0,created_at INTEGER,updated_at INTEGER,UNIQUE(job_id,action_key)', 'assistant_service_knowledge' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,question TEXT,answer TEXT,category TEXT,source_note TEXT,status TEXT,version INTEGER,created_by INTEGER,published_by INTEGER,created_at INTEGER,updated_at INTEGER', ]; 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' => 'assistant', 'root' => 0], ['id' => 12, 'name' => 'other', 'root' => 0], ['id' => 13, 'name' => 'team', 'root' => 0], ['id' => 14, 'name' => 'doctor', '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' => 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]]); foreach (Access::PERMISSIONS as $i => $permission) { Db::name('system_menu')->insert(['id' => $i + 1, 'perms' => 'tcm.assistantService/' . $permission]); foreach ([1, 2, 3] as $role) { Db::name('system_role_menu')->insert(['role_id' => $role, 'menu_id' => $i + 1]); } } Db::name('tcm_diagnosis')->insertAll([ ['id' => 101, 'patient_id' => 201, 'assistant_id' => 11, 'patient_name' => '合成甲', 'phone' => '13800138000'], ['id' => 102, 'patient_id' => 202, 'assistant_id' => 12, 'patient_name' => '合成乙', 'phone' => '13900139000'], ['id' => 103, 'patient_id' => 0, 'assistant_id' => 11, 'patient_name' => '未映射', 'phone' => ''], ]); Db::name('doctor_appointment')->insert(['id' => 1, 'patient_id' => 101, 'doctor_id' => 14, 'status' => 1]); $now = time(); Db::name('tracking_note')->insert(['id' => 1, 'diagnosis_id' => 101, 'admin_id' => 11, 'note_date' => date('Y-m-d'), 'content' => '患者要求周六确认预约', 'create_time' => $now, 'update_time' => $now]); foreach ([ [1, 201, 'patient_201', 'doctor_11', 'text', '请帮我核对预约,电话13800138000'], [2, 201, 'doctor_11', 'patient_201', 'text', '我将在明天回复可预约时段'], [3, 201, 'doctor_12', 'patient_201', 'text', '其他部门私有沟通'], [4, 201, 'doctor_11', 'patient_999', 'text', '错误患者映射'], [5, 201, 'doctor_11', 'patient_201', 'image', '不可读图片'], [6, 201, 'doctor_999', 'patient_201', 'text', '不存在的员工'], ] as [$id, $patient, $from, $to, $type, $text]) { Db::name('tcm_im_chat_message')->insert(['id' => $id, 'patient_id' => $patient, 'diagnosis_id' => 999, 'from_account' => $from, 'to_account' => $to, 'doctor_peer_account' => 'doctor_11', 'msg_type' => $type, 'text' => $text, 'msg_time' => $now - 100]); } // 与 API 测试使用相同合成表;新增目标链路必须走真实 Access/Context/Worker,不能用上下文桩替代。 $checks = 0; $ok = static function (bool $condition, string $label) use (&$checks): void { if (!$condition) { throw new RuntimeException($label); } $checks++; }; $deny = static function (callable $call, string $label) use ($ok): void { try { $call(); } catch (DomainException $e) { $ok(true, $label); return; } $ok(false, $label); }; $call = static fn (string $action, array $p = [], int $actor = 13): array => Logic::$action(Validate::parameters($action, $p), $actor); $ok(Access::permissionForAction('teamOverview') === 'view' && Access::permissionForAction('analyzeStaff') === 'analyze', 'explicit HTTP action grants'); $deny(fn () => Access::permissionForAction('analyzeAutomatic'), 'automatic enqueue is not an HTTP action'); foreach ([['filter' => 'unknown'], ['keyword' => []], ['page_size' => 51], ['page_no' => 0], ['days' => 7], ['assistant_id' => 11]] as $invalid) { $deny(fn () => $call('teamOverview', $invalid), 'team parameters reject unrecognized or invalid input'); } Db::name('admin')->insertAll([ ['id' => 15, 'name' => '同组医助', 'root' => 0, 'disable' => 0], ['id' => 16, 'name' => '停用医助', 'root' => 0, 'disable' => 1], ['id' => 17, 'name' => '空白医助', 'root' => 0, 'disable' => 0], ]); Db::name('admin_role')->insertAll([['admin_id' => 15, 'role_id' => 2], ['admin_id' => 16, 'role_id' => 2], ['admin_id' => 17, 'role_id' => 2]]); Db::name('admin_dept')->insertAll([['admin_id' => 15, 'dept_id' => 10], ['admin_id' => 16, 'dept_id' => 10], ['admin_id' => 17, 'dept_id' => 10]]); Db::name('tcm_diagnosis')->insertAll([ ['id' => 104, 'patient_id' => 201, 'assistant_id' => 15, 'patient_name' => '同患者另一诊单', 'phone' => ''], ['id' => 105, 'patient_id' => 205, 'assistant_id' => 15, 'patient_name' => '登记患者', 'phone' => ''], ]); Db::name('assistant_service_record')->insertAll([ ['diagnosis_id' => 105, 'patient_id' => 205, 'actor_id' => 15, 'content' => '本人登记', 'record_type' => 'service_note', 'speaker_confirmed' => 0, 'occurred_at' => $now - 60, 'created_at' => $now], ['diagnosis_id' => 105, 'patient_id' => 999, 'actor_id' => 11, 'content' => '患者已换绑', 'record_type' => 'service_note', 'speaker_confirmed' => 0, 'occurred_at' => $now - 60, 'created_at' => $now], ['diagnosis_id' => 102, 'patient_id' => 202, 'actor_id' => 11, 'content' => '跨患者权限域', 'record_type' => 'service_note', 'speaker_confirmed' => 0, 'occurred_at' => $now - 60, 'created_at' => $now], ]); $overview = $call('teamOverview', ['page_size' => 1]); $ok($overview['days'] === 30 && $overview['summary']['staff_count'] === 3 && $overview['count'] === 3 && count($overview['lists']) === 1, 'summary and list count are not fabricated from current page'); $ok($overview['summary']['active_staff'] === 2 && $overview['automation']['state'] === 'disabled', 'activity counts true scoped recent sources; disabled automation is explicit'); $ok($overview['daily']['state'] === 'unavailable' && $overview['daily']['timezone'] === 'Asia/Shanghai' && $overview['daily_summary']['required'] === null && $overview['daily_summary']['outstanding'] === null, 'legacy schema retains thirty-day overview while daily coverage is explicitly unavailable'); $ok($overview['lists'][0]['today_contact']['state'] === 'unavailable' && $overview['lists'][0]['today_contact']['required'] === null, 'missing daily schema never advertises false-zero per-staff completion'); $ok($call('teamOverview', ['filter' => 'today_done'])['count'] === 0 && $call('teamOverview', ['filter' => 'today_pending'])['count'] === 0, 'unknown daily coverage never matches done or invents pending counts'); $ok($call('teamOverview', ['filter' => 'today_done'])['summary'] === $overview['summary'], 'daily list filtering preserves legacy full-team summary'); $byStaff = static fn (array $overview): array => array_combine(array_column(array_column($overview['lists'], 'staff'), 'id'), $overview['lists']); $rows = $byStaff($call('teamOverview')); $staffIds = array_keys($rows); sort($staffIds); $ok($staffIds === [11, 15, 17], 'team staff scope excludes disabled and other department'); $ok(array_key_first($rows) === 15, 'recent communication appears before older or absent sources'); $ok($rows[11]['metrics']['im_messages'] === 1 && $rows[11]['metrics']['manual_records'] === 0, 'IM dedupes duplicate patient diagnoses and invalid records never leak'); $ok($rows[15]['metrics']['assigned_cases'] === 2 && $rows[15]['metrics']['manual_records'] === 1, 'current assignment and reliable record counts remain separate'); $ok($rows[11]['analysis_state'] === 'pending' && $rows[17]['analysis_state'] === 'no_sources', 'no sources distinguished from awaiting analysis'); $ok($call('teamOverview', ['keyword' => '同组'])['summary']['staff_count'] === 1, 'keyword sets full summary search scope'); $ok($call('teamOverview', [], 11)['summary']['staff_count'] === 1 && $call('teamOverview', [], 14)['count'] === 0, 'ordinary assistant only self; doctor never receives other staff rows'); $ok($call('teamOverview', [], 1)['summary']['staff_count'] === 4, 'root sees eligible assistants without treating itself as one'); Db::name('system_role_menu')->where('role_id', 3)->where('menu_id', 1)->delete(); $deny(fn () => $call('teamOverview'), 'live view permission required'); Db::name('system_role_menu')->insert(['role_id' => 3, 'menu_id' => 1]); $target = ['assistant_id' => 11, 'diagnosis_id' => 101, 'days' => 30, 'scenario' => 'followup']; $job = Logic::analyzeAutomatic($target, 13); $stored = Db::name('assistant_service_job')->where('id', $job['id'])->find(); $ok(json_decode($stored['payload_json'], true)['trigger'] === 'automatic' && (int) $stored['actor_id'] === 13, 'automatic enqueue records trigger and real actor'); $ok(Logic::automationReady(), 'readiness probe uses schema and synthetic configured adapter only'); $deny(fn () => Logic::analyzeAutomatic(array_replace($target, ['days' => 7]), 13), 'automatic window fixed to 30 days'); $deny(fn () => Logic::analyzeAutomatic(array_replace($target, ['assistant_id' => 0]), 13), 'automatic subject must be explicit'); $deny(fn () => Logic::analyzeAutomatic(array_replace($target, ['assistant_id' => 12]), 13), 'automatic subject cannot escape team'); $deny(fn () => Logic::analyzeAutomatic(array_replace($target, ['diagnosis_id' => 102]), 13), 'automatic source cannot escape patient scope'); Db::name('system_role_menu')->where('role_id', 3)->where('menu_id', 2)->delete(); $deny(fn () => Logic::analyzeAutomatic($target, 13), 'automatic never bypasses revoked analyze permission'); Db::name('system_role_menu')->insert(['role_id' => 3, 'menu_id' => 2]); $queued = $call('teamOverview'); $ok($queued['summary']['queued_jobs'] === 1 && $byStaff($queued)[11]['analysis_state'] === 'queued', 'actual queue shown without GET creating jobs'); $ok(Logic::analyzeAutomatic(array_replace($target, ['scenario' => 'initial']), 13)['id'] === $job['id'], 'active target and diagnosis barrier does not depend on dedupe key'); $ok(Logic::analyzeAutomatic($target, 1)['id'] === $job['id'], 'active barrier also applies to another authorized operator'); foreach (['running', 'needs_reconciliation'] as $state) { Db::name('assistant_service_job')->where('id', $job['id'])->update(['status' => $state]); $ok(Logic::analyzeAutomatic(array_replace($target, ['scenario' => 'revisit']), 13)['id'] === $job['id'], 'automatic barrier holds for ' . $state); $report = $call('teamOverview'); $field = $state === 'running' ? 'running_jobs' : 'attention_jobs'; $ok($report['summary'][$field] === 1, 'visible team counts ' . $state); } Db::name('assistant_service_job')->where('id', $job['id'])->update(['status' => 'failed', 'error_code' => 'INTERNAL_ERROR']); $ok(Logic::analyzeAutomatic($target, 13)['status'] === 'failed', 'automatic never restores previously failed dedupe'); $ok($call('analyze', $target)['status'] === 'queued', 'manual explicit recovery remains compatible'); $quality = []; foreach (['identity', 'needs', 'explanation', 'next_step', 'followup', 'escalation'] as $key) { $quality[] = ['key' => $key, 'label' => '合成核查', 'status' => $key === 'next_step' ? 'missed' : 'met', 'reason' => '仅用于测试核查流程', 'evidence_ids' => ['im:2'], 'actor_admin_id' => 11]; } $result = ['summary' => [], 'needs' => [], 'unresolved' => [], 'tags' => [], 'suggested_actions' => [], 'quality_checks' => $quality]; $ready = static function (int $id, array $result) use ($now): void { Db::name('assistant_service_job')->where('id', $id)->update(['status' => 'ready', 'result_json' => json_encode($result), 'finished_at' => $now, 'updated_at' => $now]); }; $ready($job['id'], $result); $newer = $call('analyze', array_replace($target, ['scenario' => 'initial'])); $ready($newer['id'], $result); $overview = $call('teamOverview', ['filter' => 'issues', 'page_size' => 1]); $ok($overview['summary']['analyzed_cases'] === 1 && $overview['summary']['unreviewed_issues'] === 1 && $overview['count'] === 1, 'latest valid quality per diagnosis key prevents duplicate issues'); $ok($overview['summary']['staff_count'] === 3 && $overview['lists'][0]['last_analyzed_at'] === $now, 'filter preserves global search summary and real completion time'); $ok($call('teamOverview', ['page_no' => 4, 'page_size' => 1])['summary'] === $call('teamOverview')['summary'] && $call('teamOverview', ['page_no' => 4, 'page_size' => 1])['lists'] === [], 'empty later page still reports exact full summary'); $call('reviewQuality', ['job_id' => $newer['id'], 'item_key' => 'next_step', 'decision' => 'confirmed', 'version' => 1]); $call('reviewQuality', ['job_id' => $newer['id'], 'item_key' => 'next_step', 'decision' => 'dismissed', 'version' => 1]); $ok($call('teamOverview', ['filter' => 'issues'])['count'] === 0 && $call('teamOverview')['summary']['unreviewed_issues'] === 0, 'latest review wins and never falls back to old missed item'); $insufficient = $result; $insufficient['quality_checks'][3]['status'] = 'insufficient'; $insufficient['quality_checks'][3]['actor_admin_id'] = 0; $ready($newer['id'], $insufficient); $ok($call('teamOverview')['summary']['unreviewed_issues'] === 0, 'latest insufficient evidence suppresses older personal attribution'); $ready($newer['id'], $result); Db::name('assistant_service_action')->insertAll([ ['diagnosis_id' => 101, 'job_id' => $newer['id'], 'action_key' => 'pending', 'assignee_id' => 11, 'business_state' => 'pending', 'due_at' => $now - 1, 'completed_at' => 0], ['diagnosis_id' => 101, 'job_id' => $newer['id'], 'action_key' => 'complete', 'assignee_id' => 15, 'business_state' => 'completed', 'due_at' => $now - 1, 'completed_at' => $now], ['diagnosis_id' => 102, 'job_id' => $newer['id'], 'action_key' => 'outside', 'assignee_id' => 11, 'business_state' => 'pending', 'due_at' => $now - 1, 'completed_at' => 0], ['diagnosis_id' => 104, 'job_id' => $newer['id'], 'action_key' => 'mismatch', 'assignee_id' => 11, 'business_state' => 'pending', 'due_at' => $now - 1, 'completed_at' => 0], ]); $overview = $call('teamOverview', ['filter' => 'overdue']); $ok($call('teamOverview')['lists'][0]['staff']['id'] === 11, 'pending human attention appears before more recent communication'); $ok($overview['count'] === 1 && $overview['summary']['pending_actions'] === 1 && $overview['summary']['overdue_actions'] === 1, 'actions enforce both patient scope and original diagnosis binding'); $ok($byStaff($call('teamOverview'))[15]['metrics']['completed_actions'] === 1, 'completed period is independent from pending stock'); $ok($call('teamOverview', ['filter' => 'attention'])['count'] === 1, 'attention includes pending human followups'); $ok($call('teamOverview', ['keyword' => '同组', 'filter' => 'overdue'])['summary']['pending_actions'] === 0, 'keyword summary never includes a hidden staff member'); // 其他主管产生的、同一患者上明确属于域外员工的任务,不可借患者可见性混入统计。 $hiddenContext = Context::build(101, 1, 30, 12); $hiddenJob = Db::name('assistant_service_job')->insertGetId(['diagnosis_id' => 101, 'patient_id' => 201, 'actor_id' => 1, 'type' => 'analysis', 'scenario' => 'followup', 'days' => 30, 'status' => 'queued', 'version' => 1, 'dedupe_key' => 'synthetic-outside-staff', 'source_hash' => $hiddenContext['source_hash'], 'scope_hash' => $hiddenContext['scope_hash'], 'payload_json' => json_encode(['context' => $hiddenContext, 'knowledge' => []]), 'created_at' => $now, 'updated_at' => $now]); $ok($call('teamOverview')['summary']['queued_jobs'] === 0 && $call('teamOverview', [], 1)['summary']['queued_jobs'] === 1, 'visible patient never permits queue counts for an unauthorized staff subject'); Db::name('assistant_service_job')->where('id', $hiddenJob)->delete(); // 查询监听只计真实 SQL:同一来源快照有许多历史结果时,不应逐任务重读来源正文。 $sourceReads = 0; $manager->listen(static function (string $sql) use (&$sourceReads): void { if (preg_match('/^SELECT .*FROM [`"]?test_(assistant_service_record|tracking_note|tcm_im_chat_message)[`"]?/i', $sql) && !str_contains(strtoupper($sql), 'GROUP BY') && !str_contains(strtoupper($sql), 'LIMIT 1') && !preg_match('/\b1\s*=\s*0\b/', $sql)) { $sourceReads++; } }); $stored = Db::name('assistant_service_job')->where('id', $newer['id'])->find(); $duplicates = []; for ($i = 0; $i < 6; $i++) { $copy = $stored; unset($copy['id']); $copy['dedupe_key'] = 'synthetic-history-' . $i; $duplicates[] = (int) Db::name('assistant_service_job')->insertGetId($copy); } $beforeReads = $sourceReads; $history = $call('teamOverview'); $readCount = $sourceReads - $beforeReads; $ok($history['summary']['analyzed_cases'] === 1 && $history['summary']['unreviewed_issues'] === 1, 'many history tasks retain one current diagnosis and dimension'); $ok($readCount > 0 && $readCount <= 3, 'request context cache prevents repeated source reads per historical job'); Db::name('assistant_service_job')->whereIn('id', $duplicates)->delete(); Db::name('tcm_im_chat_message')->where('id', 2)->update(['text' => '证据原文修改']); $stale = $call('teamOverview'); $ok($stale['summary']['analyzed_cases'] === 0 && $stale['summary']['unreviewed_issues'] === 0 && $stale['summary']['attention_jobs'] === 0 && $byStaff($stale)[11]['analysis_state'] === 'pending', 'changed sources invalidate old quality and await a fresh analysis instead of inventing a failure'); $ok($byStaff($stale)[11]['last_analyzed_at'] === 0, 'old result does not fabricate current analysis coverage'); Db::name('admin_dept')->where('admin_id', 11)->update(['dept_id' => 20]); $moved = $call('teamOverview'); $ok($moved['summary']['staff_count'] === 2 && $moved['summary']['attention_jobs'] === 0 && $moved['summary']['pending_actions'] === 0, 'staff and patient domain updates remove old team data immediately'); Db::name('admin_dept')->where('admin_id', 11)->update(['dept_id' => 10]); Db::name('admin')->where('id', 11)->update(['disable' => 1]); $ok($call('teamOverview')['summary']['staff_count'] === 2, 'disabled target never counted'); Db::name('admin')->where('id', 11)->update(['disable' => 0]); Db::execute('DROP TABLE test_tcm_im_chat_message'); $ok($byStaff($call('teamOverview'))[11]['metrics']['im_messages'] === null, 'unconnected IM stays unavailable rather than false zero'); Db::execute('CREATE TABLE test_tcm_im_chat_message (id INTEGER PRIMARY KEY,patient_id INTEGER,msg_type TEXT,msg_time INTEGER,to_account TEXT,text TEXT)'); $ok($byStaff($call('teamOverview'))[11]['metrics']['im_messages'] === null, 'missing IM identity column is unavailable'); Db::execute('ALTER TABLE test_tcm_im_chat_message ADD COLUMN from_account TEXT'); $ok($byStaff($call('teamOverview'))[11]['metrics']['im_messages'] === 0, 'valid empty IM archive is zero'); Db::execute('DROP TABLE test_tracking_note'); $ok($call('teamOverview')['summary']['staff_count'] === 3, 'optional tracking source absent does not hide team'); $ok((int) Db::name('assistant_service_job')->count() === 2, 'all dashboard reads stayed read-only'); $config->set(['enabled' => false], 'assistant_service'); $ok(!Logic::automationReady(), 'automation readiness obeys disabled generation'); // 独立临时 runtime 与内存来源表验证真实计划服务贯通 API;AI 保持关闭。 Db::execute('ALTER TABLE test_assistant_service_action ADD COLUMN contact_required INTEGER NOT NULL DEFAULT 0'); Db::execute('ALTER TABLE test_assistant_service_action ADD COLUMN completed_by INTEGER NOT NULL DEFAULT 0'); Db::connect()->getSchemaInfo('test_assistant_service_action', true); foreach ([ '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,reason TEXT,due_at INTEGER,state TEXT,exempt_reason TEXT,created_at INTEGER,updated_at INTEGER,UNIQUE(business_date,assistant_id,source_type,source_id,source_order_id)', 'tcm_diagnosis_revisit_due' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,assistant_id INTEGER,source_order_id INTEGER,due_date TEXT,promised_date TEXT,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_prescription_order' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,fulfillment_status INTEGER DEFAULT 1,delete_time INTEGER NULL', ] as $name => $schema) { Db::execute('CREATE TABLE test_' . $name . ' (' . $schema . ')'); Db::connect()->getSchemaInfo('test_' . $name, true); } $now = time(); $day = \app\common\service\assistantservice\AssistantServiceDailyContact::day($now); $config->set(['daily_contact_enabled' => true, 'daily_contact_actor_id' => 13, 'daily_contact_batch_size' => 100], 'assistant_service'); Db::name('tcm_prescription_order')->insertAll([['id' => 1, 'diagnosis_id' => 101], ['id' => 2, 'diagnosis_id' => 105]]); Db::name('tcm_diagnosis_revisit_due')->insertAll([ ['id' => 1, 'diagnosis_id' => 101, 'assistant_id' => 11, 'source_order_id' => 1, 'due_date' => $day['date']], ['id' => 2, 'diagnosis_id' => 105, 'assistant_id' => 15, 'source_order_id' => 2, 'due_date' => $day['date']], ]); Db::name('tcm_im_chat_message')->insert(['id' => 31, 'patient_id' => 201, 'from_account' => 'doctor_11', 'to_account' => 'patient_201', 'msg_type' => 'text', 'text' => '今日联系合成记录', 'msg_time' => $now]); Db::name('tcm_diagnosis')->insert(['id' => 106, 'patient_id' => 206, 'assistant_id' => 15, 'patient_name' => '本人登记合成患者', 'phone' => '']); $contactJob = Db::name('assistant_service_job')->insertGetId(['diagnosis_id' => 106, 'patient_id' => 206, 'actor_id' => 13, 'type' => 'contact_fixture', 'dedupe_key' => 'daily-contact-api-fixture']); Db::name('assistant_service_action')->insert(['diagnosis_id' => 106, 'job_id' => $contactJob, 'action_key' => 'daily_contact', 'assignee_id' => 15, 'business_state' => 'completed', 'due_at' => $now, 'completed_at' => $now, 'completed_by' => 15, 'contact_required' => 1]); $planner = new \app\common\service\assistantservice\AssistantServiceDailyContact(); $scan = $planner->tick(); $ok($scan['state'] === 'ready' && $scan['created'] === 3, 'isolated deterministic scanner prepares three real daily plan sources'); $beforePlans = (int) Db::name('assistant_service_daily_contact')->count(); $beforeJobs = (int) Db::name('assistant_service_job')->count(); $today = $call('teamOverview', ['page_size' => 1]); $ok($today['daily']['state'] === 'ready' && $today['daily']['business_date'] === $day['date'] && !$config->get('assistant_service.enabled'), 'daily API is ready with server business date while AI remains disabled'); $ok($today['daily_summary'] === ['required' => 3, 'contacted' => 1, 'reported' => 1, 'pending' => 1, 'outstanding' => 2, 'exempt' => 0, 'state' => 'pending'], 'full-team daily counts precede page slicing and keep reported outstanding'); $ok($today['lists'][0]['staff']['id'] === 15 && $today['lists'][0]['today_contact']['outstanding'] === 2, 'today outstanding sorts ahead of older quality action urgency'); $todayPending = $call('teamOverview', ['filter' => 'today_pending']); $todayDone = $call('teamOverview', ['filter' => 'today_done']); $ok(array_column(array_column($todayPending['lists'], 'staff'), 'id') === [15] && array_column(array_column($todayDone['lists'], 'staff'), 'id') === [11], 'daily filters distinguish outstanding, verified completion and no tasks'); $ok($todayPending['daily_summary'] === $today['daily_summary'] && $todayDone['daily_summary'] === $today['daily_summary'] && $todayPending['summary'] === $today['summary'], 'daily filters leave both daily and thirty-day full-search summaries intact'); $later = $call('teamOverview', ['page_size' => 1, 'page_no' => 5]); $ok($later['lists'] === [] && $later['daily_summary'] === $today['daily_summary'] && $later['summary'] === $today['summary'], 'empty page retains both exact summaries'); $keyword = $call('teamOverview', ['keyword' => '同组']); $ok($keyword['count'] === 1 && $keyword['daily_summary']['required'] === 2 && $keyword['daily_summary']['contacted'] === 0, 'keyword narrows daily summary to matching authorized staff before daily filters'); $pendingPatients = $call('staffDailyContacts', ['assistant_id' => 15]); $reportedPatients = $call('staffDailyContacts', ['assistant_id' => 15, 'status' => 'reported']); $allFirst = $call('staffDailyContacts', ['assistant_id' => 15, 'status' => 'all', 'page_size' => 1]); $allSecond = $call('staffDailyContacts', ['assistant_id' => 15, 'status' => 'all', 'page_size' => 1, 'page_no' => 2]); $ok($pendingPatients['count'] === 1 && $pendingPatients['lists'][0]['patient_id'] === 205 && $reportedPatients['count'] === 1 && $reportedPatients['lists'][0]['patient_id'] === 206, 'daily patient route defaults pending and separates self-reported evidence'); $ok($allFirst['count'] === 2 && $allSecond['count'] === 2 && $allFirst['lists'][0]['key'] !== $allSecond['lists'][0]['key'] && $pendingPatients['summary'] === $reportedPatients['summary'] && $allFirst['summary'] === $pendingPatients['summary'], 'daily patient status filters and pagination never shrink staff summary'); $ok($reportedPatients['lists'][0]['evidence'][0]['type'] === 'action_completed' && $call('staffDailyContacts', ['assistant_id' => 11, 'status' => 'contacted'])['lists'][0]['evidence'][0]['type'] === 'im', 'API preserves machine contact versus manual completion evidence types'); $ok($call('staffDailyContacts', ['assistant_id' => 17, 'status' => 'all'])['summary']['state'] === 'no_tasks', 'no-task staff never receives a completed percentage state'); Db::name('tcm_diagnosis_revisit_due')->where('id', 2)->update(['closed_state' => 1]); $exempt = $call('staffDailyContacts', ['assistant_id' => 15, 'status' => 'exempt']); $ok($exempt['count'] === 1 && $exempt['summary']['required'] === 1 && $exempt['summary']['exempt'] === 1 && $exempt['summary']['reported'] === 1, 'daily API immediately separates closed source exemption from verified completion'); $ok((int) Db::name('assistant_service_daily_contact')->count() === $beforePlans && (int) Db::name('assistant_service_job')->count() === $beforeJobs, 'all integrated API GET calls remain read-only without additional plans or model jobs'); echo 'ASSISTANT_SERVICE_TEAM assertions=' . $checks . ' PASS sqlite=memory real_access_context_logic=1 network=0' . PHP_EOL;