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}`"); }