Files
zyt/server/tests/PrescriptionAiQueueTest.php
T
2026-10-17 16:51:48 +08:00

498 lines
41 KiB
PHP

<?php
declare(strict_types=1);
/** Disposable local MySQL only. Never initializes the app or loads production config. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\prescriptionai\PrescriptionAiStore as Store;
use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
use app\common\service\prescriptionai\PrescriptionAiRequest as SaveRequest;
use app\common\service\prescriptionai\PrescriptionAiVisibility as Visibility;
use app\common\service\prescriptionai\PrescriptionAiAccess as Access;
use app\common\service\ConfigService;
use app\adminapi\logic\tcm\PrescriptionAiLogic as Api;
use think\Container;
use think\facade\Db;
$port = (int) getenv('ZYT_AI_TEST_MYSQL_PORT');
if ($port <= 0) {
throw new RuntimeException('Set ZYT_AI_TEST_MYSQL_PORT to an isolated local empty-password MySQL instance');
}
$child = ($argv[1] ?? '') === '--claim';
$legacyProgressSchema = in_array('--legacy-progress-schema', $argv, true);
$database = $child ? (string) getenv('ZYT_AI_TEST_DATABASE') : 'prescription_ai_test_' . bin2hex(random_bytes(6));
if (!preg_match('/^prescription_ai_test_[a-f0-9]{12}$/D', $database)) {
throw new RuntimeException('Only disposable test database names are allowed');
}
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
if (!$child) {
$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
}
$pdo->exec("USE `{$database}`");
$app = new think\App(); // no initialize()
$manager = new think\DbManager();
$manager->setConfig(['default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false,
'connections' => ['mysql' => ['type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
'database' => $database, 'username' => 'root', 'password' => '', 'charset' => 'utf8mb4',
'prefix' => 'zyt_', 'fields_strict' => true]]]);
Container::getInstance()->instance('think\DbManager', $manager);
$config = new think\Config();
$config->set(['enabled' => true, 'encryption_key' => str_repeat('isolated-test-', 4), 'debounce_seconds' => 0,
'lease_seconds' => 600, 'max_attempts' => 3, 'max_manual_retries' => 2, 'max_parallel_per_model' => 1,
'daily_model_tasks' => 200, 'transcript_wait_seconds' => 300], 'prescription_analysis');
Container::getInstance()->instance('config', $config);
if ($child) {
$claim = Store::claimTask($argv[2] ?? 'qwen');
echo json_encode(['id' => $claim['id'] ?? null]) . "\n";
exit(0);
}
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void {
if (!$ok) { throw new RuntimeException($why); }
$checks++;
};
$root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'role_id' => [], 'dept_id' => [], 'name' => 'Test'];
try {
$pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY AUTO_INCREMENT,pid INT,type VARCHAR(5),name VARCHAR(100),icon VARCHAR(50),sort INT,perms VARCHAR(100),paths VARCHAR(100),component VARCHAR(100),selected VARCHAR(100),params VARCHAR(100),is_cache INT,is_show INT,is_disable INT,create_time INT,update_time INT)');
$pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT,menu_id INT,UNIQUE KEY(role_id,menu_id))');
$pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY,name VARCHAR(50),root INT,disable INT,delete_time INT NULL,account VARCHAR(50) DEFAULT "fixture")');
$pdo->exec('CREATE TABLE zyt_system_role (id INT PRIMARY KEY,name VARCHAR(50),data_scope INT DEFAULT 1,delete_time INT NULL)');
$pdo->exec('CREATE TABLE zyt_config (id INT PRIMARY KEY AUTO_INCREMENT,type VARCHAR(30),name VARCHAR(60),value TEXT,create_time INT,update_time INT)');
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT,role_id INT)');
$pdo->exec('CREATE TABLE zyt_admin_dept (admin_id INT,dept_id INT)');
$pdo->exec('CREATE TABLE zyt_admin_jobs (admin_id INT,jobs_id INT)');
$pdo->exec("INSERT INTO zyt_admin(id,name,root,disable,delete_time) VALUES(1,'Test',1,0,NULL),(2,'Role user',0,0,NULL),(3,'Direct user',0,0,NULL),(4,'Disabled user',0,1,NULL),(5,'Deleted user',0,0,1),(6,'Manager',0,0,NULL)");
$pdo->exec("INSERT INTO zyt_system_role VALUES(7,'Selected role',1,NULL),(8,'Other role',1,NULL),(9,'Deleted role',1,1),(10,'Policy manager',1,NULL)");
$pdo->exec('INSERT INTO zyt_admin_role VALUES(2,7),(2,8),(6,10)');
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY,patient_id INT,assistant_id INT DEFAULT 1,delete_time INT NULL)');
$pdo->exec('INSERT INTO zyt_tcm_diagnosis(id,patient_id) VALUES(1,100),(2,200)');
$pdo->exec('CREATE TABLE zyt_tcm_prescription (id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT DEFAULT 1,patient_id INT DEFAULT 100,creator_id INT DEFAULT 1,is_system_auto INT DEFAULT 0,void_status INT DEFAULT 0,delete_time INT NULL,herbs TEXT,prescription_type VARCHAR(30) DEFAULT "饮片",update_time INT DEFAULT 0) ENGINE=InnoDB');
foreach (['sn','prescription_name','dosage_unit','patient_name','phone','visit_no','prescription_date','pulse','pulse_condition',
'tongue','tongue_image','clinical_diagnosis','case_record','dose_unit','aux_usage','usage_instruction','usage_time','usage_way',
'dietary_taboo','usage_notes','doctor_name','doctor_signature','visible_role_ids','audit_by_name','audit_remark','void_by_name'] as $field) {
$pdo->exec("ALTER TABLE zyt_tcm_prescription ADD `$field` TEXT NULL");
}
foreach (['appointment_id','assistant_id','gender','age','dosage_bag_count','need_decoction','bags_per_dose','dose_count',
'usage_days','times_per_day','template_id','is_shared','audit_status','audit_time','audit_by','void_time','void_by','create_time'] as $field) {
$pdo->exec("ALTER TABLE zyt_tcm_prescription ADD `$field` INT DEFAULT 0");
}
$pdo->exec('ALTER TABLE zyt_tcm_prescription ADD dosage_amount DECIMAL(10,2) NULL, ADD amount DECIMAL(10,2) DEFAULT 0');
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order (id INT PRIMARY KEY,prescription_id INT,source_prescription_id INT,prescription_audit_status INT,fulfillment_status INT,delete_time INT NULL)');
$pdo->exec('CREATE TABLE zyt_doctor_medicine (id INT PRIMARY KEY,name VARCHAR(100),unit VARCHAR(20),status INT,delete_time INT NULL)');
$pdo->exec("INSERT INTO zyt_doctor_medicine VALUES(1,'测试药材','g',1,NULL)");
$migration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_09_09_prescription_ai_analysis.sql');
foreach (explode(';', preg_replace('/^--.*$/m', '', $migration)) as $statement) {
if (trim($statement) !== '') { $pdo->exec($statement); }
}
// Migrations are rerunnable including role grants.
foreach (explode(';', preg_replace('/^--.*$/m', '', $migration)) as $statement) {
if (trim($statement) !== '') { $pdo->exec($statement); }
}
$expect((int) Db::name('system_menu')->count() === 7, 'idempotent permission migration');
$visibilityMigration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql');
for ($repeat = 0; $repeat < 2; $repeat++) {
foreach (explode(';', preg_replace('/^--.*$/m', '', $visibilityMigration)) as $statement) {
if (trim($statement) !== '') { $pdo->exec($statement); }
}
}
$expect((int) Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->count() === 1
&& (int) Db::name('config')->count() === 1, 'visibility migration is idempotent and seeds one empty policy');
$expect((int) Db::name('system_role_menu')->count() === 0, 'migration grants no nonroot role management permission');
$expect(Visibility::capabilities(1) === ['agreement_visible' => false, 'can_manage_visibility' => true],
'root manages policy but does not bypass empty view allowlist');
$expect(Visibility::getConfig(1) === ['role_ids' => [], 'admin_ids' => []], 'default policy is closed');
$denied = static function (callable $call, string $message) use ($expect): void {
try { $call(); $expect(false, $message); }
catch (DomainException $e) { $expect(true, $message); }
};
$http = static function (string $action, int $adminId = 1, string $method = 'GET', array $params = []): array {
// Run the real final controller against a local request without bootstrapping an HTTP app.
$reflection = new ReflectionClass(\app\adminapi\controller\tcm\PrescriptionAiController::class);
$controller = $reflection->newInstanceWithoutConstructor();
$request = (new think\Request())->setMethod($method);
$request = $method === 'POST' ? $request->withPost($params) : $request->withGet($params);
foreach (['request' => $request, 'adminId' => $adminId,
'adminInfo' => ['root' => 1, 'role_id' => [7], 'permissions' => ['*']]] as $property => $value) {
$reflection->getProperty($property)->setValue($controller, $value);
}
return $controller->$action()->getData();
};
$expect($http('capabilities', 2)['data'] === ['agreement_visible' => false, 'can_manage_visibility' => false],
'capabilities is a login-only self-check, never trusts stale root/star token information');
$expect($http('capabilities', 4)['code'] === 0, 'disabled account cannot call capabilities with stale root token');
foreach (['getVisibilityConfig', 'visibilityOptions'] as $action) {
$expect($http($action, 2)['code'] === 0 && $http($action, 1, 'POST')['code'] === 0,
'config read/options enforce current manager and GET method');
}
$expect($http('setVisibilityConfig', 2, 'POST', ['role_ids' => [], 'admin_ids' => [2]])['code'] === 0
&& $http('setVisibilityConfig', 1, 'GET')['code'] === 0
&& $http('setVisibilityConfig', 1, 'POST', ['role_ids' => [], 'admin_ids' => [], 'root' => true])['code'] === 0,
'config write rejects manager escalation, wrong method and unknown parameters');
foreach ([static fn () => Visibility::getConfig(2), static fn () => Visibility::options(2),
static fn () => Visibility::setConfig(['role_ids' => [], 'admin_ids' => [2]], 2)] as $operation) {
$denied($operation, 'ordinary account cannot manage allowlist despite unregistered config routes');
}
Db::name('system_role_menu')->insert(['role_id' => 10,
'menu_id' => Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->value('id')]);
$expect(Visibility::capabilities(6) === ['agreement_visible' => false, 'can_manage_visibility' => true],
'dedicated permission enables management without granting agreement view');
$selected = Visibility::setConfig(['role_ids' => ['7', 7], 'admin_ids' => [3]], 6);
$expect($selected === ['role_ids' => [7], 'admin_ids' => [3]], 'authorized manager saves canonical policy');
$expect(Visibility::agreementVisible(2) && Visibility::agreementVisible(3) && !Visibility::agreementVisible(1),
'role union and direct user are alternatives, root has no implicit view');
$denied(static fn () => Api::statuses([], 2, Access::actor(2)), 'allowlist does not replace existing action RBAC');
Db::name('system_role_menu')->insert(['role_id' => 8,
'menu_id' => Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/statuses')->value('id')]);
$expect(Api::statuses([], 2, Access::actor(2))['agreement_visible'] === true,
'selected role plus independent action permission can view metric capability');
Db::name('admin_role')->where('admin_id', 2)->where('role_id', 7)->delete();
$expect(!Visibility::agreementVisible(2), 'role membership revocation applies immediately');
Db::name('admin_role')->insert(['admin_id' => 2, 'role_id' => 7]);
Db::name('system_role')->where('id', 7)->update(['delete_time' => 1]);
$expect(!Visibility::agreementVisible(2), 'deleted selected role never authorizes stale membership');
Db::name('system_role')->where('id', 7)->update(['delete_time' => null]);
$denied(static fn () => Visibility::setConfig(['role_ids' => [9], 'admin_ids' => []], 1), 'deleted role cannot be newly selected');
$denied(static fn () => Visibility::setConfig(['role_ids' => [], 'admin_ids' => [4]], 1), 'disabled user cannot be newly selected');
$options = Visibility::options(6);
$expect(array_column($options['roles'], 'id') === [7, 8, 10]
&& array_column($options['admins'], 'id') === [1, 2, 3, 6]
&& array_keys($options['admins'][0]) === ['id', 'name', 'account'], 'options contain active records and minimal account fields only');
Db::name('system_role')->insertAll(array_map(static fn (int $id): array =>
['id' => $id, 'name' => 'bounded selector fixture'], range(100, 599)));
$denied(static fn () => Visibility::options(1), 'oversized selector fails explicitly instead of silently truncating');
Db::name('system_role')->where('id', '>=', 100)->delete();
Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->update(['is_disable' => 1]);
$denied(static fn () => Visibility::getConfig(6), 'disabled management permission is revoked immediately');
Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->update(['is_disable' => 0]);
Db::name('config')->where('type', Visibility::CONFIG_TYPE)->update(['value' => '{"role_ids":"*","admin_ids":[1]}']);
$expect(!Visibility::agreementVisible(1) && !Visibility::agreementVisible(3), 'malformed policy fails closed as a whole');
ConfigService::set(Visibility::CONFIG_TYPE, Visibility::CONFIG_NAME, ['role_ids' => [], 'admin_ids' => [1, 4, 5]]);
$expect(Visibility::agreementVisible(1) && !Visibility::agreementVisible(4) && !Visibility::agreementVisible(5),
'stale direct grants cannot enable disabled or deleted accounts');
Visibility::setConfig(['role_ids' => [], 'admin_ids' => [1]], 1);
if (!$legacyProgressSchema) {
$progressMigration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_09_10_prescription_ai_progress.sql');
for ($i = 0; $i < 2; $i++) {
foreach (explode(';', preg_replace('/^--.*$/m', '', $progressMigration)) as $statement) {
if (trim($statement) !== '') { $pdo->exec($statement); }
}
}
$expect((int) $pdo->query("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'zyt_prescription_ai_task' AND COLUMN_NAME = 'progress_json'")->fetchColumn() === 1,
'additive progress migration is rerunnable');
}
$expect(Store::supportsProgress() === !$legacyProgressSchema, 'old and migrated schema detected without reading model caches');
$fixture = static function (array $extra = []) use ($root): array {
$id = (int) Db::name('tcm_prescription')->insertGetId($extra + [
'herbs' => json_encode([['medicine_id' => 1, 'name' => '测试药材', 'dosage' => 10]], JSON_UNESCAPED_UNICODE),
'update_time' => time(),
]);
return Db::name('tcm_prescription')->where('id', $id)->find();
};
$save = static function (array $rx, array $options = []) use ($root): ?int {
return Db::transaction(static function () use ($rx, $options, $root): ?int {
$fresh = Db::name('tcm_prescription')->where('id', $rx['id'])->lock(true)->find();
return Store::recordSaved($fresh, 1, $root, ['ai_assisted' => false] + $options);
});
};
$context = ['source' => ['clinical' => 'synthetic record'], 'source_hash' => hash('sha256', 'fixed'),
'source_diagnosis_ids' => [1], 'source_summary' => ['diagnosis_count' => 1], 'missing' => [],
'baseline_eligible' => false, 'baseline_exclusion_reasons' => ['SOURCE_HISTORY_VERSIONS_UNAVAILABLE'],
'comparison_type' => 'latest_context', 'cutoff_at' => time(), 'wait_for_transcript' => false];
$rx = $fixture(['patient_name' => 'Snapshot patient', 'gender' => 0, 'age' => 52,
'clinical_diagnosis' => 'Saved clinical diagnosis', 'usage_instruction' => 'Saved usage',
'times_per_day' => 2, 'usage_days' => 7,
'aux_usage' => '{"prescription_name":"Saved auxiliary formula","dosage_amount":1.5,"dosage_bag_count":2,"need_decoction":0,"bags_per_dose":1,"times_per_day":1,"usage_days":3,"phone":"private-aux-contact","nested":{"phone":"private-aux-nested"}}',
'phone' => 'private-contact', 'doctor_signature' => 'private-signature',
'case_record' => '{"phone":"private-nested-contact","clinical_diagnosis":"other diagnosis"}']);
$context['source_access_manifest'] = ['schema_version' => 'prescription-source-access-v1', 'patient_id' => 100,
'target' => ['prescription_id' => (int) $rx['id'], 'diagnosis_id' => 1],
'records' => [['source_kind' => 'diagnoses', 'id' => 1, 'source_id' => 'diagnoses:1', 'diagnosis_id' => 1, 'patient_id' => 100, 'staff' => []]]];
$batchId = $save($rx);
$expect($batchId > 0 && $save($rx) === $batchId, 'same clinical content enqueues once');
$pendingDetail = Api::detail($batchId, 1, $root);
$snapshot = $pendingDetail['doctor_snapshot'];
$expect($pendingDetail['status'] === 'preparing' && $pendingDetail['models'] === []
&& $snapshot['prescription']['herbs'][0]['name'] === '测试药材',
'original prescription available before model preparation and results');
$expect($snapshot['patient']['name'] === 'Snapshot patient' && $snapshot['patient']['gender_label'] === '女'
&& (int) $snapshot['patient']['age'] === 52
&& $snapshot['diagnosis']['clinical_diagnosis'] === 'Saved clinical diagnosis'
&& $snapshot['prescription']['usage_instruction'] === 'Saved usage', 'saved patient diagnosis and usage projected');
$expect((int) $snapshot['prescription']['times_per_day'] === 2 && (int) $snapshot['prescription']['usage_days'] === 7
&& $snapshot['prescription']['aux_usage'] === ['prescription_name' => 'Saved auxiliary formula',
'dosage_amount' => 1.5, 'dosage_bag_count' => 2, 'need_decoction' => 0, 'bags_per_dose' => 1,
'times_per_day' => 1, 'usage_days' => 3],
'saved main and auxiliary usage remain distinct and auxiliary private fields are excluded');
$expect(!str_contains(json_encode($snapshot), 'private-') && !isset($snapshot['prescription']['herbs'][0]['medicine_id']),
'detail original excludes contacts signatures nested records and internal medicine IDs');
try { Api::detail($batchId, 0, []); $expect(false, 'unauthenticated caller cannot read original'); }
catch (DomainException $e) { $checks++; }
Db::name('tcm_diagnosis')->where('id', 1)->update(['delete_time' => time()]);
try { Api::detail($batchId, 1, $root); $expect(false, 'revoked source access cannot read original'); }
catch (DomainException $e) { $checks++; }
Db::name('tcm_diagnosis')->where('id', 1)->update(['delete_time' => null]);
$expect(!isset($snapshot['prescription']['herbs'][0]['unit'])
&& !isset($snapshot['prescription']['herbs'][0]['dose_basis'])
&& !isset($snapshot['diagnosis']['western_diagnosis']), 'missing source units and diagnosis split are not invented');
$blank = $fixture(['is_system_auto' => 1, 'herbs' => '[]']);
$expect($save($blank) === null, 'blank prescriptions do not enqueue');
$expect((int) Db::name('prescription_ai_task')->count() === 0, 'no model call or task before snapshot');
$claim = Store::claimBatch();
$expect((int) $claim['id'] === $batchId && Store::claimBatch() === null, 'preparation lease prevents duplicate claim');
$expect(Store::finishPreparation($claim, $context), 'snapshot prepares');
$expect(!Store::finishPreparation($claim, $context), 'preparation cannot run twice');
$expect((int) Db::name('prescription_ai_task')->count() === 2, 'exactly two model tasks');
$stored = Db::name('prescription_ai_batch')->find($batchId);
$expect(!str_contains($stored['context_cipher'], 'synthetic record'), 'context encrypted');
$expect((new Cipher())->decrypt($stored['context_cipher'], 'context')['source_hash'] === $context['source_hash'], 'context decrypts exactly');
$expect((int) $stored['baseline_eligible'] === 0, 'uncertain historical provenance excluded');
$q = Store::claimTask('qwen');
$o = Store::claimTask('openai');
$expect($q !== null && $o !== null && Store::claimTask('qwen') === null, 'models run independently, claim exclusive');
$expect(Store::checkpoint($q, ['stage' => 'first', 'steps' => ['private fixture']]), 'checkpoint persists');
$expect(!str_contains(Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_cipher'), 'private fixture'), 'checkpoint encrypted');
$beforeProgressCipher = Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_cipher');
$progressPayload = ['public' => Progress::advance([], 'text', 'waiting', 1, 3), 'steps' => ['must-not-replace-cache'],
'prompt' => 'never-public'];
$expect(Store::checkpoint($q, $progressPayload, false) && Store::checkpoint($q, $progressPayload, false),
'same-second metadata no-op still recognizes a valid lease');
$expect(Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_cipher') === $beforeProgressCipher,
'metadata-only checkpoint leaves encrypted model cache unchanged');
if (!$legacyProgressSchema) {
$publicJson = Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_json');
$expect(!str_contains($publicJson, 'never-public') && !str_contains($publicJson, 'must-not-replace-cache')
&& json_decode($publicJson, true)['completed_units'] === 1, 'database stores only sanitized scalar progress');
}
$modelQueries = [];
$captureModels = true;
Db::listen(static function (string $sql) use (&$modelQueries, &$captureModels): void {
if ($captureModels && str_contains($sql, 'prescription_ai_task') && preg_match('/^SELECT/i', $sql)) { $modelQueries[] = $sql; }
});
$liveStatus = Api::statuses([$rx['id']], 1, $root)['items'][0];
$liveDetail = Api::detail($batchId, 1, $root);
$liveReports = Api::reports(['prescription_id' => $rx['id']], 1, $root);
$expect(!array_key_exists('doctor_snapshot', $liveStatus)
&& !array_key_exists('doctor_snapshot', $liveReports['lists'][0]), 'summaries do not expose original clinical content');
$captureModels = false;
$expect($modelQueries !== [], 'list/detail/history task select queries observed');
foreach ($modelQueries as $sql) {
$expect(!str_contains($sql, 'progress_cipher') && !preg_match('/SELECT\s+\*/i', $sql),
'polling selects bounded task fields, not encrypted model cache');
}
$expect($liveStatus['models']['qwen']['progress']['stage'] === ($legacyProgressSchema ? 'unknown' : 'text')
&& $liveDetail['models']['qwen']['progress']['stage'] === ($legacyProgressSchema ? 'unknown' : 'text'),
'status and detail present measured progress, with honest old-schema fallback');
$expect(isset($liveStatus['progress']) && isset($liveReports['lists'][0]['models']['qwen']['progress']),
'batch and history include public progress');
$output = ['report' => ['summary' => 'synthetic report'], 'candidate' => ['status' => 'available_for_review'],
'coverage' => ['status' => 'complete'], 'model_name' => 'test', 'prompt_version' => 'test-v1'];
$comparison = ['status' => 'comparable', 'score' => 80, 'herb_score' => 100, 'algorithm_version' => 'test-v1'];
$expect(Store::complete($q, $output, $comparison), 'first model completes');
$expect(!Store::complete($q, $output, $comparison), 'duplicate result callback fenced');
Store::fail($o, 'UPSTREAM_TIMEOUT', false);
$expect(Db::name('prescription_ai_batch')->where('id', $batchId)->value('status') === 'partial', 'one failure preserves other result');
$statuses = Api::statuses([$rx['id']], 1, $root);
$expect($statuses['items'][0]['models']['qwen']['score'] === 80.0, 'list returns persisted numeric score');
$expect($statuses['items'][0]['models']['openai']['score'] === null, 'failed score is null rather than zero');
$detail = Api::detail($batchId, 1, $root);
$expect($detail['models']['qwen']['report']['summary'] === 'synthetic report', 'authorized detail decrypts');
$expect($detail['models']['qwen']['progress']['stage'] === 'completed'
&& $statuses['items'][0]['models']['openai']['progress']['stage'] === 'failed', 'persisted task terminal state overrides stale stage');
$resultRow = Db::name('prescription_ai_result')->where('batch_id', $batchId)->where('model_key', 'qwen')->find();
$resultBody = (new Cipher())->decrypt($resultRow['body_cipher'], 'result:' . $batchId . ':qwen');
$resultBody['progress'] = ['stage' => 'poisoned', 'notice' => 'model supplied text'];
Db::name('prescription_ai_result')->where('id', $resultRow['id'])->update([
'body_cipher' => (new Cipher())->encrypt($resultBody, 'result:' . $batchId . ':qwen'),
]);
$expect(Api::detail($batchId, 1, $root)['models']['qwen']['progress']['stage'] === 'completed',
'report body cannot override trusted task progress');
Api::review($batchId, 'qwen', 'not_adopted', 'test comment', 1, $root);
$expect(Api::detail($batchId, 1, $root)['models']['qwen']['review']['status'] === 'not_adopted', 'review independent of prescription');
$resultBody['comparison']['reason'] = 'hidden-metric-sentinel';
$resultBody['comparison']['normalization'] = ['matched_contribution_sum' => 'hidden-metric-sentinel'];
foreach (['score', 'herb_score', 'raw', 'result', 'response', 'body'] as $rawKey) {
$resultBody[$rawKey] = ['unknown' => 'hidden-metric-sentinel'];
}
$resultBody['report']['unknown_debug'] = ['normalization' => 'hidden-metric-sentinel'];
$resultBody['candidate']['unknown_debug'] = 'hidden-metric-sentinel';
Db::name('prescription_ai_result')->where('id', $resultRow['id'])->update([
'body_cipher' => (new Cipher())->encrypt($resultBody, 'result:' . $batchId . ':qwen'),
]);
Visibility::setConfig(['role_ids' => [], 'admin_ids' => []], 1);
$hiddenStatus = Api::statuses([$rx['id']], 1, $root);
$hiddenReports = Api::reports(['prescription_id' => $rx['id']], 1, $root);
$hiddenDetail = Api::detail($batchId, 1, $root);
$expect($hiddenStatus['agreement_visible'] === false && $hiddenStatus['items'][0]['agreement_visible'] === false
&& $hiddenReports['agreement_visible'] === false && $hiddenReports['lists'][0]['agreement_visible'] === false
&& $hiddenDetail['agreement_visible'] === false, 'all list/history/detail levels carry current agreement capability');
foreach ([$hiddenStatus['items'][0], $hiddenReports['lists'][0], $hiddenDetail] as $hiddenBatch) {
$expect(!isset($hiddenBatch['comparison_type']) && !isset($hiddenBatch['baseline_eligible'])
&& !isset($hiddenBatch['baseline_exclusion_reasons'])
&& !isset($hiddenBatch['models']['qwen']['score']) && !isset($hiddenBatch['models']['qwen']['herb_score'])
&& !isset($hiddenBatch['models']['qwen']['comparison']) && !isset($hiddenBatch['models']['qwen']['comparison_status'])
&& !isset($hiddenBatch['models']['qwen']['comparison_reason_code']) && !isset($hiddenBatch['models']['qwen']['reason'])
&& !str_contains(json_encode($hiddenBatch), 'hidden-metric-sentinel'), 'shared API projection removes metrics and raw-body paths');
}
$expect($hiddenDetail['models']['qwen']['report']['summary'] === 'synthetic report'
&& $hiddenDetail['models']['qwen']['candidate']['status'] === 'available_for_review'
&& $hiddenDetail['doctor_snapshot'] === $snapshot && $hiddenDetail['models']['qwen']['review']['status'] === 'not_adopted'
&& $hiddenDetail['models']['openai']['reason'] !== '', 'hidden metric preserves report, candidate, original, review and failure explanation');
$denied(static fn () => Api::statistics([], 1, $root), 'root without selected grant cannot read agreement statistics');
Api::review($batchId, 'qwen', 'not_adopted', 'test comment', 1, $root);
$expect(Api::retry($batchId, 'openai', 1, $root)['status'] === 'retry_wait',
'agreement restriction does not remove independent report review/retry permission');
Visibility::setConfig(['role_ids' => [], 'admin_ids' => [1]], 1);
$restored = Api::detail($batchId, 1, $root);
$expect($restored['agreement_visible'] && $restored['models']['qwen']['score'] === 80.0
&& $restored['models']['qwen']['comparison']['score'] === 80
&& !isset($restored['models']['qwen']['raw']) && !isset($restored['models']['qwen']['result']),
'selected root gets genuine persisted metric, never arbitrary decrypted-body aliases');
Api::retry($batchId, 'openai', 1, $root);
$o2 = Store::claimTask('openai');
$expect((int) $o2['total_attempts'] === 2 && (int) $o2['attempts'] === 1, 'manual retry preserves lifetime attempts');
$expect(Store::complete($o2, $output, $comparison), 'failed model retries without repeating successful model');
$expect(Db::name('prescription_ai_batch')->where('id', $batchId)->value('status') === 'success', 'both success aggregate');
$stats = Api::statistics([], 1, $root);
$expect($stats['doctors'][0]['models']['qwen']['mean'] === null, 'nonbaseline scores never become doctor accuracy');
Db::name('prescription_ai_batch')->where('id', $batchId)->update(['baseline_eligible' => 1, 'baseline_exclusions_json' => '[]']);
$eligibleStats = Api::statistics([], 1, $root);
$expect($eligibleStats['doctors'][0]['models']['qwen']['mean'] === 80.0, 'synthetic qualified baseline has empty exclusion reason');
// 分布直方图必须随统计一起返回,否则界面只能凭均值猜形状。
$bins = $eligibleStats['doctors'][0]['models']['qwen']['distribution'] ?? null;
$expect(is_array($bins) && array_sum($bins) === 1 && ($bins['[80,100]'] ?? 0) === 1,
'statistics expose the agreement distribution bins');
Db::name('prescription_ai_batch')->where('id', $batchId)->update(['baseline_eligible' => 0, 'baseline_exclusions_json' => '["SOURCE_HISTORY_VERSIONS_UNAVAILABLE"]']);
$expect((int) Db::name('prescription_ai_attempt')->count() === 3, 'attempt history retained');
$payload = ['request_key' => '12345678-abcd-1234-abcd-123456789012', 'herbs' => [['name' => 'fixture']]];
Db::transaction(static function () use ($payload, $rx, $expect): void {
$expect(SaveRequest::replay($payload, 1, true) === null, 'first request reserved');
SaveRequest::complete($payload, 1, (int) $rx['id']);
});
$expect(SaveRequest::replay($payload, 1) === (int) $rx['id'], 'lost save response replays same prescription');
try { SaveRequest::replay($payload + ['extra' => 'changed'], 1); $expect(false, 'changed content cannot reuse key'); }
catch (DomainException $e) { $checks++; }
$countBefore = (int) Db::name('prescription_ai_batch')->count();
try {
Db::transaction(static function () use ($fixture, $save): void {
$save($fixture());
throw new RuntimeException('rollback fixture');
});
} catch (RuntimeException $e) {}
$expect((int) Db::name('prescription_ai_batch')->count() === $countBefore, 'prescription and outbox roll back together');
Db::name('tcm_prescription')->where('id', $rx['id'])->update(['herbs' => '[{"name":"changed","dosage":20}]',
'clinical_diagnosis' => 'Changed clinical diagnosis', 'patient_name' => 'Changed patient',
'aux_usage' => '{"times_per_day":3,"usage_days":9}']);
$newBatch = $save($rx);
$expect($newBatch !== $batchId, 'clinical change creates immutable new batch');
$expect(Db::name('prescription_ai_batch')->where('id', $batchId)->value('validity') === 'prescription_changed', 'previous version invalidated');
$expect((int) Db::name('prescription_ai_result')->count() === 2, 'historic model results immutable');
$expect(Api::detail($batchId, 1, $root)['doctor_snapshot'] === $snapshot,
'historic report retains saved original after live prescription patient and diagnosis change');
$expect(Api::detail($newBatch, 1, $root)['doctor_snapshot']['diagnosis']['clinical_diagnosis'] === 'Changed clinical diagnosis',
'new pending revision carries its own updated original');
$newClaim = Store::claimBatch();
Store::finishPreparation($newClaim, $context);
$lease = Store::claimTask('qwen');
Db::name('prescription_ai_task')->where('id', $lease['id'])->update(['lock_until' => time() - 1]);
$replacement = Store::claimTask('qwen');
$expect($replacement !== null && $replacement['lock_token'] !== $lease['lock_token'], 'expired lease recovered');
$expect(!Store::checkpoint($lease, []) && !Store::complete($lease, $output, $comparison), 'old worker cannot write after lease steal');
$expect(!Store::checkpoint($lease, $progressPayload, false), 'metadata-only progress is fenced after lease steal');
$expect(Db::name('prescription_ai_attempt')->where('task_id', $lease['id'])->where('attempt_no', 1)->value('status') === 'expired', 'expired attempt audited');
putenv('ZYT_AI_TEST_DATABASE=' . $database);
$pipes = [];
$process = proc_open([PHP_BINARY, __FILE__, '--claim', 'qwen'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
$childOutput = stream_get_contents($pipes[1]);
$childError = stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
$exit = proc_close($process);
$expect($exit === 0 && $childError === '' && json_decode($childOutput, true)['id'] === null, 'second PHP process cannot duplicate live lease');
Db::transaction(static function () use ($rx): void {
Db::name('tcm_prescription')->where('id', $rx['id'])->lock(true)->find();
Db::name('tcm_prescription')->where('id', $rx['id'])->update(['void_status' => 1]);
Store::invalidate((int) $rx['id'], 'voided');
});
$expect(!Store::complete($replacement, $output, $comparison), 'void during generation cannot publish');
$expect(Db::name('prescription_ai_task')->where('id', $replacement['id'])->value('status') === 'cancelled', 'void cancels tasks');
$editParams = ['id' => $blank['id'], 'herbs' => [['medicine_id' => 1, 'name' => '测试药材', 'dosage' => 10]],
'prescription_date' => '2026-09-09', 'clinical_diagnosis' => 'fixture', 'usage_instruction' => 'fixture'];
$expect(\app\adminapi\logic\tcm\PrescriptionLogic::edit($editParams, 1), 'real blank-to-manual hook: ' . \app\adminapi\logic\tcm\PrescriptionLogic::getError());
$blankBatch = Db::name('prescription_ai_batch')->where('prescription_id', $blank['id'])->find();
$expect($blankBatch['trigger_type'] === 'blank_to_manual' && (int) $blankBatch['patient_id'] === 100, 'blank hook carries authoritative binding');
$expect(\app\adminapi\logic\tcm\PrescriptionLogic::edit($editParams, 1), 'same edit can replay');
$expect((int) Db::name('prescription_ai_batch')->where('prescription_id', $blank['id'])->count() === 1, 'same edit does not generate twice');
$expect(\app\adminapi\logic\tcm\PrescriptionLogic::void((int) $blank['id'], 1, 'Test'), 'real void hook succeeds');
$expect(Db::name('prescription_ai_batch')->where('id', $blankBatch['id'])->value('validity') === 'voided', 'real void hook invalidates');
$addParams = ['request_key' => 'add-request-1234567890123456', 'diagnosis_id' => 0, 'patient_id' => 100,
'patient_name' => 'fixture', 'gender' => 1, 'age' => 50, 'clinical_diagnosis' => 'fixture',
'herbs' => [['medicine_id' => 1, 'name' => '测试药材', 'dosage' => 10]], 'doctor_signature' => 'fixture'];
$added = \app\adminapi\logic\tcm\PrescriptionLogic::add($addParams, 1, $root);
$expect($added !== null, 'real direct-manual save hook: ' . \app\adminapi\logic\tcm\PrescriptionLogic::getError());
$expect(\app\adminapi\logic\tcm\PrescriptionLogic::add($addParams, 1, $root) === $added, 'real direct save request replay returns same id');
$expect(Db::name('prescription_ai_batch')->where('prescription_id', $added)->value('error_code') === 'PATIENT_BINDING_REQUIRED', 'unbound direct prescription has explicit blocked analysis');
$pdo->exec("CREATE TRIGGER test_outbox_failure BEFORE INSERT ON zyt_prescription_ai_batch FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test fixture failure'");
$prescriptionsBefore = (int) Db::name('tcm_prescription')->count();
$addParams['request_key'] = 'rollback-request-123456789012';
$expect(\app\adminapi\logic\tcm\PrescriptionLogic::add($addParams, 1, $root) === null, 'outbox storage failure aborts real save');
$expect((int) Db::name('tcm_prescription')->count() === $prescriptionsBefore, 'real save and request reservation roll back with outbox');
$expect(!str_contains(\app\adminapi\logic\tcm\PrescriptionLogic::getError(), 'SQLSTATE'), 'outbox error text cannot expose SQL');
$pdo->exec('DROP TRIGGER test_outbox_failure');
if (defined('PRESCRIPTION_AI_PIPELINE_FIXTURE')) {
$pipelineRx = $fixture();
$pipelineBatch = $save($pipelineRx);
$worker = new \app\common\service\prescriptionai\PrescriptionAiWorker();
$expect($worker->prepareOne(), 'real coordinator freezes fixture evidence');
$comparingObservations = [];
$captureComparing = !$legacyProgressSchema;
Db::listen(static function (string $sql) use (&$comparingObservations, &$captureComparing, $pipelineBatch): void {
if (!$captureComparing || !str_starts_with($sql, 'UPDATE') || !str_contains($sql, 'progress_json') || !str_contains($sql, 'comparing')) {
return;
}
foreach (Db::name('prescription_ai_task')->where('batch_id', $pipelineBatch)->where('status', 'running')->select()->toArray() as $row) {
if ((json_decode($row['progress_json'] ?? '{}', true)['stage'] ?? '') !== 'comparing') { continue; }
$cache = (new Cipher())->decrypt($row['progress_cipher'], 'progress:' . $row['id']);
$comparingObservations[] = ['status' => $row['status'], 'cache_stage' => $cache['stage'] ?? '',
'has_result' => (bool) Db::name('prescription_ai_result')->where('batch_id', $pipelineBatch)->where('model_key', $row['model_key'])->count()];
}
});
$expect($worker->runOne('qwen'), 'first real model worker');
Db::name('doctor_medicine')->insert(['id' => 2, 'name' => 'new catalog fixture', 'unit' => 'g', 'status' => 1]);
$expect($worker->runOne('openai'), 'second real model worker');
$captureComparing = false;
if (!$legacyProgressSchema) {
$expect(count($comparingObservations) === 2, 'each worker publishes comparing before it persists a result');
foreach ($comparingObservations as $observation) {
$expect($observation === ['status' => 'running', 'cache_stage' => 'fixture', 'has_result' => false],
'comparison retains the encrypted checkpoint and never announces task completion before the result transaction');
}
}
$pipelineStatus = Api::statuses([$pipelineRx['id']], 1, $root)['items'][0];
$expect($pipelineStatus['models']['qwen']['score'] === 100.0, 'raw DB JSON prescription reaches real comparator');
$expect($pipelineStatus['models']['openai']['score'] === 100.0, 'second model score persisted');
$inputs = \app\common\service\prescriptionai\PrescriptionAiGenerator::$inputs;
$expect($inputs['qwen']['source_hash'] === $inputs['openai']['source_hash'] && $inputs['qwen']['source'] === $inputs['openai']['source'], 'same frozen evidence supplied to both');
$expect($inputs['qwen']['dictionary_version'] === $inputs['openai']['dictionary_version']
&& count($inputs['openai']['_comparison_catalog']) === 1, 'catalog changes between branches do not alter frozen dictionary');
$expect(\app\common\service\prescriptionai\PrescriptionAiContext::$builds === 1, 'evidence read once per batch');
$revokedRx = $fixture();
$revokedBatch = $save($revokedRx);
$worker->prepareOne();
\app\common\service\prescriptionai\PrescriptionAiGenerator::$revokeDuringCall = true;
$worker->runOne('qwen');
$expect((int) Db::name('prescription_ai_result')->where('batch_id', $revokedBatch)->count() === 0, 'source permission revoked during call prevents publication');
\app\common\service\prescriptionai\PrescriptionAiContext::$allowed = true;
}
$before = (int) Db::name('prescription_ai_batch')->count();
$config->set(['enabled' => false], 'prescription_analysis');
$expect($save($fixture()) === null && (int) Db::name('prescription_ai_batch')->count() === $before, 'feature disabled makes no outbox writes');
$expect(Api::statuses([1], 1, $root) === ['enabled' => false, 'agreement_visible' => true, 'items' => []], 'disabled list has graceful compatibility');
echo "Prescription AI queue: {$checks} checks passed\n";
} finally {
$pdo->exec("DROP DATABASE `{$database}`");
}