*/ 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 { return SpecialtyClinicalRecord::owned($visit->owner_ref) ->where('visit_id', $visit->id) ->where('module_key', $moduleKey) ->where('record_type', $recordType) ->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.', ]; } } 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', []); } }