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