更新
This commit is contained in:
@@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user