Deploy Ladill Care / deploy (push) Successful in 1m22s
Stop issuing missing tickets and refreshing financial gates on every GET; eager-load advances, cap board rows, and cache specialty department maps. Co-authored-by: Cursor <cursoragent@cursor.com>
280 lines
9.3 KiB
PHP
280 lines
9.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Care\Workflow;
|
|
|
|
use App\Models\FacilityWorkflow;
|
|
use App\Models\Visit;
|
|
use App\Models\VisitStageAdvance;
|
|
use App\Models\WorkflowStage;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Drives a visit through its facility workflow. Responsible for placing a visit
|
|
* on its first stage, advancing between stages, and — together with
|
|
* FinancialGateService — deciding whether a stage may be entered or whether the
|
|
* visit is blocked awaiting payment / authorization.
|
|
*/
|
|
class WorkflowEngine
|
|
{
|
|
public function __construct(
|
|
protected FinancialGateService $gate,
|
|
) {}
|
|
|
|
/** The active workflow for a visit's org + branch (branch-specific wins). */
|
|
public function workflowFor(Visit $visit): ?FacilityWorkflow
|
|
{
|
|
return FacilityWorkflow::query()
|
|
->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;
|
|
}
|
|
}
|