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); $target = ['assistant_id' => 11, 'days' => 30]; $daily = $call('staffDailyContacts', ['assistant_id' => 11]); $ok($daily['staff']['id'] === 11 && $daily['daily']['state'] === 'unavailable' && $daily['summary']['required'] === null && $daily['lists'] === [], 'daily route returns explicit unavailable with old synthetic schema'); $deny(fn () => $call('staffDailyContacts', ['assistant_id' => 12]), 'daily route checks staff scope even when schema unavailable'); Db::name('system_role_menu')->where('role_id', 3)->where('menu_id', 1)->delete(); $deny(fn () => $call('staffDailyContacts', ['assistant_id' => 11]), 'daily route checks live module view permission'); Db::name('system_role_menu')->insert(['role_id' => 3, 'menu_id' => 1]); 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('system_role')->insert(['id' => 22, 'name' => '医助', 'data_scope' => 4]); Db::name('admin_role')->insertAll([['admin_id' => 15, 'role_id' => 2], ['admin_id' => 16, 'role_id' => 2], ['admin_id' => 17, 'role_id' => 22], ['admin_id' => 1, 'role_id' => 2], ['admin_id' => 13, '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]]); $ok(array_column($call('staff')['lists'], 'id') === [11, 15, 17], 'manager sees active assistant roles only, never manager or root aliases'); $ok(array_column($call('staff', [], 11)['lists'], 'id') === [11], 'ordinary assistant sees self'); $ok($call('staff', [], 14)['count'] === 0, 'doctor is not an assistant'); $ok(array_column($call('staff', [], 1)['lists'], 'id') === [11, 12, 15, 17], 'root sees assistants without becoming one'); $ok($call('staff', ['keyword' => '新角色'])['lists'][0]['id'] === 17, 'named active assistant role works without module permission'); foreach ([12, 13, 14, 16, 999] as $id) { $deny(fn () => $call('staffOverview', ['assistant_id' => $id, 'days' => 30]), 'invalid or cross-department target ' . $id); } $deny(fn () => $call('staffOverview', $target, 15), 'peer assistant cannot inspect colleague'); foreach ([0, -1, 1.2, '1.2', true] as $id) { $deny(fn () => $call('staffOverview', ['assistant_id' => $id]), 'strict required assistant id'); } $deny(fn () => $call('analyzeStaff', $target + ['scenario' => 'followup', 'diagnosis_ids' => []]), 'empty batch rejected'); $deny(fn () => $call('analyzeStaff', $target + ['scenario' => 'followup', 'diagnosis_ids' => range(100, 110)]), 'more than ten rejected'); $deny(fn () => $call('analyzeStaff', $target + ['scenario' => 'followup', 'diagnosis_ids' => [101, 101]]), 'duplicate batch rejected'); $deny(fn () => $call('analyzeStaff', $target + ['scenario' => 'followup', 'diagnosis_ids' => [101.5]]), 'float in batch rejected'); 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' => ''], ['id' => 106, 'patient_id' => 206, 'assistant_id' => 15, 'patient_name' => '仅登记历史', 'phone' => ''], ]); Db::name('assistant_service_record')->insertAll([ ['diagnosis_id' => 101, 'patient_id' => 201, 'actor_id' => 11, 'content' => '目标医助登记', 'record_type' => 'service_note', 'speaker_confirmed' => 0, 'occurred_at' => $now - 50, 'created_at' => $now], ['diagnosis_id' => 101, 'patient_id' => 201, 'actor_id' => 15, 'content' => '同部门他人登记不可混入', 'record_type' => 'service_note', 'speaker_confirmed' => 0, 'occurred_at' => $now - 50, 'created_at' => $now], ['diagnosis_id' => 106, 'patient_id' => 206, 'actor_id' => 11, 'content' => '历史医助登记', 'record_type' => 'service_note', 'speaker_confirmed' => 0, 'occurred_at' => $now - 40, 'created_at' => $now], ['diagnosis_id' => 105, 'patient_id' => 999, 'actor_id' => 11, 'content' => '旧患者绑定不可混入', 'record_type' => 'service_note', 'speaker_confirmed' => 0, 'occurred_at' => $now - 40, 'created_at' => $now], ['diagnosis_id' => 105, 'patient_id' => 205, 'actor_id' => 11, 'content' => '期间外记录', 'record_type' => 'service_note', 'speaker_confirmed' => 0, 'occurred_at' => $now - 40 * 86400, 'created_at' => $now], ]); Db::name('tracking_note')->insert(['id' => 2, 'diagnosis_id' => 101, 'admin_id' => 15, 'note_date' => date('Y-m-d'), 'content' => '其他医助的跟踪备注', 'create_time' => $now, 'update_time' => $now]); Db::name('tcm_im_chat_message')->insert(['id' => 7, 'patient_id' => 201, 'diagnosis_id' => 101, 'from_account' => 'doctor_15', 'to_account' => 'patient_201', 'doctor_peer_account' => 'doctor_11', 'msg_type' => 'text', 'text' => '同组其他医助发言', 'msg_time' => $now - 90]); $context = $call('context', $target + ['diagnosis_id' => 101]); $ok($context['latest_job'] === null && $context['latest_analysis_job'] === null, 'empty context explicitly has no latest task or analysis'); $ids = array_column($context['sources'], 'id'); sort($ids); $ok($ids === ['im:1', 'im:2', 'manual:1', 'tracking:1'] && $context['subject_admin_id'] === 11, 'target context includes only target pair, records and authored tracking'); $manual = array_values(array_filter($context['sources'], static fn ($source) => $source['channel'] === 'manual'))[0]; $ok($manual['actor_admin_id'] === 0 && $manual['speaker'] === 'unknown', 'target recorder never becomes historical speaker'); $otherContext = $call('context', ['assistant_id' => 15, 'diagnosis_id' => 101, 'days' => 30]); $ok($otherContext['source_hash'] !== $context['source_hash'] && $otherContext['scope_hash'] !== $context['scope_hash'], 'target participates in source and scope hashes'); $ok(!str_contains(json_encode($context['sources']), '同组其他') && !str_contains(json_encode($otherContext['sources']), '目标医助登记'), 'same patient target source isolation'); $ok($call('context', ['assistant_id' => 17, 'diagnosis_id' => 101])['subject_admin_id'] === 17, 'target needs valid assistant identity but no module grants or impersonated login'); Db::name('tcm_diagnosis')->insert(['id' => 107, 'patient_id' => 207, 'assistant_id' => 17, 'patient_name' => '命名角色患者', 'phone' => '']); Db::name('system_role_menu')->insert(['role_id' => 22, 'menu_id' => 1]); $ok(array_column($call('staffCases', ['assistant_id' => 17, 'days' => 30], 17)['lists'], 'diagnosis_id') === [107], 'named assistant role with view grant retains own assigned patient scope'); $cases = $call('staffCases', $target); $caseIds = array_column($cases['lists'], 'diagnosis_id'); sort($caseIds); $ok($caseIds === [101, 103, 104, 106], 'cases are current assignment or target period activity, not all visible patients'); $ok(in_array(105, array_column($call('staffCases', ['assistant_id' => 11, 'days' => 90])['lists'], 'diagnosis_id'), true), 'changing period includes historical own registration without changing current assignee'); $ok($call('staffCases', $target + ['page_size' => 1])['count'] === 4, 'staff case pagination count'); $ok($call('staffCases', $target + ['keyword' => '13800138000'])['lists'][0]['phone_masked'] === '138****8000', 'case phone search preserves masked output'); $overview = $call('staffOverview', $target); $ok($overview['metrics']['assigned_cases'] === 2, 'assignment stock separately counted'); $ok($overview['metrics']['im_messages'] === 1 && $overview['metrics']['manual_records'] === 2, 'outgoing IM unique across duplicate patient diagnoses; reliable manual binding only'); $ok($overview['metrics']['analyzed_cases'] === 0 && count($overview['quality']) === 6, 'empty analysis is zero, not fabricated'); $legacy = Context::build(101, 13); $ok($legacy['source_hash'] === hash('sha256', json_encode($legacy['sources'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)), 'no-target legacy source hash unchanged'); $before = (int) Db::name('assistant_service_job')->count(); $deny(fn () => $call('analyzeStaff', $target + ['scenario' => 'followup', 'diagnosis_ids' => [101, 105]]), 'visible but unrelated case cannot enter target batch'); $deny(fn () => $call('analyzeStaff', $target + ['scenario' => 'followup', 'diagnosis_ids' => [101, 102]]), 'cross-department batch refused'); $deny(fn () => $call('analyzeStaff', $target + ['scenario' => 'followup', 'diagnosis_ids' => [101, 103]]), 'missing sources rolls back earlier enqueued case'); $ok((int) Db::name('assistant_service_job')->count() === $before, 'entire batch atomic on no-source failure'); $config->set(['daily_job_limit' => 1], 'assistant_service'); $deny(fn () => $call('analyzeStaff', $target + ['scenario' => 'followup', 'diagnosis_ids' => [101, 106]]), 'batch respects aggregate daily quota'); $ok((int) Db::name('assistant_service_job')->count() === $before, 'quota rejection rolls back first job'); $config->set(['daily_job_limit' => 100], 'assistant_service'); $batch = $call('analyzeStaff', $target + ['scenario' => 'followup', 'diagnosis_ids' => [101, 104]]); $ok($batch['count'] === 2 && array_column($batch['jobs'], 'subject_admin_id') === [11, 11], 'batch queues explicit target tasks'); $repeat = $call('analyzeStaff', $target + ['scenario' => 'followup', 'diagnosis_ids' => [101, 104]]); $ok(array_column($batch['jobs'], 'id') === array_column($repeat['jobs'], 'id'), 'batch deduplication reuses jobs'); $jobId = $batch['jobs'][0]['id']; $stored = Db::name('assistant_service_job')->where('id', $jobId)->find(); $ok((int) $stored['actor_id'] === 13 && json_decode($stored['payload_json'], true)['context']['subject_admin_id'] === 11, 'actual operator is manager; analysis subject is assistant'); $ok($call('jobs', ['assistant_id' => 11])['count'] === 2 && $call('jobs', ['assistant_id' => 15])['count'] === 0, 'jobs filter by payload subject, never operator or current assignment'); $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' => [['text' => '预约服务记录', 'evidence_ids' => ['im:1']]], 'needs' => [], 'unresolved' => [], 'tags' => [], 'suggested_actions' => [['key' => 'confirm_slot', 'text' => '核对可预约时段', 'evidence_ids' => ['im:1']]], 'quality_checks' => $quality]; $worker = new \app\common\service\assistantservice\AssistantServiceWorker(); $calls = 0; $generator = static function ($type, $scenario, $ctx, $knowledge, $question) use (&$calls, $ok, $result): array { $calls++; $ok($ctx['subject_admin_id'] === 11 && !in_array('im:7', array_column($ctx['sources'], 'id'), true), 'real worker retains target and excludes other assistant sources'); return ['ok' => true, 'result' => $result, 'message_id' => 'synthetic-target']; }; $ok($worker->runOnce($generator) && $worker->runOnce($generator) && $calls === 2, 'real target worker consumes queued tasks using real context and access'); $view = $call('job', ['id' => $jobId]); $ok($view['status'] === 'ready' && $view['subject_admin_id'] === 11, 'target remains valid across worker checks and manager read'); $ok($call('context', $target + ['diagnosis_id' => 101])['latest_job']['id'] === $jobId, 'context latest task matches target'); $overview = $call('staffOverview', $target); $checksByKey = array_column($overview['quality'], null, 'key'); $ok($overview['metrics']['analyzed_cases'] === 2 && $checksByKey['next_step']['missed'] === 2, 'analysis counts distinct diagnoses with attributable evidence'); $ok($call('staffIssues', $target)['count'] === 2, 'target missed issues surfaced across cases'); $ok($call('staffOverview', ['assistant_id' => 11, 'days' => 7])['metrics']['analyzed_cases'] === 0, '30-day analysis never counted as 7-day coverage'); $ok($call('staffOverview', ['assistant_id' => 15, 'days' => 30])['metrics']['analyzed_cases'] === 0, 'analysis not attributed to current other assignee'); $newJob = $call('analyze', $target + ['diagnosis_id' => 101, 'scenario' => 'initial']); $worker->runOnce($generator); // 较新回复占满历史第一页时,最新分析仍须由 context 独立返回。 Db::startTrans(); try { $latestContext = $call('context', $target + ['diagnosis_id' => 101]); $ok($latestContext['latest_analysis_job']['id'] === $newJob['id'] && $latestContext['latest_analysis_job'] === $latestContext['latest_job'], 'same latest analysis keeps complete formatted job'); $replyFixture = Db::name('assistant_service_job')->where('id', $newJob['id'])->find(); unset($replyFixture['id']); $replyFixture['type'] = 'reply'; $replyFixture['status'] = 'queued'; $replyFixture['result_json'] = null; for ($i = 0; $i < 12; $i++) { $replyFixture['dedupe_key'] = 'synthetic-context-reply-' . $i; $replyId = (int) Db::name('assistant_service_job')->insertGetId($replyFixture); } $history = $call('jobs', ['assistant_id' => 11, 'diagnosis_id' => 101, 'page_no' => 1, 'page_size' => 10]); $ok(count($history['lists']) === 10 && array_unique(array_column($history['lists'], 'type')) === ['reply'], 'history first page contains only replies'); $call('analyze', ['assistant_id' => 15, 'diagnosis_id' => 101, 'days' => 30, 'scenario' => 'initial']); $call('analyze', ['assistant_id' => 11, 'diagnosis_id' => 101, 'days' => 7, 'scenario' => 'initial']); $call('analyze', $target + ['diagnosis_id' => 104, 'scenario' => 'initial']); $mismatchedPatient = Db::name('assistant_service_job')->where('id', $newJob['id'])->find(); unset($mismatchedPatient['id']); $mismatchedPatient['patient_id'] = 999; $mismatchedPatient['dedupe_key'] = 'synthetic-context-mismatched-patient'; Db::name('assistant_service_job')->insert($mismatchedPatient); $beforeRead = (int) Db::name('assistant_service_job')->count(); $afterReply = $call('context', $target + ['diagnosis_id' => 101]); $ok($afterReply['latest_job']['id'] === $replyId && $afterReply['latest_job']['type'] === 'reply', 'generic latest task remains the newest matching reply'); $ok($afterReply['latest_analysis_job']['id'] === $newJob['id'] && $afterReply['latest_analysis_job'] === $latestContext['latest_analysis_job'], 'latest analysis ignores replies and other subjects, periods, diagnoses and patient bindings'); $ok((int) Db::name('assistant_service_job')->count() === $beforeRead, 'context latest analysis lookup does not enqueue jobs'); } finally { Db::rollback(); } $ok($call('staffIssues', $target)['count'] === 2, 'multiple analyses do not inflate same diagnosis key'); $issues = $call('staffIssues', $target)['lists']; $latest = array_values(array_filter($issues, static fn ($issue) => $issue['diagnosis_id'] === 101))[0]; $ok($latest['job_id'] === $newJob['id'], 'latest valid analysis wins per diagnosis quality item'); $call('reviewQuality', ['job_id' => $newJob['id'], 'item_key' => 'next_step', 'decision' => 'confirmed', 'note' => '核对', 'version' => 1]); $call('reviewQuality', ['job_id' => $newJob['id'], 'item_key' => 'next_step', 'decision' => 'dismissed', 'note' => '复核排除', 'version' => 1]); $overview = $call('staffOverview', $target); $checksByKey = array_column($overview['quality'], null, 'key'); $ok($checksByKey['next_step']['dismissed'] === 1 && $checksByKey['next_step']['confirmed'] === 0 && $checksByKey['next_step']['unreviewed'] === 1, 'latest human review controls aggregated review state'); $ok($call('staffIssues', $target)['count'] === 1, 'dismissed issue excluded rather than fallback to old missed analysis'); $insufficient = $result; $insufficient['quality_checks'][3]['status'] = 'insufficient'; $insufficient['quality_checks'][3]['actor_admin_id'] = 0; $insufficient['quality_checks'][3]['evidence_ids'] = []; Db::name('assistant_service_job')->where('id', $newJob['id'])->update(['result_json' => json_encode($insufficient)]); $overview = $call('staffOverview', $target); $checksByKey = array_column($overview['quality'], null, 'key'); $ok($checksByKey['next_step']['missed'] === 1 && $checksByKey['next_step']['insufficient'] === 0 && str_contains(implode('', $overview['coverage_notes']), '1 项证据不足'), 'unattributable insufficient coverage is reported separately and replaces older personal claims'); Db::name('assistant_service_job')->where('id', $newJob['id'])->update(['result_json' => json_encode($result)]); $call('reviewQuality', ['job_id' => $batch['jobs'][1]['id'], 'item_key' => 'next_step', 'decision' => 'not_applicable', 'note' => '不适用', 'version' => 1]); $ok($call('staffIssues', $target)['count'] === 0, 'human not-applicable excludes the outstanding issue'); $adopt = ['job_id' => $newJob['id'], 'action_key' => 'confirm_slot', 'version' => 1, 'due_at' => $now + 3600]; $action = $call('adoptAction', $adopt); $actionRow = Db::name('assistant_service_action')->where('id', $action['id'])->find(); $todo = Db::name('tcm_diagnosis_todo')->where('id', $action['todo_id'])->find(); $ok((int) $actionRow['assignee_id'] === 11 && (int) $todo['creator_id'] === 13, 'manager adopts to target while reminder creator preserves real operator'); $ok($call('actions', ['assistant_id' => 11])['count'] === 1 && $call('actions', ['assistant_id' => 15])['count'] === 0, 'cross-patient execution is assignee-filtered'); Db::name('assistant_service_action')->where('id', $action['id'])->update(['due_at' => $now - 1]); $overview = $call('staffOverview', $target); $ok($overview['metrics']['pending_actions'] === 1 && $overview['metrics']['overdue_actions'] === 1, 'pending and overdue current stock'); $call('completeAction', ['id' => $action['id'], 'state' => 'completed', 'note' => '已核对'], 11); $overview = $call('staffOverview', $target); $ok($overview['metrics']['pending_actions'] === 0 && $overview['metrics']['completed_actions'] === 1, 'completed period separate from notification state'); Db::name('assistant_service_action')->where('id', $action['id'])->update(['completed_at' => $now - 40 * 86400]); $ok($call('staffOverview', $target)['metrics']['completed_actions'] === 0, 'completed outside selected period excluded'); Db::name('system_role_menu')->where('role_id', 3)->where('menu_id', 7)->delete(); Db::name('admin_role')->where('admin_id', 13)->where('role_id', 2)->delete(); $deny(fn () => $call('adoptAction', $adopt), 'manager reassignment requires review privilege even on replay'); Db::name('system_role_menu')->insert(['role_id' => 3, 'menu_id' => 7]); // 上述权限变动会使历史 scope_hash 失效;另建新任务验证转派不改变历史员工归因。 $fresh = $call('analyze', $target + ['diagnosis_id' => 101, 'scenario' => 'revisit']); $worker->runOnce($generator); Db::name('tcm_diagnosis')->where('id', 101)->update(['assistant_id' => 15]); $ok($call('job', ['id' => $fresh['id']])['status'] === 'ready', 'same-team transfer preserves target historical attribution and valid context'); $overview = $call('staffOverview', $target); $ok($overview['metrics']['assigned_cases'] === 1 && $overview['metrics']['im_messages'] === 1, 'transfer changes assignment stock but not historical speaking metric'); $deny(fn () => $call('adoptAction', array_replace($adopt, ['job_id' => $fresh['id']])), 'historical target without current patient access cannot receive new assignment'); $call('reviewQuality', ['job_id' => $fresh['id'], 'item_key' => 'next_step', 'decision' => 'confirmed', 'note' => '历史核查', 'version' => 1]); $ok($call('staffIssues', $target)['lists'][0]['decision'] === 'confirmed', 'historical issue remains reviewable after within-team transfer'); Db::name('tcm_im_chat_message')->where('id', 2)->update(['text' => '原文变更']); $ok($call('staffOverview', $target)['metrics']['analyzed_cases'] === 0 && $call('staffIssues', $target)['count'] === 0, 'stale source excluded from report and issues'); $revoked = $call('analyze', $target + ['diagnosis_id' => 101, 'scenario' => 'followup']); Db::name('admin_dept')->where('admin_id', 11)->update(['dept_id' => 20]); $beforeCalls = $calls; $worker->runOnce($generator); $ok(Db::name('assistant_service_job')->where('id', $revoked['id'])->value('status') === 'stale' && $calls === $beforeCalls, 'worker rejects target moved outside operator employee domain before generation'); $deny(fn () => $call('job', ['id' => $fresh['id']]), 'job read rechecks target staff domain'); $deny(fn () => $call('actions', ['assistant_id' => 11]), 'actions recheck target staff domain'); Db::name('admin_dept')->where('admin_id', 11)->update(['dept_id' => 10]); $during = $call('analyze', $target + ['diagnosis_id' => 101, 'scenario' => 'initial']); $worker->runOnce(static function (...$args) use ($generator): array { $answer = $generator(...$args); Db::name('admin')->where('id', 11)->update(['disable' => 1]); return $answer; }); $ok(Db::name('assistant_service_job')->where('id', $during['id'])->value('status') === 'stale', 'real worker rechecks target status after generation before result commit'); Db::name('admin')->where('id', 11)->update(['disable' => 1]); $ok(!in_array(11, array_column($call('staff')['lists'], 'id'), true), 'disabled assistant immediately disappears'); $deny(fn () => $call('context', $target + ['diagnosis_id' => 101]), 'disabled target rejected independently of manager permission'); Db::name('admin')->where('id', 11)->update(['disable' => 0]); Db::name('system_role')->where('id', 2)->update(['delete_time' => 1]); $deny(fn () => $call('staffOverview', $target), 'deleted target role immediately rejected'); Db::name('system_role')->where('id', 2)->update(['delete_time' => null]); Db::name('tcm_diagnosis')->where('id', 101)->update(['assistant_id' => 11]); Db::execute('DROP TABLE test_tcm_im_chat_message'); $withoutIm = $call('staffOverview', $target); $ok($withoutIm['metrics']['im_messages'] === null && $withoutIm['metrics']['assigned_cases'] > 0 && str_contains(implode('', $withoutIm['coverage_notes']), 'IM 来源不可用'), 'missing IM keeps overview available without reporting false zero'); $withoutImCases = $call('staffCases', $target); $ok($withoutImCases['count'] > 0 && $withoutImCases['lists'][0]['im_messages'] === null, 'missing IM keeps current and manual cases available'); 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($call('staffOverview', $target)['metrics']['im_messages'] === null, 'missing IM account field is explicitly unavailable'); Db::execute('ALTER TABLE test_tcm_im_chat_message ADD COLUMN from_account TEXT'); $ok($call('staffOverview', $target)['metrics']['im_messages'] === 0, 'valid empty IM archive is a real zero'); echo 'ASSISTANT_SERVICE_STAFF assertions=' . $checks . ' PASS sqlite=memory real_access_context_worker=1 network=0' . PHP_EOL;