Files
zyt/server/tests/PrescriptionOrderUnlinkPayOrderTest.php
T
2026-08-31 15:17:34 +08:00

331 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Real ORM/transaction regression test, using a disposable local MySQL database only.
* Run with ZYT_UNLINK_TEST_MYSQL_PORT pointing at an isolated, empty-password root instance.
* Never loads the application's database configuration or touches business data.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\lists\tcm\PrescriptionOrderLists;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
use think\Container;
use think\DbManager;
use think\facade\Db;
$port = (int) getenv('ZYT_UNLINK_TEST_MYSQL_PORT');
if ($port <= 0) {
fwrite(STDERR, "Set ZYT_UNLINK_TEST_MYSQL_PORT to an isolated local MySQL instance.\n");
exit(1);
}
$isWorker = ($argv[1] ?? '') === '--worker';
$database = $isWorker ? (string) getenv('ZYT_UNLINK_TEST_DATABASE') : 'prescription_unlink_test_' . bin2hex(random_bytes(6));
if (!preg_match('/^prescription_unlink_test_[a-f0-9]{12}$/', $database)) {
throw new RuntimeException('Only this test\'s disposable 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 (!$isWorker) {
$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
}
$pdo->exec("USE `{$database}`");
$testApp = new think\App(); // Do not initialize: production config/services must never be loaded.
$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());
$testLang = new think\Lang($testApp);
think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang));
$checks = 0;
$expect = static function (bool $ok, string $message) use (&$checks): void {
if (!$ok) {
throw new RuntimeException($message . ' | ' . PrescriptionOrderLogic::getError());
}
$checks++;
};
$admin = ['root' => 1, 'admin_id' => 1, 'name' => '隔离测试管理员'];
if ($isWorker) {
echo "ready\n";
flush();
$operation = $argv[2];
$params = ['id' => (int) $argv[3], 'pay_order_id' => (int) $argv[4], 'order_type' => 3, 'pay_amount' => 300];
$result = PrescriptionOrderLogic::$operation($params, 1, $admin);
echo json_encode(['success' => is_array($result), 'error' => PrescriptionOrderLogic::getError(),
'paid' => $result['paid'] ?? null, 'linked_paid' => $result['linked_pay_paid_total'] ?? null]) . "\n";
exit(0);
}
try {
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order (
id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50), diagnosis_id INT DEFAULT 1,
prescription_id INT DEFAULT 1, creator_id INT DEFAULT 1, amount DECIMAL(10,2) NOT NULL,
agency_collect_amount DECIMAL(10,2) NULL, linked_pay_order_id INT NULL,
prescription_audit_status INT DEFAULT 1, payment_slip_audit_status INT DEFAULT 1,
payment_slip_audit_remark VARCHAR(500) DEFAULT "", fulfillment_status INT DEFAULT 6,
completion_request INT DEFAULT 0, completion_request_time INT DEFAULT 0,
completion_request_by INT DEFAULT 0, completion_request_by_name VARCHAR(100) DEFAULT "",
paid DECIMAL(10,2) DEFAULT 0, refund_amount DECIMAL(10,2) DEFAULT 0, internal_cost DECIMAL(10,2) DEFAULT 0,
remark_extra 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 0, 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
) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order_pay_order (
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, pay_order_id INT,
create_time INT, UNIQUE KEY uk_po_pay (prescription_order_id,pay_order_id)
) ENGINE=InnoDB');
$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_admin (id INT PRIMARY KEY, name VARCHAR(50), delete_time INT NULL)');
$pdo->exec("INSERT INTO zyt_admin VALUES (1, '隔离测试管理员', 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 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 DEFAULT 0, create_time INT, update_time INT
)');
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, assistant_id INT, delete_time INT NULL)');
$pdo->exec('INSERT INTO zyt_tcm_diagnosis VALUES (1,1,NULL)');
$fixture = static function (string $amount, array $payments, array $orderFields = []): array {
$id = (int) Db::name('tcm_prescription_order')->insertGetId(array_merge([
'order_no' => 'TEST-' . bin2hex(random_bytes(4)), 'amount' => $amount,
'create_time' => time(),
], $orderFields));
$payIds = [];
foreach ($payments as $payment) {
$payId = (int) Db::name('order')->insertGetId(array_merge([
'order_no' => 'PAY-' . bin2hex(random_bytes(4)), 'status' => 2,
'create_time' => time(),
], $payment));
Db::name('tcm_prescription_order_pay_order')->insert([
'prescription_order_id' => $id, 'pay_order_id' => $payId, 'create_time' => time(),
]);
$payIds[] = $payId;
}
Db::name('tcm_prescription_order')->where('id', $id)->update(['linked_pay_order_id' => $payIds[0] ?? null]);
return [$id, $payIds];
};
$remove = static fn (int $id, int $payId) => PrescriptionOrderLogic::unlinkPayOrder([
'id' => $id, 'pay_order_id' => $payId,
], 1, $admin);
$snapshot = static fn (int $id): array => [
Db::name('tcm_prescription_order')->where('id', $id)->find(),
Db::name('tcm_prescription_order_pay_order')->where('prescription_order_id', $id)->order('id')->select()->toArray(),
Db::name('tcm_prescription_order_log')->where('prescription_order_id', $id)->order('id')->select()->toArray(),
];
$amounts = static function (array $result, float $total, float $paid, float $collect, bool $checkStoredPaid = true) use ($expect): void {
$expect((float) $result['amount'] === $total, 'Unlink must leave the business total unchanged');
$expect((float) $result['linked_pay_paid_total'] === $paid, 'Paid total must use remaining active payments');
$expect((float) $result['agency_collect_amount'] === $collect, 'Collection snapshot must equal unchanged total minus remaining payments');
if ($checkStoredPaid) {
$expect((float) $result['paid'] === $paid, 'Response paid must match remaining effective receipts');
$expect((float) Db::name('tcm_prescription_order')->where('id', $result['id'])->value('paid') === $paid,
'The paid field must be persisted, not only changed in the response');
}
};
// Missing permission is denied even before the new menu has been installed.
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00'], ['amount' => '400.00', 'status' => 5]], ['completion_request' => 1]);
$before = $snapshot($id);
$expect(PrescriptionOrderLogic::unlinkPayOrder(['id' => $id, 'pay_order_id' => $payIds[0]], 1,
['root' => 0, 'admin_id' => 1, 'role_id' => [], 'name' => 'No permission']) === false, 'Viewing/owning an order must not imply unlink permission');
$expect($snapshot($id) === $before, 'Permission failure must not change data');
$originalPayments = Db::name('order')->whereIn('id', $payIds)->select()->toArray();
$out = $remove($id, $payIds[0]);
$expect(is_array($out), 'Paid payment removal must succeed');
$amounts($out, 1000.0, 400.0, 600.0);
$expect((int) $out['fulfillment_status'] === 6 && (int) $out['payment_slip_audit_status'] === 1,
'Signed order and approved audit must remain in the same statistics scope');
$expect((int) $out['completion_request'] === 1, 'Existing completion request must be preserved');
$expect($out['pay_order_ids'] === [$payIds[1]] && (int) $out['linked_pay_order_id'] === $payIds[1], 'Primary link must move to the first remaining payment');
$expect(Db::name('order')->whereIn('id', $payIds)->select()->toArray() === $originalPayments, 'Unlink must never delete/refund/edit original payments');
$log = (string) Db::name('tcm_prescription_order_log')->where('prescription_order_id', $id)->value('summary');
$expect(str_contains($log, '总金额 ¥1000.00 不变') && str_contains($log, 'paid)¥0.00 → ¥400.00'),
'Audit log must record unchanged total and the paid correction');
$before = $snapshot($id);
$expect($remove($id, $payIds[0]) === false && $snapshot($id) === $before, 'Repeated request must not deduct again');
$amounts($remove($id, $payIds[1]), 1000.0, 0.0, 1000.0);
$expect(Db::name('tcm_prescription_order')->where('id', $id)->value('linked_pay_order_id') === null, 'Removing last payment must clear legacy primary link');
[$id, $payIds] = $fixture('1950.00', [['amount' => '100.00', 'is_exempt' => 1], ['amount' => '1850.00']]);
$amounts($remove($id, $payIds[0]), 1950.0, 1850.0, 100.0);
$out = $remove($id, $payIds[1]);
$amounts($out, 1950.0, 0.0, 1950.0);
$expect($out['linked_pay_orders'] === [] && $out['pay_order_ids'] === [], 'All payments can be removed without stale rows');
[$id, $payIds] = $fixture('1.03', [['amount' => '0.29'], ['amount' => '0.14']]);
$amounts($remove($id, $payIds[0]), 1.03, 0.14, 0.89);
[$otherId, $otherPayIds] = $fixture('2.00', [['amount' => '1.00']]);
$before = $snapshot($id);
$expect($remove($id, $otherPayIds[0]) === false && $snapshot($id) === $before, 'Foreign payment ID must not affect either order');
$expect(count($snapshot($otherId)[1]) === 1, 'Foreign order must retain its payment');
foreach ([3, 4] as $status) {
[$id, $payIds] = $fixture('100.00', [['amount' => '100.00']], ['fulfillment_status' => $status, 'paid' => '100.00']);
$before = $snapshot($id);
$expect($remove($id, $payIds[0]) === false && $snapshot($id) === $before, 'Completed/cancelled orders must retain amount and paid snapshot');
}
foreach ([['amount' => '100.00', 'status' => 4], ['amount' => '100.00', 'status' => 1],
['amount' => '100.00', 'delete_time' => time()], ['amount' => '-1.00']] as $payment) {
[$id, $payIds] = $fixture('100.00', [$payment]);
$before = $snapshot($id);
$expect($remove($id, $payIds[0]) === false && $snapshot($id) === $before, 'Refunded/deleted/unpaid/invalid amount must not be removed');
}
[$id, $payIds] = $fixture('100.00', [['amount' => '100.00']], ['delete_time' => time()]);
$before = $snapshot($id);
$expect($remove($id, $payIds[0]) === false && $snapshot($id) === $before, 'Deleted order must not be changed');
// Overpayment and stale paid values do not prevent unlinking: total must not be reduced.
foreach (['0.00', '9999.00', '101.00'] as $stalePaid) {
[$id, $payIds] = $fixture('100.00', [['amount' => '101.00'], ['amount' => '20.00']], ['paid' => $stalePaid]);
$amounts($remove($id, $payIds[0]), 100.0, 20.0, 80.0);
}
[$id, $payIds] = $fixture('0.00', [['amount' => '10.00']]);
$amounts($remove($id, $payIds[0]), 0.0, 0.0, 0.0);
// Refund balances can be lower than receipt face values. Never resurrect refunded money.
[$id, $payIds] = $fixture('1500.00', [['amount' => '1000.00'], ['amount' => '500.00']],
['paid' => '1300.00', 'refund_amount' => '200.00']);
$out = $remove($id, $payIds[1]);
$amounts($out, 1500.0, 1000.0, 500.0, false);
$expect((float) $out['paid'] === 800.0 && (float) Db::name('tcm_prescription_order')->where('id', $id)->value('paid') === 800.0,
'Partial refunds must retain the net paid balance when removing a receipt');
$amounts($remove($id, $payIds[0]), 1500.0, 0.0, 1500.0);
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00', 'status' => 4], ['amount' => '500.00'], ['amount' => '300.00']],
['paid' => '800.00', 'refund_amount' => '200.00']);
$amounts($remove($id, $payIds[1]), 1000.0, 300.0, 700.0);
[$id, $payIds] = $fixture('1500.00', [['amount' => '1000.00'], ['amount' => '500.00']],
['fulfillment_status' => 10, 'paid' => '0.00', 'refund_amount' => '1500.00']);
$out = $remove($id, $payIds[1]);
$amounts($out, 1500.0, 1000.0, 500.0, false);
$expect((float) $out['paid'] === 0.0 && (int) $out['fulfillment_status'] === 10, 'Full refund must not acquire a paid balance again');
// Remaining refunds and soft-deleted payments must not reappear in paid sums.
[$id, $payIds] = $fixture('1000.00', [
['amount' => '200.00', 'status' => 4], ['amount' => '300.00'],
['amount' => '100.00', 'delete_time' => time()], ['amount' => '50.00', 'status' => 5],
]);
$amounts($remove($id, $payIds[1]), 1000.0, 50.0, 950.0);
$listsReflection = new ReflectionClass(PrescriptionOrderLists::class);
$sum = $listsReflection->getMethod('sumLinkedPayForPrescriptionOrderIds');
$expect($sum->invoke($listsReflection->newInstanceWithoutConstructor(), [$id]) === 50.0,
'List summary and detail must use the same remaining paid amount');
// A failed audit-log insert must roll back the removed link, paid and collection snapshot.
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00']], ['paid' => '200.00', 'agency_collect_amount' => '800.00']);
$before = $snapshot($id);
$pdo->exec("CREATE TRIGGER reject_unlink_log BEFORE INSERT ON zyt_tcm_prescription_order_log
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced audit log failure'");
$expect($remove($id, $payIds[0]) === false, 'Audit log failure must reject the operation');
$expect($snapshot($id) === $before, 'Transaction must roll back link, amount, snapshot and log');
$pdo->exec('DROP TRIGGER reject_unlink_log');
$amounts($remove($id, $payIds[0]), 1000.0, 0.0, 1000.0);
// Permission migration is idempotent, grants no roles, and enables explicitly granted access.
$pdo->exec("INSERT INTO zyt_system_menu (perms,is_disable) VALUES ('tcm.prescriptionOrder/lists',0)");
$migration = file_get_contents(dirname(__DIR__) . '/sql/1.9.20260831/add_prescription_order_unlink_pay_order_menu.sql');
foreach ([1, 2] as $_) {
$pdo->exec($migration);
}
$menuId = (int) Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/unlinkPayOrder')->value('id');
$expect(Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/unlinkPayOrder')->count() === 1, 'Menu migration must be repeatable');
$expect(Db::name('system_role_menu')->count() === 0, 'Migration must not grant permissions automatically');
Db::name('admin_role')->insert(['admin_id' => 2, 'role_id' => 2]);
Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => $menuId]);
[$id, $payIds] = $fixture('100.00', [['amount' => '50.00']]);
$out = PrescriptionOrderLogic::unlinkPayOrder(['id' => $id, 'pay_order_id' => $payIds[0]], 2,
['root' => 0, 'admin_id' => 2, 'role_id' => [2], 'name' => '获授权测试员']);
$expect(is_array($out) && !array_key_exists('internal_cost', $out) && !array_key_exists('remark_extra', $out), 'Explicit permission must work while preserving financial/remark masking');
// Existing add/link operations also participate in the same transaction/row lock.
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00']]);
$out = PrescriptionOrderLogic::addPayOrder(['id' => $id, 'order_type' => 3, 'pay_amount' => 300], 1, $admin);
$expect(is_array($out), 'Existing add payment flow must still succeed');
$amounts($out, 1000.0, 500.0, 500.0, false);
$amounts($remove($id, (int) end($out['pay_order_ids'])), 1000.0, 200.0, 800.0);
$freeId = (int) Db::name('order')->insertGetId(['order_no' => 'FREE-PAYMENT', 'patient_id' => 1, 'status' => 2, 'amount' => 50, 'create_time' => time()]);
$out = PrescriptionOrderLogic::linkPayOrder(['id' => $id, 'pay_order_id' => $freeId], 1, $admin);
$expect(is_array($out), 'Existing link payment flow must still succeed');
$amounts($out, 1000.0, 250.0, 750.0, false);
// Separate PHP/DB connections race against a held parent row lock. Both are ready
// before release, so this tests the database lock rather than sequential double-clicks.
$race = static function (int $id, array $operations) use ($database): array {
putenv('ZYT_UNLINK_TEST_DATABASE=' . $database);
Db::startTrans();
Db::name('tcm_prescription_order')->where('id', $id)->lock(true)->find();
$workers = [];
try {
foreach ($operations as [$operation, $payId]) {
$process = proc_open([PHP_BINARY, __FILE__, '--worker', $operation, (string) $id, (string) $payId],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) throw new RuntimeException('Unable to start concurrency worker');
fclose($pipes[0]);
$workers[] = [$process, $pipes];
if (trim((string) fgets($pipes[1])) !== 'ready') throw new RuntimeException('Concurrency worker did not initialize');
}
} finally {
Db::commit();
}
$results = [];
foreach ($workers as [$process, $pipes]) {
$output = stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
if (proc_close($process) !== 0) throw new RuntimeException('Concurrency worker failed: ' . $error . $output);
$results[] = json_decode(trim($output), true, 512, JSON_THROW_ON_ERROR);
}
return $results;
};
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00']]);
$results = $race($id, [['unlinkPayOrder', $payIds[0]], ['unlinkPayOrder', $payIds[0]]]);
$expect(count(array_filter($results, static fn (array $r): bool => $r['success'])) === 1, 'Concurrent duplicate removal must succeed exactly once');
$expect((float) Db::name('tcm_prescription_order')->where('id', $id)->value('amount') === 1000.0, 'Concurrent duplicate removal must never change total');
$expect((float) Db::name('tcm_prescription_order')->where('id', $id)->value('paid') === 0.0, 'Concurrent duplicate removal must persist the remaining paid balance');
$expect(Db::name('tcm_prescription_order_log')->where('prescription_order_id', $id)->count() === 1, 'Concurrent duplicate must write one audit log');
foreach (['addPayOrder', 'linkPayOrder'] as $operation) {
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00']]);
$freeId = (int) Db::name('order')->insertGetId(['order_no' => 'RACE-' . $id, 'patient_id' => 1, 'status' => 2, 'amount' => 300, 'create_time' => time()]);
$results = $race($id, [['unlinkPayOrder', $payIds[0]], [$operation, $freeId]]);
$expect($results[0]['success'] && $results[1]['success'], 'Adding/linking during removal must retain both changes');
$row = Db::name('tcm_prescription_order')->where('id', $id)->find();
$expect((float) $row['amount'] === 1000.0 && (float) $row['agency_collect_amount'] === 700.0, 'Concurrent add/remove must preserve the total and update the collection snapshot');
$expect((float) $results[0]['paid'] === (float) $results[0]['linked_paid'], 'Removal must persist paid using the receipts visible within its transaction');
$remaining = Db::name('tcm_prescription_order_pay_order')->where('prescription_order_id', $id)->column('pay_order_id');
$expect(count($remaining) === 1 && !in_array($payIds[0], array_map('intval', $remaining), true), 'Concurrent link replacement must not resurrect a removed payment');
}
foreach ([[], ['id' => 1], ['id' => 0, 'pay_order_id' => 1], ['id' => 1, 'pay_order_id' => -1],
['id' => 1, 'pay_order_id' => 1.5], ['id' => 1, 'pay_order_id' => [1]]] as $params) {
$expect(!(new PrescriptionOrderValidate())->scene('unlinkPayOrder')->check($params), 'Request must require two positive integer IDs');
}
$expect((new PrescriptionOrderValidate())->scene('unlinkPayOrder')->check(['id' => 1, 'pay_order_id' => 2]), 'Valid IDs must pass validation');
echo "PrescriptionOrderUnlinkPayOrderTest: {$checks} assertions passed\n";
} finally {
$manager->connect()->close();
$pdo->exec("DROP DATABASE `{$database}`");
}