Files
ladill-care/app/Services/Care/SpecialtyClinicalRecordService.php
T
isaaccladandCursor 8e23cdeae3
Deploy Ladill Care / deploy (push) Successful in 49s
Add full Radiology, Cardiology, and Psychiatry specialty clinical suites.
Mirror the Ophthalmology/Maternity shell pattern with stages, stage_tabs,
workspace clinical records, reports/print, demo seeds, and suite tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 18:41:55 +00:00

532 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Care;
use App\Models\Organization;
use App\Models\SpecialtyClinicalRecord;
use App\Models\Visit;
use Illuminate\Support\Arr;
/**
* Visit-scoped specialty clinical records (structured JSON payloads + alerts).
*/
class SpecialtyClinicalRecordService
{
/**
* Map workspace tab → record_type for modules that have clinical forms.
*
* @return array<string, string>
*/
public function recordTypesForModule(string $moduleKey): array
{
$configured = config("care_specialty_clinical.record_types.{$moduleKey}");
if (is_array($configured) && $configured !== []) {
return $configured;
}
return [
'clinical_notes' => 'clinical_note',
];
}
public function recordTypeForTab(string $moduleKey, string $tab): ?string
{
return $this->recordTypesForModule($moduleKey)[$tab] ?? null;
}
/**
* Field schemas for forms (AI-ready structured data).
*
* @return list<array{name: string, label: string, type: string, options?: list<string>, required?: bool, rows?: int}>
*/
public function fieldsFor(string $moduleKey, string $recordType): array
{
$fields = config("care_specialty_clinical.fields.{$moduleKey}.{$recordType}");
if (is_array($fields) && $fields !== []) {
return $fields;
}
return [
['name' => 'notes', 'label' => 'Clinical notes', 'type' => 'textarea', 'rows' => 4, 'required' => true],
];
}
public function findForVisit(Visit $visit, string $moduleKey, string $recordType): ?SpecialtyClinicalRecord
{
return SpecialtyClinicalRecord::owned($visit->owner_ref)
->where('visit_id', $visit->id)
->where('module_key', $moduleKey)
->where('record_type', $recordType)
->first();
}
/**
* @param array<string, mixed> $payload
*/
public function upsert(
Organization $organization,
Visit $visit,
string $moduleKey,
string $recordType,
array $payload,
string $ownerRef,
?string $actorRef = null,
?string $stageCode = null,
string $status = SpecialtyClinicalRecord::STATUS_ACTIVE,
): SpecialtyClinicalRecord {
$visit->loadMissing('patient');
$sanitized = $this->sanitizePayload($moduleKey, $recordType, $payload);
$alerts = $this->deriveAlerts($moduleKey, $recordType, $sanitized);
$record = $this->findForVisit($visit, $moduleKey, $recordType);
$attributes = [
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $visit->branch_id,
'patient_id' => $visit->patient_id,
'consultation_id' => $visit->consultations()->orderByDesc('id')->value('id'),
'module_key' => $moduleKey,
'record_type' => $recordType,
'status' => $status,
'stage_code' => $stageCode,
'payload' => $sanitized,
'alerts' => $alerts,
'recorded_by' => $actorRef ?? $ownerRef,
'recorded_at' => now(),
];
if ($record) {
$record->update($attributes);
return $record->fresh();
}
return SpecialtyClinicalRecord::create(array_merge($attributes, [
'visit_id' => $visit->id,
]));
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
public function sanitizePayload(string $moduleKey, string $recordType, array $input): array
{
$allowed = collect($this->fieldsFor($moduleKey, $recordType))->pluck('name')->all();
$out = [];
foreach ($allowed as $name) {
if (! array_key_exists($name, $input)) {
continue;
}
$value = $input[$name];
if (is_string($value)) {
$value = trim($value);
}
if ($value === '' || $value === null) {
continue;
}
if ($value === '1' || $value === '0' || $value === 'true' || $value === 'false') {
// keep as string for form round-trip; normalize booleans below via field type
}
$out[$name] = $value;
}
foreach ($this->fieldsFor($moduleKey, $recordType) as $field) {
$name = $field['name'];
if (! array_key_exists($name, $out)) {
continue;
}
if (($field['type'] ?? '') === 'boolean') {
$out[$name] = filter_var($out[$name], FILTER_VALIDATE_BOOLEAN);
}
if (($field['type'] ?? '') === 'number') {
$out[$name] = is_numeric($out[$name]) ? 0 + $out[$name] : null;
if ($out[$name] === null) {
unset($out[$name]);
}
}
}
return $out;
}
/**
* @param array<string, mixed> $payload
* @return list<array{code: string, severity: string, message: string}>
*/
public function deriveAlerts(string $moduleKey, string $recordType, array $payload): array
{
$alerts = [];
if ($moduleKey === 'emergency' && $recordType === 'triage') {
$acuity = (string) ($payload['acuity'] ?? '');
if (str_starts_with($acuity, '1') || str_starts_with($acuity, '2')) {
$alerts[] = [
'code' => 'er.high_acuity',
'severity' => 'critical',
'message' => 'High-acuity triage — prioritise resus / emergency bay.',
];
}
if (($payload['airway_ok'] ?? true) === false
|| ($payload['breathing_ok'] ?? true) === false
|| ($payload['circulation_ok'] ?? true) === false) {
$alerts[] = [
'code' => 'er.abc_compromise',
'severity' => 'critical',
'message' => 'ABC compromise flagged on triage.',
];
}
$pain = (int) ($payload['pain_score'] ?? 0);
if ($pain >= 8) {
$alerts[] = [
'code' => 'er.severe_pain',
'severity' => 'warning',
'message' => 'Severe pain score (≥8).',
];
}
}
if ($moduleKey === 'blood_bank' && $recordType === 'blood_request') {
$urgency = (string) ($payload['urgency'] ?? '');
if (str_contains(strtolower($urgency), 'emergency') || str_contains(strtolower($urgency), 'massive')) {
$alerts[] = [
'code' => 'bb.massive_transfusion',
'severity' => 'critical',
'message' => 'Emergency / massive transfusion request.',
];
} elseif (str_contains(strtolower($urgency), 'urgent')) {
$alerts[] = [
'code' => 'bb.urgent_request',
'severity' => 'warning',
'message' => 'Urgent blood product request.',
];
}
if (($payload['crossmatch_status'] ?? '') === 'Incompatible') {
$alerts[] = [
'code' => 'bb.incompatible',
'severity' => 'critical',
'message' => 'Cross-match incompatible — do not issue.',
];
}
}
if ($moduleKey === 'blood_bank' && $recordType === 'inventory_note') {
if (! empty($payload['low_stock_alert'])) {
$alerts[] = [
'code' => 'bb.low_stock',
'severity' => 'warning',
'message' => 'Blood products flagged as low stock.',
];
}
foreach (['whole_blood_units', 'packed_rbc_units', 'platelet_units', 'ffp_units', 'cryo_units'] as $key) {
if (isset($payload[$key]) && (int) $payload[$key] <= 2) {
$alerts[] = [
'code' => 'bb.unit_critical',
'severity' => 'warning',
'message' => str_replace('_', ' ', $key).' at or below 2 units.',
];
}
}
}
if ($moduleKey === 'blood_bank' && $recordType === 'transfusion_note') {
$reaction = strtolower((string) ($payload['reaction'] ?? ''));
if (str_contains($reaction, 'severe') || str_contains($reaction, 'stop')) {
$alerts[] = [
'code' => 'bb.transfusion_reaction',
'severity' => 'critical',
'message' => 'Severe transfusion reaction — stop and escalate.',
];
}
if (($payload['vitals_ok'] ?? true) === false) {
$alerts[] = [
'code' => 'bb.vitals_concern',
'severity' => 'warning',
'message' => 'Transfusion vitals not acceptable.',
];
}
}
if ($moduleKey === 'ophthalmology' && in_array($recordType, ['eye_exam', 'refraction'], true)) {
$iopOd = isset($payload['iop_od']) ? (float) $payload['iop_od'] : null;
$iopOs = isset($payload['iop_os']) ? (float) $payload['iop_os'] : null;
if (($iopOd !== null && $iopOd >= 30) || ($iopOs !== null && $iopOs >= 30)) {
$alerts[] = [
'code' => 'eye.critical_iop',
'severity' => 'critical',
'message' => 'Critical IOP (≥30 mmHg) — urgent glaucoma / acute review.',
];
} elseif (($iopOd !== null && $iopOd >= 24) || ($iopOs !== null && $iopOs >= 24)) {
$alerts[] = [
'code' => 'eye.elevated_iop',
'severity' => 'warning',
'message' => 'Elevated IOP (≥24 mmHg).',
];
}
foreach (['va_od', 'va_os'] as $vaKey) {
$va = strtolower(trim((string) ($payload[$vaKey] ?? '')));
if ($va === '') {
continue;
}
if (preg_match('/\b(cf|hm|pl|nlp|counting|hand\s*motion|perception)\b/i', $va)
|| preg_match('/^6\/(60|90|120)\b/', $va)
|| preg_match('/^20\/(200|400)\b/', $va)) {
$alerts[] = [
'code' => 'eye.low_vision',
'severity' => 'warning',
'message' => 'Low visual acuity recorded ('.$vaKey.').',
];
break;
}
}
}
if ($moduleKey === 'ophthalmology' && $recordType === 'eye_procedure') {
$outcome = strtolower((string) ($payload['outcome'] ?? ''));
if (str_contains($outcome, 'complication') || str_contains($outcome, 'aborted')) {
$alerts[] = [
'code' => 'eye.procedure_concern',
'severity' => 'warning',
'message' => 'Procedure outcome needs review (complication or aborted).',
];
}
}
if ($moduleKey === 'physiotherapy' && $recordType === 'pt_assessment') {
$pain = (int) ($payload['pain_score'] ?? 0);
if ($pain >= 8) {
$alerts[] = [
'code' => 'pt.severe_pain',
'severity' => 'warning',
'message' => 'Severe pain score (≥8) on physio assessment.',
];
}
$red = trim((string) ($payload['red_flags'] ?? ''));
if ($red !== '') {
$alerts[] = [
'code' => 'pt.red_flags',
'severity' => 'critical',
'message' => 'Physio red flags recorded — review urgently.',
];
}
}
if ($moduleKey === 'maternity' && $recordType === 'obstetric_exam') {
$danger = trim((string) ($payload['danger_signs'] ?? ''));
if ($danger !== '') {
$alerts[] = [
'code' => 'mat.danger_signs',
'severity' => 'critical',
'message' => 'Maternity danger signs flagged.',
];
}
$bp = (string) ($payload['bp'] ?? '');
if (preg_match('/(\d{2,3})\s*[\/]\s*(\d{2,3})/', $bp, $m)) {
$sys = (int) $m[1];
$dia = (int) $m[2];
if ($sys >= 160 || $dia >= 110) {
$alerts[] = [
'code' => 'mat.severe_hypertension',
'severity' => 'critical',
'message' => 'Severe hypertension on obstetric exam.',
];
} elseif ($sys >= 140 || $dia >= 90) {
$alerts[] = [
'code' => 'mat.hypertension',
'severity' => 'warning',
'message' => 'Elevated BP on obstetric exam.',
];
}
}
}
if ($moduleKey === 'maternity' && $recordType === 'fetal_notes') {
$fhr = isset($payload['fetal_heart_rate']) ? (int) $payload['fetal_heart_rate'] : null;
if ($fhr !== null && ($fhr < 110 || $fhr > 160)) {
$alerts[] = [
'code' => 'mat.abnormal_fhr',
'severity' => 'critical',
'message' => 'Fetal heart rate outside 110160 bpm.',
];
}
$movements = strtolower((string) ($payload['fetal_movements'] ?? ''));
if (str_contains($movements, 'reduced') || str_contains($movements, 'absent')) {
$alerts[] = [
'code' => 'mat.reduced_movements',
'severity' => 'critical',
'message' => 'Reduced or absent fetal movements.',
];
}
}
if ($moduleKey === 'maternity' && $recordType === 'mat_plan') {
$risk = strtolower((string) ($payload['risk_category'] ?? ''));
if (str_contains($risk, 'high')) {
$alerts[] = [
'code' => 'mat.high_risk',
'severity' => 'warning',
'message' => 'High-risk maternity care plan.',
];
}
}
if ($moduleKey === 'radiology' && $recordType === 'imaging_request') {
$urgency = strtolower((string) ($payload['urgency'] ?? ''));
if (str_contains($urgency, 'stat')) {
$alerts[] = [
'code' => 'rad.stat',
'severity' => 'critical',
'message' => 'STAT imaging request.',
];
} elseif (str_contains($urgency, 'urgent')) {
$alerts[] = [
'code' => 'rad.urgent',
'severity' => 'warning',
'message' => 'Urgent imaging request.',
];
}
}
if ($moduleKey === 'radiology' && $recordType === 'imaging_report') {
if (! empty($payload['critical_result'])) {
$alerts[] = [
'code' => 'rad.critical_result',
'severity' => 'critical',
'message' => 'Critical / unexpected imaging finding recorded.',
];
}
}
if ($moduleKey === 'cardiology' && $recordType === 'cardiac_exam') {
$nyha = (string) ($payload['nyha_class'] ?? '');
if (in_array($nyha, ['III', 'IV'], true)) {
$alerts[] = [
'code' => 'car.nyha_high',
'severity' => $nyha === 'IV' ? 'critical' : 'warning',
'message' => 'NYHA class '.$nyha.' on cardiac exam.',
];
}
$ecg = strtolower((string) ($payload['ecg_findings'] ?? ''));
if (str_contains($ecg, 'stemi') || str_contains($ecg, 'st elevation') || str_contains($ecg, 'vt') || str_contains($ecg, 'vf')) {
$alerts[] = [
'code' => 'car.ecg_critical',
'severity' => 'critical',
'message' => 'Critical ECG findings recorded.',
];
}
}
if ($moduleKey === 'psychiatry' && $recordType === 'risk_assessment') {
$level = strtolower((string) ($payload['overall_risk'] ?? ''));
if (str_contains($level, 'imminent')) {
$alerts[] = [
'code' => 'psy.risk_imminent',
'severity' => 'critical',
'message' => 'Imminent risk — escalate immediately.',
];
} elseif (str_contains($level, 'high')) {
$alerts[] = [
'code' => 'psy.risk_high',
'severity' => 'critical',
'message' => 'High psychiatric risk recorded.',
];
}
$selfHarm = strtolower((string) ($payload['self_harm'] ?? ''));
if (str_contains($selfHarm, 'intent') || str_contains($selfHarm, 'attempt')) {
$alerts[] = [
'code' => 'psy.self_harm',
'severity' => 'critical',
'message' => 'Self-harm intent or recent attempt recorded.',
];
}
}
return $alerts;
}
/**
* @return list<SpecialtyClinicalRecord>
*/
public function forVisit(Visit $visit, string $moduleKey): array
{
return SpecialtyClinicalRecord::owned($visit->owner_ref)
->where('visit_id', $visit->id)
->where('module_key', $moduleKey)
->orderBy('record_type')
->get()
->all();
}
/**
* @return list<array{code: string, severity: string, message: string}>
*/
public function alertsForVisit(Visit $visit, string $moduleKey): array
{
$alerts = [];
foreach ($this->forVisit($visit, $moduleKey) as $record) {
foreach ($record->activeAlerts() as $alert) {
$alerts[] = $alert;
}
}
return $alerts;
}
public function countOpenByType(Organization $organization, string $moduleKey, string $recordType, ?int $branchId = null): int
{
return SpecialtyClinicalRecord::owned($organization->owner_ref)
->where('organization_id', $organization->id)
->where('module_key', $moduleKey)
->where('record_type', $recordType)
->whereIn('status', [SpecialtyClinicalRecord::STATUS_DRAFT, SpecialtyClinicalRecord::STATUS_ACTIVE])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->count();
}
/**
* Validation rules derived from field schema.
*
* @return array<string, list<string>>
*/
public function validationRules(string $moduleKey, string $recordType): array
{
$rules = [];
foreach ($this->fieldsFor($moduleKey, $recordType) as $field) {
$name = 'payload.'.$field['name'];
$fieldRules = [];
if (! empty($field['required'])) {
$fieldRules[] = 'required';
} else {
$fieldRules[] = 'nullable';
}
$fieldRules[] = match ($field['type'] ?? 'text') {
'number' => 'numeric',
'boolean' => 'boolean',
'textarea', 'text', 'select' => 'string',
default => 'string',
};
if (($field['type'] ?? '') === 'select' && ! empty($field['options'])) {
$fieldRules[] = 'in:'.implode(',', $field['options']);
}
if (in_array($field['type'] ?? '', ['text', 'textarea'], true)) {
$fieldRules[] = 'max:5000';
}
$rules[$name] = $fieldRules;
}
return $rules;
}
/**
* @param array<string, mixed> $validated
* @return array<string, mixed>
*/
public function payloadFromRequest(array $validated): array
{
return Arr::get($validated, 'payload', []);
}
}