更新
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;
|
||||
|
||||
Reference in New Issue
Block a user