Deploy Ladill Care / deploy (push) Failing after 1m2s
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>
373 lines
16 KiB
PHP
373 lines
16 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
|
||
{
|
||
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 (0–10)', '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', []);
|
||
}
|
||
}
|