374 lines
26 KiB
PHP
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}`");
|
|
}
|