This commit is contained in:
Your Name
2026-09-10 15:19:17 +08:00
parent 27fbef9321
commit 36975c6c1b
487 changed files with 15696 additions and 78 deletions
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\tcm;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\tcm\PrescriptionAiLogic;
use DomainException;
final class PrescriptionAiController extends BaseAdminController
{
public function statuses()
{
return $this->handle(false, ['ids'], function (array $p): array {
$raw = $p['ids'] ?? '';
if (!is_array($raw) && !is_string($raw) && !is_int($raw)) {
throw new DomainException('处方标识列表无效');
}
$ids = is_array($raw) ? $raw : explode(',', (string) $raw);
$ids = array_map(fn ($id): int => $this->positive(['id' => $id], 'id'), array_filter($ids, static fn ($id): bool => $id !== ''));
return PrescriptionAiLogic::statuses($ids, $this->adminId, $this->adminInfo);
});
}
public function reports()
{
return $this->handle(false, ['prescription_id', 'diagnosis_id', 'page_no', 'page_size'],
fn (array $p): array => PrescriptionAiLogic::reports($p, $this->adminId, $this->adminInfo));
}
public function detail()
{
return $this->handle(false, ['batch_id'], fn (array $p): array => PrescriptionAiLogic::detail(
$this->positive($p, 'batch_id'), $this->adminId, $this->adminInfo));
}
public function regenerate()
{
return $this->handle(true, ['prescription_id', 'reason'], fn (array $p): array => PrescriptionAiLogic::regenerate(
$this->positive($p, 'prescription_id'), $this->textValue($p, 'reason', 500), $this->adminId, $this->adminInfo));
}
public function retry()
{
return $this->handle(true, ['batch_id', 'model_key'], fn (array $p): array => PrescriptionAiLogic::retry(
$this->positive($p, 'batch_id'), $this->textValue($p, 'model_key', 16), $this->adminId, $this->adminInfo));
}
public function review()
{
return $this->handle(true, ['batch_id', 'model_key', 'status', 'comment'], fn (array $p): array => PrescriptionAiLogic::review(
$this->positive($p, 'batch_id'), $this->textValue($p, 'model_key', 16), $this->textValue($p, 'status', 32),
$this->textValue($p, 'comment', 2000), $this->adminId, $this->adminInfo));
}
public function statistics()
{
return $this->handle(false, ['date_from', 'date_to', 'doctor_id'],
fn (array $p): array => PrescriptionAiLogic::statistics($p, $this->adminId, $this->adminInfo));
}
private function handle(bool $post, array $allowed, callable $handler)
{
if ($post ? !$this->request->isPost() : !$this->request->isGet()) {
return $this->fail('请求方式错误');
}
$params = $post ? $this->request->post() : $this->request->get();
if (array_diff(array_keys($params), $allowed) !== []) {
return $this->fail('请求包含不支持的字段');
}
try {
foreach (['prescription_id', 'diagnosis_id', 'batch_id', 'page_no', 'page_size', 'doctor_id'] as $field) {
if (array_key_exists($field, $params)) {
$params[$field] = $this->positive($params, $field);
}
}
foreach (['date_from', 'date_to'] as $field) {
if (array_key_exists($field, $params)) {
$params[$field] = $this->textValue($params, $field, 10);
}
}
$actor = \app\common\service\prescriptionai\PrescriptionAiAccess::actor($this->adminId);
if ($actor === null) {
throw new DomainException('账号已停用或无权访问');
}
$this->adminInfo = $actor;
return $this->data($handler($params));
} catch (DomainException $e) {
return $this->fail($e->getMessage());
} catch (\Throwable $e) {
return $this->fail('处方AI服务暂不可用,请联系管理员检查部署');
}
}
private function positive(array $p, string $key): int
{
$raw = $p[$key] ?? null;
if (!(is_int($raw) || is_string($raw)) || !preg_match('/^[1-9]\d{0,17}$/', (string) $raw)) {
throw new DomainException('记录标识无效');
}
return (int) $raw;
}
private function textValue(array $p, string $key, int $max): string
{
$raw = $p[$key] ?? '';
if (!is_string($raw) || mb_strlen($raw) > $max) {
throw new DomainException('文本参数无效');
}
return trim($raw);
}
}
@@ -23,6 +23,18 @@ use think\facade\Log;
*/
class PatientAiReportLogic extends BaseLogic
{
/** Pure normalization only. Caller must authorize every supplied row before using this helper. */
public static function normalizeAuthorizedClinicalRows(array $sources): array
{
return self::buildSourceSnapshotFromRows($sources);
}
/** Shared redaction rules; does not load data or grant access. */
public static function redactClinicalSource(array $source): array
{
return self::sanitizeSnapshotForUpstream($source);
}
public const DISCLAIMER = '仅供临床辅助参考,不可替代医生诊断。系统会把舌像、报告等附件与全部文字资料提交给已配置的模型分析,但模型识别结果仍须由执业医师核对原始资料;视频面诊以归档转写文字为准。';
private const PERMISSION_READ = 'tcm.diagnosis/patientaireports';
@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
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\PrescriptionAiPolicy as Policy;
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
use app\common\service\prescriptionai\PrescriptionAiStatistics;
use app\common\service\prescriptionai\PrescriptionAiStore as Store;
use DomainException;
use think\facade\Db;
final class PrescriptionAiLogic
{
public static function statuses(array $ids, int $actor, array $info): array
{
self::requirePermission('statuses', $actor, $info);
if (!Store::enabled()) {
return ['enabled' => false, 'items' => []];
}
$ids = array_values(array_unique(array_map('intval', $ids)));
if (count($ids) > 100 || array_filter($ids, static fn ($id): bool => $id <= 0)) {
throw new DomainException('最多查询100个有效处方');
}
if ($ids === []) {
return ['enabled' => true, 'items' => []];
}
$subjects = Db::name('prescription_ai_subject')->whereIn('prescription_id', $ids)->column('latest_batch_id', 'prescription_id');
$batches = $subjects ? Db::name('prescription_ai_batch')->whereIn('id', array_values($subjects))->select()->toArray() : [];
$batchByRx = [];
foreach ($batches as $batch) {
$batchByRx[(int) $batch['prescription_id']] = $batch;
}
$modelMap = self::models(array_column($batches, 'id'), false);
$items = [];
foreach ($ids as $id) {
$rx = Access::prescription($id, $actor, $info);
if (!$rx) {
continue;
}
$batch = $batchByRx[$id] ?? null;
if ($batch && !self::visibleBatch($batch, $actor, $info)) {
// Do not reveal that a report with a broader source scope exists.
continue;
}
$items[] = $batch ? self::formatBatch($batch, $modelMap[(int) $batch['id']] ?? [], $rx) : [
'prescription_id' => $id, 'batch_id' => null,
'status' => (int) ($rx['is_system_auto'] ?? 0) === 1 ? 'blank' : 'not_generated',
'coverage_status' => 'pending', 'validity' => 'current', 'comparison_type' => 'unavailable', 'models' => [],
];
}
return ['enabled' => true, 'items' => $items];
}
public static function reports(array $params, int $actor, array $info): array
{
self::requirePermission('reports', $actor, $info);
$rxId = (int) ($params['prescription_id'] ?? 0);
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
if (($rxId > 0) === ($diagnosisId > 0)) {
throw new DomainException('请指定处方或诊单');
}
$query = Db::name('prescription_ai_batch');
if ($rxId > 0) {
if (!Access::prescription($rxId, $actor, $info)) {
throw new DomainException('处方不存在或无权访问');
}
$query->where('prescription_id', $rxId);
} else {
if (!Access::diagnosis($diagnosisId, $actor, $info)) {
throw new DomainException('诊单不存在或无权访问');
}
$patientId = (int) Db::name('tcm_diagnosis')->where('id', $diagnosisId)->value('patient_id');
if ($patientId > 0) {
$query->where('patient_id', $patientId);
} else {
$query->where('diagnosis_id', $diagnosisId);
}
}
$page = max(1, (int) ($params['page_no'] ?? 1));
$size = max(1, min(50, (int) ($params['page_size'] ?? 20)));
// Filter before pagination so hidden snapshots do not leak counts or create gaps.
$visible = [];
$cursor = PHP_INT_MAX;
do {
$chunk = (clone $query)->where('id', '<', $cursor)->order('id', 'desc')->limit(200)->select()->toArray();
foreach ($chunk as $batch) {
$cursor = (int) $batch['id'];
if (self::visibleBatch($batch, $actor, $info)) {
$visible[] = $batch;
}
}
} while (count($chunk) === 200);
$rows = array_slice($visible, ($page - 1) * $size, $size);
$modelMap = self::models(array_column($rows, 'id'), false);
return ['lists' => array_map(static fn ($b): array => self::formatBatch($b, $modelMap[(int) $b['id']] ?? []), $rows),
'count' => count($visible), 'page_no' => $page, 'page_size' => $size];
}
public static function detail(int $batchId, int $actor, array $info): array
{
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));
}
public static function regenerate(int $rxId, string $reason, int $actor, array $info): array
{
self::requirePermission('regenerate', $actor, $info);
self::requirePermission('detail', $actor, $info);
if (!Store::enabled()) {
throw new DomainException('处方自动分析尚未启用');
}
$rx = Access::prescription($rxId, $actor, $info);
if (!$rx || !Policy::isManual($rx)) {
throw new DomainException('处方不存在、无权访问或尚未形成有效手工处方');
}
return Db::transaction(static function () use ($rxId, $actor, $reason): array {
$rx = Db::name('tcm_prescription')->where('id', $rxId)->lock(true)->find();
$count = Db::name('prescription_ai_batch')->where('prescription_id', $rxId)
->where('created_at', '>=', strtotime('today'))->count();
if ($count >= max(1, (int) config('prescription_analysis.daily_patient_batches', 10))) {
throw new DomainException('今日分析次数已达预算,请稍后再试');
}
$batchId = Store::enqueue($rx, $actor, 'manual_refresh', hash('sha256', random_bytes(32)), ['reason' => $reason]);
return ['batch_id' => $batchId, 'status' => 'queued'];
});
}
public static function retry(int $batchId, string $model, int $actor, array $info): array
{
self::requirePermission('retry', $actor, $info);
self::requirePermission('detail', $actor, $info);
if (!Store::enabled() || !in_array($model, Policy::MODELS, true)) {
throw new DomainException('分析未启用或模型参数无效');
}
$batch = self::loadBatch($batchId, $actor, $info);
if ($batch['validity'] !== 'current') {
throw new DomainException('该报告已过期,请按最新资料重新分析');
}
return Db::transaction(static function () use ($batchId, $model, $batch): array {
$rx = Db::name('tcm_prescription')->where('id', $batch['prescription_id'])->lock(true)->find();
$freshBatch = Db::name('prescription_ai_batch')->where('id', $batchId)->lock(true)->find();
if (!$rx || !Policy::isManual($rx) || $freshBatch['validity'] !== 'current'
|| !hash_equals($freshBatch['clinical_hash'], Policy::fingerprint($rx))) {
throw new DomainException('处方已变更,请重新分析');
}
$task = Db::name('prescription_ai_task')->where('batch_id', $batchId)->where('model_key', $model)->lock(true)->find();
if (!$task) {
throw new DomainException('资料尚未准备完成,请查看分析状态');
}
if ($task['status'] === 'success' || in_array($task['status'], Policy::ACTIVE_TASKS, true)) {
return ['batch_id' => $batchId, 'status' => $task['status']];
}
if ($task['status'] !== 'failed') {
throw new DomainException('该任务不能重试');
}
if ((int) $task['manual_retries'] >= (int) config('prescription_analysis.max_manual_retries', 2)) {
throw new DomainException('该模型已达手动重试上限,请检查失败原因');
}
// Attempt history and lifetime counter remain immutable across retry rounds.
Db::name('prescription_ai_task')->where('id', $task['id'])->update([
'status' => 'retry_wait', 'attempts' => 0, 'next_run_at' => time(), 'lock_token' => '',
'manual_retries' => (int) $task['manual_retries'] + 1,
'lock_until' => 0, 'error_code' => '', 'updated_at' => time(),
]);
Store::refreshBatch($batchId);
return ['batch_id' => $batchId, 'status' => 'retry_wait'];
});
}
public static function review(int $batchId, string $model, string $status, string $comment, int $actor, array $info): array
{
self::requirePermission('review', $actor, $info);
self::requirePermission('detail', $actor, $info);
self::loadBatch($batchId, $actor, $info);
if (!in_array($status, ['viewed', 'needs_information', 'not_adopted', 'reviewed'], true)
|| !in_array($model, Policy::MODELS, true) || mb_strlen($comment) > 2000) {
throw new DomainException('复核内容无效');
}
$resultId = Db::name('prescription_ai_result')->where('batch_id', $batchId)->where('model_key', $model)->value('id');
if (!$resultId) {
throw new DomainException('报告尚未生成');
}
Db::name('prescription_ai_review')->insert([
'result_id' => $resultId, 'admin_id' => $actor, 'status' => $status,
'comment_cipher' => (new Cipher())->encrypt(['comment' => $comment], 'review:' . $resultId), 'created_at' => time(),
]);
return ['saved' => true];
}
public static function statistics(array $params, int $actor, array $info): array
{
self::requirePermission('statistics', $actor, $info);
self::requirePermission('detail', $actor, $info);
$from = self::date((string) ($params['date_from'] ?? date('Y-m-d', strtotime('-30 days'))));
$to = self::date((string) ($params['date_to'] ?? date('Y-m-d'))) + 86399;
if ($from > $to || $to - $from > 366 * 86400) {
throw new DomainException('统计范围须在一年以内');
}
$query = Db::name('prescription_ai_batch')->alias('b')->join('prescription_ai_subject s', 's.first_batch_id=b.id')
->where('b.created_at', '>=', $from)->where('b.created_at', '<=', $to);
if ((int) ($params['doctor_id'] ?? 0) > 0) {
$query->where('b.doctor_id', (int) $params['doctor_id']);
}
$groups = [];
$cursor = 0;
do {
$rows = (clone $query)->where('b.id', '>', $cursor)->field('b.*')->order('b.id')->limit(200)->select()->toArray();
$modelMap = self::models(array_column($rows, 'id'), false);
foreach ($rows as $batch) {
$cursor = (int) $batch['id'];
if (!self::visibleBatch($batch, $actor, $info)) {
continue;
}
foreach (Policy::MODELS as $model) {
$result = $modelMap[(int) $batch['id']][$model] ?? [];
$exclusions = Policy::decode($batch['baseline_exclusions_json']);
$eligible = (bool) $batch['baseline_eligible'] && ($result['coverage_status'] ?? '') === 'complete';
$groups[(int) $batch['doctor_id']][] = [
'event_id' => (int) $batch['id'], 'patient_id' => (int) $batch['patient_id'],
'doctor_id' => (int) $batch['doctor_id'], 'model_key' => $model,
'baseline_eligible' => $eligible,
'exclusion_reason' => $eligible ? '' : ($exclusions[0] ?? (($result['status'] ?? '') === 'success' ? 'incomplete_coverage' : 'missing_result')),
'comparison' => ['status' => $result['comparison_status'] ?? 'not_comparable',
'reason_code' => $result['comparison_reason_code'] ?? '',
'score' => $result['score'] ?? null, 'algorithm_version' => $result['algorithm_version'] ?? ''],
'model_version' => $result['model_name'] ?? '', 'prompt_version' => $result['prompt_version'] ?? '',
'dictionary_version' => $result['dictionary_version'] ?? '',
];
}
}
} while (count($rows) === 200);
$doctors = [];
$allRows = [];
$names = $groups ? Admin::whereIn('id', array_keys($groups))->column('name', 'id') : [];
foreach ($groups as $doctorId => $rows) {
$summary = PrescriptionAiStatistics::summarize($rows);
$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'] ?? []];
}
$doctors[] = ['doctor_id' => $doctorId, 'doctor_name' => (string) ($names[$doctorId] ?? ''),
'total_count' => $summary['total_events'] ?? 0, 'patient_count' => $summary['patient_count'] ?? 0,
'models' => $models, 'paired_count' => $summary['paired_count'] ?? 0,
// Viewing or rejecting an AI report is not independent expert adjudication.
'review' => ['evaluated_count' => 0, 'qualified_count' => 0, 'qualified_rate' => null]];
array_push($allRows, ...$rows);
}
$total = PrescriptionAiStatistics::summarize($allRows);
return ['total_count' => $total['total_events'] ?? 0, 'patient_count' => $total['patient_count'] ?? 0,
'doctors' => $doctors, 'date_from' => date('Y-m-d', $from), 'date_to' => date('Y-m-d', $to),
'metric_label' => '药味与剂量一致度(不代表临床准确率)'];
}
private static function models(array $batchIds, bool $full): array
{
if ($batchIds === []) {
return [];
}
$taskFields = ['batch_id', 'model_key', 'status', 'error_code', 'attempts', 'total_attempts', 'result_id',
'started_at', 'finished_at', 'updated_at', 'next_run_at'];
if (Store::supportsProgress()) {
$taskFields[] = 'progress_json';
}
$tasks = Db::name('prescription_ai_task')->whereIn('batch_id', $batchIds)->field($taskFields)->select()->toArray();
$fields = ['id', 'batch_id', 'model_key', 'score', 'herb_score', 'comparison_status', 'comparison_reason_code',
'coverage_status', 'model_name', 'prompt_version', 'algorithm_version', 'dictionary_version', 'generated_at'];
if ($full) {
$fields[] = 'body_cipher';
}
$results = Db::name('prescription_ai_result')->whereIn('batch_id', $batchIds)->field($fields)->select()->toArray();
$map = [];
foreach ($tasks as $task) {
$map[(int) $task['batch_id']][$task['model_key']] = [
'status' => $task['status'], 'score' => null, 'herb_score' => null,
'error_code' => $task['error_code'], 'error_message' => $task['error_code'] !== '' ? Policy::errorMessage($task['error_code']) : '',
'reason' => $task['error_code'] !== '' ? Policy::errorMessage($task['error_code']) : '',
'report_id' => (int) $task['result_id'],
'progress' => Progress::task($task),
];
}
foreach ($results as $result) {
$body = $full ? (new Cipher())->decrypt($result['body_cipher'], 'result:' . $result['batch_id'] . ':' . $result['model_key']) : [];
unset($result['body_cipher']);
$result['score'] = $result['score'] === null ? null : (float) $result['score'];
$result['herb_score'] = $result['herb_score'] === null ? null : (float) $result['herb_score'];
$result['report_id'] = (int) $result['id'];
$result['reason'] = $body['comparison']['reason'] ?? ($result['comparison_status'] === 'comparable' ? '' : '点击查看不可比原因');
$trustedProgress = $map[(int) $result['batch_id']][$result['model_key']]['progress'] ?? Progress::task([
'status' => 'success', 'updated_at' => $result['generated_at'], 'finished_at' => $result['generated_at'],
]);
$map[(int) $result['batch_id']][$result['model_key']] = array_merge(
$map[(int) $result['batch_id']][$result['model_key']] ?? [], $result, $body, ['progress' => $trustedProgress]);
if ($full) {
$review = Db::name('prescription_ai_review')->where('result_id', $result['id'])->order('id', 'desc')->find();
if ($review) {
$comment = (new Cipher())->decrypt($review['comment_cipher'], 'review:' . $result['id']);
$map[(int) $result['batch_id']][$result['model_key']]['review'] = [
'status' => $review['status'], 'comment' => $comment['comment'] ?? '', 'created_at' => (int) $review['created_at'],
];
}
}
}
return $map;
}
private static function formatBatch(array $batch, array $models, ?array $rx = null): array
{
$validity = $batch['validity'];
if ($rx && (!Policy::isManual($rx) || !hash_equals($batch['clinical_hash'], Policy::fingerprint($rx)))) {
$validity = 'prescription_changed';
}
return [
'id' => (int) $batch['id'], 'batch_id' => (int) $batch['id'], 'prescription_id' => (int) $batch['prescription_id'],
'prescription_revision' => (int) $batch['prescription_revision'], 'patient_id' => (int) $batch['patient_id'],
'diagnosis_id' => (int) $batch['diagnosis_id'], 'status' => $batch['status'], 'validity' => $validity,
'comparison_type' => $batch['comparison_type'], 'baseline_eligible' => (bool) $batch['baseline_eligible'],
'baseline_exclusion_reasons' => Policy::decode($batch['baseline_exclusions_json']),
'source_summary' => Policy::decode($batch['source_summary_json']), 'missing' => Policy::decode($batch['missing_json']),
'coverage_status' => $batch['coverage_status'], 'cutoff_at' => (int) $batch['cutoff_at'],
'created_at' => (int) $batch['created_at'], 'updated_at' => (int) $batch['updated_at'],
'error_code' => $batch['error_code'], 'error_message' => $batch['error_code'] !== '' ? Policy::errorMessage($batch['error_code']) : '',
'models' => $models,
'progress' => Progress::batch($batch, null, $models),
];
}
private static function loadBatch(int $id, int $actor, array $info): array
{
$batch = Db::name('prescription_ai_batch')->where('id', $id)->find();
if (!$batch || !self::visibleBatch($batch, $actor, $info)) {
throw new DomainException('报告不存在或无权访问');
}
return $batch;
}
private static function visibleBatch(array $batch, int $actor, array $info): bool
{
if (!Access::prescription((int) $batch['prescription_id'], $actor, $info)) {
return false;
}
$ids = Policy::decode($batch['source_diagnosis_ids_json']);
if ($ids === []) {
return empty($batch['context_cipher'])
&& ((int) $batch['diagnosis_id'] === 0 || Access::diagnosis((int) $batch['diagnosis_id'], $actor, $info));
}
if (!Access::sourceIds($ids, $actor, $info) || empty($batch['access_cipher'])) {
return false;
}
$access = (new Cipher())->decrypt($batch['access_cipher'], 'access:' . $batch['id']);
return \app\common\service\prescriptionai\PrescriptionAiContext::assertSnapshotAccess($access, $actor, $info);
}
private static function requirePermission(string $action, int $actor, array $info): void
{
if (!Access::allowed($actor, $info, $action)) {
throw new DomainException('无权使用此处方AI功能');
}
}
private static function date(string $value): int
{
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) || date('Y-m-d', strtotime($value)) !== $value) {
throw new DomainException('日期格式无效');
}
return strtotime($value);
}
}
@@ -15,7 +15,10 @@ use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
use app\common\service\pharmacy\LockedPharmacySnapshotMutation;
use app\adminapi\logic\auth\AuthLogic;
use think\facade\Config;
use think\facade\Log;
use think\facade\Log;
use think\facade\Db;
use app\common\service\prescriptionai\PrescriptionAiStore;
use app\common\service\prescriptionai\PrescriptionAiRequest;
class PrescriptionLogic
{
@@ -251,6 +254,15 @@ class PrescriptionLogic
public static function add(array $params, int $adminId, array $adminInfo): ?int
{
self::setError('');
try {
$replayed = PrescriptionAiRequest::replay($params, $adminId);
if ($replayed !== null) {
return $replayed;
}
} catch (\DomainException $e) {
self::setError($e->getMessage());
return null;
}
$diagnosis = null;
$authoritativeCaseRecord = null;
$authoritativeAppointmentId = 0;
@@ -288,9 +300,6 @@ class PrescriptionLogic
}
$dateYmd = self::normalizePrescriptionDate($params['prescription_date'] ?? date('Y-m-d'));
if ($diagnosisIdRule > 0 && !self::assertUniquePrescriptionPerDiagnosisDay($diagnosisIdRule, $adminId, $dateYmd, null)) {
return null;
}
$herbs = $params['herbs'] ?? [];
if (empty($herbs) || !is_array($herbs)) {
@@ -385,10 +394,32 @@ class PrescriptionLogic
'assistant_id' => $assistantIdForRx,
];
$prescription = new Prescription();
$prescription->save($data);
return (int) $prescription->id;
try {
return Db::transaction(static function () use ($data, $params, $adminId, $adminInfo, $diagnosisIdRule, $dateYmd): int {
$replayed = PrescriptionAiRequest::replay($params, $adminId, true);
if ($replayed !== null) {
return $replayed;
}
if ($diagnosisIdRule > 0) {
Diagnosis::where('id', $diagnosisIdRule)->lock(true)->find();
if (!self::assertUniquePrescriptionPerDiagnosisDay($diagnosisIdRule, $adminId, $dateYmd, null)) {
throw new \DomainException(self::getError());
}
}
$prescription = new Prescription();
$prescription->save($data);
$id = (int) $prescription->id;
// Read database defaults and JSON exactly as workers will fingerprint them.
$saved = Db::name('tcm_prescription')->where('id', $id)->find();
self::scheduleAiSaved($saved, $adminId, $adminInfo,
['trigger' => 'first_manual'] + array_intersect_key($params, ['ai_assisted' => true]));
PrescriptionAiRequest::complete($params, $adminId, $id);
return $id;
});
} catch (\Throwable $e) {
self::setError($e instanceof \DomainException ? $e->getMessage() : '处方保存失败,请稍后重试');
return null;
}
}
/**
@@ -411,10 +442,10 @@ class PrescriptionLogic
}
}
private static function editLocked(array $params, int $adminId): bool
{
try {
$prescription = Prescription::find($params['id']);
private static function editLocked(array $params, int $adminId): bool
{
try {
$prescription = Prescription::where('id', $params['id'])->lock(true)->find();
if (!$prescription) {
self::setError('处方不存在');
return false;
@@ -477,7 +508,8 @@ class PrescriptionLogic
return false;
}
$wasVoid = (int) ($prescription->void_status ?? 0) === 1;
$wasVoid = (int) ($prescription->void_status ?? 0) === 1;
$wasBlank = (int) ($prescription->is_system_auto ?? 0) === 1;
$assistantIdForRx = (int) ($prescription->assistant_id ?? 0);
if ($newDiagnosisId > 0) {
@@ -487,8 +519,11 @@ class PrescriptionLogic
$assistantIdForRx = 0;
}
$data = [
'diagnosis_id' => $newDiagnosisId,
$data = [
'diagnosis_id' => $newDiagnosisId,
'patient_id' => $newDiagnosisId > 0
? (int) Diagnosis::where('id', $newDiagnosisId)->value('patient_id')
: (int) ($prescription->patient_id ?? 0),
'assistant_id' => $assistantIdForRx,
'prescription_name' => $params['prescription_name'] ?? $prescription->prescription_name,
'prescription_type' => $params['prescription_type'] ?? $prescription->prescription_type,
@@ -544,7 +579,12 @@ class PrescriptionLogic
}
$prescription->save($data);
PrescriptionOrderLogic::onConsumerPrescriptionSaved((int) $params['id']);
PrescriptionOrderLogic::onConsumerPrescriptionSaved((int) $params['id']);
self::scheduleAiSaved(
Db::name('tcm_prescription')->where('id', (int) $params['id'])->find(), $adminId, null,
['trigger' => $wasBlank ? 'blank_to_manual' : 'clinical_change', 'restored' => $wasVoid]
+ array_intersect_key($params, ['ai_assisted' => true])
);
return true;
} catch (\Exception $e) {
@@ -553,9 +593,18 @@ class PrescriptionLogic
}
}
/**
* 仅修正处方笺展示用患者姓名、手机号与性别(zyt_tcm_prescription),不改变审核状态与其它字段
*/
private static function scheduleAiSaved(array $saved, int $actor, ?array $info, array $options): void
{
try {
PrescriptionAiStore::recordSaved($saved, $actor, $info, $options);
} catch (\Throwable $e) {
throw new \DomainException('AI分析任务登记失败,本次处方保存已回滚,请联系管理员检查服务');
}
}
/**
* 仅修正处方笺展示用患者姓名、手机号与性别(zyt_tcm_prescription),不改变审核状态与其它字段
*/
public static function patchPatientContact(int $rxId, string $patientName, string $phone, int $gender, int $adminId, array $adminInfo): bool
{
self::setError('');
@@ -704,10 +753,10 @@ class PrescriptionLogic
}
}
private static function deleteLocked(int $id): bool
{
try {
$prescription = Prescription::find($id);
private static function deleteLocked(int $id): bool
{
try {
$prescription = Prescription::where('id', $id)->lock(true)->find();
if (!$prescription) {
self::setError('处方不存在');
return false;
@@ -718,7 +767,8 @@ class PrescriptionLogic
return false;
}
$prescription->delete();
$prescription->delete();
PrescriptionAiStore::invalidate($id, 'deleted');
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -908,10 +958,17 @@ class PrescriptionLogic
$row->void_by = $adminId;
$row->void_by_name = $name;
$ok = (bool) $row->save();
if ($ok) {
self::$lastAuditWecomNotify = self::notifyCreatorAuditResult($row, 'reject', $remark, $adminInfo);
}
$ok = Db::transaction(static function () use ($row, $id): bool {
Prescription::where('id', $id)->lock(true)->find();
$saved = (bool) $row->save();
if ($saved) {
PrescriptionAiStore::invalidate($id, 'voided');
}
return $saved;
});
if ($ok) {
self::$lastAuditWecomNotify = self::notifyCreatorAuditResult($row, 'reject', $remark, $adminInfo);
}
return $ok;
}
@@ -1202,9 +1259,9 @@ class PrescriptionLogic
}
}
private static function voidLocked(int $id, int $adminId, string $adminName): bool
{
$row = Prescription::find($id);
private static function voidLocked(int $id, int $adminId, string $adminName): bool
{
$row = Prescription::where('id', $id)->lock(true)->find();
if (!$row) {
self::setError('处方不存在');
return false;
@@ -1227,6 +1284,10 @@ class PrescriptionLogic
$row->void_time = time();
$row->void_by = $adminId;
$row->void_by_name = $adminName;
return $row->save();
$saved = (bool) $row->save();
if ($saved) {
PrescriptionAiStore::invalidate($id, 'voided');
}
return $saved;
}
}
@@ -22,6 +22,8 @@ class PrescriptionValidate extends BaseValidate
'doctor_signature' => 'require',
'action' => 'require|in:approve,reject',
'remark' => 'max:500',
'ai_assisted' => 'boolean',
'request_key' => 'alphaDash|length:16,64',
];
protected $message = [
@@ -46,7 +48,7 @@ class PrescriptionValidate extends BaseValidate
'pulse_condition', 'clinical_diagnosis', 'herbs', 'dose_count', 'dose_unit',
'usage_days', 'times_per_day', 'aux_usage', 'usage_instruction', 'usage_time', 'usage_way', 'dietary_taboo',
'usage_notes', 'doctor_name', 'doctor_signature', 'is_shared', 'visible_role_ids',
'diagnosis_id', 'appointment_id', 'case_record', 'audit_status',
'diagnosis_id', 'appointment_id', 'case_record', 'audit_status', 'ai_assisted', 'request_key',
]);
}
@@ -58,7 +60,7 @@ class PrescriptionValidate extends BaseValidate
'visit_no', 'prescription_date', 'tongue', 'tongue_image', 'pulse',
'pulse_condition', 'clinical_diagnosis', 'herbs', 'dose_count', 'dose_unit',
'usage_days', 'times_per_day', 'aux_usage', 'usage_instruction', 'usage_time', 'usage_way', 'dietary_taboo',
'usage_notes', 'doctor_name', 'doctor_signature', 'is_shared', 'visible_role_ids', 'diagnosis_id',
'usage_notes', 'doctor_name', 'doctor_signature', 'is_shared', 'visible_role_ids', 'diagnosis_id', 'ai_assisted', 'request_key',
]);
}