Deploy Ladill Care / deploy (push) Successful in 49s
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>
532 lines
20 KiB
PHP
532 lines
20 KiB
PHP
<?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 110–160 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', []);
|
||
}
|
||
}
|