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