232 lines
14 KiB
PHP
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}`");
|
|
}
|