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}`"); }