> */ public static array $rows = []; public static function where(string $field, $value): self { return new self(); } public function field(array $fields): self { return $this; } public function order(string $field, string $direction): self { return $this; } public function select(): self { return $this; } /** @return array> */ public function toArray(): array { return self::$rows; } } patientPermissionExpect( class_alias(PatientAiReportHistoryQueryDouble::class, 'app\\common\\model\\tcm\\PatientAiReport'), 'history model test double is installed before logic autoload' ); function patientPermissionExpect(bool $condition, string $message): void { if (!$condition) { fwrite(STDERR, "FAIL: {$message}\n"); exit(1); } } $reflection = new ReflectionClass(PatientAiReportLogic::class); $hasPermission = $reflection->getMethod('hasPermission'); patientPermissionExpect( $hasPermission->invoke(null, 1, ['root' => 1], 'tcm.diagnosis/patientaireports') === true, 'root remains compatible without menu rows' ); patientPermissionExpect( $hasPermission->invoke(null, 0, ['root' => 0], 'tcm.diagnosis/patientaireports') === false, 'invalid unauthenticated admin fails closed' ); $source = file_get_contents($reflection->getFileName()); patientPermissionExpect(is_string($source), 'logic source is readable'); patientPermissionExpect( str_contains($source, 'MyPatientLogic::applyScope($query, $adminId, $adminInfo)'), 'patient access applies doctor/assistant/team department scope' ); patientPermissionExpect( str_contains($source, "->where('d.patient_id', \$patientId)") && str_contains($source, "->whereNull('d.delete_time')") && !str_contains($source, "->where('d.status', 1)"), 'authorization derives every non-deleted visible diagnosis row from the stable patient id' ); patientPermissionExpect( str_contains($source, "->whereIn('diagnosis_id', \$diagnosisIds)"), 'all subordinate sources are restricted to authorized diagnosis ids' ); $historyMethod = $reflection->getMethod('buildHistoryPayload'); $sourceLines = file($reflection->getFileName()); $historySource = is_array($sourceLines) ? implode('', array_slice( $sourceLines, $historyMethod->getStartLine() - 1, $historyMethod->getEndLine() - $historyMethod->getStartLine() + 1 )) : ''; patientPermissionExpect( str_contains($historySource, "PatientAiReport::where('patient_id', \$patientId)") && str_contains($historySource, "self::decodeJsonArray(\$row['source_diagnosis_ids_json'] ?? '')") && str_contains($historySource, "array_filter(\$sourceDiagnosisIds") && str_contains($historySource, '!isset($authorized[$id])') && str_contains($historySource, "\$sourceDiagnosisIds = [(int) \$row['diagnosis_id']]") && !str_contains($historySource, "->whereIn('diagnosis_id', \$diagnosisIds)"), 'history requires every source diagnosis to remain authorized, with legacy diagnosis fallback only' ); $baseRow = [ 'patient_id' => 77, 'model_key' => 'qwen', 'model_name' => 'server-model', 'model_label' => 'Qwen', 'report_json' => '{"diagnosis":"诊断","risk_assessment":[],"treatment_advice":"建议"}', 'source_summary_json' => '{"diagnosis_count":2}', 'source_hash' => str_repeat('a', 64), 'prompt_version' => 'patient-longitudinal-report-v2', 'generated_at' => 1786665600, 'created_at' => 1786665600, ]; PatientAiReportHistoryQueryDouble::$rows = [ $baseRow + ['id' => 1, 'diagnosis_id' => 12, 'source_diagnosis_ids_json' => '[11,12]'], $baseRow + ['id' => 2, 'diagnosis_id' => 12, 'source_diagnosis_ids_json' => '[11,99]'], $baseRow + ['id' => 3, 'diagnosis_id' => 12, 'source_diagnosis_ids_json' => ''], $baseRow + ['id' => 4, 'diagnosis_id' => null, 'source_diagnosis_ids_json' => '[]'], ]; $history = $historyMethod->invoke(null, 77, [11, 12], 1); patientPermissionExpect( array_column($history['reports'], 'id') === [1, 3], 'history executable filter keeps complete authorized and legacy rows but hides partial or missing source sets' ); patientPermissionExpect( $history['generated_report']['id'] === 1 && $history['report']['id'] === 1, 'generated report selection still works after complete-source authorization filtering' ); patientPermissionExpect( str_contains($source, "self::setError('患者不存在或无权访问')"), 'missing and unauthorized patients share a non-enumerating error' ); $migration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_08_14_patient_ai_report.sql'); patientPermissionExpect(is_string($migration), 'permission migration is readable'); patientPermissionExpect( substr_count($migration, "WHERE `perms` = 'tcm.diagnosis/patientAiReports'") >= 2, 'read permission registration is idempotent and addressable' ); patientPermissionExpect( substr_count($migration, "WHERE `perms` = 'tcm.diagnosis/generatePatientAiReport'") >= 2, 'generate permission registration is idempotent and addressable' ); echo "Patient AI report permission scope: OK\n";