PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 2, ]); $pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4"); $pdo->exec("USE `{$database}`"); $testApp = new think\App(); // Do not initialize: production services/configuration must not 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, 'trigger_sql' => true, ]], ]); $config = new think\Config(); $config->set(['data_scope' => ['enabled' => false]], 'project'); Container::getInstance()->instance('think\DbManager', $manager); Container::getInstance()->instance('config', $config); $sqlLog = []; $manager->listen(static function (string $sql) use (&$sqlLog): void { $sqlLog[] = $sql; }); $checks = 0; $expect = static function (bool $condition, string $message) use (&$checks): void { if (!$condition) throw new RuntimeException($message); ++$checks; }; $admin = ['root' => 1, 'name' => 'Isolated administrator']; // Bypass request/bootstrap construction while executing the real lists(), count(), and permission filters. $lists = static function (int $adminId = 1, bool $root = true, int $offset = 0, int $size = 100, array $where = []): OrderLists { return new class($adminId, $root, $offset, $size, $where) extends OrderLists { public function __construct(int $adminId, bool $root, int $offset, int $size, array $where) { $this->adminId = $adminId; $this->adminInfo = ['root' => (int) $root]; $this->params = []; $this->searchWhere = $where; $this->limitOffset = $offset; $this->pageSize = $size; } }; }; $associationQueries = static fn (array $queries): array => array_values(array_filter($queries, static fn (string $sql): bool => str_starts_with($sql, 'SELECT') && str_contains($sql, 'tcm_prescription_order_pay_order'))); try { $pdo->exec('CREATE TABLE zyt_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(60), patient_id INT DEFAULT 50, creator_id INT DEFAULT 1, order_type INT DEFAULT 3, amount DECIMAL(10,2) DEFAULT 100, status INT DEFAULT 2, is_exempt INT DEFAULT 0, remark VARCHAR(500) DEFAULT "", payment_method VARCHAR(50) DEFAULT "manual", create_type VARCHAR(50) DEFAULT "normal", payment_time INT NULL, trade_no VARCHAR(100) NULL, payee_userid VARCHAR(100) NULL, payer_external_userid VARCHAR(100) 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 ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(60), diagnosis_id INT DEFAULT 50, prescription_id INT DEFAULT 1, creator_id INT DEFAULT 1, amount DECIMAL(10,2) DEFAULT 100, agency_collect_amount DECIMAL(10,2) DEFAULT 0, 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, paid DECIMAL(10,2) DEFAULT 100, refund_amount DECIMAL(10,2) DEFAULT 0, internal_cost DECIMAL(10,2) DEFAULT 20, recipient_name VARCHAR(100) DEFAULT "private fixture", remark_extra VARCHAR(500) DEFAULT "private note", create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL ) 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(100), delete_time INT NULL)'); $pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)'); $pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, assistant_id INT, delete_time INT NULL)'); $payment = static function (string $no, array $fields = []): int { return (int) Db::name('order')->insertGetId(array_merge([ 'order_no' => $no, 'create_time' => 1800000000 + (int) Db::name('order')->count(), ], $fields)); }; $businessOrder = static fn (string $no, array $fields = []): int => (int) Db::name('tcm_prescription_order') ->insertGetId(array_merge(['order_no' => $no, 'create_time' => 1800000000], $fields)); $link = static fn (int $po, int $pay): int => (int) Db::name('tcm_prescription_order_pay_order') ->insertGetId(['prescription_order_id' => $po, 'pay_order_id' => $pay, 'create_time' => 1800000000]); $brief = static fn (int $id, string $no): array => ['id' => $id, 'order_no' => $no]; $legacyPay = $payment('PAY-LEGACY'); $legacyPo = $businessOrder('RX-LEGACY', ['linked_pay_order_id' => $legacyPay]); // Existing schema migration is the supported bridge from legacy primary links. $migration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_04_09_prescription_order_pay_links.sql'); $pdo->exec($migration); $pdo->exec($migration); $expect(Db::name('tcm_prescription_order_pay_order')->count() === 1, 'Historical migration must remain idempotent'); $expect(PaymentPrescriptionOrderService::forPaymentIds([$legacyPay])[$legacyPay] === [$brief($legacyPo, 'RX-LEGACY')], 'Migrated historical legacy association must be visible'); $singlePay = $payment('PAY-SINGLE'); $singlePo = $businessOrder('RX-SINGLE'); $link($singlePo, $singlePay); $multiPay = $payment('PAY-MULTI'); $cancelPo = $businessOrder('RX-CANCELLED', ['fulfillment_status' => 4]); $refundPo = $businessOrder('RX-REFUNDED', ['fulfillment_status' => 10]); $link($cancelPo, $multiPay); $link($refundPo, $multiPay); $refundPay = $payment('PAY-REFUNDED', ['status' => 4]); $link($refundPo, $refundPay); $cancelPay = $payment('PAY-CANCELLED', ['status' => 3]); $link($cancelPo, $cancelPay); $nonePay = $payment('PAY-NONE'); $businessOrder('RX-SAME-PATIENT'); // Same diagnosis alone is never an association. $stalePay = $payment('PAY-STALE'); $businessOrder('RX-STALE-PRIMARY', ['linked_pay_order_id' => $stalePay]); $deletedPoPay = $payment('PAY-DELETED-RX'); $deletedPo = $businessOrder('RX-DELETED', ['delete_time' => 1800000001]); $link($deletedPo, $deletedPoPay); $danglingPay = $payment('PAY-DANGLING'); $link(999999, $danglingPay); $deletedPay = $payment('PAY-DELETED', ['delete_time' => 1800000001]); $link($singlePo, $deletedPay); $hiddenPay = $payment('PAY-OTHER-CREATOR', ['creator_id' => 2]); $link($singlePo, $hiddenPay); // Tolerate duplicate legacy/corrupt junction rows without duplicating the exposed order. $pdo->exec('ALTER TABLE zyt_tcm_prescription_order_pay_order DROP INDEX uk_po_pay'); $link($refundPo, $multiPay); $map = PaymentPrescriptionOrderService::forPaymentIds([$singlePay, $multiPay, $nonePay, $stalePay, $deletedPoPay, $danglingPay, $refundPay, $cancelPay]); $expect($map[$singlePay] === [$brief($singlePo, 'RX-SINGLE')], 'Single exact junction link must resolve'); $expect($map[$multiPay] === [$brief($cancelPo, 'RX-CANCELLED'), $brief($refundPo, 'RX-REFUNDED')], 'Multiple cancelled/refunded historical business links must be ordered and deduplicated'); foreach ([$nonePay, $stalePay, $deletedPoPay, $danglingPay] as $id) { $expect($map[$id] === [], 'Unassociated, stale primary, deleted and dangling links must be empty'); } $expect($map[$refundPay] === [$brief($refundPo, 'RX-REFUNDED')], 'Refunded payment must retain its actual association'); $expect($map[$cancelPay] === [$brief($cancelPo, 'RX-CANCELLED')], 'Cancelled payment must retain its actual association'); $sqlLog = []; $expect(PaymentPrescriptionOrderService::forPaymentIds([]) === [], 'Empty input must be empty'); $expect($associationQueries($sqlLog) === [], 'Empty input must not execute an association query'); $rootLists = $lists(); $expectedCount = (int) Db::name('order')->whereNull('delete_time')->count(); $sqlLog = []; $rows = $rootLists->lists(); $expect(count($associationQueries($sqlLog)) === 1, 'A multi-payment page must use exactly one association SELECT'); $expect(count($rows) === $expectedCount && $rootLists->count() === $expectedCount, 'Links must not multiply payment rows/count'); $byId = array_column($rows, null, 'id'); $expect(!isset($byId[$deletedPay]), 'Soft-deleted payments must remain absent from the list'); foreach ($rows as $row) { $detail = OrderLogic::detail((int) $row['id']); $expect($row['prescription_orders'] === $detail['prescription_orders'], 'List and detail associations must agree'); foreach ($row['prescription_orders'] as $order) { $expect(array_keys($order) === ['id', 'order_no'] && is_int($order['id']) && is_string($order['order_no']), 'Association payload must contain only typed id and order_no'); } } $expect(OrderLogic::detail($deletedPay) === null && OrderLogic::detail(999999) === null, 'Deleted/missing detail must stay null'); $expect($byId[$nonePay]['prescription_orders'] === [] && $byId[$stalePay]['prescription_orders'] === [], 'List must expose [] for no actual association, including stale legacy-only field'); $sqlLog = []; $page = $lists(1, true, 2, 3)->lists(); $expect(array_column($page, 'id') === array_column(array_slice($rows, 2, 3), 'id'), 'Pagination order and offset must remain unchanged'); $pageQueries = $associationQueries($sqlLog); $expect(count($pageQueries) === 1, 'Paginated lookup must execute once'); $expect(preg_match('/`link`\.`pay_order_id` IN \(([^)]+)\)/', $pageQueries[0], $matches) === 1, 'Association query must be bounded by visible payment IDs'); $queryIds = array_map(static fn (string $id): int => (int) trim($id, " '\""), explode(',', $matches[1])); $expect($queryIds === array_map('intval', array_column($page, 'id')), 'Association query must include exactly current page IDs'); $emptyPage = $lists(1, true, 999, 3); $sqlLog = []; $expect($emptyPage->lists() === [] && $associationQueries($sqlLog) === [], 'Empty pages must skip lookup'); $ordinaryLists = $lists(1, false); $ordinaryRows = $ordinaryLists->lists(); $expect(!in_array($hiddenPay, array_column($ordinaryRows, 'id')), 'Existing creator permission must continue hiding other payments'); $expect($ordinaryLists->count() === $expectedCount - 1, 'Permission-filtered count must be unchanged'); $filtered = $lists(1, true, 0, 100, [['status', '=', 4]]); $expect(array_column($filtered->lists(), 'id') === [$refundPay] && $filtered->count() === 1, 'Existing status search must be preserved'); $removePay = $payment('PAY-UNLINK'); $removePo = $businessOrder('RX-UNLINK', ['linked_pay_order_id' => $removePay]); $link($removePo, $removePay); $expect(OrderLogic::detail($removePay)['prescription_orders'] === [$brief($removePo, 'RX-UNLINK')], 'Link must be visible before unlink'); $removed = PrescriptionOrderLogic::unlinkPayOrder(['id' => $removePo, 'pay_order_id' => $removePay], 1, $admin); $expect(is_array($removed), 'Real unlink must succeed: ' . PrescriptionOrderLogic::getError()); $expect(OrderLogic::detail($removePay)['prescription_orders'] === [], 'Explicitly removed association must disappear'); $expect(Db::name('tcm_prescription_order')->where('id', $removePo)->value('linked_pay_order_id') === null, 'Existing unlink must clear the legacy primary'); Db::name('tcm_prescription_order')->where('id', $removePo)->update(['linked_pay_order_id' => $removePay]); $expect(OrderLogic::detail($removePay)['prescription_orders'] === [], 'A stale primary must never resurrect an explicitly removed association'); $splitPay = $payment('PAY-SPLIT', ['amount' => 100]); $splitPo = $businessOrder('RX-SPLIT', ['linked_pay_order_id' => $splitPay]); $link($splitPo, $splitPay); $split = OrderLogic::split($splitPay, [40, 60], [3, 3]); $expect(is_array($split), 'Real payment split must succeed: ' . OrderLogic::getError()); $expect(OrderLogic::detail($splitPay) === null, 'Split parent must remain soft deleted'); $expect(PaymentPrescriptionOrderService::forPaymentIds([$splitPay])[$splitPay] === [], 'Transferred junction must not remain on stale split parent'); foreach ($split['new_order_ids'] as $id) { $expect(OrderLogic::detail($id)['prescription_orders'] === [$brief($splitPo, 'RX-SPLIT')], 'Every split child must show the transferred business association'); } echo "PASS PaymentPrescriptionOrderLinksTest ({$checks} checks; real ORM list/detail, one batch query, legacy migration/unlink/split)\n"; } finally { $pdo->exec("DROP DATABASE `{$database}`"); }