*/ 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) : []; } /** * Named route for advancing/setting specialty visit stage, if the module has one. */ public function stageAdvanceRoute(string $moduleKey): ?string { return match ($moduleKey) { 'dentistry' => 'care.specialty.dentistry.stage', 'emergency' => 'care.specialty.emergency.stage', 'blood_bank' => 'care.specialty.blood-bank.stage', 'ophthalmology' => 'care.specialty.ophthalmology.stage', 'physiotherapy' => 'care.specialty.physiotherapy.stage', 'maternity' => 'care.specialty.maternity.stage', 'radiology' => 'care.specialty.radiology.stage', 'cardiology' => 'care.specialty.cardiology.stage', 'psychiatry' => 'care.specialty.psychiatry.stage', 'pediatrics' => 'care.specialty.pediatrics.stage', 'orthopedics' => 'care.specialty.orthopedics.stage', 'ent' => 'care.specialty.ent.stage', 'oncology' => 'care.specialty.oncology.stage', 'renal' => 'care.specialty.renal.stage', 'surgery' => 'care.specialty.surgery.stage', default => null, }; } /** * Default stage progression for Move CTAs and action menus. * * @return array */ public function stageFlow(string $moduleKey): array { return match ($moduleKey) { 'emergency' => app(EmergencyWorkflowService::class)->stageFlow(), 'blood_bank' => app(BloodBankWorkflowService::class)->stageFlow(), 'ophthalmology' => app(OphthalmologyWorkflowService::class)->stageFlow(), 'physiotherapy' => app(PhysiotherapyWorkflowService::class)->stageFlow(), 'maternity' => app(MaternityWorkflowService::class)->stageFlow(), 'radiology' => app(RadiologyWorkflowService::class)->stageFlow(), 'cardiology' => app(CardiologyWorkflowService::class)->stageFlow(), 'psychiatry' => app(PsychiatryWorkflowService::class)->stageFlow(), 'pediatrics' => app(PediatricsWorkflowService::class)->stageFlow(), 'orthopedics' => app(OrthopedicsWorkflowService::class)->stageFlow(), 'ent' => app(EntWorkflowService::class)->stageFlow(), 'oncology' => app(OncologyWorkflowService::class)->stageFlow(), 'renal' => app(RenalWorkflowService::class)->stageFlow(), 'surgery' => app(SurgeryWorkflowService::class)->stageFlow(), 'dentistry' => [ 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], 'procedure' => ['next' => 'recovery', 'label' => 'Move to recovery'], 'recovery' => ['next' => 'completed', 'label' => 'Complete visit'], ], default => $this->sequentialStageFlow($moduleKey), }; } /** * Build next-stage map from configured stage order when no suite workflow exists. * * @return array */ protected function sequentialStageFlow(string $moduleKey): array { $stages = $this->stages($moduleKey); $flow = []; for ($i = 0, $count = count($stages); $i < $count; $i++) { $code = (string) ($stages[$i]['code'] ?? ''); if ($code === '') { continue; } $next = $stages[$i + 1] ?? null; if ($next === null) { $flow[$code] = ['next' => $code, 'label' => 'Completed']; continue; } $nextCode = (string) ($next['code'] ?? ''); if ($nextCode === '') { continue; } $nextLabel = (string) ($next['label'] ?? $nextCode); $flow[$code] = [ 'next' => $nextCode, 'label' => 'Move to '.mb_strtolower($nextLabel), ]; } return $flow; } /** * 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 { $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 */ 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'] ?? []; 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; } /** * Workspace tab to open after advancing/setting a visit specialty stage. * Uses module `stage_tabs` config, then same-named tab, then overview. */ public function workspaceTabForStage(string $moduleKey, string $stage): string { $stage = trim($stage); $tabs = $this->workspaceTabs($moduleKey); $map = $this->definition($moduleKey)['stage_tabs'] ?? []; if (is_array($map) && isset($map[$stage]) && is_string($map[$stage]) && $map[$stage] !== '') { $candidate = $map[$stage]; if (array_key_exists($candidate, $tabs)) { return $candidate; } } if ($stage !== '' && array_key_exists($stage, $tabs)) { return $stage; } return array_key_exists('overview', $tabs) ? 'overview' : (array_key_first($tabs) ?: 'overview'); } /** * @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, $moduleKey, $organization, $ownerRef, $visitIds, ) { $code = (string) ($stage['code'] ?? ''); if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery'], 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), 'physiotherapy' => $clinical->countOpenByType($organization, 'physiotherapy', 'pt_assessment', $branchScope), 'maternity' => $clinical->countOpenByType($organization, 'maternity', 'anc_history', $branchScope), 'radiology' => $clinical->countOpenByType($organization, 'radiology', 'imaging_request', $branchScope), 'cardiology' => $clinical->countOpenByType($organization, 'cardiology', 'cardiac_exam', $branchScope), 'psychiatry' => $clinical->countOpenByType($organization, 'psychiatry', 'mental_status', $branchScope), 'pediatrics' => $clinical->countOpenByType($organization, 'pediatrics', 'pediatric_exam', $branchScope), 'orthopedics' => $clinical->countOpenByType($organization, 'orthopedics', 'ortho_exam', $branchScope), 'ent' => $clinical->countOpenByType($organization, 'ent', 'ent_exam', $branchScope), 'oncology' => $clinical->countOpenByType($organization, 'oncology', 'onc_staging', $branchScope), 'renal' => $clinical->countOpenByType($organization, 'renal', 'renal_exam', $branchScope), 'surgery' => $clinical->countOpenByType($organization, 'surgery', 'surg_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|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, 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); } }