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]); } $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 = 11): array => Logic::$action(Validate::parameters($action, $p), $actor); $ok(Access::permissionForAction('staffDailyContacts') === 'view', 'daily contacts explicitly require module view'); $dailyParams = Validate::parameters('staffDailyContacts', ['assistant_id' => '11']); $ok($dailyParams === ['assistant_id' => 11, 'page_no' => 1, 'page_size' => 20, 'status' => 'pending'], 'daily list defaults and numeric GET id'); foreach (['all', 'pending', 'reported', 'contacted', 'exempt'] as $status) { $ok(Validate::parameters('staffDailyContacts', ['assistant_id' => 11, 'status' => $status])['status'] === $status, 'daily list allows status ' . $status); } foreach ([[], ['assistant_id' => 0], ['assistant_id' => true], ['assistant_id' => 1.1], ['assistant_id' => '01'], ['assistant_id' => 11, 'status' => 'done'], ['assistant_id' => 11, 'status' => []], ['assistant_id' => 11, 'page_size' => 51], ['assistant_id' => 11, 'status' => null], ['assistant_id' => 11, 'page_no' => null], ['assistant_id' => 11, 'page_size' => null], ['assistant_id' => 11, 'page_no' => 0], ['assistant_id' => 11, 'page_size' => 1.1], ['assistant_id' => 11, 'days' => 30], ['assistant_id' => 11, 'business_date' => '2026-01-01'], ['assistant_id' => 11, 'actor_id' => 1]] as $input) { $deny(fn () => Validate::parameters('staffDailyContacts', $input), 'daily list rejects invalid or injected parameters'); } foreach (['today_pending', 'today_done'] as $filter) { $ok(Validate::parameters('teamOverview', ['filter' => $filter])['filter'] === $filter, 'team accepts daily filter ' . $filter); } $ok($call('capabilities')['ready'], 'capabilities uses configured adapter without contacting network'); $ok($call('patients')['count'] === 2, 'assistant only sees own diagnoses'); $ok($call('patients', [], 13)['count'] === 2, 'manager scoped to department'); $ok($call('patients', [], 14)['count'] === 1, 'doctor ALL config still restricted to own appointment'); Db::name('system_role')->insert(['id' => 7, 'name' => '已删除组长', 'data_scope' => 1, 'delete_time' => 1]); Db::name('admin_role')->insert(['admin_id' => 11, 'role_id' => 7]); $ok($call('patients')['count'] === 2, 'deleted role relationship does not permanently block valid actor or expand scope'); $legacy = Db::name('tcm_diagnosis')->alias('d')->whereNull('d.delete_time')->where('d.status', 1); \app\adminapi\logic\firstvisit\MyPatientLogic::applyScope($legacy, 14, Access::actor(14)); $ok((int) $legacy->count() === 1, 'legacy MyPatient default signature retains doctor scope'); $ok($call('patients')['lists'][1]['phone_masked'] === '138****8000', 'phone masking'); $deny(fn () => Access::diagnosis(102, 11), 'cross-patient read rejected'); $deny(fn () => $call('record', ['diagnosis_id' => 102, 'content' => 'bad', 'occurred_at' => $now, 'record_type' => 'service_note']), 'cross-patient write rejected'); Db::name('admin')->where('id', 11)->update(['disable' => 1]); $deny(fn () => $call('capabilities'), 'disabled account rejected immediately'); Db::name('admin')->where('id', 11)->update(['disable' => 0]); Db::name('system_role_menu')->where('role_id', 2)->where('menu_id', 2)->delete(); $deny(fn () => $call('analyze', ['diagnosis_id' => 101, 'scenario' => 'followup']), 'fresh permission revocation'); Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => 2]); $context = Context::build(101, 11); $ok(array_column($context['sources'], 'id') === ['im:1', 'im:2', 'tracking:1'] || array_column($context['sources'], 'id') === ['tracking:1', 'im:1', 'im:2'], 'IM verifies patient/staff/type instead of trusting stored peer'); $ok(!str_contains(json_encode($context['sources']), '13800138000'), 'source phone redacted'); $service = array_values(array_filter($context['sources'], static fn ($s) => $s['id'] === 'im:2'))[0]; $ok($service['speaker'] === 'service' && $service['actor_admin_id'] === 11, 'IM exact historical actor attribution'); $ok(Context::build(101, 11)['source_hash'] === $context['source_hash'], 'source hash deterministic'); $ok(Context::build(101, 11)['scope_hash'] === $context['scope_hash'], 'scope hash deterministic'); $ok(count(Context::build(101, 1)['sources']) === 4, 'root can see known other employee but excludes unknown account'); $ok(count(Context::build(101, 14)['sources']) === 1, 'doctor cannot read assistant IM merely because patient appointment visible'); $unmapped = Context::build(103, 11); $ok(in_array('unmapped', array_column($unmapped['coverage'], 'status'), true), 'unmapped patient has explicit coverage'); $deny(fn () => $call('record', ['diagnosis_id' => 101, 'content' => 'transcript', 'occurred_at' => $now, 'record_type' => 'verified_transcript']), 'transcript explicit confirmation'); $deny(fn () => $call('record', ['diagnosis_id' => 101, 'content' => 'transcript', 'occurred_at' => $now, 'record_type' => 'verified_transcript', 'speaker_confirmed' => 'true']), 'strict JSON boolean'); $config->set(['enabled' => false], 'assistant_service'); $ok(!$call('capabilities')['ready'], 'AI disabled capabilities cannot advertise generation'); $deny(fn () => $call('analyze', ['diagnosis_id' => 101, 'scenario' => 'followup']), 'AI switch blocks analysis'); $deny(fn () => $call('draftReply', ['diagnosis_id' => 101, 'scenario' => 'followup', 'question' => '如何预约?']), 'AI switch blocks reply generation'); $manual = $call('record', ['diagnosis_id' => 101, 'content' => '患者说周六有空;客服说待核实。', 'occurred_at' => $now, 'record_type' => 'verified_transcript', 'speaker_confirmed' => true]); $ok($manual['id'] > 0, 'AI disabled still permits authorized manual recording'); $config->set(['enabled' => true], 'assistant_service'); $source = array_values(array_filter(Context::build(101, 11)['sources'], static fn ($s) => $s['id'] === 'manual:' . $manual['id']))[0]; $ok($source['speaker'] === 'unknown' && $source['actor_admin_id'] === 0, 'recorder is never assumed historical speaker'); $deny(fn () => $call('draftReply', ['diagnosis_id' => 101, 'scenario' => 'followup', 'question' => '如何预约?']), 'empty published knowledge blocks reply'); $knowledge = $call('saveKnowledge', ['question' => '如何预约?', 'answer' => '请由医助核对可预约时段。', 'category' => '预约', 'source_note' => '合成已审核流程']); $ok($call('knowledge')['lists'][0]['status'] === 'draft', 'new knowledge draft'); Db::name('system_role_menu')->where('role_id', 2)->where('menu_id', 8)->delete(); $ok($call('knowledge')['count'] === 0, 'view without manage sees published only'); $deny(fn () => $call('publishKnowledge', $knowledge + ['status' => 'published']), 'knowledge requires independent manage privilege'); $published = $call('publishKnowledge', $knowledge + ['status' => 'published'], 1); $ok($published['version'] === 2 && $call('knowledge')['count'] === 1, 'publish increments version and makes visible'); $deny(fn () => $call('saveKnowledge', ['id' => $knowledge['id'], 'version' => 1, 'question' => 'stale', 'answer' => 'stale'], 1), 'optimistic version conflict'); $job = $call('analyze', ['diagnosis_id' => 101, 'scenario' => 'followup']); $ok($job['status'] === 'queued' && $job['result'] === null, 'async enqueue does not fabricate result'); $same = $call('analyze', ['diagnosis_id' => 101, 'scenario' => 'followup']); $ok($same['id'] === $job['id'] && Db::name('assistant_service_job')->count() === 1, 'unchanged request deduplication'); $config->set(['daily_job_limit' => 1], 'assistant_service'); $ok($call('analyze', ['diagnosis_id' => 101, 'scenario' => 'followup'])['id'] === $job['id'], 'dedupe does not consume daily quota'); $deny(fn () => $call('analyze', ['diagnosis_id' => 101, 'scenario' => 'initial']), 'daily limit checked after dedupe'); $config->set(['daily_job_limit' => 100], 'assistant_service'); $result = ['summary' => [['text' => '希望预约周六', 'evidence_ids' => ['im:1']]], 'needs' => [], 'unresolved' => [], 'tags' => [], 'suggested_actions' => [['key' => 'confirm_slot', 'text' => '核对预约时段并回复患者', 'evidence_ids' => ['im:1']]], 'quality_checks' => [['key' => 'next_step', 'label' => '下一步', 'status' => 'met', 'reason' => '已明确下次回复', 'evidence_ids' => ['im:2'], 'actor_admin_id' => 11]]]; Db::name('assistant_service_job')->where('id', $job['id'])->update(['status' => 'ready', 'result_json' => json_encode($result)]); $ok(!isset($call('job', ['id' => $job['id']])['payload_json']), 'job response excludes private payload'); $ok($call('jobs', ['status' => 'ready'])['count'] === 1, 'fresh ready filter counts matching task'); $deny(fn () => $call('job', ['id' => $job['id']], 12), 'job read repeats patient scope'); $hidden = $call('job', ['id' => $job['id']], 14); $ok($hidden['stale'] && $hidden['result'] === null && count($hidden['sources']) === 2, 'narrower employee scope cannot see generated facts based on hidden IM'); $config->set(['enabled' => false], 'assistant_service'); $review = $call('reviewQuality', ['job_id' => $job['id'], 'item_key' => 'next_step', 'decision' => 'confirmed', 'note' => '已回看', 'version' => 1], 13); $ok($review['id'] > 0, 'authorized manager can review existing dimension'); $deny(fn () => $call('reviewQuality', ['job_id' => $job['id'], 'item_key' => 'invented', 'decision' => 'confirmed', 'note' => '', 'version' => 1], 13), 'reject forged review key'); $adopt = ['job_id' => $job['id'], 'action_key' => 'confirm_slot', 'version' => 1, 'due_at' => $now + 3600]; $ok(Validate::parameters('adoptAction', $adopt)['contact_required'] === false, 'contact intent defaults false'); foreach ([0, 1, 'true', 'false', '1', null, [], new stdClass()] as $value) { $deny(fn () => Validate::parameters('adoptAction', $adopt + ['contact_required' => $value]), 'contact intent requires a real JSON boolean'); } $beforeContact = Db::name('tcm_diagnosis_todo')->count(); try { $call('adoptAction', $adopt + ['contact_required' => true]); $ok(false, 'contact intent requires migration'); } catch (DomainException $e) { $ok(str_contains($e->getMessage(), '迁移'), 'missing contact column reports migration requirement'); } $ok(Db::name('tcm_diagnosis_todo')->count() === $beforeContact && Db::name('assistant_service_action')->count() === 0, 'unavailable contact schema never creates an ordinary fallback reminder'); $action = $call('adoptAction', $adopt); $repeat = $call('adoptAction', $adopt + ['content' => '重复请求不同内容']); $ok($action === $repeat && Db::name('tcm_diagnosis_todo')->count() === 1, 'adoption idempotent and one linked reminder'); $ok((int) Db::name('tcm_diagnosis_todo')->where('id', $action['todo_id'])->value('creator_id') === 11, 'responsibility fixed to current actor'); $ok($call('actions')['lists'][0]['notification_status'] === 0, 'notification distinct from pending business state'); $ok($call('actions')['lists'][0]['contact_required'] === false && $call('actions')['lists'][0]['completed_by'] === 0, 'legacy action schema exposes explicit false contact intent and unknown completion actor'); Db::name('system_role_menu')->where('role_id', 1)->where('menu_id', 7)->delete(); $deny(fn () => $call('completeAction', ['id' => $action['id'], 'state' => 'completed', 'note' => '非负责人'], 14), 'visible patient alone does not authorize non-assignee completion'); Db::name('system_role_menu')->insert(['role_id' => 1, 'menu_id' => 7]); $call('completeAction', ['id' => $action['id'], 'state' => 'in_progress', 'note' => '已着手核对']); $deny(fn () => $call('completeAction', ['id' => $action['id'], 'state' => 'completed', 'note' => '']), 'completion requires execution note'); $call('completeAction', ['id' => $action['id'], 'state' => 'completed', 'note' => '已核实并人工回复']); $ok((int) Db::name('tcm_diagnosis_todo')->where('id', $action['todo_id'])->value('status') === 2, 'completion atomically cancels unsent reminder'); $ok($call('completeAction', ['id' => $action['id'], 'state' => 'completed', 'note' => '重复提交'])['id'] === $action['id'], 'terminal same-state replay'); $ok(Db::name('assistant_service_action')->where('id', $action['id'])->value('completion_note') === '已核实并人工回复', 'terminal replay preserves execution record'); $deny(fn () => $call('completeAction', ['id' => $action['id'], 'state' => 'in_progress', 'note' => 'reopen']), 'terminal cannot reopen'); $deny(fn () => $call('completeAction', ['id' => $action['id'], 'state' => 'cancelled', 'note' => 'switch']), 'terminal cannot silently switch'); $ok(!$call('capabilities')['enabled'], 'review, adoption and completion remain available while AI is disabled'); $config->set(['enabled' => true], 'assistant_service'); Db::name('tcm_diagnosis')->where('id', 101)->update(['assistant_id' => 12]); $deny(fn () => $call('completeAction', ['id' => $action['id'], 'state' => 'completed', 'note' => '撤权后重放']), 'assignment revocation beats idempotence'); Db::name('tcm_diagnosis')->where('id', 101)->update(['assistant_id' => 11]); $result['suggested_actions'][] = ['key' => 'later_followup', 'text' => '再次核对时段', 'evidence_ids' => ['im:1']]; Db::name('assistant_service_job')->where('id', $job['id'])->update(['result_json' => json_encode($result)]); $secondAdopt = array_replace($adopt, ['action_key' => 'later_followup']); Db::execute("CREATE TRIGGER fail_action_insert BEFORE INSERT ON test_assistant_service_action BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END"); $before = Db::name('tcm_diagnosis_todo')->count(); try { $call('adoptAction', $secondAdopt); $ok(false, 'simulated sidecar failure'); } catch (\think\db\exception\PDOException $e) { $ok(true, 'simulated sidecar failure'); } $ok(Db::name('tcm_diagnosis_todo')->count() === $before, 'transaction rolls back reminder when sidecar insertion fails'); Db::execute('DROP TRIGGER fail_action_insert'); $sentAction = $call('adoptAction', $secondAdopt); Db::name('tcm_diagnosis_todo')->where('id', $sentAction['todo_id'])->update(['status' => 1]); $call('completeAction', ['id' => $sentAction['id'], 'state' => 'completed', 'note' => '主管已核对'], 13); $ok((int) Db::name('tcm_diagnosis_todo')->where('id', $sentAction['todo_id'])->value('status') === 1, 'supervisor completion preserves already-sent reminder state'); // 在同一内存库升级增量列,验证真实写入与旧记录兼容;不读取部署数据库。 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 (['contact_self', 'contact_manager', 'ordinary', 'contact_cancel'] as $key) { $result['suggested_actions'][] = ['key' => $key, 'text' => '合成联系登记', 'evidence_ids' => ['im:1']]; } Db::name('assistant_service_job')->where('id', $job['id'])->update(['result_json' => json_encode($result)]); $selfContact = $call('adoptAction', array_replace($adopt, ['action_key' => 'contact_self', 'contact_required' => true])); $managerContact = $call('adoptAction', array_replace($adopt, ['action_key' => 'contact_manager', 'contact_required' => true])); $ordinary = $call('adoptAction', array_replace($adopt, ['action_key' => 'ordinary', 'contact_required' => false])); $cancelContact = $call('adoptAction', array_replace($adopt, ['action_key' => 'contact_cancel', 'contact_required' => true])); $storedContact = Db::name('assistant_service_action')->where('id', $selfContact['id'])->find(); $ok((int) $storedContact['contact_required'] === 1 && (int) $storedContact['completed_by'] === 0, 'explicit contact intent stored as integer before completion'); $ok((int) Db::name('assistant_service_action')->where('id', $ordinary['id'])->value('contact_required') === 0, 'ordinary adoption keeps contact intent false'); $ok($call('adoptAction', array_replace($adopt, ['action_key' => 'ordinary', 'contact_required' => true])) === $ordinary && (int) Db::name('assistant_service_action')->where('id', $ordinary['id'])->value('contact_required') === 0, 'idempotent replay never silently upgrades old ordinary action into daily plan'); $listed = array_column($call('actions')['lists'], null, 'id'); $ok($listed[$selfContact['id']]['contact_required'] === true && $listed[$ordinary['id']]['contact_required'] === false, 'actions return actual boolean contact intent for frontend'); Db::name('tcm_diagnosis_todo')->where('id', $selfContact['todo_id'])->update(['status' => 1]); $listed = array_column($call('actions')['lists'], null, 'id'); $ok($listed[$selfContact['id']]['business_state'] === 'pending' && $listed[$selfContact['id']]['notification_status'] === 1, 'sent reminder does not mark action completed'); $call('completeAction', ['id' => $selfContact['id'], 'state' => 'in_progress', 'note' => '核对中']); $ok((int) Db::name('assistant_service_action')->where('id', $selfContact['id'])->value('completed_by') === 0, 'in-progress action has no completing actor'); $call('completeAction', ['id' => $selfContact['id'], 'state' => 'completed', 'note' => '本人登记电话联系']); $call('completeAction', ['id' => $selfContact['id'], 'state' => 'completed', 'note' => '主管重复请求'], 13); $selfStored = Db::name('assistant_service_action')->where('id', $selfContact['id'])->find(); $ok((int) $selfStored['completed_by'] === 11 && $selfStored['completion_note'] === '本人登记电话联系', 'terminal replay preserves actual original completing actor'); $ok((int) Db::name('tcm_diagnosis_todo')->where('id', $selfContact['todo_id'])->value('status') === 1, 'contact completion also preserves already-sent notification'); $call('completeAction', ['id' => $managerContact['id'], 'state' => 'completed', 'note' => '主管代登记'], 13); $call('completeAction', ['id' => $managerContact['id'], 'state' => 'completed', 'note' => '本人重发']); $ok((int) Db::name('assistant_service_action')->where('id', $managerContact['id'])->value('completed_by') === 13, 'supervisor completion never becomes assignee completion on replay'); $call('completeAction', ['id' => $cancelContact['id'], 'state' => 'cancelled', 'note' => '无需联系']); $listed = array_column($call('actions')['lists'], null, 'id'); $ok($listed[$managerContact['id']]['completed_by'] === 13 && $listed[$cancelContact['id']]['business_state'] === 'cancelled', 'read response retains supervisor actor and separate cancellation state'); $ok($listed[$sentAction['id']]['completed_by'] === 0 && $listed[$action['id']]['contact_required'] === false, 'migration does not infer old contact intent or old completion actor'); $edited = $call('saveKnowledge', ['id' => $knowledge['id'], 'version' => 2, 'question' => '如何预约?', 'answer' => '请先核对患者身份及可预约时段。', 'category' => '预约', 'source_note' => '合成新流程'], 1); $ok($edited['version'] === 3 && $call('knowledge')['count'] === 0, 'editing published content returns to draft'); $ok($call('job', ['id' => $job['id']])['stale'], 'knowledge changes invalidate prior task'); $deny(fn () => $call('adoptAction', $adopt), 'knowledge change blocks even repeated adoption'); $deny(fn () => $call('reviewQuality', ['job_id' => $job['id'], 'item_key' => 'next_step', 'decision' => 'dismissed', 'note' => '', 'version' => 1], 13), 'stale review rejected'); $newJob = $call('analyze', ['diagnosis_id' => 101, 'scenario' => 'initial']); Db::name('assistant_service_job')->where('id', $newJob['id'])->update(['status' => 'ready', 'result_json' => json_encode($result)]); Db::name('tracking_note')->where('id', 1)->update(['content' => '患者现在希望周日预约']); $ok($call('job', ['id' => $newJob['id']])['stale'], 'edited original source invalidates ready result'); $deny(fn () => $call('adoptAction', array_replace($adopt, ['job_id' => $newJob['id']])), 'source change blocks action adoption'); $ok($call('jobs', ['status' => 'ready'])['count'] === 0, 'ready filter excludes dynamically stale rows'); $stalePage1 = $call('jobs', ['status' => 'stale', 'page_no' => 1, 'page_size' => 1]); $stalePage2 = $call('jobs', ['status' => 'stale', 'page_no' => 2, 'page_size' => 1]); $ok($stalePage1['count'] === 2 && $stalePage2['count'] === 2 && count($stalePage1['lists']) === 1 && $stalePage1['lists'][0]['id'] !== $stalePage2['lists'][0]['id'], 'dynamic stale filter has exact count and page boundaries'); $retryParams = ['diagnosis_id' => 101, 'days' => 7, 'scenario' => 'revisit']; $retryJob = $call('analyze', $retryParams); Db::name('assistant_service_job')->where('id', $retryJob['id'])->update(['status' => 'failed', 'error_code' => 'CONFIG_INVALID']); $retried = $call('analyze', $retryParams); $ok($retried['id'] === $retryJob['id'] && $retried['status'] === 'queued' && $retried['version'] === 2, 'explicit request safely requeues known-not-generated config failure with new version'); Db::name('assistant_service_job')->where('id', $retryJob['id'])->update(['status' => 'needs_reconciliation', 'error_code' => 'UPSTREAM_UNCERTAIN']); $ok($call('analyze', $retryParams)['status'] === 'needs_reconciliation', 'uncertain upstream outcome is never automatically retried'); Db::name('assistant_service_job')->where('id', $retryJob['id'])->update(['status' => 'failed', 'error_code' => 'RESULT_INVALID']); $ok($call('analyze', $retryParams)['status'] === 'failed', 'already-generated invalid result is not automatically retried'); $config->set(['max_source_rows' => 1], 'assistant_service'); $deny(fn () => Context::build(101, 11), 'oversized sources explicit refusal instead of silent truncation'); $overLimitJob = $call('job', ['id' => $job['id']]); $ok($overLimitJob['stale'] && $overLimitJob['result'] === null && $overLimitJob['sources'] === [], 'source growth beyond capacity keeps task readable as stale without leaking unchecked snapshot'); $config->set(['max_source_rows' => 200], 'assistant_service'); $deny(fn () => $call('analyze', ['diagnosis_id' => 101, 'scenario' => 'followup', 'actor_id' => 1]), 'client cannot inject actor'); $deny(fn () => $call('job', ['id' => 1.1]), 'float identifiers rejected'); $deny(fn () => $call('adoptAction', $adopt + ['content' => str_repeat('字', 501)]), '500-character reminder constraint'); Db::name('tcm_diagnosis')->where('id', 101)->update(['patient_id' => 888]); $deny(fn () => $call('job', ['id' => $job['id']], 1), 'patient rebinding protects historical snapshot even for root'); $ok($call('jobs', [], 1)['count'] === 0 && $call('actions', [], 1)['count'] === 0, 'lists exclude old patient binding'); Db::execute('DROP TABLE test_assistant_service_review'); $caps = $call('capabilities'); $ok(!$caps['schema_ready'] && !$caps['ready'] && str_contains($caps['reason'], '迁移'), 'missing schema clearly reported'); echo 'ASSISTANT_SERVICE_API assertions=' . $checks . ' PASS sqlite=memory real_access_context_logic=1 network=0' . PHP_EOL;