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'] ?? []));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ladill Care — Facility categories + workflow templates
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The WORKFLOW is the primary configuration object in Ladill Care. A facility
|
||||
| picks a Category (which modules to enable) and a Workflow Template (how
|
||||
| patients actually move + where money is collected). Templates materialize
|
||||
| into care_facility_workflows / care_workflow_stages and can be edited.
|
||||
|
|
||||
| Stage schema (per stage):
|
||||
| code, name, type, queue_context (nullable Queue integration context),
|
||||
| department_type (nullable), sort_order,
|
||||
| requires_payment (bool), payment_timing (before|after|none),
|
||||
| payment_modes (internal_cashier|external_bank|digital),
|
||||
| insurance_eligible, credit_allowed, allow_override,
|
||||
| charge_code (nullable service catalog key), charge_label, default_amount_minor,
|
||||
| default_next (code|null), can_return, can_skip, optional,
|
||||
| creates_child (bool — doctor decision branches).
|
||||
|
|
||||
| Payment timing:
|
||||
| before → gate blocks the stage's queue until obligation clears
|
||||
| after → service runs, settle later (e.g. pay at exit)
|
||||
| none → no financial gate
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'version' => 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'],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Workflow-centric patient journey + financial gates.
|
||||
*
|
||||
* FacilityWorkflow → ordered WorkflowStage graph (materialized from a template).
|
||||
* VisitStageAdvance → a visit's position + history through those stages.
|
||||
* FinancialObligation → charge intent created before/after a stage; a stage's
|
||||
* queue stays gated until its obligation clears (paid / authorized / waived).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('care_facility_workflows', 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->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');
|
||||
}
|
||||
};
|
||||
@@ -1,30 +1,84 @@
|
||||
<x-app-layout title="Set up Care">
|
||||
<div class="mx-auto max-w-xl">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Welcome to Ladill Care</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">Set up your healthcare facility to get started.</p>
|
||||
<p class="mt-2 text-sm text-slate-600">Two quick choices shape your facility: what you do (category) and how patients move through it (workflow).</p>
|
||||
|
||||
<form method="POST" action="{{ route('care.onboarding.store') }}" enctype="multipart/form-data" class="mt-8 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@php
|
||||
$categoryDefaults = [];
|
||||
foreach ($categories as $key => $category) {
|
||||
$categoryDefaults[$key] = $category['default_template'] ?? $defaultTemplate;
|
||||
}
|
||||
@endphp
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('care.onboarding.store') }}"
|
||||
enctype="multipart/form-data"
|
||||
class="mt-8 space-y-6 rounded-2xl border border-slate-200 bg-white p-6"
|
||||
x-data="{
|
||||
category: @js(old('facility_category', array_key_first($categories))),
|
||||
template: @js(old('workflow_template', $defaultTemplate)),
|
||||
defaults: @js($categoryDefaults),
|
||||
pickCategory(key) {
|
||||
this.category = key;
|
||||
if (this.defaults[key]) this.template = this.defaults[key];
|
||||
},
|
||||
}"
|
||||
>
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Facility / organization name</label>
|
||||
<input type="text" name="organization_name" value="{{ old('organization_name', $user->name."'s Clinic") }}" required
|
||||
<input type="text" name="organization_name" value="{{ old('organization_name', $user->name."'s Facility") }}" required
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@error('organization_name') <p class="mt-1 text-sm text-rose-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Facility type</label>
|
||||
<select name="facility_type" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($facilityTypes as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('facility_type', 'clinic') === $value)>{{ $label }}</option>
|
||||
<p class="text-sm font-medium text-slate-700">1. Facility category</p>
|
||||
<p class="text-xs text-slate-500">Decides which modules (lab, pharmacy, imaging, billing) are turned on.</p>
|
||||
<input type="hidden" name="facility_category" :value="category">
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
@foreach ($categories as $key => $category)
|
||||
<button type="button" @click="pickCategory(@js($key))"
|
||||
class="rounded-xl border px-3 py-2 text-left text-sm transition"
|
||||
:class="category === @js($key) ? 'border-sky-500 bg-sky-50 text-sky-900 ring-1 ring-sky-500' : 'border-slate-200 text-slate-700 hover:border-slate-300'">
|
||||
{{ $category['label'] }}
|
||||
</button>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@error('facility_category') <p class="mt-1 text-sm text-rose-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Primary branch</label>
|
||||
<input type="text" name="branch_name" value="{{ old('branch_name', 'Main Branch') }}" required
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<p class="text-sm font-medium text-slate-700">2. Workflow template</p>
|
||||
<p class="text-xs text-slate-500">Defines the patient journey and where payments are collected. You can customize this later.</p>
|
||||
<div class="mt-3 space-y-2">
|
||||
@foreach ($templates as $key => $template)
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border px-3 py-3 transition"
|
||||
:class="template === @js($key) ? 'border-sky-500 bg-sky-50 ring-1 ring-sky-500' : 'border-slate-200 hover:border-slate-300'">
|
||||
<input type="radio" name="workflow_template" value="{{ $key }}" x-model="template" class="mt-1 text-sky-600">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">{{ $template['label'] }}</span>
|
||||
<span class="block text-xs text-slate-500">{{ $template['description'] ?? '' }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('workflow_template') <p class="mt-1 text-sm text-rose-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Primary branch</label>
|
||||
<input type="text" name="branch_name" value="{{ old('branch_name', 'Main Branch') }}" required
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch phone</label>
|
||||
<input type="tel" name="branch_phone" value="{{ old('branch_phone') }}"
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -33,12 +87,6 @@
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch phone</label>
|
||||
<input type="tel" name="branch_phone" value="{{ old('branch_phone') }}"
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Logo (optional)</label>
|
||||
<input type="file" name="logo" accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
|
||||
@@ -113,7 +113,8 @@ class CareWebTest extends TestCase
|
||||
$this->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
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\Branch;
|
||||
use App\Models\FinancialObligation;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Patient;
|
||||
use App\Models\User;
|
||||
use App\Models\Visit;
|
||||
use App\Models\VisitStageAdvance;
|
||||
use App\Services\Care\OrganizationResolver;
|
||||
use App\Services\Care\Workflow\FinancialGateService;
|
||||
use App\Services\Care\Workflow\WorkflowEngine;
|
||||
use App\Services\Care\Workflow\WorkflowTemplateInstaller;
|
||||
use App\Services\Care\Workflow\WorkflowTemplateRegistry;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CareWorkflowEngineTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $owner;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
protected Branch $branch;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user