*/ 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, 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 { $keys = [$moduleKey]; if ($moduleKey === 'womens_health') { $keys[] = 'maternity'; // legacy clinical rows } return SpecialtyClinicalRecord::owned($visit->owner_ref) ->where('visit_id', $visit->id) ->whereIn('module_key', $keys) ->where('record_type', $recordType) ->orderByDesc('id') ->first(); } /** * @param array $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 $input * @return array */ 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 $payload * @return list */ 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 === 'womens_health' && $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 === 'womens_health' && $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 === 'womens_health' && $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 === 'dermatology' && $recordType === 'skin_exam') { $urgency = (string) ($payload['urgency'] ?? ''); if (in_array($urgency, ['High', 'Suspected malignancy'], true)) { $alerts[] = [ 'code' => 'der.urgency_high', 'severity' => $urgency === 'Suspected malignancy' ? 'critical' : 'warning', 'message' => 'Skin exam urgency: '.$urgency.'.', ]; } } if ($moduleKey === 'ambulance' && $recordType === 'amb_scene') { $acuity = (string) ($payload['acuity'] ?? ''); if (in_array($acuity, ['Critical', 'Urgent'], true)) { $alerts[] = [ 'code' => 'amb.acuity_high', 'severity' => $acuity === 'Critical' ? 'critical' : 'warning', 'message' => 'Scene acuity: '.$acuity.'.', ]; } if (array_key_exists('circulation_ok', $payload) && in_array($payload['circulation_ok'], [false, 0, '0'], true)) { $alerts[] = [ 'code' => 'amb.circulation_unstable', 'severity' => 'critical', 'message' => 'Circulation marked unstable on scene.', ]; } } if ($moduleKey === 'ambulance' && $recordType === 'amb_en_route') { if (! empty($payload['deterioration'])) { $alerts[] = [ 'code' => 'amb.deterioration', 'severity' => 'critical', 'message' => 'Patient deterioration noted en route.', ]; } } if ($moduleKey === 'podiatry' && $recordType === 'foot_exam') { $risk = (string) ($payload['diabetes'] ?? ''); if (in_array($risk, ['High', 'Active ulcer'], true)) { $alerts[] = [ 'code' => 'pod.diabetes_high', 'severity' => $risk === 'Active ulcer' ? 'critical' : 'warning', 'message' => 'Diabetic foot risk: '.$risk.'.', ]; } } if ($moduleKey === 'fertility' && $recordType === 'fert_exam') { $priority = (string) ($payload['priority'] ?? ''); if (in_array($priority, ['Urgent', 'Time-sensitive'], true)) { $alerts[] = [ 'code' => 'fer.priority_high', 'severity' => $priority === 'Urgent' ? 'critical' : 'warning', 'message' => 'Fertility priority: '.$priority.'.', ]; } } if ($moduleKey === 'pediatrics' && $recordType === 'pediatric_exam') { $concern = (string) ($payload['growth_concern'] ?? ''); if ($concern !== '' && $concern !== 'None') { $alerts[] = [ 'code' => 'ped.growth_concern', 'severity' => in_array($concern, ['Wasting', 'Underweight'], true) ? 'critical' : 'warning', 'message' => 'Growth concern: '.$concern.'.', ]; } $temp = (float) ($payload['temperature_c'] ?? 0); if ($temp >= 39.0) { $alerts[] = [ 'code' => 'ped.fever_high', 'severity' => 'critical', 'message' => 'High fever recorded ('.$temp.'°C).', ]; } elseif ($temp >= 38.0) { $alerts[] = [ 'code' => 'ped.fever', 'severity' => 'warning', 'message' => 'Fever recorded ('.$temp.'°C).', ]; } } if ($moduleKey === 'orthopedics' && $recordType === 'ortho_exam') { $nv = strtolower((string) ($payload['neurovascular'] ?? '')); if (str_contains($nv, 'vascular compromise')) { $alerts[] = [ 'code' => 'ort.nv_vascular', 'severity' => 'critical', 'message' => 'Vascular compromise on ortho exam.', ]; } elseif (str_contains($nv, 'deficit')) { $alerts[] = [ 'code' => 'ort.nv_deficit', 'severity' => 'warning', 'message' => 'Neurovascular deficit recorded.', ]; } } if ($moduleKey === 'ent' && $recordType === 'ent_exam') { if (! empty($payload['airway_concern'])) { $alerts[] = [ 'code' => 'ent.airway_concern', 'severity' => 'critical', 'message' => 'Airway concern flagged on ENT exam.', ]; } } if ($moduleKey === 'oncology' && $recordType === 'onc_staging') { $ecog = (string) ($payload['performance_status'] ?? ''); if (in_array($ecog, ['3', '4'], true)) { $alerts[] = [ 'code' => 'onc.ecog_high', 'severity' => 'warning', 'message' => 'High ECOG performance status ('.$ecog.') — review treatment fitness.', ]; } } if ($moduleKey === 'vaccination' && $recordType === 'vax_eligibility') { if (empty($payload['cleared']) && ! empty($payload['vaccine'])) { $alerts[] = [ 'code' => 'vax.not_cleared', 'severity' => 'critical', 'message' => 'Vaccine requested but patient is not cleared to vaccinate.', ]; } if (! empty($payload['fever_today'])) { $alerts[] = [ 'code' => 'vax.fever', 'severity' => 'warning', 'message' => 'Fever recorded on vaccination eligibility screen.', ]; } } if ($moduleKey === 'vaccination' && $recordType === 'vax_observation') { $aefi = strtolower((string) ($payload['aefi'] ?? '')); if (str_contains($aefi, 'severe')) { $alerts[] = [ 'code' => 'vax.aefi_severe', 'severity' => 'critical', 'message' => 'Severe AEFI recorded — escalate and document follow-up.', ]; } } if ($moduleKey === 'pathology' && $recordType === 'path_request') { $urgency = strtolower((string) ($payload['urgency'] ?? '')); if (str_contains($urgency, 'urgent') || str_contains($urgency, 'intra')) { $alerts[] = [ 'code' => 'path.urgent', 'severity' => 'warning', 'message' => 'Urgent / intra-op pathology request — prioritise processing.', ]; } } if ($moduleKey === 'pathology' && $recordType === 'path_specimen') { $adequacy = strtolower((string) ($payload['adequacy'] ?? '')); if (str_contains($adequacy, 'inadequate')) { $alerts[] = [ 'code' => 'path.inadequate', 'severity' => 'critical', 'message' => 'Specimen marked inadequate — notify requesting clinician.', ]; } } if ($moduleKey === 'infusion' && $recordType === 'infusion_assessment') { if (empty($payload['fit_for_infusion']) && ! empty($payload['indication'])) { $alerts[] = [ 'code' => 'inf.not_fit', 'severity' => 'critical', 'message' => 'Indication recorded but patient not marked fit for infusion.', ]; } } if ($moduleKey === 'infusion' && in_array($recordType, ['infusion_session', 'infusion_monitoring'], true)) { $reaction = strtolower((string) ($payload['reactions'] ?? $payload['reaction'] ?? '')); if (str_contains($reaction, 'severe') || str_contains($reaction, 'stop')) { $alerts[] = [ 'code' => 'inf.reaction', 'severity' => 'critical', 'message' => 'Infusion reaction / stop flagged — review interventions.', ]; } } if ($moduleKey === 'renal' && $recordType === 'renal_exam') { $fluid = strtolower((string) ($payload['fluid_status'] ?? '')); if (str_contains($fluid, 'overload')) { $alerts[] = [ 'code' => 'ren.fluid_overload', 'severity' => 'warning', 'message' => 'Fluid overload recorded on renal exam.', ]; } } if ($moduleKey === 'surgery' && $recordType === 'surg_plan') { if (empty($payload['consent_obtained']) && ! empty($payload['procedure_planned'])) { $alerts[] = [ 'code' => 'sur.consent_missing', 'severity' => 'critical', 'message' => 'Procedure planned without documented consent.', ]; } } 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.', ]; } } if ($moduleKey === 'child_welfare' && $recordType === 'cwc_assessment') { $flag = strtolower((string) ($payload['safeguarding_flag'] ?? '')); if ($flag === 'escalated') { $alerts[] = [ 'code' => 'cwc.safeguarding', 'severity' => 'critical', 'message' => 'Safeguarding escalated — follow facility protocol.', ]; } elseif ($flag === 'concern') { $alerts[] = [ 'code' => 'cwc.safeguarding', 'severity' => 'warning', 'message' => 'Safeguarding concern recorded on CWC assessment.', ]; } $nutrition = strtolower((string) ($payload['nutrition_risk'] ?? '')); if ($nutrition === 'high') { $alerts[] = [ 'code' => 'cwc.nutrition_risk', 'severity' => 'warning', 'message' => 'High nutrition risk on child welfare assessment.', ]; } $growth = strtolower((string) ($payload['growth_status'] ?? '')); if (in_array($growth, ['wasted', 'underweight'], true)) { $alerts[] = [ 'code' => 'cwc.growth', 'severity' => 'warning', 'message' => 'Abnormal growth status: '.$payload['growth_status'].'.', ]; } } return $alerts; } /** * @return list */ 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 */ 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> */ 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 $validated * @return array */ public function payloadFromRequest(array $validated): array { return Arr::get($validated, 'payload', []); } }