where('organization_id', $visit->organization_id) ->where('is_active', true) ->where(function ($q) use ($visit) { $q->where('branch_id', $visit->branch_id)->orWhereNull('branch_id'); }) ->orderByRaw('branch_id is null') ->with('stages') ->first(); } /** * Place the visit on the workflow's first stage. Idempotent — returns the * existing active advance if the visit is already started. */ public function start(Visit $visit, ?FacilityWorkflow $workflow = null): ?VisitStageAdvance { return DB::transaction(function () use ($visit, $workflow) { Visit::query()->whereKey($visit->id)->lockForUpdate()->first(); $workflow ??= $this->workflowFor($visit); if ($workflow === null) { return null; } $existing = $this->currentAdvance($visit); if ($existing !== null) { return $existing; } $first = $workflow->firstStage(); if ($first === null) { return null; } return $this->enterStage($visit, $workflow, $first); }); } public function currentAdvance(Visit $visit): ?VisitStageAdvance { if ($visit->relationLoaded('currentStageAdvance')) { return $visit->currentStageAdvance; } if ($visit->relationLoaded('stageAdvances')) { return $visit->stageAdvances ->whereIn('status', [VisitStageAdvance::STATUS_ACTIVE, VisitStageAdvance::STATUS_BLOCKED]) ->sortByDesc('id') ->first(); } return VisitStageAdvance::query() ->where('visit_id', $visit->id) ->whereIn('status', [VisitStageAdvance::STATUS_ACTIVE, VisitStageAdvance::STATUS_BLOCKED]) ->orderByDesc('id') ->first(); } public function currentStage(Visit $visit): ?WorkflowStage { return $this->currentAdvance($visit)?->stage; } /** * Whether the visit may advance to $toCode (or the current stage's * default_next when omitted). Blocked when the target stage has an unmet * "before" financial gate. */ 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; } 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); } /** * Advance the visit to the next stage. Completes the current advance and * enters the target stage; if the target is gated and unpaid the new * advance is created in the "blocked" state (visit is not queued until the * obligation clears). */ 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; } $target = $this->resolveTarget($visit, $toCode, $workflow); if ($target === null) { return null; } $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, 'completed_at' => now(), ])->save(); } return $this->enterStage($visit, $workflow, $target); }); } /** * Whether the current stage may release the patient into its Queue service * point. False while a "before" gate is unmet. */ public function isQueueReleasable(Visit $visit): bool { $advance = $this->currentAdvance($visit); if ($advance === null || $advance->stage === null) { return true; } return $this->gate->isSatisfied($visit, $advance->stage); } /** * Re-evaluate the current advance after an obligation changes. Moves a * blocked advance to active once its gate clears. */ public function refreshGate(Visit $visit): ?VisitStageAdvance { $advance = $this->currentAdvance($visit); if ($advance === null || $advance->stage === null) { return $advance; } $this->gate->obligationFor($visit, $advance->stage); $satisfied = $this->gate->isSatisfied($visit, $advance->stage); $becameBlocked = false; 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(); $becameBlocked = true; } $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 { $this->gate->obligationFor($visit, $stage); $satisfied = $this->gate->isSatisfied($visit, $stage); $advance = VisitStageAdvance::create([ 'owner_ref' => $visit->owner_ref, 'visit_id' => $visit->id, 'workflow_id' => $workflow->id, 'workflow_stage_id' => $stage->id, 'stage_code' => $stage->code, 'status' => $satisfied ? VisitStageAdvance::STATUS_ACTIVE : VisitStageAdvance::STATUS_BLOCKED, 'blocked_reason' => $satisfied ? null : $this->gate->blockedReason($visit, $stage), '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 { $workflow ??= $this->workflowFor($visit); if ($workflow === null) { return null; } if ($toCode !== null && $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; $nextCode = $current?->default_next; return $nextCode ? $workflow->stage($nextCode) : null; } }