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>
550 lines
20 KiB
PHP
550 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Care;
|
|
|
|
use App\Models\Appointment;
|
|
use App\Models\Bill;
|
|
use App\Models\Department;
|
|
use App\Models\Member;
|
|
use App\Models\Organization;
|
|
use App\Models\Patient;
|
|
use App\Models\Visit;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* Shared specialty module shell — KPIs, Visit-backed lists, stage maps, service catalogs.
|
|
*/
|
|
class SpecialtyShellService
|
|
{
|
|
public function __construct(
|
|
protected SpecialtyModuleService $modules,
|
|
protected BillService $bills,
|
|
) {}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function definition(string $moduleKey): array
|
|
{
|
|
$base = $this->modules->definition($moduleKey) ?? [];
|
|
$shell = config("care_specialty_shell.modules.{$moduleKey}", []);
|
|
$defaults = config('care_specialty_shell.defaults', []);
|
|
|
|
return array_merge($defaults, $base, is_array($shell) ? $shell : []);
|
|
}
|
|
|
|
/**
|
|
* @return list<array{code: string, label: string, queue_point: ?string}>
|
|
*/
|
|
public function stages(string $moduleKey): array
|
|
{
|
|
$stages = $this->definition($moduleKey)['stages'] ?? [];
|
|
|
|
return is_array($stages) ? array_values($stages) : [];
|
|
}
|
|
|
|
/**
|
|
* Catalog from config (authoritative template).
|
|
*
|
|
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
|
|
*/
|
|
public function catalogServices(string $moduleKey): array
|
|
{
|
|
$services = $this->definition($moduleKey)['services'] ?? [];
|
|
if (! is_array($services)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_map(function ($row) {
|
|
return [
|
|
'code' => (string) ($row['code'] ?? ''),
|
|
'label' => (string) ($row['label'] ?? ''),
|
|
'amount_minor' => (int) ($row['amount_minor'] ?? 0),
|
|
'type' => (string) ($row['type'] ?? 'misc'),
|
|
];
|
|
}, $services));
|
|
}
|
|
|
|
/**
|
|
* Seeded services stored on the organization when the module was activated.
|
|
*
|
|
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
|
|
*/
|
|
public function provisionedServices(Organization $organization, string $moduleKey): array
|
|
{
|
|
$catalog = collect($this->catalogServices($moduleKey))->keyBy(fn ($s) => (string) ($s['code'] ?? ''));
|
|
$stored = data_get($organization->settings, "specialty_module_provisioning.{$moduleKey}.services");
|
|
if (is_array($stored)) {
|
|
foreach ($stored as $service) {
|
|
$code = (string) ($service['code'] ?? '');
|
|
if ($code === '') {
|
|
continue;
|
|
}
|
|
$prior = $catalog->get($code, []);
|
|
$catalog->put($code, array_merge(is_array($prior) ? $prior : [], $service));
|
|
}
|
|
}
|
|
|
|
return $catalog->filter(fn ($s, $code) => $code !== '')->values()->all();
|
|
}
|
|
|
|
/**
|
|
* Persist catalog services onto org settings (idempotent merge by code).
|
|
*
|
|
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
|
|
*/
|
|
public function seedServices(Organization $organization, string $moduleKey): array
|
|
{
|
|
$catalog = $this->catalogServices($moduleKey);
|
|
$settings = $organization->settings ?? [];
|
|
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
|
|
? $settings['specialty_module_provisioning']
|
|
: [];
|
|
$record = is_array($provisioning[$moduleKey] ?? null) ? $provisioning[$moduleKey] : [];
|
|
$existing = collect(is_array($record['services'] ?? null) ? $record['services'] : [])
|
|
->keyBy(fn ($s) => (string) ($s['code'] ?? ''));
|
|
|
|
foreach ($catalog as $service) {
|
|
if ($service['code'] === '') {
|
|
continue;
|
|
}
|
|
$prior = $existing->get($service['code'], []);
|
|
$existing->put($service['code'], array_merge(is_array($prior) ? $prior : [], $service));
|
|
}
|
|
|
|
$merged = $existing->filter(fn ($s, $code) => $code !== '')->values()->all();
|
|
$record['services'] = $merged;
|
|
$provisioning[$moduleKey] = $record;
|
|
$settings['specialty_module_provisioning'] = $provisioning;
|
|
$organization->update(['settings' => $settings]);
|
|
|
|
return $merged;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public function workspaceTabs(string $moduleKey): array
|
|
{
|
|
$tabs = $this->definition($moduleKey)['workspace_tabs'] ?? [];
|
|
if (! is_array($tabs)) {
|
|
return [];
|
|
}
|
|
|
|
if (array_key_exists('timeline', $tabs)) {
|
|
return $tabs;
|
|
}
|
|
|
|
$merged = [];
|
|
foreach ($tabs as $key => $label) {
|
|
$merged[$key] = $label;
|
|
if ($key === 'overview') {
|
|
$merged['timeline'] = 'Timeline';
|
|
}
|
|
}
|
|
|
|
if (! array_key_exists('timeline', $merged)) {
|
|
$merged['timeline'] = 'Timeline';
|
|
}
|
|
|
|
return $merged;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public function actions(string $moduleKey): array
|
|
{
|
|
$actions = $this->definition($moduleKey)['actions'] ?? [];
|
|
|
|
return is_array($actions) ? $actions : [];
|
|
}
|
|
|
|
/**
|
|
* @return list<int>
|
|
*/
|
|
public function departmentIds(
|
|
Organization $organization,
|
|
string $moduleKey,
|
|
string $ownerRef,
|
|
?int $branchScope = null,
|
|
): array {
|
|
$definition = $this->modules->definition($moduleKey);
|
|
if (! $definition) {
|
|
return [];
|
|
}
|
|
|
|
$fromSettings = data_get($organization->settings, "specialty_module_provisioning.{$moduleKey}.department_ids", []);
|
|
if (is_array($fromSettings) && $fromSettings !== []) {
|
|
$ids = array_map('intval', $fromSettings);
|
|
} else {
|
|
$ids = Department::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->where('type', $definition['department_type'] ?? 'general')
|
|
->where('is_active', true)
|
|
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
|
->pluck('id')
|
|
->map(fn ($id) => (int) $id)
|
|
->all();
|
|
}
|
|
|
|
if ($branchScope) {
|
|
$ids = Department::owned($ownerRef)
|
|
->whereIn('id', $ids ?: [0])
|
|
->where('branch_id', $branchScope)
|
|
->pluck('id')
|
|
->map(fn ($id) => (int) $id)
|
|
->all();
|
|
}
|
|
|
|
return $ids;
|
|
}
|
|
|
|
/**
|
|
* @param list<int>|null $practitionerScope
|
|
* @return array{
|
|
* waiting: int,
|
|
* in_progress: int,
|
|
* completed_today: int,
|
|
* open_visits: int,
|
|
* revenue_today_minor: int,
|
|
* stages: list<array{code: string, label: string, count: int}>
|
|
* }
|
|
*/
|
|
public function kpis(
|
|
Organization $organization,
|
|
string $moduleKey,
|
|
string $ownerRef,
|
|
?int $branchScope = null,
|
|
?array $practitionerScope = null,
|
|
): array {
|
|
$departmentIds = $this->departmentIds($organization, $moduleKey, $ownerRef, $branchScope);
|
|
$todayStart = now()->startOfDay();
|
|
|
|
$appointmentBase = Appointment::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds))
|
|
->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0'))
|
|
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
|
->when(
|
|
$practitionerScope !== null,
|
|
fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]),
|
|
);
|
|
|
|
$waiting = (clone $appointmentBase)
|
|
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
|
->count();
|
|
|
|
$inProgress = (clone $appointmentBase)
|
|
->where('status', Appointment::STATUS_IN_CONSULTATION)
|
|
->count();
|
|
|
|
$completedToday = (clone $appointmentBase)
|
|
->where('status', Appointment::STATUS_COMPLETED)
|
|
->where('completed_at', '>=', $todayStart)
|
|
->count();
|
|
|
|
$visitIds = (clone $appointmentBase)->whereNotNull('visit_id')->pluck('visit_id');
|
|
|
|
$openVisits = Visit::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds)
|
|
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
|
|
->count();
|
|
|
|
$revenueToday = (int) Bill::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds)
|
|
->where('created_at', '>=', $todayStart)
|
|
->whereNotIn('status', [Bill::STATUS_VOID])
|
|
->sum('amount_paid_minor');
|
|
|
|
$stages = collect($this->stages($moduleKey))->map(function (array $stage) use (
|
|
$waiting,
|
|
$inProgress,
|
|
$completedToday,
|
|
$moduleKey,
|
|
$organization,
|
|
$ownerRef,
|
|
$visitIds,
|
|
) {
|
|
$code = (string) ($stage['code'] ?? '');
|
|
|
|
if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology'], true) && $visitIds->isNotEmpty()) {
|
|
$count = Visit::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->whereIn('id', $visitIds)
|
|
->where('specialty_stage', $code)
|
|
->when(
|
|
! in_array($code, ['completed', 'disposition'], true),
|
|
fn ($q) => $q->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]),
|
|
)
|
|
->count();
|
|
} else {
|
|
$count = match (true) {
|
|
in_array($code, ['waiting', 'arrival', 'request'], true) => $waiting,
|
|
in_array($code, ['in_care', 'chair', 'procedure', 'treatment', 'resus', 'crossmatch', 'issue', 'observation'], true) => $inProgress,
|
|
in_array($code, ['completed', 'disposition', 'transfusion', 'recovery'], true) => $completedToday,
|
|
default => 0,
|
|
};
|
|
}
|
|
|
|
return [
|
|
'code' => $code,
|
|
'label' => (string) ($stage['label'] ?? $code),
|
|
'count' => $count,
|
|
];
|
|
})->all();
|
|
|
|
$clinical = app(SpecialtyClinicalRecordService::class);
|
|
$clinicalOpen = match ($moduleKey) {
|
|
'emergency' => $clinical->countOpenByType($organization, 'emergency', 'triage', $branchScope),
|
|
'blood_bank' => $clinical->countOpenByType($organization, 'blood_bank', 'blood_request', $branchScope),
|
|
'ophthalmology' => $clinical->countOpenByType($organization, 'ophthalmology', 'eye_exam', $branchScope),
|
|
'dentistry' => \App\Models\DentalPlanItem::query()
|
|
->whereHas('plan', function ($q) use ($organization, $ownerRef) {
|
|
$q->owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->whereIn('status', [
|
|
\App\Models\DentalTreatmentPlan::STATUS_ACCEPTED,
|
|
\App\Models\DentalTreatmentPlan::STATUS_IN_PROGRESS,
|
|
]);
|
|
})
|
|
->whereIn('status', [
|
|
\App\Models\DentalPlanItem::STATUS_ACCEPTED,
|
|
\App\Models\DentalPlanItem::STATUS_PROPOSED,
|
|
])
|
|
->count(),
|
|
default => 0,
|
|
};
|
|
|
|
return [
|
|
'waiting' => $waiting,
|
|
'in_progress' => $inProgress,
|
|
'completed_today' => $completedToday,
|
|
'open_visits' => $openVisits,
|
|
'revenue_today_minor' => $revenueToday,
|
|
'clinical_open' => $clinicalOpen,
|
|
'stages' => $stages,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<int>|null $practitionerScope
|
|
* @return Collection<int, Visit>
|
|
*/
|
|
public function openVisits(
|
|
Organization $organization,
|
|
string $moduleKey,
|
|
string $ownerRef,
|
|
?int $branchScope = null,
|
|
?array $practitionerScope = null,
|
|
int $limit = 40,
|
|
): Collection {
|
|
$departmentIds = $this->departmentIds($organization, $moduleKey, $ownerRef, $branchScope);
|
|
|
|
return Visit::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
|
|
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
|
->whereHas('appointment', function ($q) use ($departmentIds, $practitionerScope) {
|
|
$q->when($departmentIds !== [], fn ($qq) => $qq->whereIn('department_id', $departmentIds))
|
|
->when($departmentIds === [], fn ($qq) => $qq->whereRaw('1 = 0'))
|
|
->when(
|
|
$practitionerScope !== null,
|
|
fn ($qq) => $qq->whereIn('practitioner_id', $practitionerScope ?: [0]),
|
|
);
|
|
})
|
|
->with(['patient', 'appointment.practitioner', 'appointment.department', 'bill'])
|
|
->orderByDesc('checked_in_at')
|
|
->limit($limit)
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @param list<int>|null $practitionerScope
|
|
* @return Collection<int, Visit>
|
|
*/
|
|
public function visitHistory(
|
|
Organization $organization,
|
|
string $moduleKey,
|
|
string $ownerRef,
|
|
?int $branchScope = null,
|
|
?array $practitionerScope = null,
|
|
int $limit = 40,
|
|
): Collection {
|
|
$departmentIds = $this->departmentIds($organization, $moduleKey, $ownerRef, $branchScope);
|
|
|
|
return Visit::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->where('status', Visit::STATUS_COMPLETED)
|
|
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
|
->whereHas('appointment', function ($q) use ($departmentIds, $practitionerScope) {
|
|
$q->when($departmentIds !== [], fn ($qq) => $qq->whereIn('department_id', $departmentIds))
|
|
->when($departmentIds === [], fn ($qq) => $qq->whereRaw('1 = 0'))
|
|
->when(
|
|
$practitionerScope !== null,
|
|
fn ($qq) => $qq->whereIn('practitioner_id', $practitionerScope ?: [0]),
|
|
);
|
|
})
|
|
->with(['patient', 'appointment.practitioner', 'appointment.department', 'bill'])
|
|
->orderByDesc('completed_at')
|
|
->limit($limit)
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @return list<array{at: string, label: string, detail: ?string}>
|
|
*/
|
|
public function patientTimeline(Patient $patient, int $limit = 20): array
|
|
{
|
|
$patient->loadMissing([
|
|
'visits' => fn ($q) => $q->orderByDesc('checked_in_at')->limit(10),
|
|
'appointments' => fn ($q) => $q->orderByDesc('scheduled_at')->limit(10),
|
|
'bills' => fn ($q) => $q->orderByDesc('created_at')->limit(10),
|
|
]);
|
|
|
|
$events = collect();
|
|
|
|
foreach ($patient->visits as $visit) {
|
|
if ($visit->checked_in_at) {
|
|
$events->push([
|
|
'at' => $visit->checked_in_at->toIso8601String(),
|
|
'label' => 'Visit opened',
|
|
'detail' => 'Visit '.$visit->uuid,
|
|
]);
|
|
}
|
|
if ($visit->completed_at) {
|
|
$events->push([
|
|
'at' => $visit->completed_at->toIso8601String(),
|
|
'label' => 'Visit completed',
|
|
'detail' => null,
|
|
]);
|
|
}
|
|
}
|
|
|
|
foreach ($patient->appointments as $appointment) {
|
|
if ($appointment->waiting_at) {
|
|
$events->push([
|
|
'at' => $appointment->waiting_at->toIso8601String(),
|
|
'label' => 'Joined waiting list',
|
|
'detail' => $appointment->queue_ticket_number
|
|
? 'Ticket '.$appointment->queue_ticket_number
|
|
: null,
|
|
]);
|
|
}
|
|
if ($appointment->started_at) {
|
|
$events->push([
|
|
'at' => $appointment->started_at->toIso8601String(),
|
|
'label' => 'Consultation started',
|
|
'detail' => $appointment->practitioner?->name,
|
|
]);
|
|
}
|
|
if ($appointment->completed_at) {
|
|
$events->push([
|
|
'at' => $appointment->completed_at->toIso8601String(),
|
|
'label' => 'Consultation completed',
|
|
'detail' => null,
|
|
]);
|
|
}
|
|
}
|
|
|
|
foreach ($patient->bills as $bill) {
|
|
$events->push([
|
|
'at' => $bill->created_at?->toIso8601String() ?? now()->toIso8601String(),
|
|
'label' => 'Invoice '.$bill->invoice_number,
|
|
'detail' => strtoupper((string) $bill->status),
|
|
]);
|
|
}
|
|
|
|
return $events
|
|
->sortByDesc('at')
|
|
->take($limit)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* outstanding_minor: int,
|
|
* allergies: list<string>,
|
|
* insurance_summary: ?string,
|
|
* emergency: bool
|
|
* }
|
|
*/
|
|
public function patientHeaderMeta(Patient $patient): array
|
|
{
|
|
$patient->loadMissing(['allergies', 'insurancePolicies', 'emergencyContacts']);
|
|
|
|
$outstanding = (int) Bill::owned($patient->owner_ref)
|
|
->where('patient_id', $patient->id)
|
|
->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL, Bill::STATUS_DRAFT])
|
|
->sum('balance_minor');
|
|
|
|
$allergies = $patient->allergies->map(fn ($a) => (string) $a->allergen)->filter()->values()->all();
|
|
|
|
$policy = $patient->insurancePolicies->first();
|
|
$insurance = $policy
|
|
? trim(implode(' · ', array_filter([(string) ($policy->provider_name ?? ''), (string) ($policy->policy_number ?? '')])))
|
|
: null;
|
|
|
|
return [
|
|
'outstanding_minor' => $outstanding,
|
|
'allergies' => $allergies,
|
|
'insurance_summary' => $insurance !== '' ? $insurance : null,
|
|
'emergency' => $patient->emergencyContacts->isNotEmpty(),
|
|
];
|
|
}
|
|
|
|
public function addCatalogServiceToVisit(
|
|
Organization $organization,
|
|
Visit $visit,
|
|
string $moduleKey,
|
|
string $serviceCode,
|
|
string $ownerRef,
|
|
?string $actorRef = null,
|
|
int $quantity = 1,
|
|
): Bill {
|
|
$service = collect($this->provisionedServices($organization, $moduleKey))
|
|
->first(fn ($s) => ($s['code'] ?? '') === $serviceCode);
|
|
|
|
if (! $service) {
|
|
throw new \InvalidArgumentException("Unknown specialty service [{$serviceCode}].");
|
|
}
|
|
|
|
// Reuse an open visit bill without syncLineItemsFromVisit — that sync deletes
|
|
// prior specialty_service lines and would wipe multi-item issue/billing batches.
|
|
$bill = Bill::owned($ownerRef)
|
|
->where('visit_id', $visit->id)
|
|
->whereNotIn('status', [Bill::STATUS_VOID])
|
|
->first();
|
|
|
|
if (! $bill) {
|
|
$bill = $this->bills->generateFromVisit($visit, $ownerRef, $actorRef);
|
|
}
|
|
|
|
return $this->bills->addManualLineItem($bill, $ownerRef, [
|
|
'type' => $service['type'] ?? 'misc',
|
|
'description' => $service['label'],
|
|
'quantity' => max(1, $quantity),
|
|
'unit_price_minor' => (int) ($service['amount_minor'] ?? 0),
|
|
'source_type' => 'specialty_service',
|
|
'source_id' => null,
|
|
], $actorRef);
|
|
}
|
|
|
|
public function navItems(string $moduleKey): array
|
|
{
|
|
// Queue home is the primary surface; history/billing are secondary links in the shell header.
|
|
return [
|
|
'history' => ['label' => 'History', 'route' => 'care.specialty.history'],
|
|
'billing' => ['label' => 'Billing', 'route' => 'care.specialty.billing'],
|
|
];
|
|
}
|
|
|
|
public function memberCanAccess(Organization $organization, ?Member $member, string $moduleKey): bool
|
|
{
|
|
return $this->modules->memberCanAccess($organization, $member, $moduleKey);
|
|
}
|
|
}
|