Files
zyt/server/tests/PaymentPrescriptionOrderLinksTest.php
2026-10-22 11:02:57 +08:00

232 lines
14 KiB
PHP

<?php
declare(strict_types=1);
/**
* Real ORM integration against an explicitly selected disposable local MySQL instance.
* Run: ZYT_PAYMENT_LINK_TEST_MYSQL_PORT=13429 php tests/PaymentPrescriptionOrderLinksTest.php
* Never initializes the application or loads its business database/environment configuration.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\lists\order\OrderLists;
use app\adminapi\logic\order\OrderLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\adminapi\service\order\PaymentPrescriptionOrderService;
use think\Container;
use think\DbManager;
use think\facade\Db;
$port = (int) getenv('ZYT_PAYMENT_LINK_TEST_MYSQL_PORT');
if ($port <= 0 || $port === 3306) {
fwrite(STDERR, "Set ZYT_PAYMENT_LINK_TEST_MYSQL_PORT to an isolated local MySQL instance outside port 3306.\n");
exit(1);
}
$database = 'payment_rx_links_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::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}`");
}