Files
ladill-care/app/Services/Care/SpecialtyClinicalRecordService.php
T
isaaccladandCursor 57358e4206
Deploy Ladill Care / deploy (push) Failing after 1m2s
Add specialty clinical records on the shared shell (Phase 3).
Visit-scoped structured forms for Emergency triage, Blood Bank requests,
and Dentistry odontogram with derived alerts, document upload, and KPI counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 10:09:32 +00:00

373 lines
16 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
{
return match ($moduleKey) {
'emergency' => [
'triage' => 'triage',
'clinical_notes' => 'clinical_note',
],
'blood_bank' => [
'requests' => 'blood_request',
'inventory' => 'inventory_note',
],
'dentistry' => [
'odontogram' => 'odontogram',
'clinical_notes' => 'clinical_note',
],
default => [
'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
{
$key = "{$moduleKey}.{$recordType}";
return match ($key) {
'emergency.triage' => [
['name' => 'acuity', 'label' => 'Triage acuity', 'type' => 'select', 'required' => true, 'options' => ['1 - Resuscitation', '2 - Emergency', '3 - Urgent', '4 - Semi-urgent', '5 - Non-urgent']],
['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true],
['name' => 'onset', 'label' => 'Onset', 'type' => 'text'],
['name' => 'airway_ok', 'label' => 'Airway patent', 'type' => 'boolean'],
['name' => 'breathing_ok', 'label' => 'Breathing adequate', 'type' => 'boolean'],
['name' => 'circulation_ok', 'label' => 'Circulation stable', 'type' => 'boolean'],
['name' => 'gcs', 'label' => 'GCS', 'type' => 'number'],
['name' => 'pain_score', 'label' => 'Pain score (010)', 'type' => 'number'],
['name' => 'mode_of_arrival', 'label' => 'Mode of arrival', 'type' => 'select', 'options' => ['Walk-in', 'Ambulance', 'Police', 'Referral', 'Other']],
['name' => 'disposition_intent', 'label' => 'Disposition intent', 'type' => 'select', 'options' => ['Resus', 'Treatment bay', 'Observation', 'Discharge', 'Admit', 'Transfer']],
['name' => 'notes', 'label' => 'Triage notes', 'type' => 'textarea', 'rows' => 3],
],
'emergency.clinical_note' => [
['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'rows' => 4, 'required' => true],
['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3],
['name' => 'working_diagnosis', 'label' => 'Working diagnosis', 'type' => 'text'],
['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 3],
['name' => 'procedures', 'label' => 'Procedures performed', 'type' => 'textarea', 'rows' => 2],
],
'blood_bank.blood_request' => [
['name' => 'product', 'label' => 'Product', 'type' => 'select', 'required' => true, 'options' => ['Whole blood', 'Packed RBC', 'Platelets', 'FFP', 'Cryoprecipitate']],
['name' => 'units', 'label' => 'Units requested', 'type' => 'number', 'required' => true],
['name' => 'urgency', 'label' => 'Urgency', 'type' => 'select', 'required' => true, 'options' => ['Routine', 'Urgent', 'Emergency / massive']],
['name' => 'patient_blood_group', 'label' => 'Patient blood group', 'type' => 'select', 'options' => ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-', 'Unknown']],
['name' => 'indication', 'label' => 'Clinical indication', 'type' => 'textarea', 'rows' => 2, 'required' => true],
['name' => 'crossmatch_status', 'label' => 'Cross-match status', 'type' => 'select', 'options' => ['Not started', 'In progress', 'Compatible', 'Incompatible', 'Issued']],
['name' => 'issued_units', 'label' => 'Units issued', 'type' => 'number'],
['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2],
],
'blood_bank.inventory_note' => [
['name' => 'whole_blood_units', 'label' => 'Whole blood on hand', 'type' => 'number'],
['name' => 'packed_rbc_units', 'label' => 'Packed RBC on hand', 'type' => 'number'],
['name' => 'platelet_units', 'label' => 'Platelets on hand', 'type' => 'number'],
['name' => 'ffp_units', 'label' => 'FFP on hand', 'type' => 'number'],
['name' => 'low_stock_alert', 'label' => 'Flag low stock', 'type' => 'boolean'],
['name' => 'notes', 'label' => 'Inventory notes', 'type' => 'textarea', 'rows' => 3],
],
'dentistry.odontogram' => [
['name' => 'teeth_affected', 'label' => 'Teeth affected (FDI numbers, comma-separated)', 'type' => 'text', 'required' => true],
['name' => 'findings', 'label' => 'Chart findings', 'type' => 'textarea', 'rows' => 3, 'required' => true],
['name' => 'planned_procedures', 'label' => 'Planned procedures', 'type' => 'textarea', 'rows' => 2],
['name' => 'completed_procedures', 'label' => 'Completed this visit', 'type' => 'textarea', 'rows' => 2],
['name' => 'anesthesia', 'label' => 'Anesthesia', 'type' => 'select', 'options' => ['None', 'Local', 'Sedation', 'GA']],
['name' => 'occlusion_notes', 'label' => 'Occlusion / bite notes', 'type' => 'text'],
],
'dentistry.clinical_note' => [
['name' => 'history', 'label' => 'History', 'type' => 'textarea', 'rows' => 3, 'required' => true],
['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3],
['name' => 'working_diagnosis', 'label' => 'Diagnosis', 'type' => 'text'],
['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 2],
],
default => [
['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.',
];
}
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'] 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.',
];
}
}
}
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', []);
}
}