Files
ladill-care/app/Services/Care/Workflow/WorkflowEngine.php
T
isaaccladandCursor 86bfce1e17
Deploy Ladill Care / deploy (push) Failing after 45s
Add workflow-centric patient journey with financial gates.
Introduces a facility workflow engine as Care's primary configuration
object: onboarding now leads with a facility category (which modules to
enable) and a workflow template (how patients move + where money is
collected), including standard herbal hospital/clinic templates.

Templates materialize into care_facility_workflows/care_workflow_stages.
The engine tracks a visit's position via care_visit_stage_advances and,
with FinancialGateService, gates a stage's queue behind a cleared
FinancialObligation (paid/authorized/waived/deferred) for "before"
payment timing while leaving "after" (pay-at-exit) flows unblocked.
Gated behavior is opt-in behind the workflow_engine/financial_gates
rollout flags; legacy orgs keep current behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 20:47:33 +00:00

190 lines
6.2 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
{
$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
{
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
{
$target = $this->resolveTarget($visit, $toCode);
return $target !== null && $this->gate->isSatisfied($visit, $target);
}
/**
* 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) {
$workflow = $this->workflowFor($visit);
if ($workflow === null) {
return null;
}
$target = $this->resolveTarget($visit, $toCode, $workflow);
if ($target === null) {
return null;
}
$current = $this->currentAdvance($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;
}
if ($advance->isBlocked() && $this->gate->isSatisfied($visit, $advance->stage)) {
$advance->forceFill([
'status' => VisitStageAdvance::STATUS_ACTIVE,
'blocked_reason' => null,
'cleared_at' => now(),
])->save();
}
return $advance->fresh();
}
protected function enterStage(Visit $visit, FacilityWorkflow $workflow, WorkflowStage $stage): VisitStageAdvance
{
$this->gate->obligationFor($visit, $stage);
$satisfied = $this->gate->isSatisfied($visit, $stage);
return 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,
]);
}
protected function resolveTarget(Visit $visit, ?string $toCode, ?FacilityWorkflow $workflow = null): ?WorkflowStage
{
$workflow ??= $this->workflowFor($visit);
if ($workflow === null) {
return null;
}
if ($toCode !== null && $toCode !== '') {
return $workflow->stage($toCode);
}
$current = $this->currentAdvance($visit)?->stage;
$nextCode = $current?->default_next;
return $nextCode ? $workflow->stage($nextCode) : null;
}
}