Add workflow-centric patient journey with financial gates.
Deploy Ladill Care / deploy (push) Failing after 45s
Deploy Ladill Care / deploy (push) Failing after 45s
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>
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Care;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Services\Care\OrganizationResolver;
|
||||
use App\Services\Care\Workflow\WorkflowTemplateRegistry;
|
||||
use App\Support\OrganizationBranding;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -16,6 +17,7 @@ class OnboardingController extends Controller
|
||||
|
||||
public function __construct(
|
||||
protected OrganizationResolver $organizations,
|
||||
protected WorkflowTemplateRegistry $workflows,
|
||||
) {}
|
||||
|
||||
public function show(Request $request): View|RedirectResponse
|
||||
@@ -27,12 +29,9 @@ class OnboardingController extends Controller
|
||||
return view('care.onboarding.show', [
|
||||
'user' => $request->user(),
|
||||
'timezones' => timezone_identifiers_list(),
|
||||
'facilityTypes' => [
|
||||
'clinic' => 'Clinic',
|
||||
'hospital' => 'Hospital',
|
||||
'diagnostic' => 'Diagnostic laboratory',
|
||||
'specialist' => 'Specialist practice',
|
||||
],
|
||||
'categories' => $this->workflows->categories(),
|
||||
'templates' => $this->workflows->templates(),
|
||||
'defaultTemplate' => $this->workflows->defaultTemplateKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -42,9 +41,13 @@ class OnboardingController extends Controller
|
||||
return redirect()->route('care.dashboard');
|
||||
}
|
||||
|
||||
$categoryKeys = array_keys($this->workflows->categories());
|
||||
$templateKeys = array_keys($this->workflows->templates());
|
||||
|
||||
$validated = $request->validate([
|
||||
'organization_name' => ['required', 'string', 'max:255'],
|
||||
'facility_type' => ['required', 'string', 'in:clinic,hospital,diagnostic,specialist'],
|
||||
'facility_category' => ['required', 'string', 'in:'.implode(',', $categoryKeys)],
|
||||
'workflow_template' => ['required', 'string', 'in:'.implode(',', $templateKeys)],
|
||||
'branch_name' => ['required', 'string', 'max:255'],
|
||||
'branch_address' => ['nullable', 'string', 'max:500'],
|
||||
'branch_phone' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
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\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FacilityWorkflow extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'care_facility_workflows';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'name',
|
||||
'template_key', 'category', 'is_active', 'settings',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'settings' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (FacilityWorkflow $workflow) {
|
||||
if (! $workflow->uuid) {
|
||||
$workflow->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function branch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Branch::class, 'branch_id');
|
||||
}
|
||||
|
||||
public function stages(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkflowStage::class, 'workflow_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function firstStage(): ?WorkflowStage
|
||||
{
|
||||
return $this->stages()->first();
|
||||
}
|
||||
|
||||
public function stage(string $code): ?WorkflowStage
|
||||
{
|
||||
return $this->stages()->where('code', $code)->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FinancialObligation extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_AUTHORIZED = 'authorized';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
public const STATUS_WAIVED = 'waived';
|
||||
|
||||
public const STATUS_DEFERRED = 'deferred';
|
||||
|
||||
public const STATUS_VOID = 'void';
|
||||
|
||||
/** Statuses that satisfy a financial gate. */
|
||||
public const CLEARED_STATUSES = [
|
||||
self::STATUS_AUTHORIZED,
|
||||
self::STATUS_PAID,
|
||||
self::STATUS_WAIVED,
|
||||
self::STATUS_DEFERRED,
|
||||
];
|
||||
|
||||
protected $table = 'care_financial_obligations';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'patient_id',
|
||||
'workflow_stage_id', 'bill_id', 'stage_code', 'charge_code', 'label',
|
||||
'amount_minor', 'status', 'clearance_method', 'payment_mode',
|
||||
'external_reference', 'cleared_by', 'cleared_at', 'meta',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount_minor' => 'integer',
|
||||
'cleared_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (FinancialObligation $obligation) {
|
||||
if (! $obligation->uuid) {
|
||||
$obligation->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
public function visit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Visit::class, 'visit_id');
|
||||
}
|
||||
|
||||
public function patient(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Patient::class, 'patient_id');
|
||||
}
|
||||
|
||||
public function stage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkflowStage::class, 'workflow_stage_id');
|
||||
}
|
||||
|
||||
public function bill(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Bill::class, 'bill_id');
|
||||
}
|
||||
|
||||
public function isCleared(): bool
|
||||
{
|
||||
return in_array($this->status, self::CLEARED_STATUSES, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VisitStageAdvance extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_BLOCKED = 'blocked';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
protected $table = 'care_visit_stage_advances';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'visit_id', 'workflow_id', 'workflow_stage_id', 'stage_code',
|
||||
'status', 'blocked_reason', 'entered_at', 'cleared_at', 'completed_at', 'meta',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'entered_at' => 'datetime',
|
||||
'cleared_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function visit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Visit::class, 'visit_id');
|
||||
}
|
||||
|
||||
public function workflow(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FacilityWorkflow::class, 'workflow_id');
|
||||
}
|
||||
|
||||
public function stage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkflowStage::class, 'workflow_stage_id');
|
||||
}
|
||||
|
||||
public function isBlocked(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_BLOCKED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WorkflowStage extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
public const TIMING_BEFORE = 'before';
|
||||
|
||||
public const TIMING_AFTER = 'after';
|
||||
|
||||
public const TIMING_NONE = 'none';
|
||||
|
||||
protected $table = 'care_workflow_stages';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'workflow_id', 'code', 'name', 'type',
|
||||
'queue_context', 'department_type', 'sort_order',
|
||||
'requires_payment', 'payment_timing', 'payment_modes',
|
||||
'insurance_eligible', 'credit_allowed', 'allow_override',
|
||||
'charge_code', 'charge_label', 'default_amount_minor',
|
||||
'default_next', 'can_return', 'can_skip', 'optional', 'creates_child', 'meta',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'sort_order' => 'integer',
|
||||
'requires_payment' => 'boolean',
|
||||
'payment_modes' => 'array',
|
||||
'insurance_eligible' => 'boolean',
|
||||
'credit_allowed' => 'boolean',
|
||||
'allow_override' => 'boolean',
|
||||
'default_amount_minor' => 'integer',
|
||||
'can_return' => 'boolean',
|
||||
'can_skip' => 'boolean',
|
||||
'optional' => 'boolean',
|
||||
'creates_child' => 'boolean',
|
||||
'meta' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (WorkflowStage $stage) {
|
||||
if (! $stage->uuid) {
|
||||
$stage->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function workflow(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FacilityWorkflow::class, 'workflow_id');
|
||||
}
|
||||
|
||||
/** Whether entering this stage's service queue is gated on a cleared obligation. */
|
||||
public function gatesBeforeService(): bool
|
||||
{
|
||||
return $this->requires_payment && $this->payment_timing === self::TIMING_BEFORE;
|
||||
}
|
||||
|
||||
public function collectsAfterService(): bool
|
||||
{
|
||||
return $this->requires_payment && $this->payment_timing === self::TIMING_AFTER;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ class CareFeatures
|
||||
|
||||
public const ASSESSMENT_REQUIRED_ON_COMPLETE = 'assessment_required_on_complete';
|
||||
|
||||
/** Workflow-centric patient journey + financial gates (opt-in). */
|
||||
public const WORKFLOW_ENGINE = 'workflow_engine';
|
||||
|
||||
/** Enforce financial gates (block gated stages until obligations clear). */
|
||||
public const FINANCIAL_GATES = 'financial_gates';
|
||||
|
||||
/**
|
||||
* Flags that default ON when unset (opt-out). Others default OFF (opt-in).
|
||||
*
|
||||
|
||||
@@ -7,6 +7,8 @@ use App\Models\Department;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Services\Care\Workflow\WorkflowTemplateInstaller;
|
||||
use App\Services\Care\Workflow\WorkflowTemplateRegistry;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OrganizationResolver
|
||||
@@ -73,6 +75,15 @@ class OrganizationResolver
|
||||
{
|
||||
$ref = $user->ownerRef();
|
||||
|
||||
$registry = app(WorkflowTemplateRegistry::class);
|
||||
|
||||
// New onboarding leads with Facility Category (modules) + Workflow
|
||||
// Template (patient flow); legacy callers may still pass facility_type.
|
||||
$category = $data['facility_category'] ?? $this->categoryFromLegacyType($data['facility_type'] ?? null);
|
||||
$templateKey = $data['workflow_template']
|
||||
?? $registry->defaultTemplateForCategory($category);
|
||||
$modules = $registry->modulesForCategory($category);
|
||||
|
||||
$organization = Organization::create([
|
||||
'owner_ref' => $ref,
|
||||
'name' => $data['organization_name'],
|
||||
@@ -80,7 +91,10 @@ class OrganizationResolver
|
||||
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
|
||||
'settings' => [
|
||||
'onboarded' => true,
|
||||
'facility_type' => $data['facility_type'] ?? 'clinic',
|
||||
'facility_category' => $category,
|
||||
'facility_type' => $data['facility_type'] ?? $category,
|
||||
'modules' => $modules,
|
||||
'workflow_template' => $templateKey,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -103,12 +117,28 @@ class OrganizationResolver
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
if ($registry->hasTemplate($templateKey)) {
|
||||
app(WorkflowTemplateInstaller::class)->install($organization, $branch, $templateKey, [
|
||||
'category' => $category,
|
||||
]);
|
||||
}
|
||||
|
||||
AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id);
|
||||
AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id);
|
||||
|
||||
return $organization;
|
||||
}
|
||||
|
||||
protected function categoryFromLegacyType(?string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'hospital' => 'hospital',
|
||||
'diagnostic' => 'diagnostic',
|
||||
'specialist' => 'specialist',
|
||||
default => 'clinic',
|
||||
};
|
||||
}
|
||||
|
||||
/** Branch ID the member may access; null = all branches. */
|
||||
public function branchScope(?Member $member): ?int
|
||||
{
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care\Workflow;
|
||||
|
||||
use App\Models\FinancialObligation;
|
||||
use App\Models\Visit;
|
||||
use App\Models\WorkflowStage;
|
||||
|
||||
/**
|
||||
* Owns FinancialObligation lifecycle and answers whether a stage's financial
|
||||
* gate is satisfied for a given visit.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
class FinancialGateService
|
||||
{
|
||||
/**
|
||||
* Create (or return existing) obligation for a visit + stage. Only stages
|
||||
* that require payment produce an obligation.
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
public function obligationFor(Visit $visit, WorkflowStage $stage, array $overrides = []): ?FinancialObligation
|
||||
{
|
||||
if (! $stage->requires_payment) {
|
||||
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;
|
||||
}
|
||||
|
||||
return FinancialObligation::create(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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the visit may enter the stage. Stages with a "before" payment
|
||||
* gate require a cleared obligation; everything else is allowed.
|
||||
*/
|
||||
public function isSatisfied(Visit $visit, WorkflowStage $stage): bool
|
||||
{
|
||||
if (! $stage->gatesBeforeService()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$obligation = FinancialObligation::query()
|
||||
->where('visit_id', $visit->id)
|
||||
->where('workflow_stage_id', $stage->id)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
return $obligation !== null && $obligation->isCleared();
|
||||
}
|
||||
|
||||
public function blockedReason(Visit $visit, WorkflowStage $stage): ?string
|
||||
{
|
||||
if ($this->isSatisfied($visit, $stage)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'awaiting_payment';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear an obligation (paid / insurance authorized / waived / credit).
|
||||
*/
|
||||
public function clear(
|
||||
FinancialObligation $obligation,
|
||||
string $method,
|
||||
?string $actorRef = null,
|
||||
?string $paymentMode = null,
|
||||
?string $externalReference = null,
|
||||
): FinancialObligation {
|
||||
$status = match ($method) {
|
||||
'insurance' => FinancialObligation::STATUS_AUTHORIZED,
|
||||
'waiver' => FinancialObligation::STATUS_WAIVED,
|
||||
'credit' => FinancialObligation::STATUS_DEFERRED,
|
||||
default => FinancialObligation::STATUS_PAID,
|
||||
};
|
||||
|
||||
$obligation->forceFill([
|
||||
'status' => $status,
|
||||
'clearance_method' => $method,
|
||||
'payment_mode' => $paymentMode,
|
||||
'external_reference' => $externalReference,
|
||||
'cleared_by' => $actorRef,
|
||||
'cleared_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $obligation;
|
||||
}
|
||||
|
||||
public function void(FinancialObligation $obligation): FinancialObligation
|
||||
{
|
||||
$obligation->forceFill(['status' => FinancialObligation::STATUS_VOID])->save();
|
||||
|
||||
return $obligation;
|
||||
}
|
||||
|
||||
protected function defaultAmountFor(WorkflowStage $stage): int
|
||||
{
|
||||
if ($stage->charge_code === 'consultation') {
|
||||
return (int) config('care.billing.consultation_fee_minor', 0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care\Workflow;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\FacilityWorkflow;
|
||||
use App\Models\Organization;
|
||||
use App\Models\WorkflowStage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Materializes a workflow template from config into a FacilityWorkflow +
|
||||
* WorkflowStage graph for an organization. Idempotent per (org, branch):
|
||||
* re-installing replaces the workflow's stages with the template definition.
|
||||
*/
|
||||
class WorkflowTemplateInstaller
|
||||
{
|
||||
public function __construct(
|
||||
protected WorkflowTemplateRegistry $registry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{name?: string, category?: string, activate?: bool, replace?: bool} $options
|
||||
*/
|
||||
public function install(Organization $organization, ?Branch $branch, string $templateKey, array $options = []): FacilityWorkflow
|
||||
{
|
||||
$template = $this->registry->template($templateKey);
|
||||
if ($template === null) {
|
||||
throw new \InvalidArgumentException("Unknown workflow template [{$templateKey}].");
|
||||
}
|
||||
|
||||
$ownerRef = (string) $organization->owner_ref;
|
||||
$activate = (bool) ($options['activate'] ?? true);
|
||||
$replace = (bool) ($options['replace'] ?? true);
|
||||
$name = (string) ($options['name'] ?? ($template['label'] ?? $templateKey));
|
||||
$category = $options['category'] ?? data_get($organization->settings, 'facility_category');
|
||||
|
||||
return DB::transaction(function () use ($organization, $branch, $template, $templateKey, $ownerRef, $activate, $replace, $name, $category) {
|
||||
$query = FacilityWorkflow::query()
|
||||
->where('organization_id', $organization->id)
|
||||
->where('branch_id', $branch?->id);
|
||||
|
||||
$workflow = $query->first();
|
||||
|
||||
if ($workflow === null) {
|
||||
$workflow = FacilityWorkflow::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'organization_id' => $organization->id,
|
||||
'branch_id' => $branch?->id,
|
||||
'name' => $name,
|
||||
'template_key' => $templateKey,
|
||||
'category' => $category,
|
||||
'is_active' => $activate,
|
||||
'settings' => ['description' => $template['description'] ?? null],
|
||||
]);
|
||||
} else {
|
||||
$workflow->forceFill([
|
||||
'name' => $name,
|
||||
'template_key' => $templateKey,
|
||||
'category' => $category,
|
||||
'is_active' => $activate ? true : $workflow->is_active,
|
||||
'settings' => array_merge($workflow->settings ?? [], [
|
||||
'description' => $template['description'] ?? null,
|
||||
]),
|
||||
])->save();
|
||||
|
||||
if ($replace) {
|
||||
WorkflowStage::query()->where('workflow_id', $workflow->id)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
if ($replace || $workflow->stages()->count() === 0) {
|
||||
$this->syncStages($workflow, $ownerRef, (array) ($template['stages'] ?? []));
|
||||
}
|
||||
|
||||
return $workflow->fresh(['stages']) ?? $workflow;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $stages
|
||||
*/
|
||||
protected function syncStages(FacilityWorkflow $workflow, string $ownerRef, array $stages): void
|
||||
{
|
||||
$order = 0;
|
||||
foreach ($stages as $stage) {
|
||||
$order += 10;
|
||||
WorkflowStage::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'workflow_id' => $workflow->id,
|
||||
'code' => (string) $stage['code'],
|
||||
'name' => (string) ($stage['name'] ?? $stage['code']),
|
||||
'type' => (string) ($stage['type'] ?? 'custom'),
|
||||
'queue_context' => $stage['queue_context'] ?? null,
|
||||
'department_type' => $stage['department_type'] ?? null,
|
||||
'sort_order' => $stage['sort_order'] ?? $order,
|
||||
'requires_payment' => (bool) ($stage['requires_payment'] ?? false),
|
||||
'payment_timing' => (string) ($stage['payment_timing'] ?? 'none'),
|
||||
'payment_modes' => $stage['payment_modes'] ?? ['internal_cashier', 'digital'],
|
||||
'insurance_eligible' => (bool) ($stage['insurance_eligible'] ?? false),
|
||||
'credit_allowed' => (bool) ($stage['credit_allowed'] ?? false),
|
||||
'allow_override' => (bool) ($stage['allow_override'] ?? true),
|
||||
'charge_code' => $stage['charge_code'] ?? null,
|
||||
'charge_label' => $stage['charge_label'] ?? null,
|
||||
'default_amount_minor' => $stage['default_amount_minor'] ?? null,
|
||||
'default_next' => $stage['default_next'] ?? null,
|
||||
'can_return' => (bool) ($stage['can_return'] ?? false),
|
||||
'can_skip' => (bool) ($stage['can_skip'] ?? false),
|
||||
'optional' => (bool) ($stage['optional'] ?? false),
|
||||
'creates_child' => (bool) ($stage['creates_child'] ?? false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care\Workflow;
|
||||
|
||||
/**
|
||||
* Read-only access to config/care_workflows.php — facility categories and
|
||||
* workflow templates. Templates are materialized into care_facility_workflows
|
||||
* by WorkflowTemplateInstaller.
|
||||
*/
|
||||
class WorkflowTemplateRegistry
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function categories(): array
|
||||
{
|
||||
return (array) config('care_workflows.categories', []);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function category(?string $key): ?array
|
||||
{
|
||||
if ($key === null || $key === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$category = config("care_workflows.categories.{$key}");
|
||||
|
||||
return is_array($category) ? $category : null;
|
||||
}
|
||||
|
||||
/** @return array<string, string> key => label */
|
||||
public function categoryOptions(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (array $category) => (string) ($category['label'] ?? ''),
|
||||
$this->categories(),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function templates(): array
|
||||
{
|
||||
return (array) config('care_workflows.templates', []);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function template(?string $key): ?array
|
||||
{
|
||||
if ($key === null || $key === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$template = config("care_workflows.templates.{$key}");
|
||||
|
||||
return is_array($template) ? $template : null;
|
||||
}
|
||||
|
||||
public function hasTemplate(?string $key): bool
|
||||
{
|
||||
return $this->template($key) !== null;
|
||||
}
|
||||
|
||||
/** @return array<string, string> key => label */
|
||||
public function templateOptions(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (array $template) => (string) ($template['label'] ?? ''),
|
||||
$this->templates(),
|
||||
);
|
||||
}
|
||||
|
||||
public function defaultTemplateKey(): string
|
||||
{
|
||||
return (string) config('care_workflows.default_template', 'legacy_clinic');
|
||||
}
|
||||
|
||||
/** Preferred template for a facility category, falling back to the global default. */
|
||||
public function defaultTemplateForCategory(?string $categoryKey): string
|
||||
{
|
||||
$category = $this->category($categoryKey);
|
||||
|
||||
$preferred = $category['default_template'] ?? null;
|
||||
if (is_string($preferred) && $this->hasTemplate($preferred)) {
|
||||
return $preferred;
|
||||
}
|
||||
|
||||
return $this->defaultTemplateKey();
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function modulesForCategory(?string $categoryKey): array
|
||||
{
|
||||
$category = $this->category($categoryKey);
|
||||
|
||||
return array_values((array) ($category['modules'] ?? []));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user