Deploy Ladill Care / deploy (push) Successful in 35s
Mirror Emergency/Blood Bank depth with stages, workspace tabs, workflow/analytics services, reports/print, demo clinical seed, and feature tests. Co-authored-by: Cursor <cursoragent@cursor.com>
383 lines
14 KiB
PHP
383 lines
14 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
|
|
{
|
|
$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).',
|
|
];
|
|
}
|
|
}
|
|
|
|
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', []);
|
|
}
|
|
}
|