Files
zyt/server/tests/PrescriptionOrderAuditSignatureTest.php
2026-11-14 15:19:59 +08:00

374 lines
26 KiB
PHP

<?php
declare(strict_types=1);
/** Real controllers/ORM/transactions. Explicit disposable local MySQL only; never load business .env. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\controller\firstvisit\MyPatientController;
use app\adminapi\controller\tcm\PrescriptionController;
use app\adminapi\controller\tcm\PrescriptionOrderController;
use app\adminapi\lists\tcm\PrescriptionOrderLists;
use app\adminapi\logic\tcm\PrescriptionLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
use app\common\service\tcm\PrescriptionAuditSignature;
use think\Container;
use think\DbManager;
use think\facade\Db;
$port = (int) getenv('ZYT_RX_SIGNATURE_TEST_MYSQL_PORT');
if ($port <= 0) {
fwrite(STDERR, "Set ZYT_RX_SIGNATURE_TEST_MYSQL_PORT to an isolated local root/no-password MySQL instance.\n");
exit(1);
}
$worker = ($argv[1] ?? '') === '--worker';
$database = $worker ? (string) getenv('ZYT_RX_SIGNATURE_TEST_DATABASE') : 'rx_signature_test_' . bin2hex(random_bytes(6));
if (!preg_match('/^rx_signature_test_[a-f0-9]{12}$/D', $database)) {
throw new RuntimeException('Only this test\'s disposable databases are allowed');
}
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
if (!$worker) $pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
$pdo->exec("USE `{$database}`");
$testApp = new think\App();
$manager = new 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);
Container::getInstance()->instance('config', new think\Config());
Container::getInstance()->instance('log', new Psr\Log\NullLogger());
$testLang = new think\Lang($testApp);
think\Validate::maker(static function (think\Validate $validator) use ($testLang) {
$validator->setLang($testLang);
$validator->setRequest(Container::getInstance()->make('request'));
});
$admin = ['root' => 1, 'admin_id' => 1, 'name' => '测试药师甲'];
$checks = 0;
$expect = static function (bool $ok, string $label) use (&$checks): void {
if (!$ok) throw new RuntimeException($label . ' | ' . PrescriptionOrderLogic::getError());
$checks++;
};
$png = static function (string $kind = 'ink', int $variant = 0, int $width = 120): string {
$image = imagecreatetruecolor($width, 60);
imagealphablending($image, false);
imagesavealpha($image, true);
$white = imagecolorallocatealpha($image, 255, 255, 255, $kind === 'transparent' ? 127 : 0);
imagefill($image, 0, 0, $white);
if ($kind === 'ink') {
$black = imagecolorallocate($image, 25, 40, 55);
imagesetthickness($image, 3);
imageline($image, 8, 40, 42 + $variant, 10, $black);
imageline($image, 42 + $variant, 10, 30, 48, $black);
imageline($image, 30, 48, 90, 20 + $variant, $black);
}
ob_start(); imagepng($image); $bytes = (string) ob_get_clean(); imagedestroy($image);
return 'data:image/png;base64,' . base64_encode($bytes);
};
$signature = $png();
$signature2 = $png('ink', 8);
$normalized = PrescriptionAuditSignature::normalize($signature);
if ($worker) {
echo "ready\n"; flush();
$result = PrescriptionOrderLogic::auditPrescription((int) $argv[2], 'approve', '', 1, $admin, $signature);
echo json_encode(['success' => is_array($result), 'error' => PrescriptionOrderLogic::getError()]) . "\n";
exit(0);
}
$call = static function (string $class, string $method, array $params, string $verb = 'POST') use ($admin): array {
$request = (new app\Request())->withServer(['REQUEST_METHOD' => $verb]);
$request = $verb === 'GET' ? $request->withGet($params) : $request->withPost($params);
Container::getInstance()->instance('request', $request);
Container::getInstance()->instance(think\Request::class, $request);
$controller = (new ReflectionClass($class))->newInstanceWithoutConstructor();
foreach (['request' => $request, 'adminId' => 1, 'adminInfo' => $admin] as $name => $value) {
(new ReflectionProperty($controller, $name))->setValue($controller, $value);
}
try {
return $controller->$method()->getData();
} catch (think\exception\HttpResponseException $exception) {
return $exception->getResponse()->getData();
}
};
try {
$pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_04_07_create_tcm_prescription_order.sql'));
$pdo->exec('ALTER TABLE zyt_tcm_prescription_order
ADD agency_collect_amount DECIMAL(10,2) NULL, ADD paid DECIMAL(10,2) DEFAULT 0,
ADD completion_request INT DEFAULT 0, ADD express_company VARCHAR(20) DEFAULT "auto",
ADD ship_mode VARCHAR(20) DEFAULT "gancao", ADD gancao_reciperl_order_no VARCHAR(100) DEFAULT ""');
$pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_04_09_prescription_order_pay_links.sql'));
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order_log (
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, admin_id INT,
admin_name VARCHAR(64), action VARCHAR(32), summary VARCHAR(500), create_time INT
) ENGINE=InnoDB');
$migration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_13_prescription_order_audit_signature.sql');
$pdo->exec($migration); $pdo->exec($migration);
$expect(file_get_contents(dirname(__DIR__) . '/sql/1.9.20261113/01_prescription_order_audit_signature.sql') === $migration,
'Deployment and canonical migrations agree and run twice');
$pdo->exec('CREATE TABLE zyt_tcm_prescription (
id INT PRIMARY KEY AUTO_INCREMENT, diagnosis_id INT DEFAULT 1, gender INT DEFAULT 0,
creator_id INT DEFAULT 0, assistant_id INT DEFAULT 0, is_shared INT DEFAULT 0,
doctor_name VARCHAR(100) DEFAULT "原开方医师", doctor_signature TEXT, phone VARCHAR(20) DEFAULT "",
herbs TEXT, audit_status INT DEFAULT 1, void_status INT DEFAULT 0, visible_role_ids VARCHAR(100) DEFAULT "",
audit_time INT NULL, audit_by INT NULL, audit_by_name VARCHAR(100) DEFAULT "", audit_remark VARCHAR(500) DEFAULT "",
create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_order (id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50),
patient_id INT DEFAULT 1, creator_id INT DEFAULT 1, order_type INT DEFAULT 3, amount DECIMAL(10,2),
status INT, is_exempt INT DEFAULT 0, remark VARCHAR(200) DEFAULT "", payment_method VARCHAR(50) DEFAULT "",
create_type VARCHAR(50) DEFAULT "", payment_time INT NULL, create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL)');
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, assistant_id INT DEFAULT 0, status INT DEFAULT 1, delete_time INT NULL)');
$pdo->exec('INSERT INTO zyt_tcm_diagnosis (id) VALUES (1)');
$pdo->exec('CREATE TABLE zyt_pharmacy_submission_claim (id INT PRIMARY KEY AUTO_INCREMENT,
prescription_order_id INT, source_revision INT, status VARCHAR(50), target VARCHAR(50), lease_expires_at INT)');
$pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY, name VARCHAR(100), account VARCHAR(100), delete_time INT NULL)');
$pdo->exec("INSERT INTO zyt_admin VALUES (1, '测试药师甲', 'fixture', NULL)");
$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('CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)');
$pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY, perms VARCHAR(100), is_disable INT DEFAULT 0)');
$pdo->exec('CREATE TABLE zyt_doctor_appointment (id INT PRIMARY KEY, patient_id INT)');
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis_assign_log (id INT PRIMARY KEY, diagnosis_id INT,
to_assistant_id INT, related_po_creator_id INT, related_po_create_time INT)');
$pdo->exec('CREATE TABLE zyt_dept (id INT PRIMARY KEY, pid INT, name VARCHAR(100), delete_time INT NULL)');
$newRx = static fn (): int => (int) Db::name('tcm_prescription')->insertGetId(['herbs' => '[]', 'doctor_signature' => 'original-doctor-signature']);
$fixture = static function (array $fields = []) use ($newRx): int {
return (int) Db::name('tcm_prescription_order')->insertGetId(array_replace([
'prescription_id' => $newRx(), 'diagnosis_id' => 1, 'creator_id' => 1, 'amount' => 100,
'order_no' => 'SIG-' . bin2hex(random_bytes(6)), 'create_time' => time(),
], $fields));
};
$row = static fn (int $id): array => Db::name('tcm_prescription_order')->where('id', $id)->find();
$logs = static fn (int $id): array => Db::name('tcm_prescription_order_log')->where('prescription_order_id', $id)->order('id')->select()->toArray();
$snapshot = static fn (int $id): array => [$row($id), $logs($id), Db::name('tcm_prescription_order_pay_order')->where('prescription_order_id', $id)->select()->toArray()];
// Real Logic calls must reject blank/forged/oversized images without touching row, links or logs.
$invalid = ['', ' ', null, ['signature'], 42, 'https://fixture.test/signature.png',
'data:image/svg+xml;base64,' . base64_encode('<svg/>'), 'data:image/png;base64,***',
'data:image/png;base64,' . base64_encode('not png'), $png('white'), $png('transparent'),
$png('ink', 0, 2049), 'data:image/png;base64,' . str_repeat('A', 350000)];
foreach (['approve', 'reject'] as $action) {
foreach ($invalid as $value) {
$id = $fixture(); $before = $snapshot($id);
$expect(PrescriptionOrderLogic::auditPrescription($id, $action, '测试意见', 1, $admin, $value) === false,
'Both review outcomes require actual bounded PNG handwriting');
$expect($snapshot($id) === $before, 'Invalid signature causes no persisted mutation');
}
}
$id = $fixture();
$expect(PrescriptionOrderLogic::auditPrescription($id, 'reject', '', 1, $admin, $signature) === false, 'Rejection still needs a reason');
$expect(PrescriptionOrderLogic::auditPrescription($id, 'other', 'reason', 1, $admin, $signature) === false, 'Direct Logic rejects invalid action');
$expect(PrescriptionOrderLogic::auditPrescription($id, 'approve', '', 2,
['root' => 0, 'role_id' => [999], 'name' => '无权限人员'], $signature) === false, 'Signature grants no audit permission');
foreach ([[PrescriptionOrderController::class, 'auditPrescription'], [MyPatientController::class, 'orderAuditPrescription']] as [$class, $method]) {
$id = $fixture(); $before = $snapshot($id);
$expect($call($class, $method, ['id' => $id, 'action' => 'approve'])['code'] === 0 && $snapshot($id) === $before,
'Both real HTTP controllers enforce missing signature');
$result = $call($class, $method, ['id' => $id, 'action' => 'approve', 'signature' => $signature,
'prescription_audit_signed_by' => 999, 'prescription_audit_signed_name' => '伪造姓名', 'prescription_audit_signed_at' => 1]);
$expect($result['code'] === 1, 'Both real HTTP controllers forward handwriting');
$stored = $row($id); $log = $logs($id)[0];
$expect($stored['prescription_audit_signature'] === $normalized && $log['audit_signature'] === $normalized,
'Canonical image is stored in current review and immutable audit log');
$expect((int) $stored['prescription_audit_signed_by'] === 1 && $stored['prescription_audit_signed_name'] === $admin['name']
&& (int) $stored['prescription_audit_signed_at'] > 1 && (int) $log['admin_id'] === 1
&& $log['admin_name'] === $admin['name'] && $log['create_time'] === $stored['prescription_audit_signed_at'],
'Authenticated identity and one server timestamp override forged request metadata');
}
// Revoke -> new review keeps the original event and never falls back to the old current image.
$firstSignedId = $id; $rx = (int) $row($id)['prescription_id'];
$expect(is_array(PrescriptionOrderLogic::revokeRxAudit($id, 1, $admin)), 'Signed review can be revoked');
$expect($row($id)['prescription_audit_signature'] === null && $row($id)['prescription_audit_signed_by'] === null
&& $row($id)['prescription_audit_signed_name'] === '' && $row($id)['prescription_audit_signed_at'] === null,
'Revoke clears all current signature metadata');
$expect($logs($id)[0]['audit_signature'] === $normalized, 'Revoke preserves old signature event');
$expect(PrescriptionLogic::detail($rx, 1, $admin, $id)['prescription_audit_signed_name'] === '', 'Revoked pharmacist is not printed');
$expect(PrescriptionOrderLogic::auditPrescription($id, 'approve', '', 1, $admin) === false, 'Re-review cannot reuse stored image implicitly');
$expect(is_array(PrescriptionOrderLogic::auditPrescription($id, 'approve', '', 1, $admin, $signature2)), 'Re-review accepts newly supplied handwriting');
$expect(count($logs($id)) === 3 && $logs($id)[2]['audit_signature'] !== $logs($id)[0]['audit_signature'], 'Both signed rounds remain independently traceable');
// Detail/print context picks the requested order, never another order for the same prescription.
$detail = PrescriptionLogic::detail($rx, 1, $admin);
$expect($detail['prescription_audit_signed_name'] === $admin['name'] && $detail['doctor_signature'] === 'original-doctor-signature',
'Pharmacist metadata supplements, never overwrites, prescribing doctor');
$other = $fixture(['prescription_id' => $rx]);
$expect(PrescriptionLogic::detail($rx, 1, $admin)['prescription_audit_signed_name'] === '', 'Latest pending order never borrows earlier signature');
$result = $call(PrescriptionController::class, 'detail', ['id' => $rx, 'prescription_order_id' => $id], 'GET');
$expect($result['code'] === 1 && $result['data']['prescription_order_id'] === $id
&& $result['data']['prescription_audit_signed_name'] === $admin['name'], 'Print API honors explicit authorized order context');
$expect(PrescriptionLogic::detail($rx, 1, $admin, $fixture()) === null, 'Cross-prescription order context is rejected');
$pdo->exec("UPDATE zyt_tcm_prescription_order SET fulfillment_status=10 WHERE id={$other}");
$expect(PrescriptionLogic::detail($rx, 1, $admin)['prescription_order_id'] === $id, 'Default print context ignores refunded orders');
$nested = PrescriptionOrderLogic::detail($id, 1, $admin);
$expect($nested['prescription']['prescription_order_id'] === $id, 'Order details bind their nested prescription to the same order');
foreach (['prescription_audit_signature', 'prescription_audit_signed_name',
'prescription_audit_signed_by', 'prescription_audit_signed_at'] as $field) {
$expect($nested[$field] === $nested['prescription'][$field] && $nested[$field] === $row($id)[$field],
'Fresh order detail and its printable prescription preserve stored ' . $field);
}
foreach ([4, 10] as $releasedStatus) {
$releasedId = $fixture();
$expect(is_array(PrescriptionOrderLogic::auditPrescription($releasedId, 'approve', '', 1, $admin, $signature)),
'Released-history display fixture starts with a real persisted signature');
Db::name('tcm_prescription_order')->where('id', $releasedId)->update(['fulfillment_status' => $releasedStatus]);
$releasedDetail = PrescriptionOrderLogic::detail($releasedId, 1, $admin);
$expect($releasedDetail['prescription_audit_signature'] !== ''
&& $releasedDetail['prescription']['prescription_audit_signature'] === ''
&& $releasedDetail['prescription']['prescription_audit_signed_name'] === '',
'Cancelled/refunded order retains audit history but never presents a current printable pharmacist');
}
$legacy = $fixture(['prescription_audit_status' => 1]);
$expect(PrescriptionLogic::detail((int) $row($legacy)['prescription_id'], 1, $admin)['prescription_audit_signed_name'] === '',
'Historical unsigned approval never fabricates pharmacist attribution');
// All manual terminal outcomes, including no status change, must be signed.
foreach ([0, 1, 2] as $old) {
foreach ([1, 2] as $next) {
$id = $fixture(['prescription_audit_status' => $old]); $before = $snapshot($id);
$params = ['id' => $id, 'summary' => '人工核对', 'prescription_audit_status' => $next];
$expect(PrescriptionOrderLogic::addLog($params, 1, $admin) === false && $snapshot($id) === $before,
"Manual {$old} -> {$next} cannot bypass signature");
$result = $call(PrescriptionOrderController::class, 'addLog', $params + ['signature' => $signature]);
$expect($result['code'] === 1 && $logs($id)[0]['audit_signature'] === $normalized,
"Manual {$old} -> {$next} signs and logs through actual controller");
}
}
$expect(is_array(PrescriptionOrderLogic::addLog(['id' => $id, 'summary' => '重审', 'prescription_audit_status' => 0], 1, $admin))
&& $row($id)['prescription_audit_signature'] === null && $logs($id)[0]['audit_signature'] === $normalized,
'Manual reset clears current signature but preserves history');
$expect(is_array(PrescriptionOrderLogic::addLog(['id' => $id, 'summary' => '普通日志'], 1, $admin)), 'Plain manual log needs no signature');
$expect(is_array(PrescriptionOrderLogic::addLog(['id' => $id, 'summary' => '支付调整', 'payment_slip_audit_status' => 1], 1, $admin)),
'Payment-only manual correction needs no signature');
$id = $fixture();
$pay = (int) Db::name('order')->insertGetId(['amount' => 50, 'status' => 2]);
Db::name('tcm_prescription_order_pay_order')->insert(['prescription_order_id' => $id, 'pay_order_id' => $pay, 'create_time' => time()]);
$expect(is_array(PrescriptionOrderLogic::auditPrescription($id, 'reject', '重新调整', 1, $admin, $signature)), 'Signed rejection succeeds');
$expect(Db::name('tcm_prescription_order_pay_order')->where('prescription_order_id', $id)->count() === 0, 'Rejection preserves payment unlink behavior');
$rx = (int) $row($id)['prescription_id'];
$expect(PrescriptionLogic::detail($rx, 1, $admin)['prescription_audit_signed_name'] === '', 'Rejected pharmacist is not printed as approved');
PrescriptionOrderLogic::onConsumerPrescriptionSaved($rx);
$expect((int) $row($id)['prescription_audit_status'] === 0 && $row($id)['prescription_audit_signature'] === null
&& $logs($id)[0]['audit_signature'] === $normalized, 'Prescription edit resets current review and preserves signed rejection');
$id = $fixture();
$expect(is_array(PrescriptionOrderLogic::auditPrescription($id, 'approve', '', 1, $admin, $signature)), 'Cancellation fixture is signed');
$expect(is_array(PrescriptionOrderLogic::withdraw($id, 1, $admin)) && $row($id)['prescription_audit_signature'] === null
&& $logs($id)[0]['audit_signature'] === $normalized, 'Cancellation clears current image, keeping original review event');
$id = $fixture(['prescription_audit_status' => 1]);
$expect(is_array(PrescriptionOrderLogic::auditPaymentSlip($id, 'approve', '', 1, $admin)), 'Payment approval still works without handwriting');
// Audit journal failure must roll back review, image and unlink side effects.
$id = $fixture();
Db::name('tcm_prescription_order_pay_order')->insert(['prescription_order_id' => $id, 'pay_order_id' => $pay, 'create_time' => time()]);
$before = $snapshot($id);
$pdo->exec("CREATE TRIGGER reject_signature_log BEFORE INSERT ON zyt_tcm_prescription_order_log FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='fixture journal unavailable'");
$expect(PrescriptionOrderLogic::auditPrescription($id, 'reject', 'reason', 1, $admin, $signature) === false
&& $snapshot($id) === $before, 'Rejected audit journal rolls back row, signature, and payment links');
$expect(PrescriptionOrderLogic::addLog(['id' => $id, 'summary' => '人工', 'prescription_audit_status' => 1, 'signature' => $signature], 1, $admin) === false
&& $snapshot($id) === $before, 'Manual audit journal failure is atomic');
$pdo->exec('DROP TRIGGER reject_signature_log');
// Real list projection excludes images; real detail and authorized history retain them.
$listClass = new ReflectionClass(PrescriptionOrderLists::class);
$list = $listClass->newInstanceWithoutConstructor();
foreach (['adminInfo' => $admin, 'adminId' => 1, 'params' => [], 'searchWhere' => [],
'limitOffset' => 0, 'limitLength' => 1000, 'export' => 0] as $name => $value) {
$listClass->getProperty($name)->setValue($list, $value);
}
foreach ($list->lists() as $item) $expect(!array_key_exists('prescription_audit_signature', $item), 'List response excludes signature image');
$expect(PrescriptionOrderLogic::getLogs($firstSignedId, 1, $admin)[0]['audit_signature'] !== '', 'Authorized audit history retains images');
$logListener = new app\adminapi\listener\OperationLog();
$method = new ReflectionMethod($logListener, 'stripLargeLogFields');
$payload = ['signature' => [$signature], 'data' => ['prescription_audit_signature' => $signature2, 'audit_signature' => $signature]];
$method->invokeArgs($logListener, [&$payload]);
$expect(!str_contains(json_encode($payload), 'base64'), 'Generic request/response log sanitizer omits all audit images');
// Two independent PHP connections contend on the same pending order.
$id = $fixture(); putenv('ZYT_RX_SIGNATURE_TEST_DATABASE=' . $database);
Db::startTrans(); Db::name('tcm_prescription_order')->where('id', $id)->lock(true)->find();
$workers = [];
try {
foreach ([1, 2] as $_) {
$process = proc_open([PHP_BINARY, __FILE__, '--worker', (string) $id],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) throw new RuntimeException('Cannot launch concurrency worker');
fclose($pipes[0]); $workers[] = [$process, $pipes];
if (trim((string) fgets($pipes[1])) !== 'ready') throw new RuntimeException('Concurrency worker failed initialization');
}
} finally {
Db::commit();
}
$outcomes = [];
foreach ($workers as [$process, $pipes]) {
$output = stream_get_contents($pipes[1]); $errors = stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
if (proc_close($process) !== 0) throw new RuntimeException('Worker failed: ' . $errors . $output);
$outcomes[] = json_decode(trim($output), true, 512, JSON_THROW_ON_ERROR);
}
$expect(count(array_filter($outcomes, static fn (array $result): bool => $result['success'])) === 1
&& count($logs($id)) === 1, 'Concurrent reviewers cannot overwrite each other or create competing signed events');
// A Model::save silently ignores fields absent from its schema. Test both missing migrations
// and stale in-process metadata after the database has gained the new columns.
$storageColumns = [
['zyt_tcm_prescription_order', 'prescription_audit_signature', 'MEDIUMTEXT NULL'],
['zyt_tcm_prescription_order', 'prescription_audit_signed_by', 'INT UNSIGNED NULL'],
['zyt_tcm_prescription_order', 'prescription_audit_signed_name', "VARCHAR(64) NOT NULL DEFAULT ''"],
['zyt_tcm_prescription_order', 'prescription_audit_signed_at', 'INT UNSIGNED NULL'],
['zyt_tcm_prescription_order_log', 'audit_signature', 'MEDIUMTEXT NULL'],
];
foreach (['missing-column', 'stale-schema'] as $storageFault) {
foreach ($storageColumns as [$table, $column, $definition]) {
$pdo->exec("ALTER TABLE `{$table}` DROP COLUMN `{$column}`");
$manager->connect()->getSchemaInfo($table, true);
if ($storageFault === 'stale-schema') {
$pdo->exec("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
// Deliberately keep ThinkORM's cached field list without this column.
}
try {
foreach (['approve', 'reject', 'manual'] as $operation) {
$id = $fixture(); $before = $snapshot($id);
$result = $operation === 'manual'
? PrescriptionOrderLogic::addLog(['id' => $id, 'summary' => '人工核对',
'prescription_audit_status' => 1, 'signature' => $signature], 1, $admin)
: PrescriptionOrderLogic::auditPrescription($id, $operation, '核对意见', 1, $admin, $signature);
$expect($result === false && $snapshot($id) === $before,
"{$storageFault} {$column}: {$operation} must not report success or persist a partial review");
$expect(str_contains(PrescriptionOrderLogic::getError(), '迁移')
&& str_contains(PrescriptionOrderLogic::getError(), '缓存'), 'Storage failure gives actionable migration/cache guidance');
}
} finally {
if ($storageFault === 'missing-column') {
$pdo->exec("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
$manager->connect()->getSchemaInfo($table, true);
}
$id = $fixture();
$expect(is_array(PrescriptionOrderLogic::auditPrescription($id, 'approve', '', 1, $admin, $signature)),
'Applying migration and refreshing schema restores verified signature writes');
}
}
foreach (['admin_id' => '999', 'admin_name' => "'different reviewer'", 'create_time' => '1', 'audit_signature' => "'different image'"] as $field => $replacement) {
$id = $fixture(); $before = $snapshot($id);
$pdo->exec("CREATE TRIGGER corrupt_signature_log BEFORE INSERT ON zyt_tcm_prescription_order_log FOR EACH ROW SET NEW.{$field}={$replacement}");
try {
$expect(PrescriptionOrderLogic::auditPrescription($id, 'approve', '', 1, $admin, $signature) === false
&& $snapshot($id) === $before, 'Stored audit journal ' . $field . ' must match this authenticated signing event');
} finally {
$pdo->exec('DROP TRIGGER corrupt_signature_log');
}
}
echo "PrescriptionOrderAuditSignatureTest: {$checks} assertions passed\n";
} finally {
$manager->connect()->close();
$pdo->exec("DROP DATABASE `{$database}`");
}