333 lines
31 KiB
PHP
333 lines
31 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]);
|
|
}
|
|
|
|
// 与 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;
|