Files
ladill-care/app/Services/Care/SpecialtyClinicalRecordService.php
T
isaaccladandCursor cfa71c2c15
Deploy Ladill Care / deploy (push) Successful in 52s
Add full Physiotherapy and Maternity specialty clinical suites.
Mirror Eye Care depth with stages, workspace tabs, clinical JSON, stage Move→tab routing, reports/print, demo seeds, and suite feature tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:39:22 +00:00

461 lines
17 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.',
];
}
}
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', []);
}
}