Files
zyt/server/tests/AssistantServiceApiTest.php
2026-11-12 09:07:07 +08:00

314 lines
30 KiB
PHP

<?php
declare(strict_types=1);
// 真实 ORM 与权限/业务代码,临时 SQLite;不初始化应用、不读 .env、不联网。
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\logic\tcm\AssistantServiceLogic as Logic;
use app\adminapi\validate\tcm\AssistantServiceValidate as Validate;
use app\common\service\assistantservice\AssistantServiceAccess as Access;
use app\common\service\assistantservice\AssistantServiceContext as Context;
use think\Container;
use think\facade\Db;
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) {
throw new RuntimeException('Run: php -d extension=php_pdo_sqlite.dll tests/AssistantServiceApiTest.php');
}
new think\App();
set_exception_handler(static function (Throwable $e): void {
fwrite(STDERR, $e->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;