diff --git a/app/Http/Controllers/Care/FinancialObligationController.php b/app/Http/Controllers/Care/FinancialObligationController.php index a86fd8d..de9d67c 100644 --- a/app/Http/Controllers/Care/FinancialObligationController.php +++ b/app/Http/Controllers/Care/FinancialObligationController.php @@ -9,8 +9,10 @@ use App\Models\Payment; use App\Models\WorkflowStage; use App\Services\Care\AuditLogger; use App\Services\Care\BillService; +use App\Services\Care\BranchContext; use App\Services\Care\CareFeatures; use App\Services\Care\CarePermissions; +use App\Services\Care\CareQueueBridge; use App\Services\Care\CareQueueContexts; use App\Services\Care\OrganizationResolver; use App\Services\Care\Workflow\FinancialGateService; @@ -35,6 +37,8 @@ class FinancialObligationController extends Controller protected WorkflowQueueReleaseService $queueRelease, protected WorkflowStageCompletionService $workflowCompletion, protected BillService $bills, + protected CareQueueBridge $queueBridge, + protected BranchContext $branchContext, ) {} public function index(Request $request): View @@ -43,7 +47,10 @@ class FinancialObligationController extends Controller $organization = $this->organization($request); abort_unless($this->features->enabled($organization, CareFeatures::WORKFLOW_ENGINE), 404); - $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); + $member = $this->member($request); + $permissions = app(CarePermissions::class); + $branchScope = app(OrganizationResolver::class)->branchScope($member); + $branchId = $this->branchContext->resolve($request, $organization, $member); $status = (string) $request->query('status', FinancialObligation::STATUS_PENDING); $obligations = FinancialObligation::owned($this->ownerRef($request)) @@ -61,8 +68,13 @@ class FinancialObligationController extends Controller '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'), + 'canClear' => $permissions->can($member, 'payments.manage'), + 'branchId' => $branchId, + 'queueIntegration' => $branchId && $permissions->handlesFloorCare($member) + ? $this->queueBridge->listMeta($organization, CareQueueContexts::BILLING, $branchId) + : ['enabled' => false], + 'canManageQueue' => $permissions->can($member, 'service_queues.console') + && $permissions->handlesFloorCare($member), ]); } @@ -139,19 +151,21 @@ class FinancialObligationController extends Controller return $locked->fresh(); }); + $organization = $this->organization($request); + $visit = $settled->visit; if ($visit) { $advance = $this->workflows->refreshGate($visit); if ($advance?->stage?->type === 'payment') { $this->workflowCompletion->complete( - $this->organization($request), + $organization, $visit, CareQueueContexts::BILLING, $this->ownerRef($request), $this->ownerRef($request), ); } else { - $this->queueRelease->releaseCurrent($this->organization($request), $visit); + $this->queueRelease->releaseCurrent($organization, $visit); } } diff --git a/app/Services/Care/Workflow/FinancialGateService.php b/app/Services/Care/Workflow/FinancialGateService.php index 5abf67b..89e91d2 100644 --- a/app/Services/Care/Workflow/FinancialGateService.php +++ b/app/Services/Care/Workflow/FinancialGateService.php @@ -18,6 +18,9 @@ use App\Services\Care\CarePricingService; * A "before" gate blocks entry to the stage's service queue until the stage's * obligation is cleared (paid / authorized / waived / deferred). An "after" * gate never blocks entry but is created so the balance is collected at exit. + * + * When Care Queue Engine is enabled, WorkflowEngine / WorkflowQueueReleaseService + * place awaiting_payment visits on the billing (cashier) queue via an open bill. */ class FinancialGateService { @@ -143,7 +146,17 @@ class FinancialGateService 'cleared_at' => now(), ])->save(); - return $obligation; + $organization = Organization::query()->find($obligation->organization_id); + if ($organization) { + // Lazy resolve avoids a constructor cycle with WorkflowQueueReleaseService. + app(WorkflowQueueReleaseService::class)->completeCashier( + $organization, + $obligation->fresh(['bill']) ?? $obligation, + $method, + ); + } + + return $obligation->fresh() ?? $obligation; } public function void(FinancialObligation $obligation): FinancialObligation diff --git a/app/Services/Care/Workflow/WorkflowEngine.php b/app/Services/Care/Workflow/WorkflowEngine.php index 1c5c946..daca071 100644 --- a/app/Services/Care/Workflow/WorkflowEngine.php +++ b/app/Services/Care/Workflow/WorkflowEngine.php @@ -174,6 +174,7 @@ class WorkflowEngine $this->gate->obligationFor($visit, $advance->stage); $satisfied = $this->gate->isSatisfied($visit, $advance->stage); + $becameBlocked = false; if ($advance->isBlocked() && $satisfied) { $advance->forceFill([ @@ -187,9 +188,17 @@ class WorkflowEngine 'blocked_reason' => $this->gate->blockedReason($visit, $advance->stage), 'cleared_at' => null, ])->save(); + $becameBlocked = true; } - return $advance->fresh(); + $fresh = $advance->fresh(); + // Only enqueue when the gate newly blocks — not on every refresh — so + // CareQueueBridge::issueForBill → canRelease → refreshGate cannot recurse. + if ($becameBlocked && $fresh?->blocked_reason === 'awaiting_payment') { + $this->enqueueCashierBooth($visit); + } + + return $fresh; } protected function enterStage(Visit $visit, FacilityWorkflow $workflow, WorkflowStage $stage): VisitStageAdvance @@ -198,7 +207,7 @@ class WorkflowEngine $satisfied = $this->gate->isSatisfied($visit, $stage); - return VisitStageAdvance::create([ + $advance = VisitStageAdvance::create([ 'owner_ref' => $visit->owner_ref, 'visit_id' => $visit->id, 'workflow_id' => $workflow->id, @@ -209,6 +218,26 @@ class WorkflowEngine 'entered_at' => now(), 'cleared_at' => $satisfied ? now() : null, ]); + + if ($advance->isBlocked() && $advance->blocked_reason === 'awaiting_payment') { + $this->enqueueCashierBooth($visit); + } + + return $advance; + } + + /** + * Resolve lazily to avoid a constructor cycle with WorkflowQueueReleaseService. + */ + protected function enqueueCashierBooth(Visit $visit): void + { + $organization = $visit->organization + ?? \App\Models\Organization::query()->find($visit->organization_id); + if ($organization === null) { + return; + } + + app(WorkflowQueueReleaseService::class)->enqueueCashier($organization, $visit); } protected function resolveTarget(Visit $visit, ?string $toCode, ?FacilityWorkflow $workflow = null): ?WorkflowStage diff --git a/app/Services/Care/Workflow/WorkflowQueueGate.php b/app/Services/Care/Workflow/WorkflowQueueGate.php index e1ee2e5..8b128e7 100644 --- a/app/Services/Care/Workflow/WorkflowQueueGate.php +++ b/app/Services/Care/Workflow/WorkflowQueueGate.php @@ -4,6 +4,7 @@ namespace App\Services\Care\Workflow; use App\Models\Organization; use App\Models\Visit; +use App\Models\WorkflowStage; use App\Services\Care\CareFeatures; use App\Services\Care\CareQueueContexts; @@ -42,6 +43,13 @@ class WorkflowQueueGate return true; } + // Pay-before-service holds belong on the cashier / billing line, not the + // clinical service queue for the current stage. + if ($queueContext === CareQueueContexts::BILLING + && $this->isAwaitingFinancialGate($organization, $visit, $stage)) { + return true; + } + if (! $this->contextsMatch($stage->queue_context, $stage->type, $queueContext)) { return false; } @@ -53,12 +61,36 @@ class WorkflowQueueGate return $this->engine->isQueueReleasable($visit); } + /** + * Whether the visit is blocked on a before-service financial gate. + */ + public function isAwaitingFinancialGate( + Organization $organization, + Visit $visit, + ?WorkflowStage $stage = null, + ): bool { + if (! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) { + return false; + } + + $stage ??= $this->engine->currentStage($visit); + if ($stage === null || ! $stage->gatesBeforeService()) { + return false; + } + + return ! $this->engine->isQueueReleasable($visit); + } + protected function contextsMatch(?string $stageContext, string $stageType, string $queueContext): bool { if ($stageContext === $queueContext) { return true; } + if ($stageType === 'payment' && $queueContext === CareQueueContexts::BILLING) { + return true; + } + // Specialty appointments still fulfill a consultation workflow stage. return $stageType === 'consultation' && CareQueueContexts::isSpecialty($queueContext) diff --git a/app/Services/Care/Workflow/WorkflowQueueReleaseService.php b/app/Services/Care/Workflow/WorkflowQueueReleaseService.php index eb3c564..201e0f4 100644 --- a/app/Services/Care/Workflow/WorkflowQueueReleaseService.php +++ b/app/Services/Care/Workflow/WorkflowQueueReleaseService.php @@ -4,16 +4,21 @@ namespace App\Services\Care\Workflow; use App\Models\Appointment; use App\Models\Bill; +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\BillService; use App\Services\Care\CareQueueBridge; use App\Services\Care\CareQueueContexts; +use Illuminate\Support\Facades\Log; /** * Releases the entity represented by the visit's current workflow stage to - * Ladill Queue after stage entry or financial clearance. + * Ladill Queue after stage entry or financial clearance. Also owns the cashier + * booth queue for pay-before-service financial gates. */ class WorkflowQueueReleaseService { @@ -24,8 +29,24 @@ class WorkflowQueueReleaseService public function releaseCurrent(Organization $organization, Visit $visit): void { - $stage = $this->engine->currentStage($visit); - if ($stage === null || $stage->queue_context === null) { + $advance = $this->engine->currentAdvance($visit); + $stage = $advance?->stage; + if ($stage === null) { + return; + } + + // Hold at the cashier booth while a before-service gate is unpaid. + if ($advance->isBlocked() && $advance->blocked_reason === 'awaiting_payment') { + $this->enqueueCashier($organization, $visit, $stage); + + return; + } + + if ($stage->queue_context === null) { + if ($stage->type === 'payment') { + $this->enqueueCashier($organization, $visit, $stage); + } + return; } @@ -93,11 +114,112 @@ class WorkflowQueueReleaseService } 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)); + $this->enqueueCashier($organization, $visit, $stage); + } + } + + /** + * Ensure a visit blocked on payment has an open bill and an active cashier + * booth ticket. No-ops when Care Queue Engine is disabled. + */ + public function enqueueCashier( + Organization $organization, + Visit $visit, + ?WorkflowStage $stage = null, + ): ?Bill { + if (! $this->queue->isEnabled($organization)) { + return null; + } + + $stage ??= $this->engine->currentStage($visit); + if ($stage === null) { + return null; + } + + $obligation = FinancialObligation::query() + ->where('visit_id', $visit->id) + ->where('workflow_stage_id', $stage->id) + ->whereNull('deleted_at') + ->first(); + + if ($obligation === null + || $obligation->isCleared() + || (int) $obligation->amount_minor <= 0) { + return null; + } + + try { + $bill = app(BillService::class)->billForObligation( + $obligation, + (string) $visit->owner_ref, + (string) $visit->owner_ref, + ); + + return $this->queue->issueForBill($organization, $bill->fresh(['patient', 'visit'])); + } catch (\Throwable $e) { + Log::warning('care.cashier_queue_enqueue_failed', [ + 'visit_id' => $visit->id, + 'stage_code' => $stage->code, + 'message' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * Complete (or retire) the cashier booth ticket after a financial gate clears. + * Non-payment clearances void unpaid obligation-only placeholder bills. + */ + public function completeCashier( + Organization $organization, + FinancialObligation $obligation, + string $clearanceMethod, + ): void { + if (! $this->queue->isEnabled($organization)) { + return; + } + + $obligation->loadMissing('bill'); + $bill = $obligation->bill; + if ($bill === null) { + return; + } + + if (filled($bill->queue_ticket_uuid) + && ! in_array($bill->queue_ticket_status, ['completed', 'cancelled'], true)) { + $this->queue->complete($organization, $bill); + $bill = $bill->fresh(); + } + + if (in_array($clearanceMethod, ['payment', 'bank_receipt'], true)) { + return; + } + + $bill = $bill->fresh(['lineItems']) ?? $bill; + if ((int) $bill->amount_paid_minor > 0 + || in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) { + return; + } + + $hasForeignLines = $bill->lineItems + ->contains(function ($item) use ($obligation) { + return $item->source_type !== FinancialObligation::class + || (int) $item->source_id !== (int) $obligation->id; + }); + + if ($hasForeignLines) { + return; + } + + try { + app(BillService::class)->void($bill, (string) $obligation->owner_ref, $obligation->cleared_by); + } catch (\Throwable $e) { + Log::warning('care.cashier_placeholder_bill_void_failed', [ + 'bill_id' => $bill->id, + 'obligation_id' => $obligation->id, + 'message' => $e->getMessage(), + ]); } } } diff --git a/config/care_workflows.php b/config/care_workflows.php index 15c1b97..2c75567 100644 --- a/config/care_workflows.php +++ b/config/care_workflows.php @@ -208,7 +208,7 @@ return [ ['code' => 'reception', 'name' => 'Reception', 'type' => 'registration', 'queue_context' => 'reception', 'department_type' => 'outpatient', 'default_next' => 'consultation'], ['code' => 'consultation', 'name' => 'Consultation', 'type' => 'consultation', 'queue_context' => 'consultation', 'department_type' => 'general', 'can_return' => true, 'creates_child' => true, 'default_next' => 'pharmacy'], ['code' => 'pharmacy', 'name' => 'Pharmacy', 'type' => 'pharmacy', 'queue_context' => 'pharmacy', 'department_type' => 'pharmacy', 'optional' => true, 'default_next' => 'checkout'], - ['code' => 'checkout', 'name' => 'Checkout', 'type' => 'payment', 'requires_payment' => true, 'payment_timing' => 'after', 'charge_code' => 'visit_total', 'charge_label' => 'Visit total', 'insurance_eligible' => true, 'default_next' => 'exit'], + ['code' => 'checkout', 'name' => 'Checkout', 'type' => 'payment', 'queue_context' => 'billing', 'requires_payment' => true, 'payment_timing' => 'after', 'charge_code' => 'visit_total', 'charge_label' => 'Visit total', 'insurance_eligible' => true, 'default_next' => 'exit'], ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], ], ], @@ -221,7 +221,7 @@ return [ 'description' => 'Reception, payment before sample collection, testing, and results delivery.', 'stages' => [ ['code' => 'reception', 'name' => 'Reception', 'type' => 'registration', 'queue_context' => 'reception', 'department_type' => 'outpatient', 'default_next' => 'payment'], - ['code' => 'payment', 'name' => 'Payment', 'type' => 'payment', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'lab', 'charge_label' => 'Diagnostic tests', 'insurance_eligible' => true, 'default_next' => 'sample'], + ['code' => 'payment', 'name' => 'Payment', 'type' => 'payment', 'queue_context' => 'billing', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'lab', 'charge_label' => 'Diagnostic tests', 'insurance_eligible' => true, 'default_next' => 'sample'], ['code' => 'sample', 'name' => 'Sample collection', 'type' => 'laboratory', 'queue_context' => 'laboratory', 'department_type' => 'laboratory', 'default_next' => 'testing'], ['code' => 'testing', 'name' => 'Testing', 'type' => 'laboratory', 'department_type' => 'laboratory', 'default_next' => 'results'], ['code' => 'results', 'name' => 'Results', 'type' => 'results', 'default_next' => 'exit'], @@ -238,7 +238,7 @@ return [ 'stages' => [ ['code' => 'reception', 'name' => 'Reception', 'type' => 'registration', 'queue_context' => 'reception', 'department_type' => 'outpatient', 'default_next' => 'consultation'], ['code' => 'consultation', 'name' => 'Consultation', 'type' => 'consultation', 'queue_context' => 'consultation', 'department_type' => 'general', 'can_return' => true, 'creates_child' => true, 'default_next' => 'payment'], - ['code' => 'payment', 'name' => 'Payment', 'type' => 'payment', 'requires_payment' => true, 'payment_timing' => 'after', 'charge_code' => 'consultation', 'charge_label' => 'Consultation fee', 'insurance_eligible' => true, 'default_next' => 'exit'], + ['code' => 'payment', 'name' => 'Payment', 'type' => 'payment', 'queue_context' => 'billing', 'requires_payment' => true, 'payment_timing' => 'after', 'charge_code' => 'consultation', 'charge_label' => 'Consultation fee', 'insurance_eligible' => true, 'default_next' => 'exit'], ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], ], ], @@ -251,7 +251,7 @@ return [ 'description' => 'Prescription intake, billing, payment, and dispensing.', 'stages' => [ ['code' => 'intake', 'name' => 'Prescription intake', 'type' => 'registration', 'queue_context' => 'reception', 'department_type' => 'pharmacy', 'default_next' => 'payment'], - ['code' => 'payment', 'name' => 'Payment', 'type' => 'payment', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'pharmacy', 'charge_label' => 'Medication', 'insurance_eligible' => true, 'default_next' => 'dispensing'], + ['code' => 'payment', 'name' => 'Payment', 'type' => 'payment', 'queue_context' => 'billing', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'pharmacy', 'charge_label' => 'Medication', 'insurance_eligible' => true, 'default_next' => 'dispensing'], ['code' => 'dispensing', 'name' => 'Dispensing', 'type' => 'pharmacy', 'queue_context' => 'pharmacy', 'department_type' => 'pharmacy', 'default_next' => 'exit'], ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], ], @@ -283,7 +283,7 @@ return [ 'stages' => [ ['code' => 'reception', 'name' => 'Reception', 'type' => 'registration', 'queue_context' => 'reception', 'department_type' => 'outpatient', 'default_next' => 'consultation'], ['code' => 'consultation', 'name' => 'Herbal consultation', 'type' => 'consultation', 'queue_context' => 'consultation', 'department_type' => 'general', 'can_return' => true, 'creates_child' => true, 'default_next' => 'payment'], - ['code' => 'payment', 'name' => 'Payment', 'type' => 'payment', 'requires_payment' => true, 'payment_timing' => 'before', 'payment_modes' => ['internal_cashier', 'digital'], 'charge_code' => 'pharmacy', 'charge_label' => 'Consultation + herbal medicine', 'insurance_eligible' => false, 'default_next' => 'dispensing'], + ['code' => 'payment', 'name' => 'Payment', 'type' => 'payment', 'queue_context' => 'billing', 'requires_payment' => true, 'payment_timing' => 'before', 'payment_modes' => ['internal_cashier', 'digital'], 'charge_code' => 'pharmacy', 'charge_label' => 'Consultation + herbal medicine', 'insurance_eligible' => false, 'default_next' => 'dispensing'], ['code' => 'dispensing', 'name' => 'Dispensing', 'type' => 'pharmacy', 'queue_context' => 'pharmacy', 'department_type' => 'pharmacy', 'default_next' => 'exit'], ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], ], diff --git a/resources/views/care/bills/index.blade.php b/resources/views/care/bills/index.blade.php index c04b5b8..bf331c2 100644 --- a/resources/views/care/bills/index.blade.php +++ b/resources/views/care/bills/index.blade.php @@ -71,7 +71,7 @@