更新
This commit is contained in:
@@ -144,7 +144,7 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅修改承运商与快递单号,不受订单履约状态或远端药房快照锁限制。
|
||||
* 处方与支付单双审核通过后可修改承运商与快递单号,不另设履约状态或远端药房快照锁限制。
|
||||
*/
|
||||
public function ddcode()
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace app\adminapi\logic\tcm;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\service\prescriptionai\PrescriptionAiAccess as Access;
|
||||
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
|
||||
use app\common\service\prescriptionai\PrescriptionAiDoctorSnapshot as DoctorSnapshot;
|
||||
use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
|
||||
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
|
||||
use app\common\service\prescriptionai\PrescriptionAiStatistics;
|
||||
@@ -106,7 +107,13 @@ final class PrescriptionAiLogic
|
||||
self::requirePermission('detail', $actor, $info);
|
||||
$batch = self::loadBatch($batchId, $actor, $info);
|
||||
$models = self::models([$batchId], true);
|
||||
return self::formatBatch($batch, $models[$batchId] ?? [], Access::prescription((int) $batch['prescription_id'], $actor, $info));
|
||||
$detail = self::formatBatch($batch, $models[$batchId] ?? [], Access::prescription((int) $batch['prescription_id'], $actor, $info));
|
||||
// loadBatch has checked both current prescription and historical source permissions.
|
||||
// This cipher is saved at enqueue time, so it is available before models start and
|
||||
// remains the correct original when the user selects an older report revision.
|
||||
$detail['doctor_snapshot'] = empty($batch['prescription_cipher']) ? null
|
||||
: DoctorSnapshot::project((new Cipher())->decrypt($batch['prescription_cipher'], 'prescription'));
|
||||
return $detail;
|
||||
}
|
||||
|
||||
public static function regenerate(int $rxId, string $reason, int $actor, array $info): array
|
||||
@@ -244,9 +251,10 @@ final class PrescriptionAiLogic
|
||||
$models = [];
|
||||
foreach (Policy::MODELS as $model) {
|
||||
$m = $summary['models'][$model] ?? [];
|
||||
// 分布直方图与均值同源,前端据此画分箱;缺失时给空数组,不补零。
|
||||
$models[$model] = ['eligible_count' => $m['valid_count'] ?? 0, 'coverage_rate' => $m['coverage_percent'] ?? 0,
|
||||
'mean' => $m['mean'] ?? null, 'median' => $m['median'] ?? null, 'excluded_reasons' => $m['exclusion_reasons'] ?? [],
|
||||
'strata' => $m['strata'] ?? []];
|
||||
'distribution' => $m['distribution'] ?? [], 'strata' => $m['strata'] ?? []];
|
||||
}
|
||||
$doctors[] = ['doctor_id' => $doctorId, 'doctor_name' => (string) ($names[$doctorId] ?? ''),
|
||||
'total_count' => $summary['total_events'] ?? 0, 'patient_count' => $summary['patient_count'] ?? 0,
|
||||
|
||||
@@ -45,6 +45,8 @@ class PrescriptionOrderLogic
|
||||
{
|
||||
private static string $error = '';
|
||||
|
||||
private const TRACKING_AUDIT_REQUIRED = '处方审核和支付单审核均通过后,才可填写或修改快递单号';
|
||||
|
||||
public static function setError(string $msg): void
|
||||
{
|
||||
self::$error = $msg;
|
||||
@@ -55,6 +57,17 @@ class PrescriptionOrderLogic
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
private static function assertTrackingAuditApproved(PrescriptionOrder $order): bool
|
||||
{
|
||||
if ((int) $order->prescription_audit_status !== 1 || (int) $order->payment_slip_audit_status !== 1) {
|
||||
self::$error = self::TRACKING_AUDIT_REQUIRED;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function assertRemoteSnapshotMutable(PrescriptionOrder $order): bool
|
||||
{
|
||||
try {
|
||||
@@ -990,6 +1003,12 @@ class PrescriptionOrderLogic
|
||||
private static function createLocked(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
// 新建业务订单的两项审核均为待审核,不能在创建时预填单号。
|
||||
if (trim((string) ($params['tracking_number'] ?? '')) !== '') {
|
||||
self::$error = self::TRACKING_AUDIT_REQUIRED;
|
||||
|
||||
return false;
|
||||
}
|
||||
$rxId = (int) $params['prescription_id'];
|
||||
$diagId = (int) $params['diagnosis_id'];
|
||||
$payOrderIds = self::normalizePayOrderIds($params['pay_order_ids'] ?? []);
|
||||
@@ -1086,7 +1105,7 @@ class PrescriptionOrderLogic
|
||||
$order->prev_staff = (string) ($params['prev_staff'] ?? '');
|
||||
$order->service_channel = (string) ($params['service_channel'] ?? '');
|
||||
$order->service_package = (string) ($params['service_package'] ?? '');
|
||||
$order->tracking_number = (string) ($params['tracking_number'] ?? '');
|
||||
$order->tracking_number = '';
|
||||
$order->express_company = self::normalizeExpressCompany($params['express_company'] ?? 'auto');
|
||||
$order->ship_mode = self::canSelectShipMode($adminInfo)
|
||||
? self::normalizeShipMode((string) ($params['ship_mode'] ?? 'gancao'))
|
||||
@@ -1867,6 +1886,15 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
// 未提交单号时保留原值;仅实际填写、修改或清空单号需要双审核通过。
|
||||
$trackingNumber = array_key_exists('tracking_number', $params)
|
||||
? (string) $params['tracking_number']
|
||||
: (string) ($order->tracking_number ?? '');
|
||||
$trackingChanged = $trackingNumber !== (string) ($order->tracking_number ?? '');
|
||||
if ($trackingChanged && !self::assertTrackingAuditApproved($order)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$medDays = $params['medication_days'] ?? null;
|
||||
$medDays = $medDays === '' || $medDays === null ? null : (int) $medDays;
|
||||
|
||||
@@ -1892,7 +1920,7 @@ class PrescriptionOrderLogic
|
||||
$order->prev_staff = (string) ($params['prev_staff'] ?? '');
|
||||
$order->service_channel = (string) ($params['service_channel'] ?? '');
|
||||
$order->service_package = (string) ($params['service_package'] ?? '');
|
||||
$order->tracking_number = (string) ($params['tracking_number'] ?? '');
|
||||
$order->tracking_number = $trackingNumber;
|
||||
if (array_key_exists('express_company', $params)) {
|
||||
$order->express_company = self::normalizeExpressCompany($params['express_company']);
|
||||
}
|
||||
@@ -1978,6 +2006,10 @@ class PrescriptionOrderLogic
|
||||
$order->payment_slip_audit_status = 0;
|
||||
$order->payment_slip_audit_remark = '';
|
||||
}
|
||||
// 同次编辑若让支付单重新进入待审核,也不能同时写入新的单号。
|
||||
if ($trackingChanged && !self::assertTrackingAuditApproved($order)) {
|
||||
return false;
|
||||
}
|
||||
self::syncFulfillmentStatus($order);
|
||||
|
||||
try {
|
||||
@@ -2001,7 +2033,7 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅修改承运商与快递单号。物流信息不属于药房下单快照,因此允许在所有履约状态下修正。
|
||||
* 双审核通过后,仅修改承运商与快递单号。物流信息不属于药房下单快照,不另设履约状态限制。
|
||||
*
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
@@ -2061,6 +2093,10 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!self::assertTrackingAuditApproved($order)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$oldTrackingNumber = trim((string) ($order->tracking_number ?? ''));
|
||||
$oldExpressCompany = self::normalizeExpressCompany((string) ($order->express_company ?? 'auto'));
|
||||
$newExpressCompany = self::normalizeExpressCompany($expressCompany);
|
||||
@@ -2148,6 +2184,9 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::assertTrackingAuditApproved($order)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$shipMode = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao'));
|
||||
$shipModeText = $shipMode === 'direct' ? '药房直发' : '甘草药方发';
|
||||
|
||||
@@ -53,7 +53,9 @@ final class PrescriptionAiWork extends Command
|
||||
try {
|
||||
if (PrescriptionAiStore::enabled()) {
|
||||
$worked = $lane === 'prepare' ? $worker->prepareOne() : $worker->runOne($lane);
|
||||
if ($lane === 'prepare' && time() >= $sweepAt) {
|
||||
// Source and prescription sweeps rebuild whole contexts, so they only run while
|
||||
// nothing is waiting: a prescription saved right now must never queue behind them.
|
||||
if ($lane === 'prepare' && !$worked && time() >= $sweepAt) {
|
||||
$sweep = $worker->refreshSources($sourceCursor);
|
||||
$sourceCursor = $sweep['selected'] > 0 ? $sweep['last_id'] : 0;
|
||||
$rx = $worker->reconcile($rxCursor);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\prescriptionai;
|
||||
|
||||
/** Minimal display projection of the immutable prescription; never reads live records. */
|
||||
final class PrescriptionAiDoctorSnapshot
|
||||
{
|
||||
public static function project(array $prescription): array
|
||||
{
|
||||
$patient = self::scalars($prescription, ['gender', 'age']);
|
||||
$patient['name'] = self::text($prescription['patient_name'] ?? null);
|
||||
$gender = self::text($patient['gender'] ?? null);
|
||||
$patient['gender_label'] = in_array($gender, ['1', '男'], true) ? '男'
|
||||
: (in_array($gender, ['0', '2', '女'], true) ? '女' : '未知');
|
||||
$rx = self::scalars($prescription, [
|
||||
'prescription_name', 'prescription_type', 'dosage_unit', 'dose_unit', 'dose_basis',
|
||||
'dose_count', 'usage_instruction', 'usage_days', 'times_per_day', 'usage_time',
|
||||
'usage_way', 'usage_notes', 'dietary_taboo', 'dosage_amount', 'dosage_bag_count',
|
||||
'need_decoction', 'bags_per_dose',
|
||||
]);
|
||||
$auxUsage = self::scalars(PrescriptionAiPolicy::decode($prescription['aux_usage'] ?? []), [
|
||||
'prescription_name', 'dosage_amount', 'dosage_bag_count', 'need_decoction',
|
||||
'bags_per_dose', 'times_per_day', 'usage_days',
|
||||
]);
|
||||
if ($auxUsage !== []) {
|
||||
// Auxiliary formula instructions belong to that formula, never the main usage.
|
||||
$rx['aux_usage'] = $auxUsage;
|
||||
}
|
||||
$rx['herbs'] = [];
|
||||
foreach (PrescriptionAiPolicy::decode($prescription['herbs'] ?? []) as $herb) {
|
||||
if (!is_array($herb)) {
|
||||
continue;
|
||||
}
|
||||
// Preserve saved row order and duplicates, without costs, stock, IDs or attachments.
|
||||
$rx['herbs'][] = self::scalars($herb, [
|
||||
'name', 'dosage', 'unit', 'dose_basis', 'formula_type', 'processing',
|
||||
'special_usage', 'instructions', 'usage', 'remark',
|
||||
]);
|
||||
}
|
||||
return [
|
||||
'patient' => $patient,
|
||||
// The source has one combined clinical diagnosis. Do not invent a Western/TCM split
|
||||
// or expose case_record, which may contain other encounters and contact details.
|
||||
'diagnosis' => ['clinical_diagnosis' => self::text($prescription['clinical_diagnosis'] ?? null)],
|
||||
'prescription' => $rx,
|
||||
];
|
||||
}
|
||||
|
||||
private static function scalars(array $row, array $fields): array
|
||||
{
|
||||
return array_filter(array_intersect_key($row, array_flip($fields)),
|
||||
static fn ($value): bool => is_string($value) || is_int($value) || is_float($value));
|
||||
}
|
||||
|
||||
private static function text($value): string
|
||||
{
|
||||
return is_string($value) || is_numeric($value) ? trim((string) $value) : '';
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,9 @@ final class PrescriptionAiGenerator
|
||||
}
|
||||
$status = (string) ($file['status'] ?? 'pending');
|
||||
if ($status === 'restricted' || empty($file['url']) || !in_array($file['type'] ?? '', ['image', 'document'], true) || $batchSize === 0) {
|
||||
$coverage['files'][$id] = ['file_id' => $id, 'status' => $status === 'restricted' ? 'restricted' : 'unsupported', 'transmitted' => false,
|
||||
// 附件类型随覆盖清单一起保留:界面按类型展示每个附件的读取结果,不再只有编号。
|
||||
$coverage['files'][$id] = ['file_id' => $id, 'type' => (string) ($file['type'] ?? ''),
|
||||
'status' => $status === 'restricted' ? 'restricted' : 'unsupported', 'transmitted' => false,
|
||||
'version_verified' => false, 'reason' => $batchSize === 0 ? 'FILE_CAPABILITY_DISABLED' : 'FILE_UNAVAILABLE_OR_UNSUPPORTED'];
|
||||
$criticalGap = true;
|
||||
$unavailableGroups++;
|
||||
@@ -149,7 +151,8 @@ final class PrescriptionAiGenerator
|
||||
throw $e;
|
||||
}
|
||||
foreach ($batch as $file) {
|
||||
$coverage['files'][$file['file_id']] = ['file_id' => $file['file_id'], 'status' => 'unsupported', 'transmitted' => false,
|
||||
$coverage['files'][$file['file_id']] = ['file_id' => $file['file_id'], 'type' => (string) ($file['type'] ?? ''),
|
||||
'status' => 'unsupported', 'transmitted' => false,
|
||||
'version_verified' => false, 'reason' => $e->getMessage()];
|
||||
}
|
||||
$criticalGap = true;
|
||||
@@ -162,7 +165,8 @@ final class PrescriptionAiGenerator
|
||||
// Delivery was confirmed and one format repair was already spent, so no finding
|
||||
// in this malformed group is usable evidence.
|
||||
foreach ($batch as $file) {
|
||||
$coverage['files'][$file['file_id']] = ['file_id' => $file['file_id'], 'status' => 'unreadable', 'transmitted' => true,
|
||||
$coverage['files'][$file['file_id']] = ['file_id' => $file['file_id'], 'type' => (string) ($file['type'] ?? ''),
|
||||
'status' => 'unreadable', 'transmitted' => true,
|
||||
'version_verified' => false, 'reason' => 'MODEL_FILE_OUTPUT_INVALID'];
|
||||
}
|
||||
$criticalGap = true;
|
||||
@@ -171,7 +175,8 @@ final class PrescriptionAiGenerator
|
||||
}
|
||||
foreach ($result as $fileResult) {
|
||||
$file = $batch[array_search($fileResult['file_id'], array_column($batch, 'file_id'), true)];
|
||||
$coverage['files'][$fileResult['file_id']] = ['file_id' => $fileResult['file_id'], 'status' => $fileResult['status'], 'transmitted' => true,
|
||||
$coverage['files'][$fileResult['file_id']] = ['file_id' => $fileResult['file_id'], 'type' => (string) ($file['type'] ?? ''),
|
||||
'status' => $fileResult['status'], 'transmitted' => true,
|
||||
'version_verified' => !empty($file['version_verified']), 'reason' => $fileResult['status'] === 'processed' ? '' : 'MODEL_REPORTED_' . strtoupper($fileResult['status'])];
|
||||
if ($fileResult['status'] !== 'processed') {
|
||||
$criticalGap = true;
|
||||
|
||||
@@ -10,11 +10,14 @@ return [
|
||||
'lease_seconds' => 600,
|
||||
'max_attempts' => 3,
|
||||
'max_manual_retries' => 2,
|
||||
'daily_model_tasks' => 200,
|
||||
'daily_patient_batches' => 10,
|
||||
// Concurrent tasks allowed per model. It only takes effect when that many consumer
|
||||
// processes run for the lane (php think prescription-ai:work --lane=<model>).
|
||||
'max_parallel_per_model' => 2,
|
||||
'daily_model_tasks' => (int) env('prescription_analysis.DAILY_MODEL_TASKS', 200),
|
||||
// Analyses of one prescription per day. Regenerating after each change is the normal way to
|
||||
// review a case, so this is a budget guard, not a correctness limit.
|
||||
'daily_patient_batches' => (int) env('prescription_analysis.DAILY_PATIENT_BATCHES', 20),
|
||||
// Tasks that may run at the same time for one model, so several prescriptions are analysed
|
||||
// concurrently instead of queueing behind each other. Each running task needs its own consumer
|
||||
// process: start this many `php think prescription-ai:work --lane=<model>` instances.
|
||||
'max_parallel_per_model' => max(1, (int) env('prescription_analysis.MAX_PARALLEL_PER_MODEL', 4)),
|
||||
'refresh_recent_days' => 7,
|
||||
'auto_refresh_sources' => true,
|
||||
'auto_refresh_prescription' => true,
|
||||
|
||||
@@ -422,6 +422,10 @@ foreach (array_slice($badFiles['coverage']['files'], 0, 3) as $fileCoverage) {
|
||||
rxGeneratorExpect($badFiles['coverage']['files'][3]['status'] === 'processed'
|
||||
&& !isset($badFileProgress['steps']['files:0']) && isset($badFileProgress['steps']['files:1']),
|
||||
'invalid group cache is cleared without discarding the next valid group');
|
||||
// 界面要按类型展示每个附件,所以覆盖清单必须保留附件类型。
|
||||
rxGeneratorExpect(array_key_exists('type', $badFiles['coverage']['files'][3])
|
||||
&& array_key_exists('type', $badFiles['coverage']['files'][1]),
|
||||
'coverage keeps every attachment type, delivered or not');
|
||||
// The pharmacy's dispensing convention and medicine names must reach the candidate stage so both
|
||||
// models express one comparable plan; neither may carry the doctor's own herbs or dosages.
|
||||
$conventionContext = $noncriticalContext;
|
||||
|
||||
@@ -115,12 +115,42 @@ try {
|
||||
'source_diagnosis_ids' => [1], 'source_summary' => ['diagnosis_count' => 1], 'missing' => [],
|
||||
'baseline_eligible' => false, 'baseline_exclusion_reasons' => ['SOURCE_HISTORY_VERSIONS_UNAVAILABLE'],
|
||||
'comparison_type' => 'latest_context', 'cutoff_at' => time(), 'wait_for_transcript' => false];
|
||||
$rx = $fixture();
|
||||
$rx = $fixture(['patient_name' => 'Snapshot patient', 'gender' => 0, 'age' => 52,
|
||||
'clinical_diagnosis' => 'Saved clinical diagnosis', 'usage_instruction' => 'Saved usage',
|
||||
'times_per_day' => 2, 'usage_days' => 7,
|
||||
'aux_usage' => '{"prescription_name":"Saved auxiliary formula","dosage_amount":1.5,"dosage_bag_count":2,"need_decoction":0,"bags_per_dose":1,"times_per_day":1,"usage_days":3,"phone":"private-aux-contact","nested":{"phone":"private-aux-nested"}}',
|
||||
'phone' => 'private-contact', 'doctor_signature' => 'private-signature',
|
||||
'case_record' => '{"phone":"private-nested-contact","clinical_diagnosis":"other diagnosis"}']);
|
||||
$context['source_access_manifest'] = ['schema_version' => 'prescription-source-access-v1', 'patient_id' => 100,
|
||||
'target' => ['prescription_id' => (int) $rx['id'], 'diagnosis_id' => 1],
|
||||
'records' => [['source_kind' => 'diagnoses', 'id' => 1, 'source_id' => 'diagnoses:1', 'diagnosis_id' => 1, 'patient_id' => 100, 'staff' => []]]];
|
||||
$batchId = $save($rx);
|
||||
$expect($batchId > 0 && $save($rx) === $batchId, 'same clinical content enqueues once');
|
||||
$pendingDetail = Api::detail($batchId, 1, $root);
|
||||
$snapshot = $pendingDetail['doctor_snapshot'];
|
||||
$expect($pendingDetail['status'] === 'preparing' && $pendingDetail['models'] === []
|
||||
&& $snapshot['prescription']['herbs'][0]['name'] === '测试药材',
|
||||
'original prescription available before model preparation and results');
|
||||
$expect($snapshot['patient']['name'] === 'Snapshot patient' && $snapshot['patient']['gender_label'] === '女'
|
||||
&& (int) $snapshot['patient']['age'] === 52
|
||||
&& $snapshot['diagnosis']['clinical_diagnosis'] === 'Saved clinical diagnosis'
|
||||
&& $snapshot['prescription']['usage_instruction'] === 'Saved usage', 'saved patient diagnosis and usage projected');
|
||||
$expect((int) $snapshot['prescription']['times_per_day'] === 2 && (int) $snapshot['prescription']['usage_days'] === 7
|
||||
&& $snapshot['prescription']['aux_usage'] === ['prescription_name' => 'Saved auxiliary formula',
|
||||
'dosage_amount' => 1.5, 'dosage_bag_count' => 2, 'need_decoction' => 0, 'bags_per_dose' => 1,
|
||||
'times_per_day' => 1, 'usage_days' => 3],
|
||||
'saved main and auxiliary usage remain distinct and auxiliary private fields are excluded');
|
||||
$expect(!str_contains(json_encode($snapshot), 'private-') && !isset($snapshot['prescription']['herbs'][0]['medicine_id']),
|
||||
'detail original excludes contacts signatures nested records and internal medicine IDs');
|
||||
try { Api::detail($batchId, 0, []); $expect(false, 'unauthenticated caller cannot read original'); }
|
||||
catch (DomainException $e) { $checks++; }
|
||||
Db::name('tcm_diagnosis')->where('id', 1)->update(['delete_time' => time()]);
|
||||
try { Api::detail($batchId, 1, $root); $expect(false, 'revoked source access cannot read original'); }
|
||||
catch (DomainException $e) { $checks++; }
|
||||
Db::name('tcm_diagnosis')->where('id', 1)->update(['delete_time' => null]);
|
||||
$expect(!isset($snapshot['prescription']['herbs'][0]['unit'])
|
||||
&& !isset($snapshot['prescription']['herbs'][0]['dose_basis'])
|
||||
&& !isset($snapshot['diagnosis']['western_diagnosis']), 'missing source units and diagnosis split are not invented');
|
||||
$blank = $fixture(['is_system_auto' => 1, 'herbs' => '[]']);
|
||||
$expect($save($blank) === null, 'blank prescriptions do not enqueue');
|
||||
$expect((int) Db::name('prescription_ai_task')->count() === 0, 'no model call or task before snapshot');
|
||||
@@ -158,6 +188,8 @@ try {
|
||||
$liveStatus = Api::statuses([$rx['id']], 1, $root)['items'][0];
|
||||
$liveDetail = Api::detail($batchId, 1, $root);
|
||||
$liveReports = Api::reports(['prescription_id' => $rx['id']], 1, $root);
|
||||
$expect(!array_key_exists('doctor_snapshot', $liveStatus)
|
||||
&& !array_key_exists('doctor_snapshot', $liveReports['lists'][0]), 'summaries do not expose original clinical content');
|
||||
$captureModels = false;
|
||||
$expect($modelQueries !== [], 'list/detail/history task select queries observed');
|
||||
foreach ($modelQueries as $sql) {
|
||||
@@ -203,6 +235,10 @@ try {
|
||||
Db::name('prescription_ai_batch')->where('id', $batchId)->update(['baseline_eligible' => 1, 'baseline_exclusions_json' => '[]']);
|
||||
$eligibleStats = Api::statistics([], 1, $root);
|
||||
$expect($eligibleStats['doctors'][0]['models']['qwen']['mean'] === 80.0, 'synthetic qualified baseline has empty exclusion reason');
|
||||
// 分布直方图必须随统计一起返回,否则界面只能凭均值猜形状。
|
||||
$bins = $eligibleStats['doctors'][0]['models']['qwen']['distribution'] ?? null;
|
||||
$expect(is_array($bins) && array_sum($bins) === 1 && ($bins['[80,100]'] ?? 0) === 1,
|
||||
'statistics expose the agreement distribution bins');
|
||||
Db::name('prescription_ai_batch')->where('id', $batchId)->update(['baseline_eligible' => 0, 'baseline_exclusions_json' => '["SOURCE_HISTORY_VERSIONS_UNAVAILABLE"]']);
|
||||
$expect((int) Db::name('prescription_ai_attempt')->count() === 3, 'attempt history retained');
|
||||
$payload = ['request_key' => '12345678-abcd-1234-abcd-123456789012', 'herbs' => [['name' => 'fixture']]];
|
||||
@@ -221,11 +257,17 @@ try {
|
||||
});
|
||||
} catch (RuntimeException $e) {}
|
||||
$expect((int) Db::name('prescription_ai_batch')->count() === $countBefore, 'prescription and outbox roll back together');
|
||||
Db::name('tcm_prescription')->where('id', $rx['id'])->update(['herbs' => '[{"name":"changed","dosage":20}]']);
|
||||
Db::name('tcm_prescription')->where('id', $rx['id'])->update(['herbs' => '[{"name":"changed","dosage":20}]',
|
||||
'clinical_diagnosis' => 'Changed clinical diagnosis', 'patient_name' => 'Changed patient',
|
||||
'aux_usage' => '{"times_per_day":3,"usage_days":9}']);
|
||||
$newBatch = $save($rx);
|
||||
$expect($newBatch !== $batchId, 'clinical change creates immutable new batch');
|
||||
$expect(Db::name('prescription_ai_batch')->where('id', $batchId)->value('validity') === 'prescription_changed', 'previous version invalidated');
|
||||
$expect((int) Db::name('prescription_ai_result')->count() === 2, 'historic model results immutable');
|
||||
$expect(Api::detail($batchId, 1, $root)['doctor_snapshot'] === $snapshot,
|
||||
'historic report retains saved original after live prescription patient and diagnosis change');
|
||||
$expect(Api::detail($newBatch, 1, $root)['doctor_snapshot']['diagnosis']['clinical_diagnosis'] === 'Changed clinical diagnosis',
|
||||
'new pending revision carries its own updated original');
|
||||
$newClaim = Store::claimBatch();
|
||||
Store::finishPreparation($newClaim, $context);
|
||||
$lease = Store::claimTask('qwen');
|
||||
|
||||
@@ -27,6 +27,7 @@ $expect = static function (bool $condition, string $message) use (&$checks): voi
|
||||
};
|
||||
|
||||
$command = (string) file_get_contents(dirname(__DIR__) . '/app/command/PrescriptionAiWork.php');
|
||||
$configSource = (string) file_get_contents(dirname(__DIR__) . '/config/prescription_analysis.php');
|
||||
$config = require dirname(__DIR__) . '/config/prescription_analysis.php';
|
||||
|
||||
// A model task keeps its database connection idle for the whole upstream call. MySQL's
|
||||
@@ -50,4 +51,17 @@ $expect($requestTimeout > 0 && $requestTimeout < (int) $config['lease_seconds'],
|
||||
$expect((int) $config['max_attempts'] >= 1 && (int) $config['lease_seconds'] >= 60,
|
||||
'lease and attempt limits stay within a recoverable range');
|
||||
|
||||
// Several prescriptions must be analysed at the same time instead of queueing one by one.
|
||||
$expect((int) $config['max_parallel_per_model'] >= 2,
|
||||
'more than one task may run per model');
|
||||
$expect(str_contains($configSource, "env('prescription_analysis.MAX_PARALLEL_PER_MODEL'"),
|
||||
'the concurrency limit is tunable without a code edit');
|
||||
$expect(str_contains($configSource, "env('prescription_analysis.DAILY_PATIENT_BATCHES'")
|
||||
&& str_contains($configSource, "env('prescription_analysis.DAILY_MODEL_TASKS'"),
|
||||
'the daily budgets are tunable without a code edit');
|
||||
|
||||
// A context rebuild sweep must never delay a prescription that is waiting to be prepared.
|
||||
$expect(str_contains($command, '!$worked && time() >= $sweepAt'),
|
||||
'source sweeps only run while the prepare lane has nothing to claim');
|
||||
|
||||
echo 'Prescription AI worker resilience: ' . $checks . " checks passed\n";
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Exercises the real ORM and public mutation entry points in a disposable MySQL database.
|
||||
* Run with ZYT_TRACKING_TEST_MYSQL_PORT pointing at an isolated local root/no-password instance.
|
||||
* Never initializes the application or loads business database configuration.
|
||||
*/
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
use think\Container;
|
||||
use think\DbManager;
|
||||
use think\facade\Db;
|
||||
|
||||
$port = (int) getenv('ZYT_TRACKING_TEST_MYSQL_PORT');
|
||||
if ($port <= 0) {
|
||||
fwrite(STDERR, "Set ZYT_TRACKING_TEST_MYSQL_PORT to an isolated local MySQL instance.\n");
|
||||
exit(1);
|
||||
}
|
||||
$database = 'tracking_audit_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->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
|
||||
$pdo->exec("USE `{$database}`");
|
||||
$testApp = new think\App();
|
||||
$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,
|
||||
]],
|
||||
]);
|
||||
Container::getInstance()->instance('think\DbManager', $manager);
|
||||
Container::getInstance()->instance('config', new think\Config());
|
||||
$admin = ['root' => 1, 'admin_id' => 1, 'name' => '隔离测试管理员'];
|
||||
$checks = 0;
|
||||
$expect = static function (bool $ok, string $message) use (&$checks): void {
|
||||
if (!$ok) {
|
||||
throw new RuntimeException($message . ' | ' . PrescriptionOrderLogic::getError());
|
||||
}
|
||||
$checks++;
|
||||
};
|
||||
|
||||
try {
|
||||
$pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_04_07_create_tcm_prescription_order.sql'));
|
||||
$pdo->exec('ALTER TABLE zyt_tcm_prescription_order
|
||||
ADD agency_collect_amount DECIMAL(10,2) NULL, ADD paid DECIMAL(10,2) DEFAULT 0,
|
||||
ADD express_company VARCHAR(20) DEFAULT "auto", ADD ship_mode VARCHAR(20) DEFAULT "gancao",
|
||||
ADD remark_assistant VARCHAR(500) DEFAULT "", ADD dose_unit VARCHAR(10) DEFAULT "剂",
|
||||
ADD dose_count INT DEFAULT 1, ADD gancao_reciperl_order_no VARCHAR(100) DEFAULT ""');
|
||||
$pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_04_09_prescription_order_pay_links.sql'));
|
||||
$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_tcm_prescription (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT, diagnosis_id INT DEFAULT 1, gender INT DEFAULT 0,
|
||||
creator_id INT DEFAULT 1, assistant_id INT DEFAULT 0, is_shared INT DEFAULT 0,
|
||||
herbs TEXT, audit_status INT DEFAULT 1, void_status INT DEFAULT 0, visible_role_ids VARCHAR(100) DEFAULT "",
|
||||
create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
|
||||
) ENGINE=InnoDB');
|
||||
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, assistant_id INT DEFAULT 0, delete_time INT NULL)');
|
||||
$pdo->exec('INSERT INTO zyt_tcm_diagnosis (id) VALUES (1)');
|
||||
$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 (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_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)');
|
||||
|
||||
$newRx = static fn (): int => (int) Db::name('tcm_prescription')->insertGetId(['herbs' => '[]']);
|
||||
$fixture = static fn (array $fields = []): int => (int) Db::name('tcm_prescription_order')->insertGetId(array_merge([
|
||||
'prescription_id' => $newRx(), 'diagnosis_id' => 1, 'creator_id' => 1,
|
||||
'order_no' => 'TEST-' . bin2hex(random_bytes(6)), 'amount' => 100,
|
||||
'prescription_audit_status' => 1, 'payment_slip_audit_status' => 1,
|
||||
'fulfillment_status' => 2, 'tracking_number' => 'SF-OLD', 'express_company' => 'sf',
|
||||
'create_time' => time(),
|
||||
], $fields));
|
||||
$row = static fn (int $id): array => Db::name('tcm_prescription_order')->where('id', $id)->find();
|
||||
$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 => [$row($id), $logs($id)];
|
||||
$editParams = static fn (int $id): array => [
|
||||
'id' => $id, 'recipient_name' => '更新收货人', 'recipient_phone' => '13000000000',
|
||||
'shipping_address' => '更新地址', 'fee_type' => 3, 'amount' => 100, 'remark_assistant' => '其他信息已保存',
|
||||
];
|
||||
$auditError = '处方审核和支付单审核均通过后,才可填写或修改快递单号';
|
||||
|
||||
// Full audit-status matrix, covering first entry, corrections and shipped orders.
|
||||
foreach ([0, 1, 2] as $rxStatus) {
|
||||
foreach ([0, 1, 2] as $payStatus) {
|
||||
$approved = $rxStatus === 1 && $payStatus === 1;
|
||||
foreach (['', 'SF-OLD'] as $oldTracking) {
|
||||
$fields = ['prescription_audit_status' => $rxStatus, 'payment_slip_audit_status' => $payStatus,
|
||||
'tracking_number' => $oldTracking];
|
||||
$id = $fixture($fields);
|
||||
$before = $snapshot($id);
|
||||
$out = PrescriptionOrderLogic::ddcode($id, 'jd', ' JD-NEW ', 1, $admin);
|
||||
$expect(is_array($out) === $approved, "ddcode audit {$rxStatus}/{$payStatus}");
|
||||
if ($approved) {
|
||||
$expect($row($id)['tracking_number'] === 'JD-NEW' && $row($id)['express_company'] === 'jd',
|
||||
'Approved correction must persist normalized logistics');
|
||||
$expect(count($logs($id)) === 1 && $logs($id)[0]['action'] === 'fill_tracking',
|
||||
'Approved correction must record its audit trail');
|
||||
} else {
|
||||
$expect(PrescriptionOrderLogic::getError() === $auditError && $snapshot($id) === $before,
|
||||
'Unapproved correction must reject even root without changing row or log');
|
||||
}
|
||||
|
||||
foreach ([2, 5] as $status) {
|
||||
$id = $fixture($fields + ['fulfillment_status' => $status]);
|
||||
$before = $snapshot($id);
|
||||
$out = PrescriptionOrderLogic::ship($id, 'jd', 'JD-SHIP', 'gancao', 1, $admin);
|
||||
$expect(is_array($out) === $approved, "ship audit {$rxStatus}/{$payStatus}, status {$status}");
|
||||
if ($approved) {
|
||||
$expect($row($id)['tracking_number'] === 'JD-SHIP' && (int) $row($id)['fulfillment_status'] === 5,
|
||||
'Approved shipping must persist logistics and shipped status');
|
||||
} else {
|
||||
$expect(PrescriptionOrderLogic::getError() === $auditError && $snapshot($id) === $before,
|
||||
'Shipping status alone must never bypass audit requirements');
|
||||
}
|
||||
}
|
||||
|
||||
$id = $fixture($fields + ['fulfillment_status' => 5]);
|
||||
$before = $snapshot($id);
|
||||
$out = PrescriptionOrderLogic::edit($editParams($id) + ['tracking_number' => 'JD-EDIT'], 1, $admin);
|
||||
$expect(is_array($out) === $approved, "edit audit {$rxStatus}/{$payStatus}");
|
||||
if ($approved) {
|
||||
$expect($row($id)['tracking_number'] === 'JD-EDIT', 'Approved edit must persist changed tracking');
|
||||
} else {
|
||||
$expect(PrescriptionOrderLogic::getError() === $auditError && $snapshot($id) === $before,
|
||||
'General edit cannot smuggle unapproved tracking changes or partially persist other fields');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Omitted or unchanged tracking must never block normal editing or clear an existing number.
|
||||
foreach ([[0, 0], [1, 0], [2, 0], [1, 2], [1, 1]] as [$rxStatus, $payStatus]) {
|
||||
foreach ([[], ['tracking_number' => 'SF-OLD']] as $trackingParams) {
|
||||
$id = $fixture(['prescription_audit_status' => $rxStatus, 'payment_slip_audit_status' => $payStatus]);
|
||||
$out = PrescriptionOrderLogic::edit($editParams($id) + $trackingParams, 1, $admin);
|
||||
$expect(is_array($out) && $row($id)['tracking_number'] === 'SF-OLD', 'Unchanged or absent tracking must preserve old number');
|
||||
$expect($row($id)['remark_assistant'] === '其他信息已保存' && $row($id)['recipient_name'] === '更新收货人',
|
||||
'Ordinary edits must continue saving while audits are pending or rejected');
|
||||
}
|
||||
}
|
||||
foreach ([false, true] as $approved) {
|
||||
$id = $fixture(['payment_slip_audit_status' => $approved ? 1 : 0, 'fulfillment_status' => 5]);
|
||||
$before = $snapshot($id);
|
||||
$out = PrescriptionOrderLogic::edit($editParams($id) + ['tracking_number' => ''], 1, $admin);
|
||||
$expect(is_array($out) === $approved, 'Clearing a tracking number must also require both approvals');
|
||||
$expect($approved ? $row($id)['tracking_number'] === '' : $snapshot($id) === $before,
|
||||
'Rejected clearing must preserve existing tracking and all order data');
|
||||
}
|
||||
|
||||
// A form edit that resets payment audit must not simultaneously save new tracking.
|
||||
foreach ([[], ['pay_order_ids' => []], ['linked_pay_order_id' => null]] as $paymentParams) {
|
||||
$id = $fixture();
|
||||
$before = $snapshot($id);
|
||||
$out = PrescriptionOrderLogic::edit($editParams($id) + $paymentParams + ['tracking_number' => 'JD-PENDING'], 1, $admin);
|
||||
$expect($out === false && PrescriptionOrderLogic::getError() === $auditError && $snapshot($id) === $before,
|
||||
'Final pending audit must reject changed tracking and roll back all form changes');
|
||||
}
|
||||
|
||||
// New orders always start pending, even if clients submit fake audit flags or the source prescription is approved.
|
||||
$createParams = static fn (int $rxId): array => [
|
||||
'prescription_id' => $rxId, 'diagnosis_id' => 1, 'recipient_name' => '测试患者',
|
||||
'recipient_phone' => '13000000000', 'shipping_address' => '测试地址', 'fee_type' => 3, 'amount' => 100,
|
||||
'prescription_audit_status' => 1, 'payment_slip_audit_status' => 1,
|
||||
];
|
||||
$rxId = $newRx();
|
||||
$out = PrescriptionOrderLogic::create($createParams($rxId) + ['tracking_number' => 'SF-CREATE'], 1, $admin);
|
||||
$expect($out === false && PrescriptionOrderLogic::getError() === $auditError, 'Creation must reject prefilled tracking');
|
||||
$expect(Db::name('tcm_prescription_order')->where('prescription_id', $rxId)->count() === 0,
|
||||
'Rejected creation must not leave an order');
|
||||
foreach ([[], ['tracking_number' => ''], ['tracking_number' => ' ']] as $trackingParams) {
|
||||
$out = PrescriptionOrderLogic::create($createParams($newRx()) + $trackingParams, 1, $admin);
|
||||
$expect(is_array($out) && $out['tracking_number'] === ''
|
||||
&& (int) $out['prescription_audit_status'] === 0 && (int) $out['payment_slip_audit_status'] === 0,
|
||||
'Normal creation must store empty tracking and pending audits');
|
||||
}
|
||||
|
||||
// Audit revocation must be observed on the next request; historical fulfillment/snapshot data grants no exemption.
|
||||
foreach ([1, 2, 3, 4, 5, 6, 9, 10] as $status) {
|
||||
$id = $fixture(['fulfillment_status' => $status, 'gancao_reciperl_order_no' => 'REMOTE-HISTORY']);
|
||||
$out = PrescriptionOrderLogic::ddcode($id, 'jd', 'JD-APPROVED', 1, $admin);
|
||||
$expect(is_array($out) && (int) $row($id)['fulfillment_status'] === $status,
|
||||
'Approved correction must preserve existing fulfillment and remote snapshot behavior');
|
||||
Db::name('tcm_prescription_order')->where('id', $id)->update(['payment_slip_audit_status' => 0]);
|
||||
$before = $snapshot($id);
|
||||
$expect(PrescriptionOrderLogic::ddcode($id, 'sf', 'SF-REVOKED', 1, $admin) === false && $snapshot($id) === $before,
|
||||
'Revoked audit must immediately block further corrections');
|
||||
}
|
||||
|
||||
$id = $fixture();
|
||||
$before = $snapshot($id);
|
||||
$outsider = ['root' => 0, 'admin_id' => 2, 'role_id' => [], 'name' => '无权限账号'];
|
||||
$expect(PrescriptionOrderLogic::ddcode($id, 'jd', 'JD-NO-ACCESS', 2, $outsider) === false
|
||||
&& PrescriptionOrderLogic::getError() === '无权限操作' && $snapshot($id) === $before,
|
||||
'Audit approval must not grant order access');
|
||||
|
||||
// The mandatory tracking audit trail remains atomic with the logistics update.
|
||||
$pdo->exec("CREATE TRIGGER reject_tracking_log BEFORE INSERT ON zyt_tcm_prescription_order_log
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced tracking log failure'");
|
||||
$expect(PrescriptionOrderLogic::ddcode($id, 'jd', 'JD-ROLLBACK', 1, $admin) === false && $snapshot($id) === $before,
|
||||
'Log failure must roll back the approved correction');
|
||||
$pdo->exec('DROP TRIGGER reject_tracking_log');
|
||||
|
||||
echo "PrescriptionOrderTrackingAuditTest: {$checks} assertions passed\n";
|
||||
} finally {
|
||||
$manager->connect()->close();
|
||||
$pdo->exec("DROP DATABASE `{$database}`");
|
||||
}
|
||||
Reference in New Issue
Block a user