Files
zyt/server/tests/PrescriptionOrderTrackingAuditTest.php
T
2026-09-21 10:26:14 +08:00

221 lines
13 KiB
PHP

<?php
declare(strict_types=1);
/**
* Exercises the real ORM and public mutation entry points in a disposable MySQL database.
* Run with ZYT_TRACKING_TEST_MYSQL_PORT pointing at an isolated local root/no-password instance.
* Never initializes the application or loads business database configuration.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use think\Container;
use think\DbManager;
use think\facade\Db;
$port = (int) getenv('ZYT_TRACKING_TEST_MYSQL_PORT');
if ($port <= 0) {
fwrite(STDERR, "Set ZYT_TRACKING_TEST_MYSQL_PORT to an isolated local MySQL instance.\n");
exit(1);
}
$database = 'tracking_audit_test_' . bin2hex(random_bytes(6));
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$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());
$admin = ['root' => 1, 'admin_id' => 1, 'name' => '隔离测试管理员'];
$checks = 0;
$expect = static function (bool $ok, string $message) use (&$checks): void {
if (!$ok) {
throw new RuntimeException($message . ' | ' . PrescriptionOrderLogic::getError());
}
$checks++;
};
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 express_company VARCHAR(20) DEFAULT "auto", ADD ship_mode VARCHAR(20) DEFAULT "gancao",
ADD remark_assistant VARCHAR(500) DEFAULT "", ADD dose_unit VARCHAR(10) DEFAULT "剂",
ADD dose_count INT DEFAULT 1, 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');
$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 1, assistant_id INT DEFAULT 0, is_shared INT DEFAULT 0,
herbs TEXT, audit_status INT DEFAULT 1, void_status INT DEFAULT 0, visible_role_ids VARCHAR(100) DEFAULT "",
create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, assistant_id INT DEFAULT 0, 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)
) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY, name VARCHAR(100), delete_time INT NULL)');
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_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)');
$newRx = static fn (): int => (int) Db::name('tcm_prescription')->insertGetId(['herbs' => '[]']);
$fixture = static fn (array $fields = []): int => (int) Db::name('tcm_prescription_order')->insertGetId(array_merge([
'prescription_id' => $newRx(), 'diagnosis_id' => 1, 'creator_id' => 1,
'order_no' => 'TEST-' . bin2hex(random_bytes(6)), 'amount' => 100,
'prescription_audit_status' => 1, 'payment_slip_audit_status' => 1,
'fulfillment_status' => 2, 'tracking_number' => 'SF-OLD', 'express_company' => 'sf',
'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)];
$editParams = static fn (int $id): array => [
'id' => $id, 'recipient_name' => '更新收货人', 'recipient_phone' => '13000000000',
'shipping_address' => '更新地址', 'fee_type' => 3, 'amount' => 100, 'remark_assistant' => '其他信息已保存',
];
$auditError = '处方审核和支付单审核均通过后,才可填写或修改快递单号';
// Full audit-status matrix, covering first entry, corrections and shipped orders.
foreach ([0, 1, 2] as $rxStatus) {
foreach ([0, 1, 2] as $payStatus) {
$approved = $rxStatus === 1 && $payStatus === 1;
foreach (['', 'SF-OLD'] as $oldTracking) {
$fields = ['prescription_audit_status' => $rxStatus, 'payment_slip_audit_status' => $payStatus,
'tracking_number' => $oldTracking];
$id = $fixture($fields);
$before = $snapshot($id);
$out = PrescriptionOrderLogic::ddcode($id, 'jd', ' JD-NEW ', 1, $admin);
$expect(is_array($out) === $approved, "ddcode audit {$rxStatus}/{$payStatus}");
if ($approved) {
$expect($row($id)['tracking_number'] === 'JD-NEW' && $row($id)['express_company'] === 'jd',
'Approved correction must persist normalized logistics');
$expect(count($logs($id)) === 1 && $logs($id)[0]['action'] === 'fill_tracking',
'Approved correction must record its audit trail');
} else {
$expect(PrescriptionOrderLogic::getError() === $auditError && $snapshot($id) === $before,
'Unapproved correction must reject even root without changing row or log');
}
foreach ([2, 5] as $status) {
$id = $fixture($fields + ['fulfillment_status' => $status]);
$before = $snapshot($id);
$out = PrescriptionOrderLogic::ship($id, 'jd', 'JD-SHIP', 'gancao', 1, $admin);
$expect(is_array($out) === $approved, "ship audit {$rxStatus}/{$payStatus}, status {$status}");
if ($approved) {
$expect($row($id)['tracking_number'] === 'JD-SHIP' && (int) $row($id)['fulfillment_status'] === 5,
'Approved shipping must persist logistics and shipped status');
} else {
$expect(PrescriptionOrderLogic::getError() === $auditError && $snapshot($id) === $before,
'Shipping status alone must never bypass audit requirements');
}
}
$id = $fixture($fields + ['fulfillment_status' => 5]);
$before = $snapshot($id);
$out = PrescriptionOrderLogic::edit($editParams($id) + ['tracking_number' => 'JD-EDIT'], 1, $admin);
$expect(is_array($out) === $approved, "edit audit {$rxStatus}/{$payStatus}");
if ($approved) {
$expect($row($id)['tracking_number'] === 'JD-EDIT', 'Approved edit must persist changed tracking');
} else {
$expect(PrescriptionOrderLogic::getError() === $auditError && $snapshot($id) === $before,
'General edit cannot smuggle unapproved tracking changes or partially persist other fields');
}
}
}
}
// Omitted or unchanged tracking must never block normal editing or clear an existing number.
foreach ([[0, 0], [1, 0], [2, 0], [1, 2], [1, 1]] as [$rxStatus, $payStatus]) {
foreach ([[], ['tracking_number' => 'SF-OLD']] as $trackingParams) {
$id = $fixture(['prescription_audit_status' => $rxStatus, 'payment_slip_audit_status' => $payStatus]);
$out = PrescriptionOrderLogic::edit($editParams($id) + $trackingParams, 1, $admin);
$expect(is_array($out) && $row($id)['tracking_number'] === 'SF-OLD', 'Unchanged or absent tracking must preserve old number');
$expect($row($id)['remark_assistant'] === '其他信息已保存' && $row($id)['recipient_name'] === '更新收货人',
'Ordinary edits must continue saving while audits are pending or rejected');
}
}
foreach ([false, true] as $approved) {
$id = $fixture(['payment_slip_audit_status' => $approved ? 1 : 0, 'fulfillment_status' => 5]);
$before = $snapshot($id);
$out = PrescriptionOrderLogic::edit($editParams($id) + ['tracking_number' => ''], 1, $admin);
$expect(is_array($out) === $approved, 'Clearing a tracking number must also require both approvals');
$expect($approved ? $row($id)['tracking_number'] === '' : $snapshot($id) === $before,
'Rejected clearing must preserve existing tracking and all order data');
}
// A form edit that resets payment audit must not simultaneously save new tracking.
foreach ([[], ['pay_order_ids' => []], ['linked_pay_order_id' => null]] as $paymentParams) {
$id = $fixture();
$before = $snapshot($id);
$out = PrescriptionOrderLogic::edit($editParams($id) + $paymentParams + ['tracking_number' => 'JD-PENDING'], 1, $admin);
$expect($out === false && PrescriptionOrderLogic::getError() === $auditError && $snapshot($id) === $before,
'Final pending audit must reject changed tracking and roll back all form changes');
}
// New orders always start pending, even if clients submit fake audit flags or the source prescription is approved.
$createParams = static fn (int $rxId): array => [
'prescription_id' => $rxId, 'diagnosis_id' => 1, 'recipient_name' => '测试患者',
'recipient_phone' => '13000000000', 'shipping_address' => '测试地址', 'fee_type' => 3, 'amount' => 100,
'prescription_audit_status' => 1, 'payment_slip_audit_status' => 1,
];
$rxId = $newRx();
$out = PrescriptionOrderLogic::create($createParams($rxId) + ['tracking_number' => 'SF-CREATE'], 1, $admin);
$expect($out === false && PrescriptionOrderLogic::getError() === $auditError, 'Creation must reject prefilled tracking');
$expect(Db::name('tcm_prescription_order')->where('prescription_id', $rxId)->count() === 0,
'Rejected creation must not leave an order');
foreach ([[], ['tracking_number' => ''], ['tracking_number' => ' ']] as $trackingParams) {
$out = PrescriptionOrderLogic::create($createParams($newRx()) + $trackingParams, 1, $admin);
$expect(is_array($out) && $out['tracking_number'] === ''
&& (int) $out['prescription_audit_status'] === 0 && (int) $out['payment_slip_audit_status'] === 0,
'Normal creation must store empty tracking and pending audits');
}
// Audit revocation must be observed on the next request; historical fulfillment/snapshot data grants no exemption.
foreach ([1, 2, 3, 4, 5, 6, 9, 10] as $status) {
$id = $fixture(['fulfillment_status' => $status, 'gancao_reciperl_order_no' => 'REMOTE-HISTORY']);
$out = PrescriptionOrderLogic::ddcode($id, 'jd', 'JD-APPROVED', 1, $admin);
$expect(is_array($out) && (int) $row($id)['fulfillment_status'] === $status,
'Approved correction must preserve existing fulfillment and remote snapshot behavior');
Db::name('tcm_prescription_order')->where('id', $id)->update(['payment_slip_audit_status' => 0]);
$before = $snapshot($id);
$expect(PrescriptionOrderLogic::ddcode($id, 'sf', 'SF-REVOKED', 1, $admin) === false && $snapshot($id) === $before,
'Revoked audit must immediately block further corrections');
}
$id = $fixture();
$before = $snapshot($id);
$outsider = ['root' => 0, 'admin_id' => 2, 'role_id' => [], 'name' => '无权限账号'];
$expect(PrescriptionOrderLogic::ddcode($id, 'jd', 'JD-NO-ACCESS', 2, $outsider) === false
&& PrescriptionOrderLogic::getError() === '无权限操作' && $snapshot($id) === $before,
'Audit approval must not grant order access');
// The mandatory tracking audit trail remains atomic with the logistics update.
$pdo->exec("CREATE TRIGGER reject_tracking_log BEFORE INSERT ON zyt_tcm_prescription_order_log
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced tracking log failure'");
$expect(PrescriptionOrderLogic::ddcode($id, 'jd', 'JD-ROLLBACK', 1, $admin) === false && $snapshot($id) === $before,
'Log failure must roll back the approved correction');
$pdo->exec('DROP TRIGGER reject_tracking_log');
echo "PrescriptionOrderTrackingAuditTest: {$checks} assertions passed\n";
} finally {
$manager->connect()->close();
$pdo->exec("DROP DATABASE `{$database}`");
}