Files
zyt/server/tests/PrescriptionOrderAuxBagCountTest.php
T
2026-10-17 16:51:48 +08:00

309 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Main/auxiliary usage edits in every order state, using real validation, ORM and transactions.
* Set ZYT_AUX_BAG_TEST_MYSQL_PORT to an explicitly selected isolated root/empty-password instance.
* Never initializes the application or loads its business database configuration.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
use app\common\model\tcm\Prescription;
use think\Container;
use think\DbManager;
use think\facade\Db;
$port = (int) getenv('ZYT_AUX_BAG_TEST_MYSQL_PORT');
if ($port <= 0 || $port === 3306) {
fwrite(STDERR, "Set ZYT_AUX_BAG_TEST_MYSQL_PORT to an isolated local MySQL instance, outside port 3306.\n");
exit(1);
}
$database = 'aux_bag_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 application configuration or services.
$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,
]],
]);
$config = new think\Config();
$config->set(['data_scope' => ['enabled' => false]], 'project');
Container::getInstance()->instance('think\DbManager', $manager);
Container::getInstance()->instance('config', $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' => '隔离测试管理员'];
$params = static fn (int $id): array => [
'id' => $id, 'times_per_day' => 3, 'usage_days' => 9, 'medication_days' => 8,
'aux_times_per_day' => 2, 'aux_usage_days' => 6,
];
$unchangedParams = static fn (int $id): array => [
'id' => $id, 'times_per_day' => 3, 'usage_days' => 7, 'medication_days' => 7,
'aux_times_per_day' => 3, 'aux_usage_days' => 7,
];
$validate = static fn (array $data): bool => (new PrescriptionOrderValidate())
->scene('patchPrescriptionUsage')->check($data);
try {
$pdo->exec('CREATE TABLE zyt_tcm_prescription (
id INT PRIMARY KEY AUTO_INCREMENT, creator_id INT DEFAULT 1, assistant_id INT DEFAULT 0,
is_shared INT DEFAULT 0, visible_role_ids VARCHAR(100) DEFAULT "", gender INT DEFAULT 0,
prescription_type VARCHAR(30) DEFAULT "浓缩水丸", herbs JSON, aux_usage JSON,
dosage_amount DECIMAL(10,2) DEFAULT 5, dosage_bag_count INT DEFAULT 2,
times_per_day INT DEFAULT 3, usage_days INT DEFAULT 7,
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, prescription_id INT DEFAULT 0, creator_id INT DEFAULT 1,
fulfillment_status INT DEFAULT 2, medication_days INT DEFAULT 7,
gancao_reciperl_order_no VARCHAR(100) DEFAULT "", ej_pharmacy_order_no VARCHAR(100) DEFAULT "",
gancao_submit_time INT DEFAULT 0, ej_pharmacy_submit_time INT DEFAULT 0,
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_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_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, perms VARCHAR(100), is_disable INT DEFAULT 0)');
$auxSeed = [
'dosage_amount' => 5, 'dosage_bag_count' => 1, 'times_per_day' => 3, 'usage_days' => 7,
'prescription_name' => '测试辅方', 'need_decoction' => 0, 'bags_per_dose' => 1,
'extra_metadata' => ['source' => 'fixture'],
];
$fixture = static function (array $rx = [], array $order = []) use ($auxSeed): int {
$rxId = (int) Db::name('tcm_prescription')->insertGetId(array_replace([
'herbs' => json_encode([['formula_type' => '主方'], ['formula_type' => '辅方']], JSON_UNESCAPED_UNICODE),
'aux_usage' => json_encode($auxSeed, JSON_UNESCAPED_UNICODE),
], $rx));
return (int) Db::name('tcm_prescription_order')->insertGetId(array_replace(['prescription_id' => $rxId], $order));
};
$orderRow = static fn (int $id): array => Db::name('tcm_prescription_order')->where('id', $id)->find();
$rxRow = static fn (int $id): array => Db::name('tcm_prescription')
->where('id', $orderRow($id)['prescription_id'])->find();
$rxModel = static fn (int $id): Prescription => Prescription::find((int) $orderRow($id)['prescription_id']);
$logs = static fn (int $id): array => Db::name('tcm_prescription_order_log')
->where('prescription_order_id', $id)->order('id')->select()->toArray();
$snapshot = static fn (int $id): array => [$orderRow($id), $rxRow($id), $logs($id)];
// Exercise the same scene name used by the controller, including backward-compatible omission.
$expect($validate($params(1)), 'Legacy requests without a bag count must remain valid');
$invalidCounts = [0, 6, -1, 1.5, 2.0, '1.5', '2.0', 'abc', '2bags', ' 2 ', true, false, null, '', [], [2]];
foreach (['dosage_bag_count', 'aux_dosage_bag_count'] as $field) {
foreach ([1, 5, '2'] as $count) {
$expect($validate($params(1) + [$field => $count]), 'Valid integer bag count must pass the actual scene');
}
foreach ($invalidCounts as $count) {
$expect(!$validate($params(1) + [$field => $count]), 'Supplied invalid bag count must fail the actual scene');
}
}
foreach ([['id' => 0], ['times_per_day' => 'invalid'], ['usage_days' => 0], ['medication_days' => 1000],
['aux_times_per_day' => 7], ['aux_usage_days' => 0]] as $invalidField) {
$expect(!$validate(array_replace($params(1), $invalidField)), 'Existing usage rules must execute through the corrected scene');
}
// Persist through the public transaction wrapper, then read the real JSON column and model back.
foreach ([1, 5, '3'] as $count) {
$id = $fixture();
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['aux_dosage_bag_count' => $count], 1, $admin),
'Valid auxiliary bag count must save');
$expectedAux = array_replace($auxSeed, ['times_per_day' => 2, 'usage_days' => 6, 'dosage_bag_count' => (int) $count]);
$savedAux = json_decode($rxRow($id)['aux_usage'], true);
$modelAux = $rxModel($id)->aux_usage;
$expect($savedAux == $expectedAux && $modelAux == $expectedAux
&& $savedAux['dosage_bag_count'] === (int) $count && $modelAux['dosage_bag_count'] === (int) $count,
'Saved bag count must round-trip as an integer while preserving all other auxiliary JSON keys');
$expect($rxModel($id)->dosage_bag_count === 2 && $rxModel($id)->dosage_amount === 5.0,
'Auxiliary edits must preserve main dosage and bag count');
$expect((int) $rxRow($id)['times_per_day'] === 3 && (int) $rxRow($id)['usage_days'] === 9
&& (int) $orderRow($id)['medication_days'] === 8, 'Existing main and order usage updates must remain intact');
$log = $logs($id);
$expect(count($log) === 1 && $log[0]['action'] === 'patch_rx_usage'
&& ((int) $count === 1 ? !str_contains($log[0]['summary'], '辅方袋数')
: str_contains($log[0]['summary'], '辅方袋数 1袋 → ' . (int) $count . '袋'))
&& (int) $log[0]['admin_id'] === 1 && $log[0]['admin_name'] === $admin['name']
&& (int) $log[0]['create_time'] > 0, 'Usage log must include changed auxiliary bag count, operator and time');
}
foreach ([1, 5, '3'] as $count) {
$id = $fixture();
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => $count], 1, $admin),
'Valid main bag count must save');
$expect($rxModel($id)->dosage_bag_count === (int) $count
&& $rxModel($id)->aux_usage['dosage_bag_count'] === 1 && $rxModel($id)->dosage_amount === 5.0,
'Main bag edits must round-trip independently without altering auxiliary bags or dosage');
$expect(str_contains($logs($id)[0]['summary'], '主方袋数 2袋 → ' . (int) $count . '袋')
&& !str_contains($logs($id)[0]['summary'], '辅方袋数'), 'Main-only bag changes must be logged under the main formula');
}
$id = $fixture();
$allChanges = array_replace($params($id), ['times_per_day' => 4, 'dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5]);
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($allChanges, 1, $admin), 'Both formula bag counts must save together');
$expect($rxModel($id)->dosage_bag_count === 4 && $rxModel($id)->aux_usage['dosage_bag_count'] === 5,
'Simultaneous main and auxiliary bag edits must remain independent');
$summary = $logs($id)[0]['summary'];
foreach (['主方每天次数 3次 → 4次', '主方开立天数 7天 → 9天', '主方袋数 2袋 → 4袋',
'辅方每天次数 3次 → 2次', '辅方开立天数 7天 → 6天', '辅方袋数 1袋 → 5袋', '订单服用天数 7天 → 8天'] as $change) {
$expect(str_contains($summary, $change), 'Every changed usage field must include its old and new value in the log');
}
$expect(count(explode(';', $summary)) === 7, 'The combined usage log must contain every change exactly once');
$id = $fixture();
$before = $snapshot($id);
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($unchangedParams($id) + ['dosage_bag_count' => 2, 'aux_dosage_bag_count' => 1], 1, $admin)
&& $snapshot($id) === $before, 'An unchanged request must not rewrite rows or create an empty operation log');
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($unchangedParams($id) + ['dosage_bag_count' => 5], 1, $admin)
&& $logs($id)[0]['summary'] === '服用参数:主方袋数 2袋 → 5袋', 'A bag-only edit must log only that actual change');
$id = $fixture(['aux_usage' => json_encode(array_replace($auxSeed, ['dosage_bag_count' => 4]), JSON_UNESCAPED_UNICODE)]);
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id), 1, $admin), 'Legacy usage request must still save');
$expect($rxModel($id)->dosage_bag_count === 2 && $rxModel($id)->aux_usage['dosage_bag_count'] === 4
&& !str_contains($logs($id)[0]['summary'], '袋数'), 'Omission must preserve both previous bag counts without reporting a bag edit');
$missingCountAux = $auxSeed;
unset($missingCountAux['dosage_bag_count']);
$id = $fixture(['aux_usage' => json_encode($missingCountAux, JSON_UNESCAPED_UNICODE)]);
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id), 1, $admin)
&& !array_key_exists('dosage_bag_count', $rxModel($id)->aux_usage), 'Omission must not insert a default bag count into legacy JSON');
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['aux_dosage_bag_count' => 2], 1, $admin)
&& str_contains($logs($id)[1]['summary'], '辅方袋数 1袋 → 2袋'), 'First explicit bag edit must log the displayed legacy default');
foreach (['饮片', '散剂'] as $type) {
$id = $fixture(['prescription_type' => $type]);
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 5, 'aux_dosage_bag_count' => 5], 1, $admin),
'Other prescription types must retain their existing usage-edit behavior');
$expect($rxModel($id)->dosage_bag_count === 2 && $rxModel($id)->aux_usage['dosage_bag_count'] === 1
&& !str_contains($logs($id)[0]['summary'], '袋数'), 'Other prescription types must ignore both bag edits');
}
foreach ([json_encode($auxSeed, JSON_UNESCAPED_UNICODE), null] as $oldAux) {
$id = $fixture(['herbs' => json_encode([['formula_type' => '主方']], JSON_UNESCAPED_UNICODE), 'aux_usage' => $oldAux]);
$beforeAux = $rxRow($id)['aux_usage'];
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 5, 'aux_dosage_bag_count' => 5], 1, $admin),
'Main-only prescription must still support its original usage changes');
$expect($rxModel($id)->dosage_bag_count === 5 && $rxRow($id)['aux_usage'] === $beforeAux
&& !str_contains($logs($id)[0]['summary'], '辅方袋数'), 'A main-only prescription may edit main bags without changing stale auxiliary JSON');
}
// Logic callers must receive the same strict rejection even without a controller/validator.
foreach (['dosage_bag_count' => '主方', 'aux_dosage_bag_count' => '辅方'] as $field => $label) {
foreach ($invalidCounts as $count) {
$id = $fixture();
$before = $snapshot($id);
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + [$field => $count], 1, $admin) === false
&& PrescriptionOrderLogic::getError() === $label . '袋数须为 1~5 的整数' && $snapshot($id) === $before,
'Invalid direct logic bag input must leave prescription, order and logs unchanged');
}
}
foreach (['times_per_day' => 6, 'usage_days' => 999, 'medication_days' => 999, 'aux_times_per_day' => 6, 'aux_usage_days' => 999] as $field => $max) {
foreach ([0, $max + 1, 1.5, '2invalid', true] as $invalid) {
$id = $fixture();
$before = $snapshot($id);
$expect(PrescriptionOrderLogic::patchPrescriptionUsage(array_replace($params($id), [$field => $invalid]), 1, $admin) === false
&& $snapshot($id) === $before, 'Invalid direct logic usage input must not be truncated or persisted');
}
}
$reject = static function (int $id, int $adminId, array $actor, string $error) use ($params, $snapshot, $expect): void {
$before = $snapshot($id);
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['aux_dosage_bag_count' => 5], $adminId, $actor) === false
&& str_contains(PrescriptionOrderLogic::getError(), $error) && $snapshot($id) === $before,
'Rejected usage mutation must preserve all persisted state: ' . $error);
};
$outsider = ['root' => 0, 'admin_id' => 2, 'role_id' => [], 'permissions' => [], 'name' => '无权限测试账号'];
$reject($fixture(), 2, $outsider, '无权限操作');
$reject($fixture([], ['creator_id' => 2]), 2, $outsider, '无权限修改此处方');
$reject($fixture([], ['fulfillment_status' => 4, 'ej_pharmacy_order_no' => 'EJ-TEST']), 2, $outsider, '无权限操作');
$reject($fixture(['delete_time' => time()]), 1, $admin, '处方不存在');
$allow = static function (int $id) use ($params, $rxModel, $orderRow, $logs, $expect, $admin): void {
$beforeOrder = $orderRow($id);
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5], 1, $admin),
'Usage edits must be allowed regardless of fulfillment or pharmacy submission status');
$expect($rxModel($id)->dosage_bag_count === 4 && $rxModel($id)->aux_usage['dosage_bag_count'] === 5
&& (int) $orderRow($id)['medication_days'] === 8 && count($logs($id)) === 1,
'All-status edit must persist independent main/auxiliary bags, days and its operation log');
$afterOrder = $orderRow($id);
unset($beforeOrder['medication_days'], $beforeOrder['update_time'], $afterOrder['medication_days'], $afterOrder['update_time']);
$expect($afterOrder === $beforeOrder, 'Usage edits must preserve fulfillment status and remote pharmacy identity');
};
foreach ([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as $status) {
$allow($fixture([], ['fulfillment_status' => $status]));
}
foreach ([['gancao_reciperl_order_no' => 'GC-TEST'], ['ej_pharmacy_order_no' => 'EJ-TEST'],
['gancao_submit_time' => time()], ['ej_pharmacy_submit_time' => time()]] as $remoteField) {
$id = $fixture([], $remoteField);
$allow($id);
$before = $snapshot($id);
$expect(PrescriptionOrderLogic::patchPrescriptionPatient($id, '测试姓名', '13000000000', 1, $admin) === false
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
'Opening usage edits must not open patient snapshot edits after submission');
$expect(PrescriptionOrderLogic::edit(['id' => $id], 1, $admin) === false
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
'Opening usage edits must not open general order edits after submission');
$expect(PrescriptionOrderLogic::setShipMode($id, 'direct', 1, $admin) === false
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
'Opening usage edits must not open pharmacy selection after submission');
}
foreach (['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'] as $status) {
$id = $fixture();
Db::name('pharmacy_submission_claim')->insert(['prescription_order_id' => $id, 'source_revision' => 1, 'status' => $status]);
$allow($id);
$expect(Db::name('pharmacy_submission_claim')->where('prescription_order_id', $id)->value('status') === $status,
'Usage edits must leave the existing pharmacy submission claim untouched');
$before = $snapshot($id);
$expect(PrescriptionOrderLogic::patchPrescriptionPatient($id, '测试姓名', '13000000000', 1, $admin) === false
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
'A pharmacy submission claim must still lock patient edits');
}
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params(999999) + ['aux_dosage_bag_count' => 5], 1, $admin) === false
&& PrescriptionOrderLogic::getError() === '订单不存在', 'Missing order must retain the existing rejection');
// Force failure after the prescription save to prove the shared order transaction rolls it back.
$id = $fixture();
$before = $snapshot($id);
$pdo->exec("CREATE TRIGGER reject_aux_bag_order BEFORE UPDATE ON zyt_tcm_prescription_order
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced auxiliary bag order failure'");
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5], 1, $admin) === false
&& $snapshot($id) === $before, 'Order save failure must roll back the earlier prescription JSON update');
$pdo->exec('DROP TRIGGER reject_aux_bag_order');
$id = $fixture([], ['fulfillment_status' => 4, 'ej_pharmacy_order_no' => 'EJ-TEST']);
$before = $snapshot($id);
$pdo->exec("CREATE TRIGGER reject_usage_log BEFORE INSERT ON zyt_tcm_prescription_order_log
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced usage log failure'");
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5], 1, $admin) === false
&& PrescriptionOrderLogic::getError() === '操作日志写入失败,变更未保存' && $snapshot($id) === $before,
'Mandatory log failure must roll back main bags, auxiliary JSON, days and all timestamps, even on cancelled/submitted orders');
$pdo->exec('DROP TRIGGER reject_usage_log');
echo "PrescriptionOrderAuxBagCountTest: {$checks} assertions passed\n";
} finally {
$manager->connect()->close();
$pdo->exec("DROP DATABASE `{$database}`");
}