From 86bfce1e176848c1a44d3ee6425a80a05ee9959c Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 20:47:33 +0000 Subject: [PATCH] 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 --- .../Controllers/Care/OnboardingController.php | 17 +- app/Models/FacilityWorkflow.php | 69 +++++ app/Models/FinancialObligation.php | 91 ++++++ app/Models/VisitStageAdvance.php | 57 ++++ app/Models/WorkflowStage.php | 73 +++++ app/Services/Care/CareFeatures.php | 6 + app/Services/Care/OrganizationResolver.php | 32 +- .../Care/Workflow/FinancialGateService.php | 128 ++++++++ app/Services/Care/Workflow/WorkflowEngine.php | 189 ++++++++++++ .../Workflow/WorkflowTemplateInstaller.php | 114 +++++++ .../Workflow/WorkflowTemplateRegistry.php | 96 ++++++ config/care_workflows.php | 289 ++++++++++++++++++ ...000_create_care_workflow_engine_tables.php | 129 ++++++++ .../views/care/onboarding/show.blade.php | 84 +++-- tests/Feature/CareWebTest.php | 5 +- tests/Feature/CareWorkflowEngineTest.php | 212 +++++++++++++ 16 files changed, 1564 insertions(+), 27 deletions(-) create mode 100644 app/Models/FacilityWorkflow.php create mode 100644 app/Models/FinancialObligation.php create mode 100644 app/Models/VisitStageAdvance.php create mode 100644 app/Models/WorkflowStage.php create mode 100644 app/Services/Care/Workflow/FinancialGateService.php create mode 100644 app/Services/Care/Workflow/WorkflowEngine.php create mode 100644 app/Services/Care/Workflow/WorkflowTemplateInstaller.php create mode 100644 app/Services/Care/Workflow/WorkflowTemplateRegistry.php create mode 100644 config/care_workflows.php create mode 100644 database/migrations/2026_07_17_200000_create_care_workflow_engine_tables.php create mode 100644 tests/Feature/CareWorkflowEngineTest.php diff --git a/app/Http/Controllers/Care/OnboardingController.php b/app/Http/Controllers/Care/OnboardingController.php index e87e542..146190b 100644 --- a/app/Http/Controllers/Care/OnboardingController.php +++ b/app/Http/Controllers/Care/OnboardingController.php @@ -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'], diff --git a/app/Models/FacilityWorkflow.php b/app/Models/FacilityWorkflow.php new file mode 100644 index 0000000..b3dc8dc --- /dev/null +++ b/app/Models/FacilityWorkflow.php @@ -0,0 +1,69 @@ + '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(); + } +} diff --git a/app/Models/FinancialObligation.php b/app/Models/FinancialObligation.php new file mode 100644 index 0000000..9c3353b --- /dev/null +++ b/app/Models/FinancialObligation.php @@ -0,0 +1,91 @@ + '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); + } +} diff --git a/app/Models/VisitStageAdvance.php b/app/Models/VisitStageAdvance.php new file mode 100644 index 0000000..feff729 --- /dev/null +++ b/app/Models/VisitStageAdvance.php @@ -0,0 +1,57 @@ + '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; + } +} diff --git a/app/Models/WorkflowStage.php b/app/Models/WorkflowStage.php new file mode 100644 index 0000000..135c9cc --- /dev/null +++ b/app/Models/WorkflowStage.php @@ -0,0 +1,73 @@ + '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; + } +} diff --git a/app/Services/Care/CareFeatures.php b/app/Services/Care/CareFeatures.php index a90a644..dd1894e 100644 --- a/app/Services/Care/CareFeatures.php +++ b/app/Services/Care/CareFeatures.php @@ -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). * diff --git a/app/Services/Care/OrganizationResolver.php b/app/Services/Care/OrganizationResolver.php index 225e1c5..7f7cb4f 100644 --- a/app/Services/Care/OrganizationResolver.php +++ b/app/Services/Care/OrganizationResolver.php @@ -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 { diff --git a/app/Services/Care/Workflow/FinancialGateService.php b/app/Services/Care/Workflow/FinancialGateService.php new file mode 100644 index 0000000..c4f96e0 --- /dev/null +++ b/app/Services/Care/Workflow/FinancialGateService.php @@ -0,0 +1,128 @@ + $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; + } +} diff --git a/app/Services/Care/Workflow/WorkflowEngine.php b/app/Services/Care/Workflow/WorkflowEngine.php new file mode 100644 index 0000000..a1f9646 --- /dev/null +++ b/app/Services/Care/Workflow/WorkflowEngine.php @@ -0,0 +1,189 @@ +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; + } +} diff --git a/app/Services/Care/Workflow/WorkflowTemplateInstaller.php b/app/Services/Care/Workflow/WorkflowTemplateInstaller.php new file mode 100644 index 0000000..940af9d --- /dev/null +++ b/app/Services/Care/Workflow/WorkflowTemplateInstaller.php @@ -0,0 +1,114 @@ +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> $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), + ]); + } + } +} diff --git a/app/Services/Care/Workflow/WorkflowTemplateRegistry.php b/app/Services/Care/Workflow/WorkflowTemplateRegistry.php new file mode 100644 index 0000000..255a0a8 --- /dev/null +++ b/app/Services/Care/Workflow/WorkflowTemplateRegistry.php @@ -0,0 +1,96 @@ + */ + public function categories(): array + { + return (array) config('care_workflows.categories', []); + } + + /** @return array */ + 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 key => label */ + public function categoryOptions(): array + { + return array_map( + fn (array $category) => (string) ($category['label'] ?? ''), + $this->categories(), + ); + } + + /** @return array */ + public function templates(): array + { + return (array) config('care_workflows.templates', []); + } + + /** @return array|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 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 */ + public function modulesForCategory(?string $categoryKey): array + { + $category = $this->category($categoryKey); + + return array_values((array) ($category['modules'] ?? [])); + } +} diff --git a/config/care_workflows.php b/config/care_workflows.php new file mode 100644 index 0000000..1b8af91 --- /dev/null +++ b/config/care_workflows.php @@ -0,0 +1,289 @@ + 1, + + 'default_template' => 'legacy_clinic', + + /* + | Facility category → which modules/features to surface. Category controls + | features, NOT patient flow (that is the workflow template's job). + */ + 'categories' => [ + 'hospital' => [ + 'label' => 'Hospital', + 'modules' => ['lab', 'pharmacy', 'imaging', 'billing'], + 'default_template' => 'public_hospital_gh', + ], + 'clinic' => [ + 'label' => 'Clinic', + 'modules' => ['pharmacy', 'billing'], + 'default_template' => 'cashless_clinic', + ], + 'specialist' => [ + 'label' => 'Specialist practice', + 'modules' => ['billing'], + 'default_template' => 'specialist_practice', + ], + 'diagnostic' => [ + 'label' => 'Diagnostic laboratory', + 'modules' => ['lab', 'billing'], + 'default_template' => 'diagnostic_centre', + ], + 'imaging' => [ + 'label' => 'Imaging centre', + 'modules' => ['imaging', 'billing'], + 'default_template' => 'diagnostic_centre', + ], + 'pharmacy' => [ + 'label' => 'Pharmacy', + 'modules' => ['pharmacy', 'billing'], + 'default_template' => 'pharmacy_led', + ], + 'dental' => [ + 'label' => 'Dental clinic', + 'modules' => ['pharmacy', 'billing'], + 'default_template' => 'cashless_clinic', + ], + 'eye' => [ + 'label' => 'Eye clinic', + 'modules' => ['pharmacy', 'billing'], + 'default_template' => 'cashless_clinic', + ], + 'maternity' => [ + 'label' => 'Maternity home', + 'modules' => ['lab', 'pharmacy', 'billing'], + 'default_template' => 'private_hospital', + ], + 'herbal_hospital' => [ + 'label' => 'Herbal hospital', + 'modules' => ['lab', 'pharmacy', 'billing'], + 'default_template' => 'herbal_hospital', + ], + 'herbal_clinic' => [ + 'label' => 'Herbal clinic', + 'modules' => ['pharmacy', 'billing'], + 'default_template' => 'herbal_clinic', + ], + ], + + 'payment_modes' => [ + 'internal_cashier' => 'Internal cashier', + 'external_bank' => 'External bank / payment office', + 'digital' => 'Digital (MoMo / card / Paystack)', + ], + + 'stage_types' => [ + 'registration' => 'Registration', + 'payment' => 'Payment', + 'triage' => 'Triage', + 'consultation' => 'Consultation', + 'laboratory' => 'Laboratory', + 'imaging' => 'Imaging', + 'procedure' => 'Procedure', + 'pharmacy' => 'Pharmacy / dispensing', + 'results' => 'Results', + 'exit' => 'Exit', + 'custom' => 'Custom', + ], + + 'templates' => [ + + /* + | Legacy Clinic — mirrors Care's pre-workflow behavior. No financial + | gates. Used to migrate existing orgs so nothing changes for them. + */ + 'legacy_clinic' => [ + 'label' => 'Legacy (no gates)', + 'description' => 'Current Ladill Care behavior — reception, consultation, pharmacy with billing after service. No payment gates.', + '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', 'default_next' => 'pharmacy', 'can_return' => true, 'creates_child' => true], + ['code' => 'pharmacy', 'name' => 'Pharmacy', 'type' => 'pharmacy', 'queue_context' => 'pharmacy', 'department_type' => 'pharmacy', 'optional' => true, 'default_next' => 'exit'], + ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], + ], + ], + + /* + | Standard Public Hospital (Ghana) — cash-first with gates at every step. + */ + 'public_hospital_gh' => [ + 'label' => 'Standard public hospital (Ghana)', + 'description' => 'Registration and card purchase, consultation fee before the doctor, and pay-before-service for lab, imaging, and pharmacy. NHIS eligible.', + 'stages' => [ + ['code' => 'registration', 'name' => 'Registration', 'type' => 'registration', 'queue_context' => 'reception', 'department_type' => 'outpatient', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'patient_card', 'charge_label' => 'Patient card / folder', 'insurance_eligible' => false, 'default_next' => 'triage'], + ['code' => 'triage', 'name' => 'Triage', 'type' => 'triage', 'queue_context' => 'triage', 'department_type' => 'general', 'optional' => true, 'default_next' => 'consultation'], + ['code' => 'consultation', 'name' => 'Consultation', 'type' => 'consultation', 'queue_context' => 'consultation', 'department_type' => 'general', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'consultation', 'charge_label' => 'Consultation fee', 'insurance_eligible' => true, 'can_return' => true, 'creates_child' => true, 'default_next' => 'pharmacy'], + ['code' => 'laboratory', 'name' => 'Laboratory', 'type' => 'laboratory', 'queue_context' => 'laboratory', 'department_type' => 'laboratory', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'lab', 'charge_label' => 'Laboratory tests', 'insurance_eligible' => true, 'optional' => true, 'can_return' => true, 'default_next' => 'consultation'], + ['code' => 'imaging', 'name' => 'Imaging', 'type' => 'imaging', 'queue_context' => 'imaging', 'department_type' => 'radiology', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'imaging', 'charge_label' => 'Imaging services', 'insurance_eligible' => true, 'optional' => true, 'can_return' => true, 'default_next' => 'consultation'], + ['code' => 'pharmacy', 'name' => 'Pharmacy', 'type' => 'pharmacy', 'queue_context' => 'pharmacy', 'department_type' => 'pharmacy', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'pharmacy', 'charge_label' => 'Medication', 'insurance_eligible' => true, 'optional' => true, 'default_next' => 'exit'], + ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], + ], + ], + + /* + | Private Hospital — digital/internal cashier, insurance + corporate. + */ + 'private_hospital' => [ + 'label' => 'Private hospital', + 'description' => 'Registration, consultation fee, and pay-before-service for lab/imaging/pharmacy. Card, MoMo, and insurance supported.', + 'stages' => [ + ['code' => 'registration', 'name' => 'Registration', 'type' => 'registration', 'queue_context' => 'reception', 'department_type' => 'outpatient', 'requires_payment' => true, 'payment_timing' => 'before', 'payment_modes' => ['internal_cashier', 'digital'], 'charge_code' => 'registration', 'charge_label' => 'Registration', 'insurance_eligible' => true, 'default_next' => 'consultation'], + ['code' => 'consultation', 'name' => 'Consultation', 'type' => 'consultation', 'queue_context' => 'consultation', 'department_type' => 'general', 'requires_payment' => true, 'payment_timing' => 'before', 'payment_modes' => ['internal_cashier', 'digital'], 'charge_code' => 'consultation', 'charge_label' => 'Consultation fee', 'insurance_eligible' => true, 'can_return' => true, 'creates_child' => true, 'default_next' => 'pharmacy'], + ['code' => 'laboratory', 'name' => 'Laboratory', 'type' => 'laboratory', 'queue_context' => 'laboratory', 'department_type' => 'laboratory', 'requires_payment' => true, 'payment_timing' => 'before', 'payment_modes' => ['internal_cashier', 'digital'], 'charge_code' => 'lab', 'charge_label' => 'Laboratory tests', 'insurance_eligible' => true, 'optional' => true, 'can_return' => true, 'default_next' => 'consultation'], + ['code' => 'imaging', 'name' => 'Imaging', 'type' => 'imaging', 'queue_context' => 'imaging', 'department_type' => 'radiology', 'requires_payment' => true, 'payment_timing' => 'before', 'payment_modes' => ['internal_cashier', 'digital'], 'charge_code' => 'imaging', 'charge_label' => 'Imaging services', 'insurance_eligible' => true, 'optional' => true, 'can_return' => true, 'default_next' => 'consultation'], + ['code' => 'pharmacy', 'name' => 'Pharmacy', 'type' => 'pharmacy', 'queue_context' => 'pharmacy', 'department_type' => 'pharmacy', 'requires_payment' => true, 'payment_timing' => 'before', 'payment_modes' => ['internal_cashier', 'digital'], 'charge_code' => 'pharmacy', 'charge_label' => 'Medication', 'insurance_eligible' => true, 'optional' => true, 'default_next' => 'exit'], + ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], + ], + ], + + /* + | NHIS Hospital — insurance-authorization first; cash fallback. + */ + 'nhis_hospital' => [ + 'label' => 'NHIS hospital', + 'description' => 'Insurance-authorization gates for consultation, lab, imaging, and pharmacy with cash fallback for non-covered services.', + 'stages' => [ + ['code' => 'registration', 'name' => 'Registration', 'type' => 'registration', 'queue_context' => 'reception', 'department_type' => 'outpatient', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'registration', 'charge_label' => 'Registration / NHIS verification', 'insurance_eligible' => true, 'default_next' => 'triage'], + ['code' => 'triage', 'name' => 'Triage', 'type' => 'triage', 'queue_context' => 'triage', 'department_type' => 'general', 'optional' => true, 'default_next' => 'consultation'], + ['code' => 'consultation', 'name' => 'Consultation', 'type' => 'consultation', 'queue_context' => 'consultation', 'department_type' => 'general', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'consultation', 'charge_label' => 'Consultation', 'insurance_eligible' => true, 'can_return' => true, 'creates_child' => true, 'default_next' => 'pharmacy'], + ['code' => 'laboratory', 'name' => 'Laboratory', 'type' => 'laboratory', 'queue_context' => 'laboratory', 'department_type' => 'laboratory', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'lab', 'charge_label' => 'Laboratory tests', 'insurance_eligible' => true, 'optional' => true, 'can_return' => true, 'default_next' => 'consultation'], + ['code' => 'pharmacy', 'name' => 'Pharmacy', 'type' => 'pharmacy', 'queue_context' => 'pharmacy', 'department_type' => 'pharmacy', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'pharmacy', 'charge_label' => 'Medication', 'insurance_eligible' => true, 'optional' => true, 'default_next' => 'exit'], + ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], + ], + ], + + /* + | Cashless private clinic — pay at exit (no gates before service). + */ + 'cashless_clinic' => [ + 'label' => 'Cashless clinic (pay at exit)', + 'description' => 'Reception, consultation, and pharmacy with a single payment at exit. No gates block clinical service.', + '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' => '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' => 'exit', 'name' => 'Exit', 'type' => 'exit'], + ], + ], + + /* + | Diagnostic centre — reception → pay → sample → testing → results. + */ + 'diagnostic_centre' => [ + 'label' => 'Diagnostic centre', + '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' => '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'], + ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], + ], + ], + + /* + | Specialist practice — appointment → consultation → pay → exit. + */ + 'specialist_practice' => [ + 'label' => 'Specialist practice', + 'description' => 'Appointment-led consultation with payment after service.', + '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' => 'exit', 'name' => 'Exit', 'type' => 'exit'], + ], + ], + + /* + | Pharmacy-led — dispensary counter with pay-before-dispense. + */ + 'pharmacy_led' => [ + 'label' => 'Pharmacy / dispensary', + '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' => 'dispensing', 'name' => 'Dispensing', 'type' => 'pharmacy', 'queue_context' => 'pharmacy', 'department_type' => 'pharmacy', 'default_next' => 'exit'], + ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], + ], + ], + + /* + | Standard Herbal Hospital (Ghana) — herbal consultation + preparation + | and dispensing with cash/MoMo-first gates. Insurance usually N/A. + */ + 'herbal_hospital' => [ + 'label' => 'Standard herbal hospital (Ghana)', + 'description' => 'Registration, herbal consultation fee, and pay-before-service for herbal preparation and dispensing. Lab optional. Cash and MoMo first.', + 'stages' => [ + ['code' => 'registration', 'name' => 'Registration', 'type' => 'registration', 'queue_context' => 'reception', 'department_type' => 'outpatient', 'requires_payment' => true, 'payment_timing' => 'before', 'payment_modes' => ['internal_cashier', 'external_bank', 'digital'], 'charge_code' => 'patient_card', 'charge_label' => 'Patient card / folder', 'insurance_eligible' => false, 'default_next' => 'consultation'], + ['code' => 'consultation', 'name' => 'Herbal consultation', 'type' => 'consultation', 'queue_context' => 'consultation', 'department_type' => 'general', 'requires_payment' => true, 'payment_timing' => 'before', 'payment_modes' => ['internal_cashier', 'external_bank', 'digital'], 'charge_code' => 'consultation', 'charge_label' => 'Consultation fee', 'insurance_eligible' => false, 'can_return' => true, 'creates_child' => true, 'default_next' => 'dispensing'], + ['code' => 'laboratory', 'name' => 'Laboratory', 'type' => 'laboratory', 'queue_context' => 'laboratory', 'department_type' => 'laboratory', 'requires_payment' => true, 'payment_timing' => 'before', 'charge_code' => 'lab', 'charge_label' => 'Laboratory tests', 'insurance_eligible' => false, 'optional' => true, 'can_return' => true, 'default_next' => 'consultation'], + ['code' => 'preparation', 'name' => 'Herbal preparation', 'type' => 'pharmacy', 'department_type' => 'pharmacy', 'requires_payment' => true, 'payment_timing' => 'before', 'payment_modes' => ['internal_cashier', 'external_bank', 'digital'], 'charge_code' => 'pharmacy', 'charge_label' => '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'], + ], + ], + + /* + | Herbal Clinic (simple) — reception → consult → pay → dispense. + */ + 'herbal_clinic' => [ + 'label' => 'Herbal clinic (simple)', + 'description' => 'Reception, herbal consultation, payment, then dispensing. Cash and MoMo first.', + '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' => 'dispensing', 'name' => 'Dispensing', 'type' => 'pharmacy', 'queue_context' => 'pharmacy', 'department_type' => 'pharmacy', 'default_next' => 'exit'], + ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], + ], + ], + + /* + | Custom — minimal starting point for the workflow builder. + */ + 'custom' => [ + 'label' => 'Build custom', + 'description' => 'Start with reception and consultation, then build your own stages.', + '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' => 'exit'], + ['code' => 'exit', 'name' => 'Exit', 'type' => 'exit'], + ], + ], + + ], + +]; diff --git a/database/migrations/2026_07_17_200000_create_care_workflow_engine_tables.php b/database/migrations/2026_07_17_200000_create_care_workflow_engine_tables.php new file mode 100644 index 0000000..412bdef --- /dev/null +++ b/database/migrations/2026_07_17_200000_create_care_workflow_engine_tables.php @@ -0,0 +1,129 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->nullable()->constrained('care_branches')->nullOnDelete(); + $table->string('name'); + $table->string('template_key')->nullable(); + $table->string('category')->nullable(); + $table->boolean('is_active')->default(true); + $table->json('settings')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['organization_id', 'branch_id', 'is_active']); + }); + + Schema::create('care_workflow_stages', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('workflow_id')->constrained('care_facility_workflows')->cascadeOnDelete(); + $table->string('code'); + $table->string('name'); + $table->string('type')->default('custom'); + $table->string('queue_context')->nullable(); + $table->string('department_type')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + + // Financial gate configuration + $table->boolean('requires_payment')->default(false); + $table->string('payment_timing')->default('none'); // before, after, none + $table->json('payment_modes')->nullable(); // internal_cashier, external_bank, digital + $table->boolean('insurance_eligible')->default(false); + $table->boolean('credit_allowed')->default(false); + $table->boolean('allow_override')->default(true); + $table->string('charge_code')->nullable(); + $table->string('charge_label')->nullable(); + $table->unsignedInteger('default_amount_minor')->nullable(); + + // Flow configuration + $table->string('default_next')->nullable(); + $table->boolean('can_return')->default(false); + $table->boolean('can_skip')->default(false); + $table->boolean('optional')->default(false); + $table->boolean('creates_child')->default(false); + + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->unique(['workflow_id', 'code']); + $table->index(['workflow_id', 'sort_order']); + }); + + Schema::create('care_visit_stage_advances', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('visit_id')->constrained('care_visits')->cascadeOnDelete(); + $table->foreignId('workflow_id')->nullable()->constrained('care_facility_workflows')->nullOnDelete(); + $table->foreignId('workflow_stage_id')->nullable()->constrained('care_workflow_stages')->nullOnDelete(); + $table->string('stage_code')->nullable(); + $table->string('status')->default('active'); // active, blocked, completed, skipped + $table->string('blocked_reason')->nullable(); // awaiting_payment, awaiting_bank_receipt, awaiting_authorization + $table->timestamp('entered_at')->nullable(); + $table->timestamp('cleared_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['visit_id', 'status']); + $table->index(['visit_id', 'stage_code']); + }); + + Schema::create('care_financial_obligations', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->nullable()->constrained('care_branches')->nullOnDelete(); + $table->foreignId('visit_id')->nullable()->constrained('care_visits')->nullOnDelete(); + $table->foreignId('patient_id')->nullable()->constrained('care_patients')->nullOnDelete(); + $table->foreignId('workflow_stage_id')->nullable()->constrained('care_workflow_stages')->nullOnDelete(); + $table->foreignId('bill_id')->nullable()->constrained('care_bills')->nullOnDelete(); + $table->string('stage_code')->nullable(); + $table->string('charge_code')->nullable(); + $table->string('label')->nullable(); + $table->unsignedInteger('amount_minor')->default(0); + $table->string('status')->default('pending'); // pending, authorized, paid, waived, deferred, void + $table->string('clearance_method')->nullable(); // payment, insurance, waiver, credit, bank_receipt, override + $table->string('payment_mode')->nullable(); // internal_cashier, external_bank, digital + $table->string('external_reference')->nullable(); // bank slip / receipt no + $table->string('cleared_by')->nullable(); + $table->timestamp('cleared_at')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['visit_id', 'status']); + $table->index(['organization_id', 'status']); + $table->index(['stage_code', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_financial_obligations'); + Schema::dropIfExists('care_visit_stage_advances'); + Schema::dropIfExists('care_workflow_stages'); + Schema::dropIfExists('care_facility_workflows'); + } +}; diff --git a/resources/views/care/onboarding/show.blade.php b/resources/views/care/onboarding/show.blade.php index 7fb1ca9..b97cb09 100644 --- a/resources/views/care/onboarding/show.blade.php +++ b/resources/views/care/onboarding/show.blade.php @@ -1,30 +1,84 @@ -
+

Welcome to Ladill Care

-

Set up your healthcare facility to get started.

+

Two quick choices shape your facility: what you do (category) and how patients move through it (workflow).

-
+ @php + $categoryDefaults = []; + foreach ($categories as $key => $category) { + $categoryDefaults[$key] = $category['default_template'] ?? $defaultTemplate; + } + @endphp + + @csrf
- + @error('organization_name')

{{ $message }}

@enderror
- - +
+ @foreach ($categories as $key => $category) + @endforeach - +
+ @error('facility_category')

{{ $message }}

@enderror
- - +

2. Workflow template

+

Defines the patient journey and where payments are collected. You can customize this later.

+
+ @foreach ($templates as $key => $template) + + @endforeach +
+ @error('workflow_template')

{{ $message }}

@enderror +
+ +
+
+ + +
+
+ + +
@@ -33,12 +87,6 @@ class="mt-1 w-full rounded-lg border-slate-300 text-sm">
-
- - -
-
actingAs($this->user) ->post(route('care.onboarding.store'), [ 'organization_name' => 'Accra Medical Centre', - 'facility_type' => 'hospital', + 'facility_category' => 'hospital', + 'workflow_template' => 'public_hospital_gh', 'branch_name' => 'Head Office', 'branch_address' => '123 Main St', 'branch_phone' => '+233201234567', @@ -124,6 +125,8 @@ class CareWebTest extends TestCase $this->assertDatabaseHas('care_organizations', ['name' => 'Accra Medical Centre']); $this->assertDatabaseHas('care_branches', ['name' => 'Head Office']); $this->assertDatabaseHas('care_departments', ['name' => 'General Outpatient']); + $this->assertDatabaseHas('care_facility_workflows', ['template_key' => 'public_hospital_gh']); + $this->assertDatabaseHas('care_workflow_stages', ['code' => 'consultation', 'requires_payment' => true]); } public function test_branches_index_loads(): void diff --git a/tests/Feature/CareWorkflowEngineTest.php b/tests/Feature/CareWorkflowEngineTest.php new file mode 100644 index 0000000..1ba68db --- /dev/null +++ b/tests/Feature/CareWorkflowEngineTest.php @@ -0,0 +1,212 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'care-workflow-owner', + 'name' => 'Owner', + 'email' => 'workflow-engine@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Gate Hospital', + 'slug' => 'gate-hospital', + 'settings' => ['onboarded' => true, 'facility_category' => 'hospital'], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + } + + protected function makeVisit(): Visit + { + $patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-0001', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + ]); + + return Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + } + + public function test_installer_materializes_template_stages(): void + { + $workflow = app(WorkflowTemplateInstaller::class) + ->install($this->organization, $this->branch, 'public_hospital_gh'); + + $this->assertSame('public_hospital_gh', $workflow->template_key); + $this->assertTrue((bool) $workflow->stages()->where('code', 'consultation')->value('requires_payment')); + $this->assertGreaterThanOrEqual(6, $workflow->stages()->count()); + } + + public function test_herbal_hospital_template_is_registered_and_installable(): void + { + $registry = app(WorkflowTemplateRegistry::class); + $this->assertTrue($registry->hasTemplate('herbal_hospital')); + $this->assertTrue($registry->hasTemplate('herbal_clinic')); + $this->assertArrayHasKey('herbal_hospital', $registry->categories()); + + $workflow = app(WorkflowTemplateInstaller::class) + ->install($this->organization, $this->branch, 'herbal_hospital'); + + $this->assertNotNull($workflow->stage('dispensing')); + $consult = $workflow->stage('consultation'); + $this->assertTrue($consult->requires_payment); + $this->assertFalse($consult->insurance_eligible); + } + + public function test_gate_blocks_stage_until_obligation_cleared(): void + { + app(WorkflowTemplateInstaller::class) + ->install($this->organization, $this->branch, 'public_hospital_gh'); + + $engine = app(WorkflowEngine::class); + $visit = $this->makeVisit(); + + $engine->start($visit); + + // Registration is a "before" gate → visit starts blocked, not queueable. + $this->assertFalse($engine->isQueueReleasable($visit)); + $advance = $engine->currentAdvance($visit); + $this->assertSame('registration', $advance->stage_code); + $this->assertSame(VisitStageAdvance::STATUS_BLOCKED, $advance->status); + + $obligation = FinancialObligation::query() + ->where('visit_id', $visit->id) + ->where('stage_code', 'registration') + ->first(); + $this->assertNotNull($obligation); + $this->assertSame(FinancialObligation::STATUS_PENDING, $obligation->status); + + app(FinancialGateService::class)->clear($obligation, 'cash', $this->owner->public_id, 'internal_cashier'); + $engine->refreshGate($visit); + + $this->assertTrue($engine->isQueueReleasable($visit)); + $this->assertSame(VisitStageAdvance::STATUS_ACTIVE, $engine->currentAdvance($visit)->fresh()->status); + } + + public function test_advance_follows_default_next_and_blocks_on_next_gate(): void + { + app(WorkflowTemplateInstaller::class) + ->install($this->organization, $this->branch, 'public_hospital_gh'); + + $engine = app(WorkflowEngine::class); + $gate = app(FinancialGateService::class); + $visit = $this->makeVisit(); + + $engine->start($visit); + $reg = FinancialObligation::query()->where('visit_id', $visit->id)->where('stage_code', 'registration')->first(); + $gate->clear($reg, 'cash', $this->owner->public_id); + $engine->refreshGate($visit); + + // registration → triage (no gate) → advance again → consultation (gated) + $engine->advance($visit); // to triage + $this->assertSame('triage', $engine->currentAdvance($visit)->stage_code); + $this->assertTrue($engine->isQueueReleasable($visit)); + + $engine->advance($visit); // to consultation, gated + $advance = $engine->currentAdvance($visit); + $this->assertSame('consultation', $advance->stage_code); + $this->assertSame(VisitStageAdvance::STATUS_BLOCKED, $advance->status); + + // Pharmacy is also a "before" gate, so canAdvance is false until paid. + $this->assertFalse($engine->canAdvance($visit, 'pharmacy')); + } + + public function test_after_timing_stage_never_blocks_entry(): void + { + app(WorkflowTemplateInstaller::class) + ->install($this->organization, $this->branch, 'cashless_clinic'); + + $engine = app(WorkflowEngine::class); + $visit = $this->makeVisit(); + + $engine->start($visit); + $this->assertSame('reception', $engine->currentAdvance($visit)->stage_code); + $this->assertTrue($engine->isQueueReleasable($visit)); + + $engine->advance($visit); // consultation + $engine->advance($visit); // pharmacy + $engine->advance($visit); // checkout (after gate) — still enterable + $this->assertSame('checkout', $engine->currentAdvance($visit)->stage_code); + $this->assertTrue($engine->isQueueReleasable($visit)); + } + + public function test_onboarding_stores_category_modules_and_installs_workflow(): void + { + $newOwner = User::create([ + 'public_id' => 'onboard-owner', + 'name' => 'B', + 'email' => 'b@example.com', + ]); + + $org = app(OrganizationResolver::class)->completeOnboarding($newOwner, [ + 'organization_name' => 'Herbal Wellness', + 'facility_category' => 'herbal_clinic', + 'workflow_template' => 'herbal_clinic', + 'branch_name' => 'Main', + 'timezone' => 'UTC', + ]); + + $this->assertSame('herbal_clinic', data_get($org->settings, 'facility_category')); + $this->assertContains('pharmacy', data_get($org->settings, 'modules', [])); + $this->assertDatabaseHas('care_facility_workflows', [ + 'organization_id' => $org->id, + 'template_key' => 'herbal_clinic', + ]); + } +}