*/ 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 */ 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 */ 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 */ public function provisionedServices(Organization $organization, string $moduleKey): array { $stored = data_get($organization->settings, "specialty_module_provisioning.{$moduleKey}.services"); if (is_array($stored) && $stored !== []) { return array_values($stored); } return $this->catalogServices($moduleKey); } /** * Persist catalog services onto org settings (idempotent merge by code). * * @return list */ 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 */ public function workspaceTabs(string $moduleKey): array { $tabs = $this->definition($moduleKey)['workspace_tabs'] ?? []; return is_array($tabs) ? $tabs : []; } /** * @return array */ public function actions(string $moduleKey): array { $actions = $this->definition($moduleKey)['actions'] ?? []; return is_array($actions) ? $actions : []; } /** * @return list */ 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|null $practitionerScope * @return array{ * waiting: int, * in_progress: int, * completed_today: int, * open_visits: int, * revenue_today_minor: int, * stages: list * } */ 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) { $code = (string) ($stage['code'] ?? ''); $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), 'dentistry' => $clinical->countOpenByType($organization, 'dentistry', 'odontogram', $branchScope), 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|null $practitionerScope * @return Collection */ 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|null $practitionerScope * @return Collection */ 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 */ 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, * 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, ): Bill { $service = collect($this->provisionedServices($organization, $moduleKey)) ->first(fn ($s) => ($s['code'] ?? '') === $serviceCode); if (! $service) { throw new \InvalidArgumentException("Unknown specialty service [{$serviceCode}]."); } $bill = $this->bills->generateFromVisit($visit, $ownerRef, $actorRef); return $this->bills->addManualLineItem($bill, $ownerRef, [ 'type' => $service['type'] ?? 'misc', 'description' => $service['label'], 'quantity' => 1, 'unit_price_minor' => (int) ($service['amount_minor'] ?? 0), 'source_type' => 'specialty_service', 'source_id' => null, ], $actorRef); } public function navItems(string $moduleKey): array { return [ 'overview' => ['label' => 'Overview', 'route' => 'care.specialty.show'], 'visits' => ['label' => 'Visits', 'route' => 'care.specialty.visits'], 'history' => ['label' => 'History', 'route' => 'care.specialty.history'], 'billing' => ['label' => 'Billing', 'route' => 'care.specialty.billing'], 'workspace' => ['label' => 'Workspace', 'route' => 'care.specialty.workspace'], ]; } public function memberCanAccess(Organization $organization, ?Member $member, string $moduleKey): bool { return $this->modules->memberCanAccess($organization, $member, $moduleKey); } }