PDO::ERRMODE_EXCEPTION]); if (!$isWorker) $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()); $testLang = new think\Lang($testApp); think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang)); $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++; }; $createParams = static fn (int $rxId): array => [ 'prescription_id' => $rxId, 'diagnosis_id' => 1, 'recipient_name' => '测试患者', 'recipient_phone' => '13000000000', 'shipping_address' => '测试地址', 'fee_type' => 3, 'amount' => 100, ]; if ($isWorker) { echo "ready\n"; flush(); $out = PrescriptionOrderLogic::create($createParams((int) $argv[2]), 1, $admin); echo json_encode(['success' => is_array($out), 'error' => PrescriptionOrderLogic::getError()]) . "\n"; exit(0); } 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 refund_amount 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 gancao_reciperl_order_no VARCHAR(100) DEFAULT ""'); $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 "", 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, remark VARCHAR(200) DEFAULT "", is_exempt INT DEFAULT 0, payment_method VARCHAR(50) DEFAULT "", create_type VARCHAR(50) DEFAULT "", create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL ) ENGINE=InnoDB'); $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_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 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 )'); $newRx = static fn (array $fields = []): int => (int) Db::name('tcm_prescription')->insertGetId(array_merge(['herbs' => '[]'], $fields)); $fixture = static fn (int $rx, array $fields = []): int => (int) Db::name('tcm_prescription_order')->insertGetId(array_merge([ 'prescription_id' => $rx, 'order_no' => 'OLD-' . bin2hex(random_bytes(4)), 'diagnosis_id' => 1, 'creator_id' => 1, 'amount' => 100, 'paid' => 100, 'payment_slip_audit_status' => 1, 'create_time' => 1724300000, 'fulfillment_status' => 5, ], $fields)); $row = static fn (int $id): array => Db::name('tcm_prescription_order')->where('id', $id)->find(); $logRows = static fn (int $id): array => Db::name('tcm_prescription_order_log')->where('prescription_order_id', $id)->order('id')->select()->toArray(); $detail = static fn (int $rx): array => PrescriptionLogic::detail($rx, 1, $admin); $listsClass = new ReflectionClass(PrescriptionLists::class); $lists = $listsClass->newInstanceWithoutConstructor(); foreach (['adminInfo' => $admin, 'adminId' => 1, 'params' => [], 'searchWhere' => [], 'limitOffset' => 0, 'limitLength' => 1000] as $name => $value) { $listsClass->getProperty($name)->setValue($lists, $value); } $listRow = static function (int $rx) use ($lists): array { return array_values(array_filter($lists->lists(), static fn (array $r): bool => (int) $r['id'] === $rx))[0]; }; // Existing refunded/cancelled/deleted history never blocks a new order or contaminates active audit flags. foreach ([['fulfillment_status' => 10], ['fulfillment_status' => 4], ['delete_time' => time()]] as $released) { $rx = $newRx(); $oldId = $fixture($rx, array_merge($released, ['prescription_audit_status' => 2, 'prescription_audit_remark' => '旧驳回'])); $before = $row($oldId); $expect($detail($rx)['has_prescription_order'] === 0 && $listRow($rx)['has_prescription_order'] === 0, 'Released history must be available in list and detail'); $expect($detail($rx)['business_prescription_audit_rejected'] === 0 && $listRow($rx)['business_prescription_audit_rejected'] === 0, 'Released history must not carry rejection badges'); $expect($listsClass->getMethod('collectRiskPrescriptionIds')->invoke($lists, [$rx]) === [], 'Released orders must not pin herb-risk rows'); $out = PrescriptionOrderLogic::create($createParams($rx), 1, $admin); $expect(is_array($out), 'Approved prescription must support creating after released history'); $expect($row($oldId) === $before, 'Creation must preserve all historical order fields'); $expect($detail($rx)['has_prescription_order'] === 1 && $listRow($rx)['has_prescription_order'] === 1, 'New order must occupy the prescription in list and detail'); $expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'A second live order must be rejected'); } foreach ([1, 2, 3, 5, 6, 7, 8, 9, 11, 12] as $status) { $rx = $newRx(); $fixture($rx, ['fulfillment_status' => 10]); $fixture($rx, ['fulfillment_status' => $status]); $expect($detail($rx)['has_prescription_order'] === 1 && $listRow($rx)['has_prescription_order'] === 1, 'Every nonreleased live status must occupy prescription, even with refunded history'); $expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Live status must prevent duplicate creation'); } // Exercise the actual refund endpoint logic and preserve payment/remote history and approved prescription. $rx = $newRx(); $oldId = $fixture($rx, ['gancao_reciperl_order_no' => 'REMOTE-HISTORY']); $payId = Db::name('order')->insertGetId(['order_no' => 'PAID-HISTORY', 'status' => 2, 'amount' => 100]); Db::name('tcm_prescription_order_pay_order')->insert(['prescription_order_id' => $oldId, 'pay_order_id' => $payId, 'create_time' => time()]); Db::name('tcm_prescription_order')->where('id', $oldId)->update(['linked_pay_order_id' => $payId]); $out = PrescriptionOrderLogic::refund($oldId, '测试全退', 1, $admin); $expect(is_array($out) && (int) $out['fulfillment_status'] === 10, 'Full refund must transition to released status'); $expect((int) Db::name('order')->where('id', $payId)->value('status') === 4, 'Original payment must stay linked and be marked refunded'); $expect(Db::name('tcm_prescription_order_pay_order')->where('prescription_order_id', $oldId)->count() === 1, 'Refund must preserve payment association history'); $expect($detail($rx)['has_prescription_order'] === 0 && (int) $detail($rx)['audit_status'] === 1, 'Refund must release an already approved prescription without resetting approval'); $expect(is_array(PrescriptionOrderLogic::create($createParams($rx), 1, $admin)), 'Actual refund must permit a new business order'); $expect($row($oldId)['gancao_reciperl_order_no'] === 'REMOTE-HISTORY' && (int) $row($oldId)['prescription_id'] === $rx, 'New order must preserve old remote and prescription associations'); $rx = $newRx(); $oldId = $fixture($rx); $out = PrescriptionOrderLogic::refund($oldId, '测试部分退款', 1, $admin, 20); $expect(is_array($out) && (int) $out['fulfillment_status'] === 5 && (float) $out['paid'] === 80.0, 'Partial refund must remain active while there is a balance'); $expect($detail($rx)['has_prescription_order'] === 1 && PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Partial refund must not release prescription'); $out = PrescriptionOrderLogic::refund($oldId, '剩余全退', 1, $admin); $expect(is_array($out) && $detail($rx)['has_prescription_order'] === 0, 'Refunding remaining balance must release prescription'); $rx = $newRx(); $oldId = $fixture($rx, ['fulfillment_status' => 1]); $expect(is_array(PrescriptionOrderLogic::withdraw($oldId, 1, $admin)), 'Existing cancellation must still work'); $expect(is_array(PrescriptionOrderLogic::create($createParams($rx), 1, $admin)), 'Withdrawn order must still release prescription'); $rx = $newRx(['void_status' => 1]); $fixture($rx, ['fulfillment_status' => 10]); $expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Refund must never revive a voided prescription'); $rx = $newRx(['delete_time' => time()]); $expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Deleted prescription must not be orderable'); // Audit-log failures roll back order creation and creation-time edits. $rx = $newRx(); $oldId = $fixture($rx, ['fulfillment_status' => 10]); $before = $row($oldId); $pdo->exec("CREATE TRIGGER reject_order_log BEFORE INSERT ON zyt_tcm_prescription_order_log FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced audit failure'"); $expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Create log failure must reject creation'); $expect(Db::name('tcm_prescription_order')->where('prescription_id', $rx)->count() === 1, 'Create log failure must not leave a partial active order'); $timeParams = ['id' => $oldId, 'create_time' => '2026-08-22 11:40:03']; $expect(PrescriptionOrderLogic::editTime($timeParams, 1, $admin) === false && $row($oldId) === $before, 'Time edit must roll back if audit logging fails'); $pdo->exec('DROP TRIGGER reject_order_log'); // Dedicated permission is enforced before menu deployment, including owners and manager roles. foreach ([[], [3]] as $roles) { $out = PrescriptionOrderLogic::editTime($timeParams, 1, ['root' => 0, 'admin_id' => 1, 'role_id' => $roles]); $expect($out === false && $row($oldId) === $before, 'Ownership/general manager role must not imply time-edit permission'); } foreach ([[], ['id' => 0, 'create_time' => '2026-08-22 11:40:03'], ['id' => 1, 'create_time' => ''], ['id' => 1, 'create_time' => '2026-02-30 11:40:03'], ['id' => 1, 'create_time' => '2026-08-22'], ['id' => 1, 'create_time' => ['2026-08-22 11:40:03']], ['id' => [1], 'create_time' => '2026-08-22 11:40:03']] as $invalid) { $expect(!(new PrescriptionOrderValidate())->scene('editTime')->check($invalid), 'Invalid time requests must be rejected'); } $expect((new PrescriptionOrderValidate())->scene('editTime')->check($timeParams), 'Canonical creation datetime must validate'); $out = PrescriptionOrderLogic::editTime($timeParams + ['paid' => 999, 'fulfillment_status' => 1], 1, $admin); $after = $row($oldId); $expect(is_array($out) && (int) $after['create_time'] === strtotime($timeParams['create_time']), 'Timestamp schema must retain Unix creation time'); foreach ($before as $key => $value) { if (!in_array($key, ['create_time', 'update_time'], true)) $expect($after[$key] === $value, 'Time edit must preserve ' . $key); } $log = $logRows($oldId)[0]; $expect($log['action'] === 'edit_time' && (int) $log['admin_id'] === 1 && str_contains($log['summary'], date('Y-m-d H:i:s', (int) $before['create_time'])) && str_contains($log['summary'], $timeParams['create_time']), 'Audit log must record operator, previous and new time'); $expect(is_array(PrescriptionOrderLogic::editTime($timeParams, 1, $admin)) && count($logRows($oldId)) === 1, 'Repeated identical edit must not duplicate audit log'); $deletedId = $fixture($newRx(), ['delete_time' => time()]); $expect(PrescriptionOrderLogic::editTime(['id' => $deletedId, 'create_time' => $timeParams['create_time']], 1, $admin) === false, 'Deleted order time must not be editable'); $expect(PrescriptionOrderLogic::editTime(['id' => 999999, 'create_time' => $timeParams['create_time']], 1, $admin) === false, 'Missing order time must not be editable'); $pdo->exec("INSERT INTO zyt_system_menu (perms,is_disable) VALUES ('tcm.prescriptionOrder/lists',0)"); $sql = file_get_contents(dirname(__DIR__) . '/sql/1.9.20260909/add_prescription_order_edit_time_menu.sql'); $pdo->exec($sql); $pdo->exec($sql); $expect(Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/editTime')->count() === 1, 'Time permission migration must be idempotent'); $expect(Db::name('system_role_menu')->count() === 0, 'Migration must not grant privileges automatically'); $menuId = Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/editTime')->value('id'); Db::name('admin_role')->insert(['admin_id' => 2, 'role_id' => 2]); Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => $menuId]); $out = PrescriptionOrderLogic::editTime(['id' => $oldId, 'create_time' => '2026-08-23 11:40:03'], 2, ['root' => 0, 'admin_id' => 2, 'role_id' => [2], 'name' => '获授权测试员']); $expect(is_array($out) && array_keys($out) === ['id', 'create_time'], 'Explicit time permission must work and return only safe fields'); // Two independent PHP connections race behind the prescription lock, including already-refunded history. foreach ([false, true] as $withHistory) { $rx = $newRx(); if ($withHistory) $fixture($rx, ['fulfillment_status' => 10]); putenv('ZYT_RX_ORDER_TEST_DATABASE=' . $database); Db::startTrans(); Db::name('tcm_prescription')->where('id', $rx)->lock(true)->find(); $workers = []; try { foreach ([1, 2] as $_) { $process = proc_open([PHP_BINARY, __FILE__, '--worker', (string) $rx], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($process)) throw new RuntimeException('Cannot start concurrency worker'); fclose($pipes[0]); $workers[] = [$process, $pipes]; if (trim((string) fgets($pipes[1])) !== 'ready') throw new RuntimeException('Worker failed initialization'); } } finally { Db::commit(); } $results = []; 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); $results[] = json_decode(trim($output), true, 512, JSON_THROW_ON_ERROR); } $expect(count(array_filter($results, static fn (array $r): bool => $r['success'])) === 1, 'Concurrent create must succeed exactly once'); $expect(Db::name('tcm_prescription_order')->where('prescription_id', $rx)->whereNotIn('fulfillment_status', [4,10])->count() === 1, 'Concurrency must persist only one live order'); } // Compatibility with installations that store create_time as DATETIME. $pdo->exec('ALTER TABLE zyt_tcm_prescription_order ADD legacy_datetime DATETIME NULL'); $pdo->exec('UPDATE zyt_tcm_prescription_order SET legacy_datetime=FROM_UNIXTIME(create_time)'); $pdo->exec('ALTER TABLE zyt_tcm_prescription_order DROP create_time, CHANGE legacy_datetime create_time DATETIME NULL'); $out = PrescriptionOrderLogic::editTime(['id' => $oldId, 'create_time' => '2026-08-24 11:40:03'], 1, $admin); $expect(is_array($out) && $row($oldId)['create_time'] === '2026-08-24 11:40:03', 'Datetime schema must preserve canonical datetime strings'); echo "PrescriptionOrderReleaseAndTimeTest: {$checks} assertions passed\n"; } finally { $manager->connect()->close(); $pdo->exec("DROP DATABASE `{$database}`"); }