From 3ee59a095674b10b0ed3cfab33230f8217aee992 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 21:13:06 +0000 Subject: [PATCH] Activate Care workflows across check-in and financial clearance. Enforce service-queue gates, cashier settlements, and clinical stage progression so patient journeys cannot bypass configured payment or authorization rules. Co-authored-by: Cursor --- .env.example | 5 + .../Care/AppointmentController.php | 59 +- .../Care/ConsultationController.php | 26 +- .../Care/FinancialObligationController.php | 205 +++++++ app/Http/Controllers/Care/QueueController.php | 23 +- .../Controllers/Care/SettingsController.php | 51 +- .../Care/VisitWorkflowController.php | 81 +++ app/Models/Appointment.php | 2 +- app/Models/FinancialObligation.php | 10 + app/Models/Organization.php | 10 + app/Models/Visit.php | 10 + app/Models/WorkflowStage.php | 11 + app/Services/Care/AppointmentService.php | 55 +- app/Services/Care/BillService.php | 118 +++- app/Services/Care/CareQueueBridge.php | 53 +- app/Services/Care/ConsultationService.php | 6 +- app/Services/Care/DemoTenantSeeder.php | 11 + app/Services/Care/InvestigationService.php | 16 +- app/Services/Care/OrganizationResolver.php | 6 + app/Services/Care/PharmacyService.php | 14 +- app/Services/Care/PrescriptionService.php | 11 + app/Services/Care/VisitService.php | 10 + .../Care/Workflow/FinancialGateService.php | 116 +++- app/Services/Care/Workflow/WorkflowEngine.php | 84 ++- .../Care/Workflow/WorkflowQueueGate.php | 67 +++ .../Workflow/WorkflowQueueReleaseService.php | 103 ++++ .../WorkflowStageCompletionService.php | 120 ++++ config/care.php | 3 + config/care_workflows.php | 18 + ...force_unique_care_workflow_obligations.php | 25 + .../views/care/appointments/show.blade.php | 46 +- resources/views/care/bills/index.blade.php | 3 + .../financial-obligations/index.blade.php | 106 ++++ resources/views/care/settings/edit.blade.php | 30 + resources/views/partials/sidebar.blade.php | 2 +- routes/web.php | 20 +- .../CareFinancialWorkflowRuntimeTest.php | 514 ++++++++++++++++++ tests/Feature/CareWorkflowEngineTest.php | 10 +- 38 files changed, 1953 insertions(+), 107 deletions(-) create mode 100644 app/Http/Controllers/Care/FinancialObligationController.php create mode 100644 app/Http/Controllers/Care/VisitWorkflowController.php create mode 100644 app/Services/Care/Workflow/WorkflowQueueGate.php create mode 100644 app/Services/Care/Workflow/WorkflowQueueReleaseService.php create mode 100644 app/Services/Care/Workflow/WorkflowStageCompletionService.php create mode 100644 database/migrations/2026_07_17_210000_enforce_unique_care_workflow_obligations.php create mode 100644 resources/views/care/financial-obligations/index.blade.php create mode 100644 tests/Feature/CareFinancialWorkflowRuntimeTest.php diff --git a/.env.example b/.env.example index 1cb7916..271ed92 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,11 @@ AFIA_PLATFORM_API_KEY= # Platform events webhook (user/org lifecycle from the monolith) SERVICE_EVENTS_INBOUND_SECRET= +# Patient journey financial gate defaults (minor currency units) +CARE_CURRENCY=GHS +CARE_PATIENT_CARD_FEE_MINOR=2000 +CARE_CONSULTATION_FEE_MINOR=5000 + # Suite demo accounts (sales walkthroughs) — reset Care data on each SSO login DEMO_ACCOUNTS_ENABLED=false DEMO_RESET_ON_LOGIN=true diff --git a/app/Http/Controllers/Care/AppointmentController.php b/app/Http/Controllers/Care/AppointmentController.php index 04555ea..a4f215a 100644 --- a/app/Http/Controllers/Care/AppointmentController.php +++ b/app/Http/Controllers/Care/AppointmentController.php @@ -2,15 +2,21 @@ namespace App\Http\Controllers\Care; -use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; +use App\Http\Controllers\Controller; use App\Models\Appointment; use App\Models\Branch; use App\Models\Department; +use App\Models\Organization; use App\Models\Patient; use App\Models\Practitioner; use App\Services\Care\AppointmentService; +use App\Services\Care\CareFeatures; +use App\Services\Care\CarePermissions; use App\Services\Care\OrganizationResolver; +use App\Services\Care\Workflow\WorkflowEngine; +use App\Services\Care\Workflow\WorkflowQueueGate; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -108,6 +114,11 @@ class AppointmentController extends Controller $this->ownerRef($request), ); + if (app(CareFeatures::class)->enabled($organization, CareFeatures::WORKFLOW_ENGINE)) { + return redirect()->route('care.appointments.show', $appointment) + ->with('success', 'Walk-in checked in. Complete the current patient journey stage before Queue release.'); + } + return redirect()->route('care.queue.index') ->with('success', 'Walk-in added to queue.'); } @@ -119,16 +130,47 @@ class AppointmentController extends Controller $appointment->load(['patient', 'practitioner', 'branch', 'department', 'visit', 'consultation']); - $canManage = app(\App\Services\Care\CarePermissions::class) + $canManage = app(CarePermissions::class) ->can($this->member($request), 'appointments.manage'); - $canConsult = app(\App\Services\Care\CarePermissions::class) + $canConsult = app(CarePermissions::class) ->can($this->member($request), 'consultations.manage'); + $canViewBills = app(CarePermissions::class) + ->can($this->member($request), 'bills.view'); + $organization = $this->organization($request); + $workflowEnabled = $appointment->visit + && app(CareFeatures::class)->enabled($organization, CareFeatures::WORKFLOW_ENGINE); + $workflowEngine = app(WorkflowEngine::class); + $currentAdvance = $workflowEnabled + ? $workflowEngine->refreshGate($appointment->visit) + : null; + $currentObligation = $currentAdvance?->stage + ? $appointment->visit->financialObligations() + ->where('workflow_stage_id', $currentAdvance->stage->id) + ->latest('id') + ->first() + : null; + $canStartConsultation = $canConsult && (! $workflowEnabled || app(WorkflowQueueGate::class)->canRelease( + $organization, + $appointment->visit, + $this->appointments->queueContextFor($organization, $appointment), + )); return view('care.appointments.show', [ 'appointment' => $appointment, 'statuses' => config('care.appointment_statuses'), 'canManage' => $canManage, 'canConsult' => $canConsult, + 'canViewBills' => $canViewBills, + 'canStartConsultation' => $canStartConsultation, + 'workflowEnabled' => $workflowEnabled, + 'currentAdvance' => $currentAdvance, + 'currentObligation' => $currentObligation, + 'workflowHistory' => $workflowEnabled + ? $appointment->visit->stageAdvances()->with('stage')->orderBy('id')->get() + : collect(), + 'canAdvanceWorkflow' => $canManage + && $workflowEnabled + && $workflowEngine->canManuallyAdvance($appointment->visit), ]); } @@ -137,7 +179,12 @@ class AppointmentController extends Controller $this->authorizeAbility($request, 'appointments.manage'); $this->authorizeAppointment($request, $appointment); - $this->appointments->checkIn($appointment, $this->ownerRef($request), $this->ownerRef($request)); + $appointment = $this->appointments->checkIn($appointment, $this->ownerRef($request), $this->ownerRef($request)); + + if (app(CareFeatures::class)->enabled($this->organization($request), CareFeatures::WORKFLOW_ENGINE)) { + return redirect()->route('care.appointments.show', $appointment) + ->with('success', 'Patient checked in. Complete the current patient journey stage before Queue release.'); + } return redirect()->route('care.queue.index') ->with('success', 'Patient checked in and added to queue.'); @@ -177,7 +224,7 @@ class AppointmentController extends Controller /** * @return array */ - protected function formData(Request $request, \App\Models\Organization $organization): array + protected function formData(Request $request, Organization $organization): array { $ownerRef = $this->ownerRef($request); $branchQuery = Branch::owned($ownerRef)->where('organization_id', $organization->id)->where('is_active', true); @@ -214,7 +261,7 @@ class AppointmentController extends Controller } /** - * @return \Illuminate\Database\Eloquent\Collection + * @return Collection */ protected function activePractitioners(Request $request, int $organizationId) { diff --git a/app/Http/Controllers/Care/ConsultationController.php b/app/Http/Controllers/Care/ConsultationController.php index 4130c24..1ece089 100644 --- a/app/Http/Controllers/Care/ConsultationController.php +++ b/app/Http/Controllers/Care/ConsultationController.php @@ -2,13 +2,14 @@ namespace App\Http\Controllers\Care; -use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; +use App\Http\Controllers\Controller; +use App\Models\Assessment; +use App\Models\AssessmentTemplate; use App\Models\Consultation; use App\Models\InvestigationType; use App\Models\Practitioner; -use App\Models\Assessment; -use App\Models\AssessmentTemplate; +use App\Services\Care\AssessmentService; use App\Services\Care\CareFeatures; use App\Services\Care\CarePermissions; use App\Services\Care\ConsultationReturnContext; @@ -105,7 +106,7 @@ class ConsultationController extends Controller if ($universalTemplate) { try { - app(\App\Services\Care\AssessmentService::class) + app(AssessmentService::class) ->assertCaptureAllowed($member, $universalTemplate); $canCaptureUniversal = true; } catch (\Throwable) { @@ -197,9 +198,9 @@ class ConsultationController extends Controller public function update(Request $request, Consultation $consultation): RedirectResponse { - $canManage = app(\App\Services\Care\CarePermissions::class) + $canManage = app(CarePermissions::class) ->can($this->member($request), 'consultations.manage'); - $canVitals = app(\App\Services\Care\CarePermissions::class) + $canVitals = app(CarePermissions::class) ->can($this->member($request), 'vitals.manage'); abort_unless($canManage || $canVitals, 403); @@ -232,7 +233,7 @@ class ConsultationController extends Controller $this->authorizeAbility($request, 'consultations.manage'); $this->authorizeConsultation($request, $consultation); - $this->consultations->complete( + $completed = $this->consultations->complete( $consultation, $this->ownerRef($request), $this->ownerRef($request), @@ -240,6 +241,17 @@ class ConsultationController extends Controller app(ConsultationReturnContext::class)->forget(); + $completed->loadMissing(['appointment.organization']); + if ($completed->appointment + && $completed->appointment->organization + && app(CareFeatures::class)->enabled( + $completed->appointment->organization, + CareFeatures::WORKFLOW_ENGINE, + )) { + return redirect()->route('care.appointments.show', $completed->appointment) + ->with('success', 'Consultation completed. Continue the patient journey from the next stage.'); + } + return redirect()->route('care.queue.index') ->with('success', 'Consultation completed.'); } diff --git a/app/Http/Controllers/Care/FinancialObligationController.php b/app/Http/Controllers/Care/FinancialObligationController.php new file mode 100644 index 0000000..a86fd8d --- /dev/null +++ b/app/Http/Controllers/Care/FinancialObligationController.php @@ -0,0 +1,205 @@ +authorizeAbility($request, 'bills.view'); + $organization = $this->organization($request); + abort_unless($this->features->enabled($organization, CareFeatures::WORKFLOW_ENGINE), 404); + + $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); + $status = (string) $request->query('status', FinancialObligation::STATUS_PENDING); + + $obligations = FinancialObligation::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->when($branchScope, fn ($query) => $query->where('branch_id', $branchScope)) + ->when($status !== '', fn ($query) => $query->where('status', $status)) + ->with(['patient', 'branch', 'stage', 'visit', 'bill']) + ->latest('created_at') + ->paginate(20) + ->withQueryString(); + + return view('care.financial-obligations.index', [ + 'obligations' => $obligations, + 'statuses' => config('care_workflows.obligation_statuses', []), + 'clearanceMethods' => config('care_workflows.clearance_methods', []), + 'paymentModes' => config('care_workflows.payment_modes', []), + 'paymentMethods' => collect(config('care.payment_methods', []))->except(['gateway'])->all(), + 'canClear' => app(CarePermissions::class) + ->can($this->member($request), 'payments.manage'), + ]); + } + + public function clear(Request $request, FinancialObligation $financialObligation): RedirectResponse + { + $this->authorizeAbility($request, 'payments.manage'); + $this->authorizeObligation($request, $financialObligation); + + if ($financialObligation->status !== FinancialObligation::STATUS_PENDING) { + return back()->with('info', 'This financial obligation is no longer pending.'); + } + + $manualPaymentMethods = array_keys( + collect(config('care.payment_methods', []))->except(['gateway'])->all() + ); + $validated = $request->validate([ + 'method' => ['required', Rule::in(array_keys(config('care_workflows.clearance_methods', [])))], + 'payment_mode' => ['nullable', Rule::in(array_keys(config('care_workflows.payment_modes', [])))], + 'payment_method' => ['nullable', Rule::in($manualPaymentMethods)], + 'reference' => ['nullable', 'string', 'max:100'], + ]); + + $stage = $financialObligation->stage; + $method = (string) $validated['method']; + $paymentMode = $validated['payment_mode'] ?? null; + $reference = trim((string) ($validated['reference'] ?? '')) ?: null; + + $this->validateClearanceRules($stage, $method, $paymentMode, $reference); + if ($method === 'bank_receipt') { + $paymentMode = 'external_bank'; + } + + $settled = DB::transaction(function () use ($financialObligation, $validated, $method, $paymentMode, $reference, $request) { + $locked = FinancialObligation::query()->lockForUpdate()->findOrFail($financialObligation->id); + if ($locked->status !== FinancialObligation::STATUS_PENDING) { + return $locked; + } + + if (in_array($method, ['payment', 'bank_receipt'], true) && $locked->amount_minor > 0) { + $bill = $this->bills->billForObligation( + $locked, + $this->ownerRef($request), + $this->ownerRef($request), + ); + + $this->bills->recordPayment($bill, $this->ownerRef($request), [ + 'amount_minor' => $locked->amount_minor, + 'method' => $method === 'bank_receipt' + ? Payment::METHOD_OTHER + : ($validated['payment_method'] ?? Payment::METHOD_CASH), + 'reference' => $reference, + 'notes' => 'Workflow financial obligation '.$locked->uuid, + ], $this->ownerRef($request)); + } + + $this->gates->clear( + $locked, + $method, + $this->ownerRef($request), + $paymentMode, + $reference, + ); + + AuditLogger::record( + $this->ownerRef($request), + 'financial_obligation.cleared', + $locked->organization_id, + $this->ownerRef($request), + FinancialObligation::class, + $locked->id, + ['method' => $method, 'amount_minor' => $locked->amount_minor], + ); + + return $locked->fresh(); + }); + + $visit = $settled->visit; + if ($visit) { + $advance = $this->workflows->refreshGate($visit); + if ($advance?->stage?->type === 'payment') { + $this->workflowCompletion->complete( + $this->organization($request), + $visit, + CareQueueContexts::BILLING, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } else { + $this->queueRelease->releaseCurrent($this->organization($request), $visit); + } + } + + return back()->with('success', 'Financial obligation cleared. The patient may now continue.'); + } + + protected function validateClearanceRules(?WorkflowStage $stage, string $method, ?string $paymentMode, ?string $reference): void + { + if ($method === 'insurance' && ! $stage?->insurance_eligible) { + throw ValidationException::withMessages(['method' => 'Insurance is not allowed for this stage.']); + } + + if ($method === 'credit' && ! $stage?->credit_allowed) { + throw ValidationException::withMessages(['method' => 'Credit is not allowed for this stage.']); + } + + if (in_array($method, ['waiver', 'override'], true) && ! $stage?->allow_override) { + throw ValidationException::withMessages(['method' => 'Waivers and overrides are not allowed for this stage.']); + } + + if ($method === 'bank_receipt' && $reference === null) { + throw ValidationException::withMessages(['reference' => 'Enter the external bank receipt or slip number.']); + } + + if ($method === 'bank_receipt' && $stage && ! in_array('external_bank', (array) $stage->payment_modes, true)) { + throw ValidationException::withMessages(['method' => 'External bank receipts are not allowed for this stage.']); + } + + if ($method === 'payment') { + $allowedModes = (array) ($stage?->payment_modes ?? []); + if ($paymentMode === null || ($allowedModes !== [] && ! in_array($paymentMode, $allowedModes, true))) { + throw ValidationException::withMessages(['payment_mode' => 'Select a payment mode allowed for this stage.']); + } + + if ($paymentMode === 'external_bank') { + throw ValidationException::withMessages(['method' => 'Use external bank receipt verification for bank payments.']); + } + } + } + + protected function authorizeObligation(Request $request, FinancialObligation $obligation): void + { + $this->authorizeOwner($request, $obligation); + abort_unless($obligation->organization_id === $this->organization($request)->id, 404); + + $branchId = app(OrganizationResolver::class)->branchScope($this->member($request)); + if ($branchId !== null && $obligation->branch_id !== $branchId) { + abort(404); + } + } +} diff --git a/app/Http/Controllers/Care/QueueController.php b/app/Http/Controllers/Care/QueueController.php index 7e89718..4a70236 100644 --- a/app/Http/Controllers/Care/QueueController.php +++ b/app/Http/Controllers/Care/QueueController.php @@ -2,12 +2,13 @@ namespace App\Http\Controllers\Care; -use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; +use App\Http\Controllers\Controller; use App\Models\Appointment; use App\Models\Branch; use App\Models\Practitioner; use App\Services\Care\AppointmentService; +use App\Services\Care\CarePermissions; use App\Services\Care\CareQueueBridge; use App\Services\Care\CareQueueContexts; use App\Services\Care\ConsultationService; @@ -63,9 +64,9 @@ class QueueController extends Controller ->orderBy('started_at') ->get(); - $canManageQueue = app(\App\Services\Care\CarePermissions::class) + $canManageQueue = app(CarePermissions::class) ->can($this->member($request), 'queue.manage'); - $canConsult = app(\App\Services\Care\CarePermissions::class) + $canConsult = app(CarePermissions::class) ->can($this->member($request), 'consultations.manage'); $heroStats = [ @@ -162,12 +163,16 @@ class QueueController extends Controller ? (int) $request->input('practitioner_id') : $appointment->practitioner_id; - $this->appointments->startConsultation( - $appointment, - $this->ownerRef($request), - $practitionerId, - $this->ownerRef($request), - ); + try { + $this->appointments->startConsultation( + $appointment, + $this->ownerRef($request), + $practitionerId, + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } $consultation = $this->consultations->startFromAppointment( $appointment->fresh(), diff --git a/app/Http/Controllers/Care/SettingsController.php b/app/Http/Controllers/Care/SettingsController.php index 7eb39ff..c61c02e 100644 --- a/app/Http/Controllers/Care/SettingsController.php +++ b/app/Http/Controllers/Care/SettingsController.php @@ -2,9 +2,12 @@ namespace App\Http\Controllers\Care; -use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; +use App\Http\Controllers\Controller; use App\Models\Branch; +use App\Models\Organization; +use App\Services\Billing\PlatformEmailClient; +use App\Services\Billing\PlatformSmsClient; use App\Services\Care\AppointmentPatientNotifier; use App\Services\Care\AuditLogger; use App\Services\Care\CareFeatures; @@ -13,8 +16,8 @@ use App\Services\Care\CareQueueBridge; use App\Services\Care\CareQueueContexts; use App\Services\Care\CareQueueProvisioner; use App\Services\Care\PlanService; -use App\Services\Billing\PlatformEmailClient; -use App\Services\Billing\PlatformSmsClient; +use App\Services\Care\Workflow\WorkflowTemplateInstaller; +use App\Services\Care\Workflow\WorkflowTemplateRegistry; use App\Services\Messaging\MessagingCredentialsService; use App\Services\Queue\QueueClient; use App\Support\OrganizationBranding; @@ -63,18 +66,16 @@ class SettingsController extends Controller 'canUseQueueIntegration' => $plans->canUseQueueIntegration($organization), 'canUseSpecialtyModules' => $plans->canUseSpecialtyModules($organization), 'assessmentsEngineEnabled' => $careFeatures->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE), + 'workflowEngineEnabled' => $careFeatures->enabled($organization, CareFeatures::WORKFLOW_ENGINE), + 'financialGatesEnabled' => $careFeatures->enabled($organization, CareFeatures::FINANCIAL_GATES), + 'hasBillingFeature' => $plans->hasFeature($organization, 'billing'), 'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(), 'messagingEmailReady' => $suiteEmailReady || $credential->hasValidBird(), 'suiteMessagingReady' => $suiteEmailReady || $suiteSmsReady, 'messagingSettingsUrl' => function_exists('ladill_account_url') ? ladill_account_url('/account/settings/messaging') : (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'), - 'facilityTypes' => [ - 'clinic' => 'Clinic', - 'hospital' => 'Hospital', - 'diagnostic' => 'Diagnostic laboratory', - 'specialist' => 'Specialist practice', - ], + 'facilityTypes' => app(WorkflowTemplateRegistry::class)->categoryOptions(), ]); } @@ -88,11 +89,17 @@ class SettingsController extends Controller $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'timezone' => ['required', 'timezone'], - 'facility_type' => ['required', 'string', 'in:clinic,hospital,diagnostic,specialist'], + 'facility_type' => [ + 'required', + 'string', + 'in:'.implode(',', array_keys(app(WorkflowTemplateRegistry::class)->categories())), + ], 'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'], 'remove_logo' => ['nullable', 'boolean'], 'queue_integration_enabled' => ['nullable', 'boolean'], 'assessments_engine' => ['nullable', 'boolean'], + 'workflow_engine' => ['nullable', 'boolean'], + 'financial_gates' => ['nullable', 'boolean'], 'notify_appointment_booked_sms' => ['nullable', 'boolean'], 'notify_appointment_booked_email' => ['nullable', 'boolean'], 'notify_video_scheduled_sms' => ['nullable', 'boolean'], @@ -102,10 +109,17 @@ class SettingsController extends Controller $settings = $organization->settings ?? []; $settings['onboarded'] = true; $settings['facility_type'] = $validated['facility_type']; + $settings['facility_category'] = $validated['facility_type']; + $settings['modules'] = app(WorkflowTemplateRegistry::class) + ->modulesForCategory($validated['facility_type']); $rollout = is_array($settings['rollout'] ?? null) ? $settings['rollout'] : []; // Explicit save so opt-out is stored (engine defaults ON when unset). $rollout[CareFeatures::ASSESSMENTS_ENGINE] = $request->boolean('assessments_engine'); + $rollout[CareFeatures::WORKFLOW_ENGINE] = $request->boolean('workflow_engine'); + $rollout[CareFeatures::FINANCIAL_GATES] = $request->boolean('financial_gates') + && $rollout[CareFeatures::WORKFLOW_ENGINE] + && $plans->hasFeature($organization, 'billing'); $settings['rollout'] = $rollout; if ($rollout[CareFeatures::ASSESSMENTS_ENGINE]) { app(CareFeatures::class)->ensureAssessmentCatalog(); @@ -155,6 +169,21 @@ class SettingsController extends Controller 'settings' => $settings, ]); + if ($rollout[CareFeatures::WORKFLOW_ENGINE] && ! $organization->facilityWorkflows()->where('is_active', true)->exists()) { + $branch = Branch::owned($owner) + ->where('organization_id', $organization->id) + ->orderBy('id') + ->first(); + if ($branch) { + app(WorkflowTemplateInstaller::class)->install( + $organization->fresh(), + $branch, + (string) ($settings['workflow_template'] ?? config('care_workflows.default_template', 'legacy_clinic')), + ['category' => $settings['facility_category'] ?? $settings['facility_type'] ?? 'clinic'], + ); + } + } + if ($wantsQueue && $queue->configured()) { try { $organization = $organization->fresh(); @@ -178,7 +207,7 @@ class SettingsController extends Controller } } - AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id); + AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, Organization::class, $organization->id); return back()->with('success', 'Settings saved.'); } diff --git a/app/Http/Controllers/Care/VisitWorkflowController.php b/app/Http/Controllers/Care/VisitWorkflowController.php new file mode 100644 index 0000000..265b7bd --- /dev/null +++ b/app/Http/Controllers/Care/VisitWorkflowController.php @@ -0,0 +1,81 @@ +authorizeAbility($request, 'appointments.manage'); + $this->authorizeVisit($request, $visit); + $organization = $this->organization($request); + abort_unless($this->features->enabled($organization, CareFeatures::WORKFLOW_ENGINE), 404); + + $validated = $request->validate([ + 'to_code' => ['nullable', 'string', 'max:100'], + ]); + + if (! $this->workflows->canManuallyAdvance($visit)) { + return back()->with('error', 'Complete this clinical service from its own workspace before advancing the visit.'); + } + + try { + $advance = $this->workflows->advance($visit, $validated['to_code'] ?? null); + } catch (\DomainException $e) { + return back()->with('error', $e->getMessage()); + } + + if ($advance === null) { + return back()->with('info', 'This visit has no next workflow stage.'); + } + + AuditLogger::record( + $this->ownerRef($request), + 'visit.workflow_advanced', + $visit->organization_id, + $this->ownerRef($request), + VisitStageAdvance::class, + $advance->id, + ['stage_code' => $advance->stage_code, 'status' => $advance->status], + ); + + $this->queueRelease->releaseCurrent($organization, $visit); + + $message = $advance->isBlocked() + ? "Visit moved to {$advance->stage?->name}; financial clearance is required before service." + : "Visit moved to {$advance->stage?->name}."; + + return back()->with($advance->isBlocked() ? 'warning' : 'success', $message); + } + + protected function authorizeVisit(Request $request, Visit $visit): void + { + $this->authorizeOwner($request, $visit); + abort_unless($visit->organization_id === $this->organization($request)->id, 404); + + $branchId = app(OrganizationResolver::class)->branchScope($this->member($request)); + if ($branchId !== null && $visit->branch_id !== $branchId) { + abort(404); + } + } +} diff --git a/app/Models/Appointment.php b/app/Models/Appointment.php index 7ea2df8..552d6a3 100644 --- a/app/Models/Appointment.php +++ b/app/Models/Appointment.php @@ -101,7 +101,7 @@ class Appointment extends Model public function consultation(): HasOne { - return $this->hasOne(Consultation::class, 'appointment_id'); + return $this->hasOne(Consultation::class, 'appointment_id')->latestOfMany(); } /** @return list */ diff --git a/app/Models/FinancialObligation.php b/app/Models/FinancialObligation.php index 9c3353b..f15d1c2 100644 --- a/app/Models/FinancialObligation.php +++ b/app/Models/FinancialObligation.php @@ -69,6 +69,16 @@ class FinancialObligation extends Model return $this->belongsTo(Visit::class, 'visit_id'); } + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class, 'branch_id'); + } + public function patient(): BelongsTo { return $this->belongsTo(Patient::class, 'patient_id'); diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 9951b5f..43bac66 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -31,4 +31,14 @@ class Organization extends Model { return $this->hasMany(Member::class, 'organization_id'); } + + public function facilityWorkflows(): HasMany + { + return $this->hasMany(FacilityWorkflow::class, 'organization_id'); + } + + public function financialObligations(): HasMany + { + return $this->hasMany(FinancialObligation::class, 'organization_id'); + } } diff --git a/app/Models/Visit.php b/app/Models/Visit.php index 06f928f..db054d9 100644 --- a/app/Models/Visit.php +++ b/app/Models/Visit.php @@ -88,4 +88,14 @@ class Visit extends Model { return $this->hasMany(Bill::class, 'visit_id'); } + + public function stageAdvances(): HasMany + { + return $this->hasMany(VisitStageAdvance::class, 'visit_id'); + } + + public function financialObligations(): HasMany + { + return $this->hasMany(FinancialObligation::class, 'visit_id'); + } } diff --git a/app/Models/WorkflowStage.php b/app/Models/WorkflowStage.php index 135c9cc..c7bfd56 100644 --- a/app/Models/WorkflowStage.php +++ b/app/Models/WorkflowStage.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Models\Concerns\BelongsToOwner; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Str; class WorkflowStage extends Model @@ -60,6 +61,16 @@ class WorkflowStage extends Model return $this->belongsTo(FacilityWorkflow::class, 'workflow_id'); } + public function advances(): HasMany + { + return $this->hasMany(VisitStageAdvance::class, 'workflow_stage_id'); + } + + public function financialObligations(): HasMany + { + return $this->hasMany(FinancialObligation::class, 'workflow_stage_id'); + } + /** Whether entering this stage's service queue is gated on a cleared obligation. */ public function gatesBeforeService(): bool { diff --git a/app/Services/Care/AppointmentService.php b/app/Services/Care/AppointmentService.php index c400da7..34da2c9 100644 --- a/app/Services/Care/AppointmentService.php +++ b/app/Services/Care/AppointmentService.php @@ -6,6 +6,8 @@ use App\Models\Appointment; use App\Models\Organization; use App\Models\Patient; use App\Models\Visit; +use App\Services\Care\Workflow\WorkflowQueueGate; +use App\Services\Care\Workflow\WorkflowStageCompletionService; use App\Services\Meet\MeetClient; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -20,8 +22,15 @@ class AppointmentService protected VisitService $visits, protected AppointmentPatientNotifier $notifier, protected CareQueueBridge $queueBridge, + protected WorkflowQueueGate $workflowGate, + protected WorkflowStageCompletionService $workflowCompletion, ) {} + public function queueContextFor(Organization $organization, Appointment $appointment): string + { + return $this->queueBridge->contextForAppointment($organization, $appointment); + } + public function queryForOrganization(string $ownerRef, int $organizationId): Builder { return Appointment::owned($ownerRef)->where('organization_id', $organizationId); @@ -67,7 +76,7 @@ class AppointmentService $query = Appointment::owned($ownerRef) ->where('branch_id', $branchId) ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) - ->with(['patient', 'practitioner']) + ->with(['patient', 'practitioner', 'visit', 'organization']) ->orderBy('queue_position') ->orderBy('waiting_at') ->orderBy('checked_in_at'); @@ -78,7 +87,20 @@ class AppointmentService }); } - return $query->get(); + return $query->get() + ->filter(function (Appointment $appointment) { + $organization = $appointment->organization; + if (! $organization) { + return true; + } + + return $this->workflowGate->canRelease( + $organization, + $appointment->visit, + $this->queueBridge->contextForAppointment($organization, $appointment), + ); + }) + ->values(); } /** @@ -218,6 +240,15 @@ class AppointmentService $appointment->update(['visit_id' => $visit->id, 'checked_in_at' => now()]); } + $appointment->loadMissing(['organization', 'visit']); + if ($appointment->organization && ! $this->workflowGate->canRelease( + $appointment->organization, + $appointment->visit, + $this->queueBridge->contextForAppointment($appointment->organization, $appointment), + )) { + throw new InvalidArgumentException('This visit has not reached a financially cleared consultation stage.'); + } + $appointment->visit->update(['status' => Visit::STATUS_IN_PROGRESS]); $appointment->update([ @@ -245,9 +276,11 @@ class AppointmentService 'completed_at' => now(), ]); - if ($appointment->visit) { - $this->visits->complete($appointment->visit, $ownerRef, $actorRef); - } + $visit = $appointment->visit; + $organization = $appointment->organization; + $workflowEnabled = $visit + && $organization + && $this->workflowGate->enabled($organization); AuditLogger::record($ownerRef, 'appointment.completed', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id); @@ -256,6 +289,18 @@ class AppointmentService $this->queueBridge->complete($appointment->organization, $appointment); } + if ($workflowEnabled && $visit && $organization) { + $this->workflowCompletion->complete( + $organization, + $visit, + $this->queueBridge->contextForAppointment($organization, $appointment), + $ownerRef, + $actorRef, + ); + } elseif ($visit) { + $this->visits->complete($visit, $ownerRef, $actorRef); + } + return $appointment->fresh(['patient', 'practitioner', 'visit']); } diff --git a/app/Services/Care/BillService.php b/app/Services/Care/BillService.php index bd5b0cd..e1d22cc 100644 --- a/app/Services/Care/BillService.php +++ b/app/Services/Care/BillService.php @@ -4,12 +4,14 @@ namespace App\Services\Care; use App\Models\Bill; use App\Models\BillLineItem; +use App\Models\FinancialObligation; use App\Models\InvestigationRequest; use App\Models\Organization; use App\Models\Payment; use App\Models\Prescription; use App\Models\User; use App\Models\Visit; +use App\Services\Care\Workflow\WorkflowStageCompletionService; use App\Services\Payments\MerchantGatewayService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -23,6 +25,7 @@ class BillService protected InvoiceNumberGenerator $invoices, protected MerchantGatewayService $gateway, protected CareQueueBridge $queueBridge, + protected WorkflowStageCompletionService $workflowCompletion, ) {} public function queryForOrganization(string $ownerRef, int $organizationId): Builder @@ -90,6 +93,63 @@ class BillService return $bill->fresh(['lineItems', 'patient', 'payments']); } + /** + * Attach a workflow charge intent to an editable visit bill. A later stage + * may create a new bill when the previous encounter bill is already paid. + */ + public function billForObligation(FinancialObligation $obligation, string $ownerRef, ?string $actorRef = null): Bill + { + if ($obligation->bill_id) { + $linked = Bill::query()->find($obligation->bill_id); + if ($linked) { + return $linked; + } + } + + $visit = $obligation->visit()->with(['organization', 'patient'])->firstOrFail(); + $bill = Bill::owned($ownerRef) + ->where('visit_id', $visit->id) + ->whereIn('status', [Bill::STATUS_DRAFT, Bill::STATUS_OPEN, Bill::STATUS_PARTIAL]) + ->latest('id') + ->first(); + + if (! $bill) { + $bill = Bill::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $visit->organization_id, + 'branch_id' => $visit->branch_id, + 'visit_id' => $visit->id, + 'patient_id' => $visit->patient_id, + 'invoice_number' => $this->invoices->generate($visit->organization), + 'status' => Bill::STATUS_OPEN, + 'created_by' => $actorRef, + ]); + + AuditLogger::record($ownerRef, 'bill.created', $visit->organization_id, $actorRef, Bill::class, $bill->id); + } + + $exists = $bill->lineItems() + ->where('source_type', FinancialObligation::class) + ->where('source_id', $obligation->id) + ->exists(); + + if (! $exists) { + $this->addLineItem($bill, $ownerRef, [ + 'type' => $this->lineTypeForCharge($obligation->charge_code), + 'description' => $obligation->label ?? 'Workflow charge', + 'quantity' => 1, + 'unit_price_minor' => $obligation->amount_minor, + 'source_type' => FinancialObligation::class, + 'source_id' => $obligation->id, + ]); + $this->recalculate($bill); + } + + $obligation->forceFill(['bill_id' => $bill->id])->save(); + + return $bill->fresh(['lineItems', 'payments', 'patient']); + } + public function syncLineItemsFromVisit(Bill $bill, string $ownerRef, ?string $actorRef = null): Bill { if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) { @@ -98,9 +158,19 @@ class BillService $visit = $bill->visit()->with(['consultations'])->firstOrFail(); - $bill->lineItems()->whereNotNull('source_type')->delete(); + $bill->lineItems() + ->whereNotNull('source_type') + ->where('source_type', '!=', FinancialObligation::class) + ->delete(); - if ($visit->consultations()->where('status', 'completed')->exists()) { + $workflowChargeCodes = FinancialObligation::query() + ->where('bill_id', $bill->id) + ->pluck('charge_code') + ->filter() + ->all(); + + if (! in_array('consultation', $workflowChargeCodes, true) + && $visit->consultations()->where('status', 'completed')->exists()) { $fee = (int) config('care.billing.consultation_fee_minor', 5000); $this->addLineItem($bill, $ownerRef, [ 'type' => 'consultation', @@ -117,9 +187,18 @@ class BillService ->whereIn('status', [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED]) ->with('investigationType') ->get() - ->each(function (InvestigationRequest $request) use ($bill, $ownerRef) { + ->each(function (InvestigationRequest $request) use ($bill, $ownerRef, $workflowChargeCodes) { + $chargeCode = in_array( + $request->investigationType?->category, + ['xray', 'ultrasound', 'ct', 'mri'], + true, + ) ? 'imaging' : 'lab'; + if (in_array($chargeCode, $workflowChargeCodes, true)) { + return; + } + $this->addLineItem($bill, $ownerRef, [ - 'type' => 'lab', + 'type' => $chargeCode, 'description' => $request->investigationType->name, 'quantity' => 1, 'unit_price_minor' => $request->investigationType->price_minor, @@ -133,7 +212,11 @@ class BillService ->where('status', Prescription::STATUS_DISPENSED) ->with('items.drug') ->get() - ->each(function (Prescription $rx) use ($bill, $ownerRef) { + ->each(function (Prescription $rx) use ($bill, $ownerRef, $workflowChargeCodes) { + if (in_array('pharmacy', $workflowChargeCodes, true)) { + return; + } + foreach ($rx->items as $item) { if ($item->is_procedure) { $this->addLineItem($bill, $ownerRef, [ @@ -167,6 +250,21 @@ class BillService return $bill->fresh(['lineItems', 'payments', 'patient']); } + protected function lineTypeForCharge(?string $chargeCode): string + { + $candidate = match ($chargeCode) { + 'consultation' => 'consultation', + 'lab' => 'lab', + 'imaging' => 'imaging', + 'pharmacy' => 'pharmacy', + default => 'misc', + }; + + return array_key_exists($candidate, (array) config('care.bill_line_types', [])) + ? $candidate + : 'misc'; + } + /** * @param array $data */ @@ -472,6 +570,16 @@ class BillService $organization = Organization::query()->find($bill->organization_id); if ($organization) { $this->queueBridge->complete($organization, $bill); + $visit = $bill->visit; + if ($visit) { + $this->workflowCompletion->complete( + $organization, + $visit, + CareQueueContexts::BILLING, + (string) $bill->owner_ref, + $bill->payments()->latest('id')->value('recorded_by'), + ); + } } } } diff --git a/app/Services/Care/CareQueueBridge.php b/app/Services/Care/CareQueueBridge.php index 36fd6f2..00f8619 100644 --- a/app/Services/Care/CareQueueBridge.php +++ b/app/Services/Care/CareQueueBridge.php @@ -8,7 +8,9 @@ use App\Models\InvestigationRequest; use App\Models\Member; use App\Models\Organization; use App\Models\Patient; +use App\Models\Practitioner; use App\Models\Prescription; +use App\Services\Care\Workflow\WorkflowQueueGate; use App\Services\Queue\QueueClient; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Client\RequestException; @@ -25,6 +27,7 @@ class CareQueueBridge protected CareQueueProvisioner $provisioner, protected PlanService $plans, protected SpecialtyModuleService $specialties, + protected WorkflowQueueGate $workflowGate, ) {} public function isEnabled(Organization $organization): bool @@ -57,7 +60,12 @@ class CareQueueBridge return $appointment; } + $appointment->loadMissing('visit'); $context = $this->contextForAppointment($organization, $appointment); + if (! $this->workflowGate->canRelease($organization, $appointment->visit, $context)) { + return $appointment; + } + $point = $this->resolveConsultationPoint($organization, $appointment); if (CareQueueContexts::usesAssignedRouting($context) && ! $point) { @@ -104,6 +112,10 @@ class CareQueueBridge } $prescription->loadMissing(['patient', 'visit']); + if (! $this->workflowGate->canRelease($organization, $prescription->visit, CareQueueContexts::PHARMACY)) { + return $prescription; + } + $branchId = (int) ($prescription->visit?->branch_id ?? 0); if ($branchId < 1) { return $prescription; @@ -139,10 +151,15 @@ class CareQueueBridge return $request; } - $request->loadMissing('patient'); + $request->loadMissing(['patient', 'visit', 'investigationType']); + $context = $this->contextForInvestigation($request); + if (! $this->workflowGate->canRelease($organization, $request->visit, $context)) { + return $request; + } + $ticket = $this->issue( $organization, - CareQueueContexts::LABORATORY, + $context, (int) $request->branch_id, $request->patient, [ @@ -164,6 +181,15 @@ class CareQueueBridge return $request->fresh(); } + public function contextForInvestigation(InvestigationRequest $request): string + { + $request->loadMissing('investigationType'); + + return in_array($request->investigationType?->category, ['xray', 'ultrasound', 'ct', 'mri'], true) + ? CareQueueContexts::IMAGING + : CareQueueContexts::LABORATORY; + } + public function issueForBill(Organization $organization, Bill $bill): ?Bill { if (! $this->isEnabled($organization) || filled($bill->queue_ticket_uuid)) { @@ -174,7 +200,11 @@ class CareQueueBridge return $bill; } - $bill->loadMissing('patient'); + $bill->loadMissing(['patient', 'visit']); + if (! $this->workflowGate->canRelease($organization, $bill->visit, CareQueueContexts::BILLING)) { + return $bill; + } + $ticket = $this->issue( $organization, CareQueueContexts::BILLING, @@ -209,7 +239,8 @@ class CareQueueBridge } // Lab/imaging completion → return-to-doctor consultation ticket when practitioner known. - if ($fromContext === CareQueueContexts::LABORATORY && $entity instanceof InvestigationRequest) { + if (in_array($fromContext, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true) + && $entity instanceof InvestigationRequest) { $entity->loadMissing(['patient', 'practitioner', 'visit']); $appointment = Appointment::owned($organization->owner_ref) ->where('organization_id', $organization->id) @@ -243,7 +274,7 @@ class CareQueueBridge return match (true) { $context === CareQueueContexts::PHARMACY => $this->syncMissingPrescriptions($organization, $branchId, $limit), - $context === CareQueueContexts::LABORATORY => $this->syncMissingInvestigations($organization, $branchId, $limit), + in_array($context, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true) => $this->syncMissingInvestigations($organization, $context, $branchId, $limit), $context === CareQueueContexts::BILLING => $this->syncMissingBills($organization, $branchId, $limit), default => $this->syncMissingAppointments($organization, $context, $branchId, $limit), }; @@ -418,7 +449,7 @@ class CareQueueBridge ); } if ($member && $member->role === 'doctor') { - $prac = \App\Models\Practitioner::owned($organization->owner_ref) + $prac = Practitioner::owned($organization->owner_ref) ->where('organization_id', $organization->id) ->where('branch_id', $branchId) ->where(function ($q) use ($member) { @@ -569,7 +600,7 @@ class CareQueueBridge ->where('organization_id', $organization->id) ->where('queue_ticket_uuid', $ticketUuid) ->first(), - $context === CareQueueContexts::LABORATORY => InvestigationRequest::owned($owner) + in_array($context, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true) => InvestigationRequest::owned($owner) ->where('organization_id', $organization->id) ->where('queue_ticket_uuid', $ticketUuid) ->first(), @@ -643,7 +674,7 @@ class CareQueueBridge return $issued; } - protected function syncMissingInvestigations(Organization $organization, int $branchId, int $limit): int + protected function syncMissingInvestigations(Organization $organization, string $context, int $branchId, int $limit): int { $owner = (string) $organization->owner_ref; $requests = InvestigationRequest::owned($owner) @@ -657,13 +688,17 @@ class CareQueueBridge ->where(function ($q) { $q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', ''); }) - ->with('patient') + ->with(['patient', 'visit', 'investigationType']) ->orderBy('id') ->limit($limit) ->get(); $issued = 0; foreach ($requests as $request) { + if ($this->contextForInvestigation($request) !== $context) { + continue; + } + $updated = $this->issueForInvestigation($organization, $request); if ($updated && filled($updated->queue_ticket_uuid)) { $issued++; diff --git a/app/Services/Care/ConsultationService.php b/app/Services/Care/ConsultationService.php index 6d217aa..d1a85f0 100644 --- a/app/Services/Care/ConsultationService.php +++ b/app/Services/Care/ConsultationService.php @@ -7,7 +7,6 @@ use App\Models\Consultation; use App\Models\ConsultationDocument; use App\Models\Diagnosis; use App\Models\VitalSign; -use App\Models\Visit; use Illuminate\Http\UploadedFile; class ConsultationService @@ -28,7 +27,10 @@ class ConsultationService $appointment->refresh(); } - $existing = Consultation::where('appointment_id', $appointment->id)->first(); + $existing = Consultation::where('appointment_id', $appointment->id) + ->where('status', '!=', Consultation::STATUS_COMPLETED) + ->latest('id') + ->first(); if ($existing) { return $existing->load(['vitalSigns', 'diagnoses', 'documents', 'patient', 'practitioner']); } diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 11cb379..d452553 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -29,6 +29,7 @@ use App\Models\Visit; use App\Models\VitalSign; use App\Support\DemoWorld; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; /** @@ -209,6 +210,16 @@ class DemoTenantSeeder { $orgIds = DB::table('care_organizations')->where('owner_ref', $ownerRef)->pluck('id'); + if (Schema::hasTable('care_financial_obligations')) { + DB::table('care_financial_obligations')->where('owner_ref', $ownerRef)->delete(); + DB::table('care_visit_stage_advances')->where('owner_ref', $ownerRef)->delete(); + $workflowIds = DB::table('care_facility_workflows')->where('owner_ref', $ownerRef)->pluck('id'); + if ($workflowIds->isNotEmpty()) { + DB::table('care_workflow_stages')->whereIn('workflow_id', $workflowIds)->delete(); + DB::table('care_facility_workflows')->whereIn('id', $workflowIds)->delete(); + } + } + DB::table('care_assessment_answers')->where('owner_ref', $ownerRef)->delete(); DB::table('care_assessment_scores')->where('owner_ref', $ownerRef)->delete(); DB::table('care_assessments')->where('owner_ref', $ownerRef)->update(['patient_pathway_id' => null]); diff --git a/app/Services/Care/InvestigationService.php b/app/Services/Care/InvestigationService.php index 43b92ae..d30a2b6 100644 --- a/app/Services/Care/InvestigationService.php +++ b/app/Services/Care/InvestigationService.php @@ -9,17 +9,18 @@ use App\Models\InvestigationResult; use App\Models\InvestigationResultValue; use App\Models\InvestigationType; use App\Models\Organization; +use App\Services\Care\Workflow\WorkflowStageCompletionService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Storage; use InvalidArgumentException; class InvestigationService { public function __construct( protected CareQueueBridge $queueBridge, + protected WorkflowStageCompletionService $workflowCompletion, ) {} public function queryForOrganization(string $ownerRef, int $organizationId): Builder @@ -83,7 +84,7 @@ class InvestigationService ?string $actorRef = null, ): Collection { $consultation->loadMissing('visit'); - $created = new Collection(); + $created = new Collection; foreach ($typeIds as $typeId) { $type = InvestigationType::owned($ownerRef)->findOrFail($typeId); @@ -277,10 +278,19 @@ class InvestigationService AuditLogger::record($ownerRef, 'investigation.approved', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id); - $request = $request->fresh(['patient', 'investigationType', 'result.values']); + $request = $request->fresh(['patient', 'investigationType', 'result.values', 'visit']); $organization = Organization::query()->find($request->organization_id); if ($organization) { $this->queueBridge->complete($organization, $request); + if ($request->visit) { + $this->workflowCompletion->complete( + $organization, + $request->visit, + $this->queueBridge->contextForInvestigation($request), + $ownerRef, + $actorRef, + ); + } } return $request; diff --git a/app/Services/Care/OrganizationResolver.php b/app/Services/Care/OrganizationResolver.php index 7f7cb4f..ef44669 100644 --- a/app/Services/Care/OrganizationResolver.php +++ b/app/Services/Care/OrganizationResolver.php @@ -95,6 +95,12 @@ class OrganizationResolver 'facility_type' => $data['facility_type'] ?? $category, 'modules' => $modules, 'workflow_template' => $templateKey, + 'rollout' => [ + CareFeatures::WORKFLOW_ENGINE => $templateKey !== 'legacy_clinic', + // Financial gates require the paid billing module and are + // explicitly enabled by an administrator in Settings. + CareFeatures::FINANCIAL_GATES => false, + ], ], ]); diff --git a/app/Services/Care/PharmacyService.php b/app/Services/Care/PharmacyService.php index 2b04cc1..21e26e3 100644 --- a/app/Services/Care/PharmacyService.php +++ b/app/Services/Care/PharmacyService.php @@ -7,7 +7,7 @@ use App\Models\Drug; use App\Models\DrugBatch; use App\Models\Organization; use App\Models\Prescription; -use App\Models\PrescriptionItem; +use App\Services\Care\Workflow\WorkflowStageCompletionService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -17,6 +17,7 @@ class PharmacyService { public function __construct( protected CareQueueBridge $queueBridge, + protected WorkflowStageCompletionService $workflowCompletion, ) {} public function queryDrugs(string $ownerRef, int $organizationId): Builder @@ -201,10 +202,19 @@ class PharmacyService AuditLogger::record($ownerRef, 'prescription.dispensed', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id); - $prescription = $prescription->fresh(['items', 'patient']); + $prescription = $prescription->fresh(['items', 'patient', 'visit']); $organization = Organization::query()->find($prescription->organization_id); if ($organization) { $this->queueBridge->complete($organization, $prescription); + if ($prescription->visit) { + $this->workflowCompletion->complete( + $organization, + $prescription->visit, + CareQueueContexts::PHARMACY, + $ownerRef, + $actorRef, + ); + } } return $prescription; diff --git a/app/Services/Care/PrescriptionService.php b/app/Services/Care/PrescriptionService.php index 6d7eba2..deac8df 100644 --- a/app/Services/Care/PrescriptionService.php +++ b/app/Services/Care/PrescriptionService.php @@ -6,6 +6,7 @@ use App\Models\Consultation; use App\Models\Organization; use App\Models\Prescription; use App\Models\PrescriptionItem; +use App\Services\Care\Workflow\WorkflowStageCompletionService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -15,6 +16,7 @@ class PrescriptionService { public function __construct( protected CareQueueBridge $queueBridge, + protected WorkflowStageCompletionService $workflowCompletion, ) {} public function queryForOrganization(string $ownerRef, int $organizationId): Builder @@ -160,6 +162,15 @@ class PrescriptionService $organization = Organization::query()->find($prescription->organization_id); if ($organization) { $this->queueBridge->complete($organization, $prescription); + if ($prescription->visit) { + $this->workflowCompletion->complete( + $organization, + $prescription->visit, + CareQueueContexts::PHARMACY, + $ownerRef, + $actorRef, + ); + } } return $prescription->fresh(['items', 'patient']); diff --git a/app/Services/Care/VisitService.php b/app/Services/Care/VisitService.php index 524e686..9bd7925 100644 --- a/app/Services/Care/VisitService.php +++ b/app/Services/Care/VisitService.php @@ -5,9 +5,15 @@ namespace App\Services\Care; use App\Models\Organization; use App\Models\Patient; use App\Models\Visit; +use App\Services\Care\Workflow\WorkflowEngine; class VisitService { + public function __construct( + protected CareFeatures $features, + protected WorkflowEngine $workflows, + ) {} + public function checkIn( Organization $organization, string $ownerRef, @@ -27,6 +33,10 @@ class VisitService AuditLogger::record($ownerRef, 'visit.checked_in', $organization->id, $actorRef, Visit::class, $visit->id); + if ($this->features->enabled($organization, CareFeatures::WORKFLOW_ENGINE)) { + $this->workflows->start($visit); + } + return $visit; } diff --git a/app/Services/Care/Workflow/FinancialGateService.php b/app/Services/Care/Workflow/FinancialGateService.php index c4f96e0..ac12170 100644 --- a/app/Services/Care/Workflow/FinancialGateService.php +++ b/app/Services/Care/Workflow/FinancialGateService.php @@ -3,8 +3,12 @@ namespace App\Services\Care\Workflow; use App\Models\FinancialObligation; +use App\Models\InvestigationRequest; +use App\Models\Organization; +use App\Models\Prescription; use App\Models\Visit; use App\Models\WorkflowStage; +use App\Services\Care\CareFeatures; /** * Owns FinancialObligation lifecycle and answers whether a stage's financial @@ -16,6 +20,10 @@ use App\Models\WorkflowStage; */ class FinancialGateService { + public function __construct( + protected CareFeatures $features, + ) {} + /** * Create (or return existing) obligation for a visit + stage. Only stages * that require payment produce an obligation. @@ -28,29 +36,51 @@ class FinancialGateService return null; } - $existing = FinancialObligation::query() - ->where('visit_id', $visit->id) - ->where('workflow_stage_id', $stage->id) - ->whereNull('deleted_at') - ->first(); - - if ($existing !== null) { - return $existing; + $organization = Organization::query()->find($visit->organization_id); + if ($organization && ! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) { + return null; } - return FinancialObligation::create(array_merge([ + $amount = (int) ($overrides['amount_minor'] + ?? $stage->default_amount_minor + ?? $this->defaultAmountFor($stage, $visit)); + $defaults = array_merge([ 'owner_ref' => $visit->owner_ref, 'organization_id' => $visit->organization_id, 'branch_id' => $visit->branch_id, - 'visit_id' => $visit->id, 'patient_id' => $visit->patient_id, - 'workflow_stage_id' => $stage->id, 'stage_code' => $stage->code, 'charge_code' => $stage->charge_code, 'label' => $stage->charge_label ?? $stage->name, - 'amount_minor' => $stage->default_amount_minor ?? $this->defaultAmountFor($stage), - 'status' => FinancialObligation::STATUS_PENDING, - ], $overrides)); + 'amount_minor' => $amount, + 'status' => $amount > 0 + ? FinancialObligation::STATUS_PENDING + : FinancialObligation::STATUS_WAIVED, + 'clearance_method' => $amount > 0 ? null : 'no_charge', + 'cleared_at' => $amount > 0 ? null : now(), + ], $overrides); + + $obligation = FinancialObligation::firstOrCreate( + [ + 'visit_id' => $visit->id, + 'workflow_stage_id' => $stage->id, + ], + $defaults, + ); + + // A stage may initially have no billable items (for example pharmacy + // before a prescription is saved). Re-evaluate no-charge clearances + // whenever the workflow gate is refreshed. + if ($obligation->clearance_method === 'no_charge' && $amount > 0) { + $obligation->forceFill([ + 'amount_minor' => $amount, + 'status' => FinancialObligation::STATUS_PENDING, + 'clearance_method' => null, + 'cleared_at' => null, + ])->save(); + } + + return $obligation; } /** @@ -63,6 +93,11 @@ class FinancialGateService return true; } + $organization = Organization::query()->find($visit->organization_id); + if ($organization && ! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) { + return true; + } + $obligation = FinancialObligation::query() ->where('visit_id', $visit->id) ->where('workflow_stage_id', $stage->id) @@ -93,7 +128,7 @@ class FinancialGateService ): FinancialObligation { $status = match ($method) { 'insurance' => FinancialObligation::STATUS_AUTHORIZED, - 'waiver' => FinancialObligation::STATUS_WAIVED, + 'waiver', 'override' => FinancialObligation::STATUS_WAIVED, 'credit' => FinancialObligation::STATUS_DEFERRED, default => FinancialObligation::STATUS_PAID, }; @@ -117,12 +152,53 @@ class FinancialGateService return $obligation; } - protected function defaultAmountFor(WorkflowStage $stage): int + protected function defaultAmountFor(WorkflowStage $stage, Visit $visit): int { - if ($stage->charge_code === 'consultation') { - return (int) config('care.billing.consultation_fee_minor', 0); - } + return match ($stage->charge_code) { + 'patient_card', 'registration' => (int) config('care.billing.patient_card_fee_minor', 0), + 'consultation' => (int) config('care.billing.consultation_fee_minor', 0), + 'lab' => $this->investigationTotal($visit, imaging: false), + 'imaging' => $this->investigationTotal($visit, imaging: true), + 'pharmacy' => $this->pharmacyTotal($visit), + 'visit_total' => (int) $visit->bills() + ->whereNotIn('status', ['paid', 'void']) + ->sum('balance_minor'), + default => 0, + }; + } - return 0; + protected function investigationTotal(Visit $visit, bool $imaging): int + { + $imagingCategories = ['xray', 'ultrasound', 'ct', 'mri']; + + return (int) InvestigationRequest::query() + ->where('visit_id', $visit->id) + ->with('investigationType') + ->get() + ->filter(function (InvestigationRequest $request) use ($imaging, $imagingCategories) { + $isImaging = in_array($request->investigationType?->category, $imagingCategories, true); + + return $imaging ? $isImaging : ! $isImaging; + }) + ->sum(fn (InvestigationRequest $request) => (int) ($request->investigationType?->price_minor ?? 0)); + } + + protected function pharmacyTotal(Visit $visit): int + { + return (int) Prescription::query() + ->where('visit_id', $visit->id) + ->with('items.drug') + ->get() + ->sum(function (Prescription $prescription) { + return $prescription->items->sum(function ($item) { + if ($item->is_procedure) { + return 0; + } + + $quantity = max(1, (int) preg_replace('/\D/', '', (string) $item->quantity) ?: 1); + + return $quantity * (int) ($item->drug?->unit_price_minor ?? 0); + }); + }); } } diff --git a/app/Services/Care/Workflow/WorkflowEngine.php b/app/Services/Care/Workflow/WorkflowEngine.php index a1f9646..1c5c946 100644 --- a/app/Services/Care/Workflow/WorkflowEngine.php +++ b/app/Services/Care/Workflow/WorkflowEngine.php @@ -40,22 +40,26 @@ class WorkflowEngine */ public function start(Visit $visit, ?FacilityWorkflow $workflow = null): ?VisitStageAdvance { - $workflow ??= $this->workflowFor($visit); - if ($workflow === null) { - return null; - } + return DB::transaction(function () use ($visit, $workflow) { + Visit::query()->whereKey($visit->id)->lockForUpdate()->first(); - $existing = $this->currentAdvance($visit); - if ($existing !== null) { - return $existing; - } + $workflow ??= $this->workflowFor($visit); + if ($workflow === null) { + return null; + } - $first = $workflow->firstStage(); - if ($first === null) { - return null; - } + $existing = $this->currentAdvance($visit); + if ($existing !== null) { + return $existing; + } - return $this->enterStage($visit, $workflow, $first); + $first = $workflow->firstStage(); + if ($first === null) { + return null; + } + + return $this->enterStage($visit, $workflow, $first); + }); } public function currentAdvance(Visit $visit): ?VisitStageAdvance @@ -79,9 +83,31 @@ class WorkflowEngine */ public function canAdvance(Visit $visit, ?string $toCode = null): bool { + $current = $this->refreshGate($visit); + if ($current === null || $current->isBlocked()) { + return false; + } + $target = $this->resolveTarget($visit, $toCode); - return $target !== null && $this->gate->isSatisfied($visit, $target); + return $target !== null; + } + + public function canManuallyAdvance(Visit $visit): bool + { + $stage = $this->refreshGate($visit)?->stage; + if ($stage === null || in_array($stage->type, [ + 'consultation', + 'laboratory', + 'imaging', + 'procedure', + 'pharmacy', + 'payment', + ], true)) { + return false; + } + + return $this->canAdvance($visit); } /** @@ -93,6 +119,8 @@ class WorkflowEngine public function advance(Visit $visit, ?string $toCode = null): ?VisitStageAdvance { return DB::transaction(function () use ($visit, $toCode) { + Visit::query()->whereKey($visit->id)->lockForUpdate()->first(); + $workflow = $this->workflowFor($visit); if ($workflow === null) { return null; @@ -103,7 +131,11 @@ class WorkflowEngine return null; } - $current = $this->currentAdvance($visit); + $current = $this->refreshGate($visit); + if ($current?->isBlocked()) { + throw new \DomainException('Clear the current stage financial gate before advancing this visit.'); + } + if ($current !== null) { $current->forceFill([ 'status' => VisitStageAdvance::STATUS_COMPLETED, @@ -140,12 +172,21 @@ class WorkflowEngine return $advance; } - if ($advance->isBlocked() && $this->gate->isSatisfied($visit, $advance->stage)) { + $this->gate->obligationFor($visit, $advance->stage); + $satisfied = $this->gate->isSatisfied($visit, $advance->stage); + + if ($advance->isBlocked() && $satisfied) { $advance->forceFill([ 'status' => VisitStageAdvance::STATUS_ACTIVE, 'blocked_reason' => null, 'cleared_at' => now(), ])->save(); + } elseif (! $advance->isBlocked() && ! $satisfied) { + $advance->forceFill([ + 'status' => VisitStageAdvance::STATUS_BLOCKED, + 'blocked_reason' => $this->gate->blockedReason($visit, $advance->stage), + 'cleared_at' => null, + ])->save(); } return $advance->fresh(); @@ -178,7 +219,16 @@ class WorkflowEngine } if ($toCode !== null && $toCode !== '') { - return $workflow->stage($toCode); + $target = $workflow->stage($toCode); + $current = $this->currentAdvance($visit)?->stage; + if ($target === null || $current === null) { + return null; + } + + $isConfiguredNext = $current->default_next === $target->code; + $mayBranch = $current->creates_child || $current->can_return || $current->can_skip; + + return $isConfiguredNext || $mayBranch ? $target : null; } $current = $this->currentAdvance($visit)?->stage; diff --git a/app/Services/Care/Workflow/WorkflowQueueGate.php b/app/Services/Care/Workflow/WorkflowQueueGate.php new file mode 100644 index 0000000..e1ee2e5 --- /dev/null +++ b/app/Services/Care/Workflow/WorkflowQueueGate.php @@ -0,0 +1,67 @@ +features->enabled($organization, CareFeatures::WORKFLOW_ENGINE); + } + + public function canRelease( + Organization $organization, + ?Visit $visit, + string $queueContext, + ): bool { + if (! $this->enabled($organization) || $visit === null) { + return true; + } + + $advance = $this->engine->currentAdvance($visit) + ? $this->engine->refreshGate($visit) + : $this->engine->start($visit); + $stage = $advance?->stage; + + // An enabled org without a materialized workflow keeps legacy behavior. + if ($stage === null) { + return true; + } + + if (! $this->contextsMatch($stage->queue_context, $stage->type, $queueContext)) { + return false; + } + + if (! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) { + return true; + } + + return $this->engine->isQueueReleasable($visit); + } + + protected function contextsMatch(?string $stageContext, string $stageType, string $queueContext): bool + { + if ($stageContext === $queueContext) { + return true; + } + + // Specialty appointments still fulfill a consultation workflow stage. + return $stageType === 'consultation' + && CareQueueContexts::isSpecialty($queueContext) + && $stageContext === CareQueueContexts::CONSULTATION; + } +} diff --git a/app/Services/Care/Workflow/WorkflowQueueReleaseService.php b/app/Services/Care/Workflow/WorkflowQueueReleaseService.php new file mode 100644 index 0000000..eb3c564 --- /dev/null +++ b/app/Services/Care/Workflow/WorkflowQueueReleaseService.php @@ -0,0 +1,103 @@ +engine->currentStage($visit); + if ($stage === null || $stage->queue_context === null) { + return; + } + + $context = $stage->queue_context; + + if ($context === CareQueueContexts::CONSULTATION || CareQueueContexts::isSpecialty($context)) { + $appointment = Appointment::query() + ->where('visit_id', $visit->id) + ->with(['patient', 'organization', 'practitioner', 'visit']) + ->latest('id') + ->first(); + + if ($appointment) { + if ($appointment->status === Appointment::STATUS_COMPLETED) { + $nextPosition = ((int) Appointment::owned($appointment->owner_ref) + ->where('branch_id', $appointment->branch_id) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->max('queue_position')) + 1; + $appointment->forceFill([ + 'status' => Appointment::STATUS_WAITING, + 'waiting_at' => now(), + 'started_at' => null, + 'completed_at' => null, + 'queue_position' => $nextPosition, + ])->save(); + } + + if (filled($appointment->queue_ticket_uuid) + && in_array($appointment->queue_ticket_status, ['completed', 'cancelled'], true)) { + $appointment->forceFill([ + 'queue_ticket_uuid' => null, + 'queue_ticket_number' => null, + 'queue_ticket_status' => null, + 'queue_service_point_uuid' => null, + 'queue_destination' => null, + 'queue_staff_display_name' => null, + 'queue_routing_status' => null, + ])->save(); + } + + $this->queue->issueForAppointment($organization, $appointment); + } + + return; + } + + if ($context === CareQueueContexts::LABORATORY || $context === CareQueueContexts::IMAGING) { + InvestigationRequest::query() + ->where('visit_id', $visit->id) + ->with(['patient', 'visit']) + ->get() + ->each(fn (InvestigationRequest $request) => $this->queue->issueForInvestigation($organization, $request)); + + return; + } + + if ($context === CareQueueContexts::PHARMACY) { + Prescription::query() + ->where('visit_id', $visit->id) + ->with(['patient', 'visit']) + ->get() + ->each(fn (Prescription $prescription) => $this->queue->issueForPrescription($organization, $prescription)); + + return; + } + + if ($context === CareQueueContexts::BILLING) { + Bill::query() + ->where('visit_id', $visit->id) + ->whereNotIn('status', [Bill::STATUS_PAID, Bill::STATUS_VOID]) + ->get() + ->each(fn (Bill $bill) => $this->queue->issueForBill($organization, $bill)); + } + } +} diff --git a/app/Services/Care/Workflow/WorkflowStageCompletionService.php b/app/Services/Care/Workflow/WorkflowStageCompletionService.php new file mode 100644 index 0000000..c7fffd2 --- /dev/null +++ b/app/Services/Care/Workflow/WorkflowStageCompletionService.php @@ -0,0 +1,120 @@ +gate->enabled($organization)) { + return null; + } + + $current = $this->engine->refreshGate($visit); + $stage = $current?->stage; + if ($stage === null || ! $this->contextMatches($stage->queue_context, $stage->type, $context)) { + return null; + } + + if ($this->hasOutstandingWork($visit, $context)) { + return $current; + } + + if (! $this->engine->canAdvance($visit)) { + return $current; + } + + $next = $this->engine->advance($visit); + if ($next === null) { + return null; + } + + AuditLogger::record( + $ownerRef, + 'visit.workflow_advanced', + $visit->organization_id, + $actorRef, + VisitStageAdvance::class, + $next->id, + ['from_context' => $context, 'stage_code' => $next->stage_code, 'status' => $next->status], + ); + + if ($next->stage?->type === 'exit') { + $this->visits->complete($visit, $ownerRef, $actorRef); + } else { + $this->queueRelease->releaseCurrent($organization, $visit); + } + + return $next; + } + + protected function hasOutstandingWork(Visit $visit, string $context): bool + { + if (in_array($context, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true)) { + return InvestigationRequest::query() + ->where('visit_id', $visit->id) + ->whereNotIn('status', [ + InvestigationRequest::STATUS_COMPLETED, + InvestigationRequest::STATUS_DELIVERED, + InvestigationRequest::STATUS_CANCELLED, + ]) + ->with('investigationType') + ->get() + ->contains(fn (InvestigationRequest $request) => $this->queue->contextForInvestigation($request) === $context); + } + + if ($context === CareQueueContexts::PHARMACY) { + return Prescription::query() + ->where('visit_id', $visit->id) + ->whereNotIn('status', [ + Prescription::STATUS_DISPENSED, + Prescription::STATUS_CANCELLED, + ]) + ->exists(); + } + + return false; + } + + protected function contextMatches(?string $stageContext, string $stageType, string $context): bool + { + if ($stageContext === $context) { + return true; + } + + if ($stageType === 'payment' && $context === CareQueueContexts::BILLING) { + return true; + } + + return $stageType === 'consultation' + && CareQueueContexts::isSpecialty($context) + && $stageContext === CareQueueContexts::CONSULTATION; + } +} diff --git a/config/care.php b/config/care.php index 108834f..d58d54f 100644 --- a/config/care.php +++ b/config/care.php @@ -119,6 +119,7 @@ return [ 'appointment.meet_scheduled' => 'Video visit scheduled', 'appointment.meet_cancelled' => 'Video visit cancelled', 'visit.checked_in' => 'Visit opened', + 'visit.workflow_advanced' => 'Visit workflow advanced', 'visit.completed' => 'Visit completed', 'consultation.started' => 'Consultation started', 'consultation.updated' => 'Consultation updated', @@ -142,6 +143,7 @@ return [ 'bill.line_item_added' => 'Bill line item added', 'bill.voided' => 'Bill voided', 'payment.recorded' => 'Payment recorded', + 'financial_obligation.cleared' => 'Financial obligation cleared', 'payment.checkout_started' => 'Online payment checkout started', 'integrations.gateway_connected' => 'Payment gateway connected', 'integrations.gateway_disconnected' => 'Payment gateway disconnected', @@ -285,6 +287,7 @@ return [ ], 'billing' => [ + 'patient_card_fee_minor' => (int) env('CARE_PATIENT_CARD_FEE_MINOR', 2000), 'consultation_fee_minor' => (int) env('CARE_CONSULTATION_FEE_MINOR', 5000), 'currency' => env('CARE_CURRENCY', 'GHS'), ], diff --git a/config/care_workflows.php b/config/care_workflows.php index 1b8af91..15c1b97 100644 --- a/config/care_workflows.php +++ b/config/care_workflows.php @@ -100,6 +100,24 @@ return [ 'digital' => 'Digital (MoMo / card / Paystack)', ], + 'clearance_methods' => [ + 'payment' => 'Payment received', + 'bank_receipt' => 'External bank receipt verified', + 'insurance' => 'Insurance authorization', + 'credit' => 'Approved credit', + 'waiver' => 'Waiver', + 'override' => 'Authorized override', + ], + + 'obligation_statuses' => [ + 'pending' => 'Pending', + 'authorized' => 'Insurance authorized', + 'paid' => 'Paid', + 'waived' => 'Waived', + 'deferred' => 'Deferred / credit', + 'void' => 'Void', + ], + 'stage_types' => [ 'registration' => 'Registration', 'payment' => 'Payment', diff --git a/database/migrations/2026_07_17_210000_enforce_unique_care_workflow_obligations.php b/database/migrations/2026_07_17_210000_enforce_unique_care_workflow_obligations.php new file mode 100644 index 0000000..1e07b5a --- /dev/null +++ b/database/migrations/2026_07_17_210000_enforce_unique_care_workflow_obligations.php @@ -0,0 +1,25 @@ +unique( + ['visit_id', 'workflow_stage_id'], + 'care_obligations_visit_stage_unique', + ); + }); + } + + public function down(): void + { + Schema::table('care_financial_obligations', function (Blueprint $table) { + $table->dropUnique('care_obligations_visit_stage_unique'); + }); + } +}; diff --git a/resources/views/care/appointments/show.blade.php b/resources/views/care/appointments/show.blade.php index 3435b48..796a3b3 100644 --- a/resources/views/care/appointments/show.blade.php +++ b/resources/views/care/appointments/show.blade.php @@ -19,7 +19,7 @@ No show @endif - @if ($canConsult && in_array($appointment->status, [\App\Models\Appointment::STATUS_WAITING, \App\Models\Appointment::STATUS_CHECKED_IN], true)) + @if (($canStartConsultation ?? $canConsult) && in_array($appointment->status, [\App\Models\Appointment::STATUS_WAITING, \App\Models\Appointment::STATUS_CHECKED_IN], true))
@csrf @@ -62,6 +62,50 @@ + @if ($workflowEnabled ?? false) +
+
+
+

Patient journey

+

{{ $currentAdvance?->stage?->name ?? 'Workflow not started' }}

+ @if ($currentAdvance) +

+ {{ $currentAdvance->isBlocked() ? 'Blocked — financial clearance required' : 'Ready for service' }} +

+ @endif + @if ($currentObligation) +

+ {{ $currentObligation->label }} · + {{ config('care.billing.currency') }} {{ number_format($currentObligation->amount_minor / 100, 2) }} · + {{ ucfirst($currentObligation->status) }} +

+ @endif +
+
+ @if ($currentAdvance?->isBlocked() && ($canViewBills ?? false)) + Open financial gates + @endif + @if ($canAdvanceWorkflow ?? false) + + @csrf + + + @endif +
+
+ + @if (($workflowHistory ?? collect())->isNotEmpty()) +
    + @foreach ($workflowHistory as $step) +
  1. + {{ $step->stage?->name ?? $step->stage_code }} · {{ str_replace('_', ' ', $step->status) }} +
  2. + @endforeach +
+ @endif +
+ @endif +

Details

diff --git a/resources/views/care/bills/index.blade.php b/resources/views/care/bills/index.blade.php index bf401df..0633776 100644 --- a/resources/views/care/bills/index.blade.php +++ b/resources/views/care/bills/index.blade.php @@ -5,6 +5,9 @@

Bills & invoices

Encounter billing and outstanding balances

+ @if (app(\App\Services\Care\CareFeatures::class)->enabled($organization, \App\Services\Care\CareFeatures::WORKFLOW_ENGINE)) + Financial gates + @endif
+ + @foreach ($statuses as $value => $label) + + @endforeach + + +
+ +
+ @forelse ($obligations as $obligation) +
+
+
+
+

{{ $obligation->patient?->fullName() ?? 'Patient' }}

+ + {{ $statuses[$obligation->status] ?? ucfirst($obligation->status) }} + +
+

+ {{ $obligation->stage?->name ?? $obligation->stage_code }} + · {{ $obligation->label ?? 'Workflow charge' }} + @if ($obligation->branch) · {{ $obligation->branch->name }} @endif +

+

+ Created {{ $obligation->created_at->format('d M Y H:i') }} + @if ($obligation->bill) · Invoice {{ $obligation->bill->invoice_number }} @endif +

+
+

{{ $money($obligation->amount_minor) }}

+
+ + @if ($canClear && $obligation->status === \App\Models\FinancialObligation::STATUS_PENDING) + @php + $allowedPaymentModes = ! empty($obligation->stage?->payment_modes) + ? array_intersect_key($paymentModes, array_flip($obligation->stage->payment_modes)) + : $paymentModes; + $availableClearanceMethods = collect($clearanceMethods)->filter(function ($label, $value) use ($obligation, $allowedPaymentModes) { + return match ($value) { + 'payment' => collect(array_keys($allowedPaymentModes))->intersect(['internal_cashier', 'digital'])->isNotEmpty(), + 'bank_receipt' => array_key_exists('external_bank', $allowedPaymentModes), + 'insurance' => (bool) $obligation->stage?->insurance_eligible, + 'credit' => (bool) $obligation->stage?->credit_allowed, + 'waiver', 'override' => (bool) $obligation->stage?->allow_override, + default => false, + }; + }); + @endphp +
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ @endif +
+ @empty +
+ No financial obligations match this filter. +
+ @endforelse +
+ +
{{ $obligations->links() }}
+ diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index 86e6346..c18dde4 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -134,6 +134,36 @@ + +
+ + + +
+
+

Set a Ladill mailbox (From address) and optional SMS sender ID under Account → Messaging. diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 9b9375f..389341f 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -65,7 +65,7 @@ } if ($permissions->can($member, 'bills.view')) { - $nav[] = ['name' => 'Billing', 'route' => route('care.bills.index'), 'active' => request()->routeIs('care.bills.*'), + $nav[] = ['name' => 'Billing', 'route' => route('care.bills.index'), 'active' => request()->routeIs('care.bills.*', 'care.obligations.*'), 'icon' => '']; } diff --git a/routes/web.php b/routes/web.php index fb1e7e7..b625127 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,11 +2,10 @@ use App\Http\Controllers\Auth\SsoLoginController; use App\Http\Controllers\Care\AiController; -use App\Http\Controllers\NotificationController; -use App\Http\Controllers\Care\AuditLogController; use App\Http\Controllers\Care\AppointmentController; use App\Http\Controllers\Care\AppointmentMeetController; use App\Http\Controllers\Care\AssessmentController; +use App\Http\Controllers\Care\AuditLogController; use App\Http\Controllers\Care\BillController; use App\Http\Controllers\Care\BranchController; use App\Http\Controllers\Care\ConsultationController; @@ -14,25 +13,28 @@ use App\Http\Controllers\Care\DashboardController; use App\Http\Controllers\Care\DepartmentController; use App\Http\Controllers\Care\DeviceController; use App\Http\Controllers\Care\DrugController; +use App\Http\Controllers\Care\FinancialObligationController; +use App\Http\Controllers\Care\IntegrationsController; use App\Http\Controllers\Care\InvestigationController; use App\Http\Controllers\Care\InvestigationTypeController; +use App\Http\Controllers\Care\IssueReportController; use App\Http\Controllers\Care\MemberController; use App\Http\Controllers\Care\OnboardingController; use App\Http\Controllers\Care\OutcomeController; +use App\Http\Controllers\Care\PathwayController; use App\Http\Controllers\Care\PatientController; use App\Http\Controllers\Care\PatientMessageController; -use App\Http\Controllers\Care\PathwayController; use App\Http\Controllers\Care\PractitionerController; use App\Http\Controllers\Care\PrescriptionController; -use App\Http\Controllers\Care\QueueController; use App\Http\Controllers\Care\ProController; +use App\Http\Controllers\Care\QueueController; use App\Http\Controllers\Care\ReportController; use App\Http\Controllers\Care\ServiceQueueController; +use App\Http\Controllers\Care\SettingsController; use App\Http\Controllers\Care\SettingsModulesController; use App\Http\Controllers\Care\SpecialtyModuleController; -use App\Http\Controllers\Care\IntegrationsController; -use App\Http\Controllers\Care\IssueReportController; -use App\Http\Controllers\Care\SettingsController; +use App\Http\Controllers\Care\VisitWorkflowController; +use App\Http\Controllers\NotificationController; use Illuminate\Support\Facades\Route; Route::get('/', fn () => auth()->check() @@ -92,6 +94,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/queue/{appointment}/start', [QueueController::class, 'start'])->name('care.queue.start'); Route::post('/queue/{appointment}/recall', [QueueController::class, 'recall'])->name('care.queue.recall'); Route::post('/queue/{appointment}/complete-ticket', [QueueController::class, 'completeTicket'])->name('care.queue.complete-ticket'); + Route::post('/visits/{visit}/workflow/advance', [VisitWorkflowController::class, 'advance'])->name('care.visits.workflow.advance'); Route::middleware('care.paid')->group(function () { Route::get('/service-queues', [ServiceQueueController::class, 'index'])->name('care.service-queues.index'); @@ -144,6 +147,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/bills/{bill}/payments/gateway', [BillController::class, 'startGatewayPayment'])->name('care.bills.payments.gateway'); Route::post('/bills/{bill}/void', [BillController::class, 'void'])->name('care.bills.void'); + Route::get('/financial-obligations', [FinancialObligationController::class, 'index'])->name('care.obligations.index'); + Route::post('/financial-obligations/{financialObligation}/clear', [FinancialObligationController::class, 'clear'])->name('care.obligations.clear'); + Route::get('/pharmacy/drugs', [DrugController::class, 'index'])->name('care.pharmacy.drugs.index'); Route::get('/pharmacy/drugs/create', [DrugController::class, 'create'])->name('care.pharmacy.drugs.create'); Route::post('/pharmacy/drugs', [DrugController::class, 'store'])->name('care.pharmacy.drugs.store'); diff --git a/tests/Feature/CareFinancialWorkflowRuntimeTest.php b/tests/Feature/CareFinancialWorkflowRuntimeTest.php new file mode 100644 index 0000000..115e866 --- /dev/null +++ b/tests/Feature/CareFinancialWorkflowRuntimeTest.php @@ -0,0 +1,514 @@ +withoutMiddleware(EnsurePlatformSession::class); + + config([ + 'care.queue.api_url' => 'https://queue.test/api/v1', + 'care.queue.api_key' => 'workflow-test-key', + ]); + + $this->owner = User::create([ + 'public_id' => 'financial-workflow-owner', + 'name' => 'Workflow Admin', + 'email' => 'financial-workflow@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Financial Gate Hospital', + 'slug' => 'financial-gate-hospital', + 'settings' => [ + 'onboarded' => true, + 'facility_category' => 'hospital', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'queue_integration_enabled' => true, + 'rollout' => [ + CareFeatures::WORKFLOW_ENGINE => true, + CareFeatures::FINANCIAL_GATES => true, + ], + ], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'WF-0001', + 'first_name' => 'Akosua', + 'last_name' => 'Boateng', + ]); + + app(WorkflowTemplateInstaller::class) + ->install($this->organization, $this->branch, 'public_hospital_gh'); + } + + protected function checkIn(): Visit + { + return app(VisitService::class)->checkIn( + $this->organization, + $this->owner->public_id, + $this->patient, + $this->branch->id, + $this->owner->public_id, + ); + } + + protected function reachConsultation(Visit $visit): FinancialObligation + { + $engine = app(WorkflowEngine::class); + $gate = app(FinancialGateService::class); + + $registration = FinancialObligation::query() + ->where('visit_id', $visit->id) + ->where('stage_code', 'registration') + ->firstOrFail(); + $gate->clear($registration, 'override', $this->owner->public_id); + $engine->refreshGate($visit); + $engine->advance($visit); // triage + $engine->advance($visit); // consultation + + return FinancialObligation::query() + ->where('visit_id', $visit->id) + ->where('stage_code', 'consultation') + ->firstOrFail(); + } + + public function test_check_in_starts_workflow_and_blocks_initial_queue_release(): void + { + $visit = $this->checkIn(); + + $advance = app(WorkflowEngine::class)->currentAdvance($visit); + $this->assertSame('registration', $advance->stage_code); + $this->assertSame(VisitStageAdvance::STATUS_BLOCKED, $advance->status); + $this->assertDatabaseHas('care_financial_obligations', [ + 'visit_id' => $visit->id, + 'stage_code' => 'registration', + 'status' => FinancialObligation::STATUS_PENDING, + ]); + $this->assertFalse(app(WorkflowQueueGate::class)->canRelease( + $this->organization, + $visit, + CareQueueContexts::CONSULTATION, + )); + } + + public function test_queue_bridge_cannot_backfill_a_gated_appointment(): void + { + Http::fake(); + $visit = $this->checkIn(); + $appointment = Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now(), + 'checked_in_at' => now(), + 'waiting_at' => now(), + 'queue_position' => 1, + ]); + + app(CareQueueBridge::class)->issueForAppointment($this->organization, $appointment); + + Http::assertNothingSent(); + $this->assertNull($appointment->fresh()->queue_ticket_uuid); + } + + public function test_appointment_page_shows_current_workflow_stage(): void + { + $visit = $this->checkIn(); + $appointment = Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now(), + 'checked_in_at' => now(), + 'waiting_at' => now(), + 'queue_position' => 1, + ]); + + $this->actingAs($this->owner) + ->get(route('care.appointments.show', $appointment)) + ->assertOk() + ->assertSee('Patient journey') + ->assertSee('Registration') + ->assertSee('financial clearance required') + ->assertDontSee('Start consultation'); + } + + public function test_blocked_visit_cannot_advance_until_current_gate_is_cleared(): void + { + $visit = $this->checkIn(); + + $this->actingAs($this->owner) + ->from(route('care.appointments.index')) + ->post(route('care.visits.workflow.advance', $visit)) + ->assertRedirect(route('care.appointments.index')) + ->assertSessionHas('error'); + + $this->assertSame('registration', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code); + } + + public function test_cashier_clearance_creates_bill_payment_and_releases_stage(): void + { + $visit = $this->checkIn(); + $consultation = $this->reachConsultation($visit); + + $this->actingAs($this->owner) + ->get(route('care.obligations.index')) + ->assertOk() + ->assertSee('Financial gates') + ->assertSee('Akosua Boateng') + ->assertSee('Consultation fee'); + + $this->actingAs($this->owner) + ->post(route('care.obligations.clear', $consultation), [ + 'method' => 'payment', + 'payment_mode' => 'internal_cashier', + 'payment_method' => 'cash', + 'reference' => 'CASH-1001', + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + $consultation->refresh(); + $this->assertSame(FinancialObligation::STATUS_PAID, $consultation->status); + $this->assertNotNull($consultation->bill_id); + $this->assertDatabaseHas('care_bill_line_items', [ + 'bill_id' => $consultation->bill_id, + 'source_type' => FinancialObligation::class, + 'source_id' => $consultation->id, + 'total_minor' => (int) config('care.billing.consultation_fee_minor'), + ]); + $this->assertDatabaseHas('care_payments', [ + 'bill_id' => $consultation->bill_id, + 'method' => Payment::METHOD_CASH, + 'status' => Payment::STATUS_PAID, + 'reference' => 'CASH-1001', + ]); + $this->assertSame(Bill::STATUS_PAID, $consultation->bill->fresh()->status); + $this->assertSame(VisitStageAdvance::STATUS_ACTIVE, app(WorkflowEngine::class)->currentAdvance($visit)->status); + $this->assertTrue(app(WorkflowQueueGate::class)->canRelease( + $this->organization, + $visit, + CareQueueContexts::CONSULTATION, + )); + + // A repeated submission must not create a second financial payment. + $this->actingAs($this->owner) + ->post(route('care.obligations.clear', $consultation), [ + 'method' => 'payment', + 'payment_mode' => 'internal_cashier', + 'payment_method' => 'cash', + ]) + ->assertSessionHas('info'); + $this->assertSame(1, Payment::query()->where('bill_id', $consultation->bill_id)->count()); + } + + public function test_clinical_stage_cannot_be_manually_skipped(): void + { + $visit = $this->checkIn(); + $consultation = $this->reachConsultation($visit); + app(FinancialGateService::class)->clear($consultation, 'insurance', $this->owner->public_id); + app(WorkflowEngine::class)->refreshGate($visit); + + $this->actingAs($this->owner) + ->post(route('care.visits.workflow.advance', $visit)) + ->assertSessionHas('error'); + + $this->assertSame('consultation', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code); + } + + public function test_non_insurance_stage_rejects_insurance_clearance(): void + { + app(WorkflowTemplateInstaller::class) + ->install($this->organization, $this->branch, 'herbal_hospital'); + $visit = $this->checkIn(); + $obligation = FinancialObligation::query()->where('visit_id', $visit->id)->firstOrFail(); + + $this->actingAs($this->owner) + ->post(route('care.obligations.clear', $obligation), [ + 'method' => 'insurance', + ]) + ->assertSessionHasErrors('method'); + + $this->assertSame(FinancialObligation::STATUS_PENDING, $obligation->fresh()->status); + } + + public function test_lab_stage_obligation_uses_requested_test_prices(): void + { + $visit = $this->checkIn(); + $consultation = $this->reachConsultation($visit); + app(FinancialGateService::class)->clear($consultation, 'insurance', $this->owner->public_id); + app(WorkflowEngine::class)->refreshGate($visit); + + $type = InvestigationType::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Full blood count', + 'code' => 'FBC', + 'category' => 'blood', + 'price_minor' => 3500, + 'is_active' => true, + ]); + InvestigationRequest::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'investigation_type_id' => $type->id, + 'status' => InvestigationRequest::STATUS_PENDING, + 'priority' => 'routine', + ]); + + $advance = app(WorkflowEngine::class)->advance($visit, 'laboratory'); + $this->assertSame(VisitStageAdvance::STATUS_BLOCKED, $advance->status); + $this->assertDatabaseHas('care_financial_obligations', [ + 'visit_id' => $visit->id, + 'stage_code' => 'laboratory', + 'amount_minor' => 3500, + 'status' => FinancialObligation::STATUS_PENDING, + ]); + } + + public function test_imaging_requests_use_the_imaging_queue_context(): void + { + $visit = $this->checkIn(); + $type = InvestigationType::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Chest X-ray', + 'code' => 'CXR', + 'category' => 'xray', + 'price_minor' => 8000, + 'is_active' => true, + ]); + $request = InvestigationRequest::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'investigation_type_id' => $type->id, + 'status' => InvestigationRequest::STATUS_PENDING, + 'priority' => 'routine', + ]); + + $this->assertSame( + CareQueueContexts::IMAGING, + app(CareQueueBridge::class)->contextForInvestigation($request), + ); + } + + public function test_settings_exposes_workflow_controls_and_herbal_categories(): void + { + $this->actingAs($this->owner) + ->get(route('care.settings')) + ->assertOk() + ->assertSee('Patient journey workflow') + ->assertSee('Enforce payment and authorization gates') + ->assertSee('Herbal hospital') + ->assertSee('Herbal clinic'); + } + + public function test_completing_consultation_appointment_advances_instead_of_closing_visit(): void + { + Http::fake(); + $visit = $this->checkIn(); + $consultationObligation = $this->reachConsultation($visit); + app(FinancialGateService::class)->clear($consultationObligation, 'payment', $this->owner->public_id); + app(WorkflowEngine::class)->refreshGate($visit); + + $appointment = Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_IN_CONSULTATION, + 'scheduled_at' => now(), + 'checked_in_at' => now(), + 'waiting_at' => now(), + 'started_at' => now(), + 'queue_position' => 1, + ]); + + app(AppointmentService::class)->complete( + $appointment, + $this->owner->public_id, + $this->owner->public_id, + ); + + $this->assertSame(Appointment::STATUS_COMPLETED, $appointment->fresh()->status); + $this->assertNotSame(Visit::STATUS_COMPLETED, $visit->fresh()->status); + $this->assertSame('pharmacy', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code); + } + + public function test_completed_lab_stage_returns_patient_to_a_new_consultation_queue(): void + { + Http::fake(); + $visit = $this->checkIn(); + $consultation = $this->reachConsultation($visit); + app(FinancialGateService::class)->clear($consultation, 'insurance', $this->owner->public_id); + app(WorkflowEngine::class)->refreshGate($visit); + + $appointment = Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_COMPLETED, + 'scheduled_at' => now(), + 'checked_in_at' => now(), + 'completed_at' => now(), + 'queue_position' => 1, + ]); + $type = InvestigationType::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Malaria test', + 'code' => 'MAL', + 'category' => 'blood', + 'price_minor' => 3000, + 'is_active' => true, + ]); + InvestigationRequest::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'investigation_type_id' => $type->id, + 'status' => InvestigationRequest::STATUS_COMPLETED, + 'priority' => 'routine', + ]); + + $labAdvance = app(WorkflowEngine::class)->advance($visit, 'laboratory'); + $labObligation = FinancialObligation::query() + ->where('workflow_stage_id', $labAdvance->workflow_stage_id) + ->where('visit_id', $visit->id) + ->firstOrFail(); + app(FinancialGateService::class)->clear($labObligation, 'insurance', $this->owner->public_id); + app(WorkflowEngine::class)->refreshGate($visit); + + app(WorkflowStageCompletionService::class)->complete( + $this->organization, + $visit, + CareQueueContexts::LABORATORY, + $this->owner->public_id, + $this->owner->public_id, + ); + + $this->assertSame('consultation', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code); + $this->assertSame(Appointment::STATUS_WAITING, $appointment->fresh()->status); + } + + public function test_disabled_workflow_keeps_legacy_check_in_behavior(): void + { + $settings = $this->organization->settings; + data_set($settings, 'rollout.'.CareFeatures::WORKFLOW_ENGINE, false); + data_set($settings, 'rollout.'.CareFeatures::FINANCIAL_GATES, false); + $this->organization->update(['settings' => $settings]); + + $visit = $this->checkIn(); + + $this->assertDatabaseMissing('care_visit_stage_advances', ['visit_id' => $visit->id]); + $this->assertTrue(app(WorkflowQueueGate::class)->canRelease( + $this->organization->fresh(), + $visit, + CareQueueContexts::CONSULTATION, + )); + } + + public function test_enabling_financial_gates_rechecks_an_active_visit_stage(): void + { + $settings = $this->organization->settings; + data_set($settings, 'rollout.'.CareFeatures::FINANCIAL_GATES, false); + $this->organization->update(['settings' => $settings]); + + $visit = $this->checkIn(); + $this->assertSame(VisitStageAdvance::STATUS_ACTIVE, app(WorkflowEngine::class)->currentAdvance($visit)->status); + $this->assertDatabaseMissing('care_financial_obligations', ['visit_id' => $visit->id]); + + data_set($settings, 'rollout.'.CareFeatures::FINANCIAL_GATES, true); + $this->organization->update(['settings' => $settings]); + + $advance = app(WorkflowEngine::class)->refreshGate($visit); + $this->assertSame(VisitStageAdvance::STATUS_BLOCKED, $advance->status); + $this->assertDatabaseHas('care_financial_obligations', [ + 'visit_id' => $visit->id, + 'stage_code' => 'registration', + 'status' => FinancialObligation::STATUS_PENDING, + ]); + } +} diff --git a/tests/Feature/CareWorkflowEngineTest.php b/tests/Feature/CareWorkflowEngineTest.php index 1ba68db..0ed3a89 100644 --- a/tests/Feature/CareWorkflowEngineTest.php +++ b/tests/Feature/CareWorkflowEngineTest.php @@ -11,6 +11,7 @@ use App\Models\Patient; use App\Models\User; use App\Models\Visit; use App\Models\VisitStageAdvance; +use App\Services\Care\CareFeatures; use App\Services\Care\OrganizationResolver; use App\Services\Care\Workflow\FinancialGateService; use App\Services\Care\Workflow\WorkflowEngine; @@ -44,7 +45,14 @@ class CareWorkflowEngineTest extends TestCase 'owner_ref' => $this->owner->public_id, 'name' => 'Gate Hospital', 'slug' => 'gate-hospital', - 'settings' => ['onboarded' => true, 'facility_category' => 'hospital'], + 'settings' => [ + 'onboarded' => true, + 'facility_category' => 'hospital', + 'rollout' => [ + CareFeatures::WORKFLOW_ENGINE => true, + CareFeatures::FINANCIAL_GATES => true, + ], + ], ]); Member::create([