Activate Care workflows across check-in and financial clearance.
Deploy Ladill Care / deploy (push) Failing after 1m9s

Enforce service-queue gates, cashier settlements, and clinical stage progression so patient journeys cannot bypass configured payment or authorization rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 21:13:06 +00:00
co-authored by Cursor
parent 86bfce1e17
commit 3ee59a0956
38 changed files with 1953 additions and 107 deletions
+5
View File
@@ -67,6 +67,11 @@ AFIA_PLATFORM_API_KEY=
# Platform events webhook (user/org lifecycle from the monolith)
SERVICE_EVENTS_INBOUND_SECRET=
# Patient journey financial gate defaults (minor currency units)
CARE_CURRENCY=GHS
CARE_PATIENT_CARD_FEE_MINOR=2000
CARE_CONSULTATION_FEE_MINOR=5000
# Suite demo accounts (sales walkthroughs) — reset Care data on each SSO login
DEMO_ACCOUNTS_ENABLED=false
DEMO_RESET_ON_LOGIN=true
@@ -2,15 +2,21 @@
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Services\Care\AppointmentService;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\Workflow\WorkflowEngine;
use App\Services\Care\Workflow\WorkflowQueueGate;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -108,6 +114,11 @@ class AppointmentController extends Controller
$this->ownerRef($request),
);
if (app(CareFeatures::class)->enabled($organization, CareFeatures::WORKFLOW_ENGINE)) {
return redirect()->route('care.appointments.show', $appointment)
->with('success', 'Walk-in checked in. Complete the current patient journey stage before Queue release.');
}
return redirect()->route('care.queue.index')
->with('success', 'Walk-in added to queue.');
}
@@ -119,16 +130,47 @@ class AppointmentController extends Controller
$appointment->load(['patient', 'practitioner', 'branch', 'department', 'visit', 'consultation']);
$canManage = app(\App\Services\Care\CarePermissions::class)
$canManage = app(CarePermissions::class)
->can($this->member($request), 'appointments.manage');
$canConsult = app(\App\Services\Care\CarePermissions::class)
$canConsult = app(CarePermissions::class)
->can($this->member($request), 'consultations.manage');
$canViewBills = app(CarePermissions::class)
->can($this->member($request), 'bills.view');
$organization = $this->organization($request);
$workflowEnabled = $appointment->visit
&& app(CareFeatures::class)->enabled($organization, CareFeatures::WORKFLOW_ENGINE);
$workflowEngine = app(WorkflowEngine::class);
$currentAdvance = $workflowEnabled
? $workflowEngine->refreshGate($appointment->visit)
: null;
$currentObligation = $currentAdvance?->stage
? $appointment->visit->financialObligations()
->where('workflow_stage_id', $currentAdvance->stage->id)
->latest('id')
->first()
: null;
$canStartConsultation = $canConsult && (! $workflowEnabled || app(WorkflowQueueGate::class)->canRelease(
$organization,
$appointment->visit,
$this->appointments->queueContextFor($organization, $appointment),
));
return view('care.appointments.show', [
'appointment' => $appointment,
'statuses' => config('care.appointment_statuses'),
'canManage' => $canManage,
'canConsult' => $canConsult,
'canViewBills' => $canViewBills,
'canStartConsultation' => $canStartConsultation,
'workflowEnabled' => $workflowEnabled,
'currentAdvance' => $currentAdvance,
'currentObligation' => $currentObligation,
'workflowHistory' => $workflowEnabled
? $appointment->visit->stageAdvances()->with('stage')->orderBy('id')->get()
: collect(),
'canAdvanceWorkflow' => $canManage
&& $workflowEnabled
&& $workflowEngine->canManuallyAdvance($appointment->visit),
]);
}
@@ -137,7 +179,12 @@ class AppointmentController extends Controller
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeAppointment($request, $appointment);
$this->appointments->checkIn($appointment, $this->ownerRef($request), $this->ownerRef($request));
$appointment = $this->appointments->checkIn($appointment, $this->ownerRef($request), $this->ownerRef($request));
if (app(CareFeatures::class)->enabled($this->organization($request), CareFeatures::WORKFLOW_ENGINE)) {
return redirect()->route('care.appointments.show', $appointment)
->with('success', 'Patient checked in. Complete the current patient journey stage before Queue release.');
}
return redirect()->route('care.queue.index')
->with('success', 'Patient checked in and added to queue.');
@@ -177,7 +224,7 @@ class AppointmentController extends Controller
/**
* @return array<string, mixed>
*/
protected function formData(Request $request, \App\Models\Organization $organization): array
protected function formData(Request $request, Organization $organization): array
{
$ownerRef = $this->ownerRef($request);
$branchQuery = Branch::owned($ownerRef)->where('organization_id', $organization->id)->where('is_active', true);
@@ -214,7 +261,7 @@ class AppointmentController extends Controller
}
/**
* @return \Illuminate\Database\Eloquent\Collection<int, Practitioner>
* @return Collection<int, Practitioner>
*/
protected function activePractitioners(Request $request, int $organizationId)
{
@@ -2,13 +2,14 @@
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Assessment;
use App\Models\AssessmentTemplate;
use App\Models\Consultation;
use App\Models\InvestigationType;
use App\Models\Practitioner;
use App\Models\Assessment;
use App\Models\AssessmentTemplate;
use App\Services\Care\AssessmentService;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\ConsultationReturnContext;
@@ -105,7 +106,7 @@ class ConsultationController extends Controller
if ($universalTemplate) {
try {
app(\App\Services\Care\AssessmentService::class)
app(AssessmentService::class)
->assertCaptureAllowed($member, $universalTemplate);
$canCaptureUniversal = true;
} catch (\Throwable) {
@@ -197,9 +198,9 @@ class ConsultationController extends Controller
public function update(Request $request, Consultation $consultation): RedirectResponse
{
$canManage = app(\App\Services\Care\CarePermissions::class)
$canManage = app(CarePermissions::class)
->can($this->member($request), 'consultations.manage');
$canVitals = app(\App\Services\Care\CarePermissions::class)
$canVitals = app(CarePermissions::class)
->can($this->member($request), 'vitals.manage');
abort_unless($canManage || $canVitals, 403);
@@ -232,7 +233,7 @@ class ConsultationController extends Controller
$this->authorizeAbility($request, 'consultations.manage');
$this->authorizeConsultation($request, $consultation);
$this->consultations->complete(
$completed = $this->consultations->complete(
$consultation,
$this->ownerRef($request),
$this->ownerRef($request),
@@ -240,6 +241,17 @@ class ConsultationController extends Controller
app(ConsultationReturnContext::class)->forget();
$completed->loadMissing(['appointment.organization']);
if ($completed->appointment
&& $completed->appointment->organization
&& app(CareFeatures::class)->enabled(
$completed->appointment->organization,
CareFeatures::WORKFLOW_ENGINE,
)) {
return redirect()->route('care.appointments.show', $completed->appointment)
->with('success', 'Consultation completed. Continue the patient journey from the next stage.');
}
return redirect()->route('care.queue.index')
->with('success', 'Consultation completed.');
}
@@ -0,0 +1,205 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\FinancialObligation;
use App\Models\Payment;
use App\Models\WorkflowStage;
use App\Services\Care\AuditLogger;
use App\Services\Care\BillService;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\Workflow\FinancialGateService;
use App\Services\Care\Workflow\WorkflowEngine;
use App\Services\Care\Workflow\WorkflowQueueReleaseService;
use App\Services\Care\Workflow\WorkflowStageCompletionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class FinancialObligationController extends Controller
{
use ScopesToAccount;
public function __construct(
protected CareFeatures $features,
protected FinancialGateService $gates,
protected WorkflowEngine $workflows,
protected WorkflowQueueReleaseService $queueRelease,
protected WorkflowStageCompletionService $workflowCompletion,
protected BillService $bills,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'bills.view');
$organization = $this->organization($request);
abort_unless($this->features->enabled($organization, CareFeatures::WORKFLOW_ENGINE), 404);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$status = (string) $request->query('status', FinancialObligation::STATUS_PENDING);
$obligations = FinancialObligation::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->when($branchScope, fn ($query) => $query->where('branch_id', $branchScope))
->when($status !== '', fn ($query) => $query->where('status', $status))
->with(['patient', 'branch', 'stage', 'visit', 'bill'])
->latest('created_at')
->paginate(20)
->withQueryString();
return view('care.financial-obligations.index', [
'obligations' => $obligations,
'statuses' => config('care_workflows.obligation_statuses', []),
'clearanceMethods' => config('care_workflows.clearance_methods', []),
'paymentModes' => config('care_workflows.payment_modes', []),
'paymentMethods' => collect(config('care.payment_methods', []))->except(['gateway'])->all(),
'canClear' => app(CarePermissions::class)
->can($this->member($request), 'payments.manage'),
]);
}
public function clear(Request $request, FinancialObligation $financialObligation): RedirectResponse
{
$this->authorizeAbility($request, 'payments.manage');
$this->authorizeObligation($request, $financialObligation);
if ($financialObligation->status !== FinancialObligation::STATUS_PENDING) {
return back()->with('info', 'This financial obligation is no longer pending.');
}
$manualPaymentMethods = array_keys(
collect(config('care.payment_methods', []))->except(['gateway'])->all()
);
$validated = $request->validate([
'method' => ['required', Rule::in(array_keys(config('care_workflows.clearance_methods', [])))],
'payment_mode' => ['nullable', Rule::in(array_keys(config('care_workflows.payment_modes', [])))],
'payment_method' => ['nullable', Rule::in($manualPaymentMethods)],
'reference' => ['nullable', 'string', 'max:100'],
]);
$stage = $financialObligation->stage;
$method = (string) $validated['method'];
$paymentMode = $validated['payment_mode'] ?? null;
$reference = trim((string) ($validated['reference'] ?? '')) ?: null;
$this->validateClearanceRules($stage, $method, $paymentMode, $reference);
if ($method === 'bank_receipt') {
$paymentMode = 'external_bank';
}
$settled = DB::transaction(function () use ($financialObligation, $validated, $method, $paymentMode, $reference, $request) {
$locked = FinancialObligation::query()->lockForUpdate()->findOrFail($financialObligation->id);
if ($locked->status !== FinancialObligation::STATUS_PENDING) {
return $locked;
}
if (in_array($method, ['payment', 'bank_receipt'], true) && $locked->amount_minor > 0) {
$bill = $this->bills->billForObligation(
$locked,
$this->ownerRef($request),
$this->ownerRef($request),
);
$this->bills->recordPayment($bill, $this->ownerRef($request), [
'amount_minor' => $locked->amount_minor,
'method' => $method === 'bank_receipt'
? Payment::METHOD_OTHER
: ($validated['payment_method'] ?? Payment::METHOD_CASH),
'reference' => $reference,
'notes' => 'Workflow financial obligation '.$locked->uuid,
], $this->ownerRef($request));
}
$this->gates->clear(
$locked,
$method,
$this->ownerRef($request),
$paymentMode,
$reference,
);
AuditLogger::record(
$this->ownerRef($request),
'financial_obligation.cleared',
$locked->organization_id,
$this->ownerRef($request),
FinancialObligation::class,
$locked->id,
['method' => $method, 'amount_minor' => $locked->amount_minor],
);
return $locked->fresh();
});
$visit = $settled->visit;
if ($visit) {
$advance = $this->workflows->refreshGate($visit);
if ($advance?->stage?->type === 'payment') {
$this->workflowCompletion->complete(
$this->organization($request),
$visit,
CareQueueContexts::BILLING,
$this->ownerRef($request),
$this->ownerRef($request),
);
} else {
$this->queueRelease->releaseCurrent($this->organization($request), $visit);
}
}
return back()->with('success', 'Financial obligation cleared. The patient may now continue.');
}
protected function validateClearanceRules(?WorkflowStage $stage, string $method, ?string $paymentMode, ?string $reference): void
{
if ($method === 'insurance' && ! $stage?->insurance_eligible) {
throw ValidationException::withMessages(['method' => 'Insurance is not allowed for this stage.']);
}
if ($method === 'credit' && ! $stage?->credit_allowed) {
throw ValidationException::withMessages(['method' => 'Credit is not allowed for this stage.']);
}
if (in_array($method, ['waiver', 'override'], true) && ! $stage?->allow_override) {
throw ValidationException::withMessages(['method' => 'Waivers and overrides are not allowed for this stage.']);
}
if ($method === 'bank_receipt' && $reference === null) {
throw ValidationException::withMessages(['reference' => 'Enter the external bank receipt or slip number.']);
}
if ($method === 'bank_receipt' && $stage && ! in_array('external_bank', (array) $stage->payment_modes, true)) {
throw ValidationException::withMessages(['method' => 'External bank receipts are not allowed for this stage.']);
}
if ($method === 'payment') {
$allowedModes = (array) ($stage?->payment_modes ?? []);
if ($paymentMode === null || ($allowedModes !== [] && ! in_array($paymentMode, $allowedModes, true))) {
throw ValidationException::withMessages(['payment_mode' => 'Select a payment mode allowed for this stage.']);
}
if ($paymentMode === 'external_bank') {
throw ValidationException::withMessages(['method' => 'Use external bank receipt verification for bank payments.']);
}
}
}
protected function authorizeObligation(Request $request, FinancialObligation $obligation): void
{
$this->authorizeOwner($request, $obligation);
abort_unless($obligation->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $obligation->branch_id !== $branchId) {
abort(404);
}
}
}
@@ -2,12 +2,13 @@
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Practitioner;
use App\Services\Care\AppointmentService;
use App\Services\Care\CarePermissions;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\ConsultationService;
@@ -63,9 +64,9 @@ class QueueController extends Controller
->orderBy('started_at')
->get();
$canManageQueue = app(\App\Services\Care\CarePermissions::class)
$canManageQueue = app(CarePermissions::class)
->can($this->member($request), 'queue.manage');
$canConsult = app(\App\Services\Care\CarePermissions::class)
$canConsult = app(CarePermissions::class)
->can($this->member($request), 'consultations.manage');
$heroStats = [
@@ -162,12 +163,16 @@ class QueueController extends Controller
? (int) $request->input('practitioner_id')
: $appointment->practitioner_id;
try {
$this->appointments->startConsultation(
$appointment,
$this->ownerRef($request),
$practitionerId,
$this->ownerRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
$consultation = $this->consultations->startFromAppointment(
$appointment->fresh(),
@@ -2,9 +2,12 @@
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Branch;
use App\Models\Organization;
use App\Services\Billing\PlatformEmailClient;
use App\Services\Billing\PlatformSmsClient;
use App\Services\Care\AppointmentPatientNotifier;
use App\Services\Care\AuditLogger;
use App\Services\Care\CareFeatures;
@@ -13,8 +16,8 @@ use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\CareQueueProvisioner;
use App\Services\Care\PlanService;
use App\Services\Billing\PlatformEmailClient;
use App\Services\Billing\PlatformSmsClient;
use App\Services\Care\Workflow\WorkflowTemplateInstaller;
use App\Services\Care\Workflow\WorkflowTemplateRegistry;
use App\Services\Messaging\MessagingCredentialsService;
use App\Services\Queue\QueueClient;
use App\Support\OrganizationBranding;
@@ -63,18 +66,16 @@ class SettingsController extends Controller
'canUseQueueIntegration' => $plans->canUseQueueIntegration($organization),
'canUseSpecialtyModules' => $plans->canUseSpecialtyModules($organization),
'assessmentsEngineEnabled' => $careFeatures->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE),
'workflowEngineEnabled' => $careFeatures->enabled($organization, CareFeatures::WORKFLOW_ENGINE),
'financialGatesEnabled' => $careFeatures->enabled($organization, CareFeatures::FINANCIAL_GATES),
'hasBillingFeature' => $plans->hasFeature($organization, 'billing'),
'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(),
'messagingEmailReady' => $suiteEmailReady || $credential->hasValidBird(),
'suiteMessagingReady' => $suiteEmailReady || $suiteSmsReady,
'messagingSettingsUrl' => function_exists('ladill_account_url')
? ladill_account_url('/account/settings/messaging')
: (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'),
'facilityTypes' => [
'clinic' => 'Clinic',
'hospital' => 'Hospital',
'diagnostic' => 'Diagnostic laboratory',
'specialist' => 'Specialist practice',
],
'facilityTypes' => app(WorkflowTemplateRegistry::class)->categoryOptions(),
]);
}
@@ -88,11 +89,17 @@ class SettingsController extends Controller
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'timezone' => ['required', 'timezone'],
'facility_type' => ['required', 'string', 'in:clinic,hospital,diagnostic,specialist'],
'facility_type' => [
'required',
'string',
'in:'.implode(',', array_keys(app(WorkflowTemplateRegistry::class)->categories())),
],
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
'remove_logo' => ['nullable', 'boolean'],
'queue_integration_enabled' => ['nullable', 'boolean'],
'assessments_engine' => ['nullable', 'boolean'],
'workflow_engine' => ['nullable', 'boolean'],
'financial_gates' => ['nullable', 'boolean'],
'notify_appointment_booked_sms' => ['nullable', 'boolean'],
'notify_appointment_booked_email' => ['nullable', 'boolean'],
'notify_video_scheduled_sms' => ['nullable', 'boolean'],
@@ -102,10 +109,17 @@ class SettingsController extends Controller
$settings = $organization->settings ?? [];
$settings['onboarded'] = true;
$settings['facility_type'] = $validated['facility_type'];
$settings['facility_category'] = $validated['facility_type'];
$settings['modules'] = app(WorkflowTemplateRegistry::class)
->modulesForCategory($validated['facility_type']);
$rollout = is_array($settings['rollout'] ?? null) ? $settings['rollout'] : [];
// Explicit save so opt-out is stored (engine defaults ON when unset).
$rollout[CareFeatures::ASSESSMENTS_ENGINE] = $request->boolean('assessments_engine');
$rollout[CareFeatures::WORKFLOW_ENGINE] = $request->boolean('workflow_engine');
$rollout[CareFeatures::FINANCIAL_GATES] = $request->boolean('financial_gates')
&& $rollout[CareFeatures::WORKFLOW_ENGINE]
&& $plans->hasFeature($organization, 'billing');
$settings['rollout'] = $rollout;
if ($rollout[CareFeatures::ASSESSMENTS_ENGINE]) {
app(CareFeatures::class)->ensureAssessmentCatalog();
@@ -155,6 +169,21 @@ class SettingsController extends Controller
'settings' => $settings,
]);
if ($rollout[CareFeatures::WORKFLOW_ENGINE] && ! $organization->facilityWorkflows()->where('is_active', true)->exists()) {
$branch = Branch::owned($owner)
->where('organization_id', $organization->id)
->orderBy('id')
->first();
if ($branch) {
app(WorkflowTemplateInstaller::class)->install(
$organization->fresh(),
$branch,
(string) ($settings['workflow_template'] ?? config('care_workflows.default_template', 'legacy_clinic')),
['category' => $settings['facility_category'] ?? $settings['facility_type'] ?? 'clinic'],
);
}
}
if ($wantsQueue && $queue->configured()) {
try {
$organization = $organization->fresh();
@@ -178,7 +207,7 @@ class SettingsController extends Controller
}
}
AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id);
AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, Organization::class, $organization->id);
return back()->with('success', 'Settings saved.');
}
@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Visit;
use App\Models\VisitStageAdvance;
use App\Services\Care\AuditLogger;
use App\Services\Care\CareFeatures;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\Workflow\WorkflowEngine;
use App\Services\Care\Workflow\WorkflowQueueReleaseService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class VisitWorkflowController extends Controller
{
use ScopesToAccount;
public function __construct(
protected CareFeatures $features,
protected WorkflowEngine $workflows,
protected WorkflowQueueReleaseService $queueRelease,
) {}
public function advance(Request $request, Visit $visit): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeVisit($request, $visit);
$organization = $this->organization($request);
abort_unless($this->features->enabled($organization, CareFeatures::WORKFLOW_ENGINE), 404);
$validated = $request->validate([
'to_code' => ['nullable', 'string', 'max:100'],
]);
if (! $this->workflows->canManuallyAdvance($visit)) {
return back()->with('error', 'Complete this clinical service from its own workspace before advancing the visit.');
}
try {
$advance = $this->workflows->advance($visit, $validated['to_code'] ?? null);
} catch (\DomainException $e) {
return back()->with('error', $e->getMessage());
}
if ($advance === null) {
return back()->with('info', 'This visit has no next workflow stage.');
}
AuditLogger::record(
$this->ownerRef($request),
'visit.workflow_advanced',
$visit->organization_id,
$this->ownerRef($request),
VisitStageAdvance::class,
$advance->id,
['stage_code' => $advance->stage_code, 'status' => $advance->status],
);
$this->queueRelease->releaseCurrent($organization, $visit);
$message = $advance->isBlocked()
? "Visit moved to {$advance->stage?->name}; financial clearance is required before service."
: "Visit moved to {$advance->stage?->name}.";
return back()->with($advance->isBlocked() ? 'warning' : 'success', $message);
}
protected function authorizeVisit(Request $request, Visit $visit): void
{
$this->authorizeOwner($request, $visit);
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $visit->branch_id !== $branchId) {
abort(404);
}
}
}
+1 -1
View File
@@ -101,7 +101,7 @@ class Appointment extends Model
public function consultation(): HasOne
{
return $this->hasOne(Consultation::class, 'appointment_id');
return $this->hasOne(Consultation::class, 'appointment_id')->latestOfMany();
}
/** @return list<string> */
+10
View File
@@ -69,6 +69,16 @@ class FinancialObligation extends Model
return $this->belongsTo(Visit::class, 'visit_id');
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
+10
View File
@@ -31,4 +31,14 @@ class Organization extends Model
{
return $this->hasMany(Member::class, 'organization_id');
}
public function facilityWorkflows(): HasMany
{
return $this->hasMany(FacilityWorkflow::class, 'organization_id');
}
public function financialObligations(): HasMany
{
return $this->hasMany(FinancialObligation::class, 'organization_id');
}
}
+10
View File
@@ -88,4 +88,14 @@ class Visit extends Model
{
return $this->hasMany(Bill::class, 'visit_id');
}
public function stageAdvances(): HasMany
{
return $this->hasMany(VisitStageAdvance::class, 'visit_id');
}
public function financialObligations(): HasMany
{
return $this->hasMany(FinancialObligation::class, 'visit_id');
}
}
+11
View File
@@ -5,6 +5,7 @@ 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\Support\Str;
class WorkflowStage extends Model
@@ -60,6 +61,16 @@ class WorkflowStage extends Model
return $this->belongsTo(FacilityWorkflow::class, 'workflow_id');
}
public function advances(): HasMany
{
return $this->hasMany(VisitStageAdvance::class, 'workflow_stage_id');
}
public function financialObligations(): HasMany
{
return $this->hasMany(FinancialObligation::class, 'workflow_stage_id');
}
/** Whether entering this stage's service queue is gated on a cleared obligation. */
public function gatesBeforeService(): bool
{
+50 -5
View File
@@ -6,6 +6,8 @@ use App\Models\Appointment;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Visit;
use App\Services\Care\Workflow\WorkflowQueueGate;
use App\Services\Care\Workflow\WorkflowStageCompletionService;
use App\Services\Meet\MeetClient;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@@ -20,8 +22,15 @@ class AppointmentService
protected VisitService $visits,
protected AppointmentPatientNotifier $notifier,
protected CareQueueBridge $queueBridge,
protected WorkflowQueueGate $workflowGate,
protected WorkflowStageCompletionService $workflowCompletion,
) {}
public function queueContextFor(Organization $organization, Appointment $appointment): string
{
return $this->queueBridge->contextForAppointment($organization, $appointment);
}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return Appointment::owned($ownerRef)->where('organization_id', $organizationId);
@@ -67,7 +76,7 @@ class AppointmentService
$query = Appointment::owned($ownerRef)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->with(['patient', 'practitioner'])
->with(['patient', 'practitioner', 'visit', 'organization'])
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at');
@@ -78,7 +87,20 @@ class AppointmentService
});
}
return $query->get();
return $query->get()
->filter(function (Appointment $appointment) {
$organization = $appointment->organization;
if (! $organization) {
return true;
}
return $this->workflowGate->canRelease(
$organization,
$appointment->visit,
$this->queueBridge->contextForAppointment($organization, $appointment),
);
})
->values();
}
/**
@@ -218,6 +240,15 @@ class AppointmentService
$appointment->update(['visit_id' => $visit->id, 'checked_in_at' => now()]);
}
$appointment->loadMissing(['organization', 'visit']);
if ($appointment->organization && ! $this->workflowGate->canRelease(
$appointment->organization,
$appointment->visit,
$this->queueBridge->contextForAppointment($appointment->organization, $appointment),
)) {
throw new InvalidArgumentException('This visit has not reached a financially cleared consultation stage.');
}
$appointment->visit->update(['status' => Visit::STATUS_IN_PROGRESS]);
$appointment->update([
@@ -245,9 +276,11 @@ class AppointmentService
'completed_at' => now(),
]);
if ($appointment->visit) {
$this->visits->complete($appointment->visit, $ownerRef, $actorRef);
}
$visit = $appointment->visit;
$organization = $appointment->organization;
$workflowEnabled = $visit
&& $organization
&& $this->workflowGate->enabled($organization);
AuditLogger::record($ownerRef, 'appointment.completed', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
@@ -256,6 +289,18 @@ class AppointmentService
$this->queueBridge->complete($appointment->organization, $appointment);
}
if ($workflowEnabled && $visit && $organization) {
$this->workflowCompletion->complete(
$organization,
$visit,
$this->queueBridge->contextForAppointment($organization, $appointment),
$ownerRef,
$actorRef,
);
} elseif ($visit) {
$this->visits->complete($visit, $ownerRef, $actorRef);
}
return $appointment->fresh(['patient', 'practitioner', 'visit']);
}
+113 -5
View File
@@ -4,12 +4,14 @@ namespace App\Services\Care;
use App\Models\Bill;
use App\Models\BillLineItem;
use App\Models\FinancialObligation;
use App\Models\InvestigationRequest;
use App\Models\Organization;
use App\Models\Payment;
use App\Models\Prescription;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\Workflow\WorkflowStageCompletionService;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@@ -23,6 +25,7 @@ class BillService
protected InvoiceNumberGenerator $invoices,
protected MerchantGatewayService $gateway,
protected CareQueueBridge $queueBridge,
protected WorkflowStageCompletionService $workflowCompletion,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
@@ -90,6 +93,63 @@ class BillService
return $bill->fresh(['lineItems', 'patient', 'payments']);
}
/**
* Attach a workflow charge intent to an editable visit bill. A later stage
* may create a new bill when the previous encounter bill is already paid.
*/
public function billForObligation(FinancialObligation $obligation, string $ownerRef, ?string $actorRef = null): Bill
{
if ($obligation->bill_id) {
$linked = Bill::query()->find($obligation->bill_id);
if ($linked) {
return $linked;
}
}
$visit = $obligation->visit()->with(['organization', 'patient'])->firstOrFail();
$bill = Bill::owned($ownerRef)
->where('visit_id', $visit->id)
->whereIn('status', [Bill::STATUS_DRAFT, Bill::STATUS_OPEN, Bill::STATUS_PARTIAL])
->latest('id')
->first();
if (! $bill) {
$bill = Bill::create([
'owner_ref' => $ownerRef,
'organization_id' => $visit->organization_id,
'branch_id' => $visit->branch_id,
'visit_id' => $visit->id,
'patient_id' => $visit->patient_id,
'invoice_number' => $this->invoices->generate($visit->organization),
'status' => Bill::STATUS_OPEN,
'created_by' => $actorRef,
]);
AuditLogger::record($ownerRef, 'bill.created', $visit->organization_id, $actorRef, Bill::class, $bill->id);
}
$exists = $bill->lineItems()
->where('source_type', FinancialObligation::class)
->where('source_id', $obligation->id)
->exists();
if (! $exists) {
$this->addLineItem($bill, $ownerRef, [
'type' => $this->lineTypeForCharge($obligation->charge_code),
'description' => $obligation->label ?? 'Workflow charge',
'quantity' => 1,
'unit_price_minor' => $obligation->amount_minor,
'source_type' => FinancialObligation::class,
'source_id' => $obligation->id,
]);
$this->recalculate($bill);
}
$obligation->forceFill(['bill_id' => $bill->id])->save();
return $bill->fresh(['lineItems', 'payments', 'patient']);
}
public function syncLineItemsFromVisit(Bill $bill, string $ownerRef, ?string $actorRef = null): Bill
{
if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) {
@@ -98,9 +158,19 @@ class BillService
$visit = $bill->visit()->with(['consultations'])->firstOrFail();
$bill->lineItems()->whereNotNull('source_type')->delete();
$bill->lineItems()
->whereNotNull('source_type')
->where('source_type', '!=', FinancialObligation::class)
->delete();
if ($visit->consultations()->where('status', 'completed')->exists()) {
$workflowChargeCodes = FinancialObligation::query()
->where('bill_id', $bill->id)
->pluck('charge_code')
->filter()
->all();
if (! in_array('consultation', $workflowChargeCodes, true)
&& $visit->consultations()->where('status', 'completed')->exists()) {
$fee = (int) config('care.billing.consultation_fee_minor', 5000);
$this->addLineItem($bill, $ownerRef, [
'type' => 'consultation',
@@ -117,9 +187,18 @@ class BillService
->whereIn('status', [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED])
->with('investigationType')
->get()
->each(function (InvestigationRequest $request) use ($bill, $ownerRef) {
->each(function (InvestigationRequest $request) use ($bill, $ownerRef, $workflowChargeCodes) {
$chargeCode = in_array(
$request->investigationType?->category,
['xray', 'ultrasound', 'ct', 'mri'],
true,
) ? 'imaging' : 'lab';
if (in_array($chargeCode, $workflowChargeCodes, true)) {
return;
}
$this->addLineItem($bill, $ownerRef, [
'type' => 'lab',
'type' => $chargeCode,
'description' => $request->investigationType->name,
'quantity' => 1,
'unit_price_minor' => $request->investigationType->price_minor,
@@ -133,7 +212,11 @@ class BillService
->where('status', Prescription::STATUS_DISPENSED)
->with('items.drug')
->get()
->each(function (Prescription $rx) use ($bill, $ownerRef) {
->each(function (Prescription $rx) use ($bill, $ownerRef, $workflowChargeCodes) {
if (in_array('pharmacy', $workflowChargeCodes, true)) {
return;
}
foreach ($rx->items as $item) {
if ($item->is_procedure) {
$this->addLineItem($bill, $ownerRef, [
@@ -167,6 +250,21 @@ class BillService
return $bill->fresh(['lineItems', 'payments', 'patient']);
}
protected function lineTypeForCharge(?string $chargeCode): string
{
$candidate = match ($chargeCode) {
'consultation' => 'consultation',
'lab' => 'lab',
'imaging' => 'imaging',
'pharmacy' => 'pharmacy',
default => 'misc',
};
return array_key_exists($candidate, (array) config('care.bill_line_types', []))
? $candidate
: 'misc';
}
/**
* @param array<string, mixed> $data
*/
@@ -472,6 +570,16 @@ class BillService
$organization = Organization::query()->find($bill->organization_id);
if ($organization) {
$this->queueBridge->complete($organization, $bill);
$visit = $bill->visit;
if ($visit) {
$this->workflowCompletion->complete(
$organization,
$visit,
CareQueueContexts::BILLING,
(string) $bill->owner_ref,
$bill->payments()->latest('id')->value('recorded_by'),
);
}
}
}
}
+44 -9
View File
@@ -8,7 +8,9 @@ use App\Models\InvestigationRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Models\Prescription;
use App\Services\Care\Workflow\WorkflowQueueGate;
use App\Services\Queue\QueueClient;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\RequestException;
@@ -25,6 +27,7 @@ class CareQueueBridge
protected CareQueueProvisioner $provisioner,
protected PlanService $plans,
protected SpecialtyModuleService $specialties,
protected WorkflowQueueGate $workflowGate,
) {}
public function isEnabled(Organization $organization): bool
@@ -57,7 +60,12 @@ class CareQueueBridge
return $appointment;
}
$appointment->loadMissing('visit');
$context = $this->contextForAppointment($organization, $appointment);
if (! $this->workflowGate->canRelease($organization, $appointment->visit, $context)) {
return $appointment;
}
$point = $this->resolveConsultationPoint($organization, $appointment);
if (CareQueueContexts::usesAssignedRouting($context) && ! $point) {
@@ -104,6 +112,10 @@ class CareQueueBridge
}
$prescription->loadMissing(['patient', 'visit']);
if (! $this->workflowGate->canRelease($organization, $prescription->visit, CareQueueContexts::PHARMACY)) {
return $prescription;
}
$branchId = (int) ($prescription->visit?->branch_id ?? 0);
if ($branchId < 1) {
return $prescription;
@@ -139,10 +151,15 @@ class CareQueueBridge
return $request;
}
$request->loadMissing('patient');
$request->loadMissing(['patient', 'visit', 'investigationType']);
$context = $this->contextForInvestigation($request);
if (! $this->workflowGate->canRelease($organization, $request->visit, $context)) {
return $request;
}
$ticket = $this->issue(
$organization,
CareQueueContexts::LABORATORY,
$context,
(int) $request->branch_id,
$request->patient,
[
@@ -164,6 +181,15 @@ class CareQueueBridge
return $request->fresh();
}
public function contextForInvestigation(InvestigationRequest $request): string
{
$request->loadMissing('investigationType');
return in_array($request->investigationType?->category, ['xray', 'ultrasound', 'ct', 'mri'], true)
? CareQueueContexts::IMAGING
: CareQueueContexts::LABORATORY;
}
public function issueForBill(Organization $organization, Bill $bill): ?Bill
{
if (! $this->isEnabled($organization) || filled($bill->queue_ticket_uuid)) {
@@ -174,7 +200,11 @@ class CareQueueBridge
return $bill;
}
$bill->loadMissing('patient');
$bill->loadMissing(['patient', 'visit']);
if (! $this->workflowGate->canRelease($organization, $bill->visit, CareQueueContexts::BILLING)) {
return $bill;
}
$ticket = $this->issue(
$organization,
CareQueueContexts::BILLING,
@@ -209,7 +239,8 @@ class CareQueueBridge
}
// Lab/imaging completion → return-to-doctor consultation ticket when practitioner known.
if ($fromContext === CareQueueContexts::LABORATORY && $entity instanceof InvestigationRequest) {
if (in_array($fromContext, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true)
&& $entity instanceof InvestigationRequest) {
$entity->loadMissing(['patient', 'practitioner', 'visit']);
$appointment = Appointment::owned($organization->owner_ref)
->where('organization_id', $organization->id)
@@ -243,7 +274,7 @@ class CareQueueBridge
return match (true) {
$context === CareQueueContexts::PHARMACY => $this->syncMissingPrescriptions($organization, $branchId, $limit),
$context === CareQueueContexts::LABORATORY => $this->syncMissingInvestigations($organization, $branchId, $limit),
in_array($context, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true) => $this->syncMissingInvestigations($organization, $context, $branchId, $limit),
$context === CareQueueContexts::BILLING => $this->syncMissingBills($organization, $branchId, $limit),
default => $this->syncMissingAppointments($organization, $context, $branchId, $limit),
};
@@ -418,7 +449,7 @@ class CareQueueBridge
);
}
if ($member && $member->role === 'doctor') {
$prac = \App\Models\Practitioner::owned($organization->owner_ref)
$prac = Practitioner::owned($organization->owner_ref)
->where('organization_id', $organization->id)
->where('branch_id', $branchId)
->where(function ($q) use ($member) {
@@ -569,7 +600,7 @@ class CareQueueBridge
->where('organization_id', $organization->id)
->where('queue_ticket_uuid', $ticketUuid)
->first(),
$context === CareQueueContexts::LABORATORY => InvestigationRequest::owned($owner)
in_array($context, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true) => InvestigationRequest::owned($owner)
->where('organization_id', $organization->id)
->where('queue_ticket_uuid', $ticketUuid)
->first(),
@@ -643,7 +674,7 @@ class CareQueueBridge
return $issued;
}
protected function syncMissingInvestigations(Organization $organization, int $branchId, int $limit): int
protected function syncMissingInvestigations(Organization $organization, string $context, int $branchId, int $limit): int
{
$owner = (string) $organization->owner_ref;
$requests = InvestigationRequest::owned($owner)
@@ -657,13 +688,17 @@ class CareQueueBridge
->where(function ($q) {
$q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', '');
})
->with('patient')
->with(['patient', 'visit', 'investigationType'])
->orderBy('id')
->limit($limit)
->get();
$issued = 0;
foreach ($requests as $request) {
if ($this->contextForInvestigation($request) !== $context) {
continue;
}
$updated = $this->issueForInvestigation($organization, $request);
if ($updated && filled($updated->queue_ticket_uuid)) {
$issued++;
+4 -2
View File
@@ -7,7 +7,6 @@ use App\Models\Consultation;
use App\Models\ConsultationDocument;
use App\Models\Diagnosis;
use App\Models\VitalSign;
use App\Models\Visit;
use Illuminate\Http\UploadedFile;
class ConsultationService
@@ -28,7 +27,10 @@ class ConsultationService
$appointment->refresh();
}
$existing = Consultation::where('appointment_id', $appointment->id)->first();
$existing = Consultation::where('appointment_id', $appointment->id)
->where('status', '!=', Consultation::STATUS_COMPLETED)
->latest('id')
->first();
if ($existing) {
return $existing->load(['vitalSigns', 'diagnoses', 'documents', 'patient', 'practitioner']);
}
+11
View File
@@ -29,6 +29,7 @@ use App\Models\Visit;
use App\Models\VitalSign;
use App\Support\DemoWorld;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
@@ -209,6 +210,16 @@ class DemoTenantSeeder
{
$orgIds = DB::table('care_organizations')->where('owner_ref', $ownerRef)->pluck('id');
if (Schema::hasTable('care_financial_obligations')) {
DB::table('care_financial_obligations')->where('owner_ref', $ownerRef)->delete();
DB::table('care_visit_stage_advances')->where('owner_ref', $ownerRef)->delete();
$workflowIds = DB::table('care_facility_workflows')->where('owner_ref', $ownerRef)->pluck('id');
if ($workflowIds->isNotEmpty()) {
DB::table('care_workflow_stages')->whereIn('workflow_id', $workflowIds)->delete();
DB::table('care_facility_workflows')->whereIn('id', $workflowIds)->delete();
}
}
DB::table('care_assessment_answers')->where('owner_ref', $ownerRef)->delete();
DB::table('care_assessment_scores')->where('owner_ref', $ownerRef)->delete();
DB::table('care_assessments')->where('owner_ref', $ownerRef)->update(['patient_pathway_id' => null]);
+13 -3
View File
@@ -9,17 +9,18 @@ use App\Models\InvestigationResult;
use App\Models\InvestigationResultValue;
use App\Models\InvestigationType;
use App\Models\Organization;
use App\Services\Care\Workflow\WorkflowStageCompletionService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
class InvestigationService
{
public function __construct(
protected CareQueueBridge $queueBridge,
protected WorkflowStageCompletionService $workflowCompletion,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
@@ -83,7 +84,7 @@ class InvestigationService
?string $actorRef = null,
): Collection {
$consultation->loadMissing('visit');
$created = new Collection();
$created = new Collection;
foreach ($typeIds as $typeId) {
$type = InvestigationType::owned($ownerRef)->findOrFail($typeId);
@@ -277,10 +278,19 @@ class InvestigationService
AuditLogger::record($ownerRef, 'investigation.approved', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
$request = $request->fresh(['patient', 'investigationType', 'result.values']);
$request = $request->fresh(['patient', 'investigationType', 'result.values', 'visit']);
$organization = Organization::query()->find($request->organization_id);
if ($organization) {
$this->queueBridge->complete($organization, $request);
if ($request->visit) {
$this->workflowCompletion->complete(
$organization,
$request->visit,
$this->queueBridge->contextForInvestigation($request),
$ownerRef,
$actorRef,
);
}
}
return $request;
@@ -95,6 +95,12 @@ class OrganizationResolver
'facility_type' => $data['facility_type'] ?? $category,
'modules' => $modules,
'workflow_template' => $templateKey,
'rollout' => [
CareFeatures::WORKFLOW_ENGINE => $templateKey !== 'legacy_clinic',
// Financial gates require the paid billing module and are
// explicitly enabled by an administrator in Settings.
CareFeatures::FINANCIAL_GATES => false,
],
],
]);
+12 -2
View File
@@ -7,7 +7,7 @@ use App\Models\Drug;
use App\Models\DrugBatch;
use App\Models\Organization;
use App\Models\Prescription;
use App\Models\PrescriptionItem;
use App\Services\Care\Workflow\WorkflowStageCompletionService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@@ -17,6 +17,7 @@ class PharmacyService
{
public function __construct(
protected CareQueueBridge $queueBridge,
protected WorkflowStageCompletionService $workflowCompletion,
) {}
public function queryDrugs(string $ownerRef, int $organizationId): Builder
@@ -201,10 +202,19 @@ class PharmacyService
AuditLogger::record($ownerRef, 'prescription.dispensed', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
$prescription = $prescription->fresh(['items', 'patient']);
$prescription = $prescription->fresh(['items', 'patient', 'visit']);
$organization = Organization::query()->find($prescription->organization_id);
if ($organization) {
$this->queueBridge->complete($organization, $prescription);
if ($prescription->visit) {
$this->workflowCompletion->complete(
$organization,
$prescription->visit,
CareQueueContexts::PHARMACY,
$ownerRef,
$actorRef,
);
}
}
return $prescription;
+11
View File
@@ -6,6 +6,7 @@ use App\Models\Consultation;
use App\Models\Organization;
use App\Models\Prescription;
use App\Models\PrescriptionItem;
use App\Services\Care\Workflow\WorkflowStageCompletionService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@@ -15,6 +16,7 @@ class PrescriptionService
{
public function __construct(
protected CareQueueBridge $queueBridge,
protected WorkflowStageCompletionService $workflowCompletion,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
@@ -160,6 +162,15 @@ class PrescriptionService
$organization = Organization::query()->find($prescription->organization_id);
if ($organization) {
$this->queueBridge->complete($organization, $prescription);
if ($prescription->visit) {
$this->workflowCompletion->complete(
$organization,
$prescription->visit,
CareQueueContexts::PHARMACY,
$ownerRef,
$actorRef,
);
}
}
return $prescription->fresh(['items', 'patient']);
+10
View File
@@ -5,9 +5,15 @@ namespace App\Services\Care;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Visit;
use App\Services\Care\Workflow\WorkflowEngine;
class VisitService
{
public function __construct(
protected CareFeatures $features,
protected WorkflowEngine $workflows,
) {}
public function checkIn(
Organization $organization,
string $ownerRef,
@@ -27,6 +33,10 @@ class VisitService
AuditLogger::record($ownerRef, 'visit.checked_in', $organization->id, $actorRef, Visit::class, $visit->id);
if ($this->features->enabled($organization, CareFeatures::WORKFLOW_ENGINE)) {
$this->workflows->start($visit);
}
return $visit;
}
@@ -3,8 +3,12 @@
namespace App\Services\Care\Workflow;
use App\Models\FinancialObligation;
use App\Models\InvestigationRequest;
use App\Models\Organization;
use App\Models\Prescription;
use App\Models\Visit;
use App\Models\WorkflowStage;
use App\Services\Care\CareFeatures;
/**
* Owns FinancialObligation lifecycle and answers whether a stage's financial
@@ -16,6 +20,10 @@ use App\Models\WorkflowStage;
*/
class FinancialGateService
{
public function __construct(
protected CareFeatures $features,
) {}
/**
* Create (or return existing) obligation for a visit + stage. Only stages
* that require payment produce an obligation.
@@ -28,29 +36,51 @@ class FinancialGateService
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;
$organization = Organization::query()->find($visit->organization_id);
if ($organization && ! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) {
return null;
}
return FinancialObligation::create(array_merge([
$amount = (int) ($overrides['amount_minor']
?? $stage->default_amount_minor
?? $this->defaultAmountFor($stage, $visit));
$defaults = 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),
'amount_minor' => $amount,
'status' => $amount > 0
? FinancialObligation::STATUS_PENDING
: FinancialObligation::STATUS_WAIVED,
'clearance_method' => $amount > 0 ? null : 'no_charge',
'cleared_at' => $amount > 0 ? null : now(),
], $overrides);
$obligation = FinancialObligation::firstOrCreate(
[
'visit_id' => $visit->id,
'workflow_stage_id' => $stage->id,
],
$defaults,
);
// A stage may initially have no billable items (for example pharmacy
// before a prescription is saved). Re-evaluate no-charge clearances
// whenever the workflow gate is refreshed.
if ($obligation->clearance_method === 'no_charge' && $amount > 0) {
$obligation->forceFill([
'amount_minor' => $amount,
'status' => FinancialObligation::STATUS_PENDING,
], $overrides));
'clearance_method' => null,
'cleared_at' => null,
])->save();
}
return $obligation;
}
/**
@@ -63,6 +93,11 @@ class FinancialGateService
return true;
}
$organization = Organization::query()->find($visit->organization_id);
if ($organization && ! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) {
return true;
}
$obligation = FinancialObligation::query()
->where('visit_id', $visit->id)
->where('workflow_stage_id', $stage->id)
@@ -93,7 +128,7 @@ class FinancialGateService
): FinancialObligation {
$status = match ($method) {
'insurance' => FinancialObligation::STATUS_AUTHORIZED,
'waiver' => FinancialObligation::STATUS_WAIVED,
'waiver', 'override' => FinancialObligation::STATUS_WAIVED,
'credit' => FinancialObligation::STATUS_DEFERRED,
default => FinancialObligation::STATUS_PAID,
};
@@ -117,12 +152,53 @@ class FinancialGateService
return $obligation;
}
protected function defaultAmountFor(WorkflowStage $stage): int
protected function defaultAmountFor(WorkflowStage $stage, Visit $visit): int
{
if ($stage->charge_code === 'consultation') {
return (int) config('care.billing.consultation_fee_minor', 0);
return match ($stage->charge_code) {
'patient_card', 'registration' => (int) config('care.billing.patient_card_fee_minor', 0),
'consultation' => (int) config('care.billing.consultation_fee_minor', 0),
'lab' => $this->investigationTotal($visit, imaging: false),
'imaging' => $this->investigationTotal($visit, imaging: true),
'pharmacy' => $this->pharmacyTotal($visit),
'visit_total' => (int) $visit->bills()
->whereNotIn('status', ['paid', 'void'])
->sum('balance_minor'),
default => 0,
};
}
protected function investigationTotal(Visit $visit, bool $imaging): int
{
$imagingCategories = ['xray', 'ultrasound', 'ct', 'mri'];
return (int) InvestigationRequest::query()
->where('visit_id', $visit->id)
->with('investigationType')
->get()
->filter(function (InvestigationRequest $request) use ($imaging, $imagingCategories) {
$isImaging = in_array($request->investigationType?->category, $imagingCategories, true);
return $imaging ? $isImaging : ! $isImaging;
})
->sum(fn (InvestigationRequest $request) => (int) ($request->investigationType?->price_minor ?? 0));
}
protected function pharmacyTotal(Visit $visit): int
{
return (int) Prescription::query()
->where('visit_id', $visit->id)
->with('items.drug')
->get()
->sum(function (Prescription $prescription) {
return $prescription->items->sum(function ($item) {
if ($item->is_procedure) {
return 0;
}
$quantity = max(1, (int) preg_replace('/\D/', '', (string) $item->quantity) ?: 1);
return $quantity * (int) ($item->drug?->unit_price_minor ?? 0);
});
});
}
}
+54 -4
View File
@@ -40,6 +40,9 @@ class WorkflowEngine
*/
public function start(Visit $visit, ?FacilityWorkflow $workflow = null): ?VisitStageAdvance
{
return DB::transaction(function () use ($visit, $workflow) {
Visit::query()->whereKey($visit->id)->lockForUpdate()->first();
$workflow ??= $this->workflowFor($visit);
if ($workflow === null) {
return null;
@@ -56,6 +59,7 @@ class WorkflowEngine
}
return $this->enterStage($visit, $workflow, $first);
});
}
public function currentAdvance(Visit $visit): ?VisitStageAdvance
@@ -79,9 +83,31 @@ class WorkflowEngine
*/
public function canAdvance(Visit $visit, ?string $toCode = null): bool
{
$current = $this->refreshGate($visit);
if ($current === null || $current->isBlocked()) {
return false;
}
$target = $this->resolveTarget($visit, $toCode);
return $target !== null && $this->gate->isSatisfied($visit, $target);
return $target !== null;
}
public function canManuallyAdvance(Visit $visit): bool
{
$stage = $this->refreshGate($visit)?->stage;
if ($stage === null || in_array($stage->type, [
'consultation',
'laboratory',
'imaging',
'procedure',
'pharmacy',
'payment',
], true)) {
return false;
}
return $this->canAdvance($visit);
}
/**
@@ -93,6 +119,8 @@ class WorkflowEngine
public function advance(Visit $visit, ?string $toCode = null): ?VisitStageAdvance
{
return DB::transaction(function () use ($visit, $toCode) {
Visit::query()->whereKey($visit->id)->lockForUpdate()->first();
$workflow = $this->workflowFor($visit);
if ($workflow === null) {
return null;
@@ -103,7 +131,11 @@ class WorkflowEngine
return null;
}
$current = $this->currentAdvance($visit);
$current = $this->refreshGate($visit);
if ($current?->isBlocked()) {
throw new \DomainException('Clear the current stage financial gate before advancing this visit.');
}
if ($current !== null) {
$current->forceFill([
'status' => VisitStageAdvance::STATUS_COMPLETED,
@@ -140,12 +172,21 @@ class WorkflowEngine
return $advance;
}
if ($advance->isBlocked() && $this->gate->isSatisfied($visit, $advance->stage)) {
$this->gate->obligationFor($visit, $advance->stage);
$satisfied = $this->gate->isSatisfied($visit, $advance->stage);
if ($advance->isBlocked() && $satisfied) {
$advance->forceFill([
'status' => VisitStageAdvance::STATUS_ACTIVE,
'blocked_reason' => null,
'cleared_at' => now(),
])->save();
} elseif (! $advance->isBlocked() && ! $satisfied) {
$advance->forceFill([
'status' => VisitStageAdvance::STATUS_BLOCKED,
'blocked_reason' => $this->gate->blockedReason($visit, $advance->stage),
'cleared_at' => null,
])->save();
}
return $advance->fresh();
@@ -178,7 +219,16 @@ class WorkflowEngine
}
if ($toCode !== null && $toCode !== '') {
return $workflow->stage($toCode);
$target = $workflow->stage($toCode);
$current = $this->currentAdvance($visit)?->stage;
if ($target === null || $current === null) {
return null;
}
$isConfiguredNext = $current->default_next === $target->code;
$mayBranch = $current->creates_child || $current->can_return || $current->can_skip;
return $isConfiguredNext || $mayBranch ? $target : null;
}
$current = $this->currentAdvance($visit)?->stage;
@@ -0,0 +1,67 @@
<?php
namespace App\Services\Care\Workflow;
use App\Models\Organization;
use App\Models\Visit;
use App\Services\Care\CareFeatures;
use App\Services\Care\CareQueueContexts;
/**
* Single boundary for deciding whether a visit may be exposed to a Queue
* service line. Every direct issue and background backfill passes through it.
*/
class WorkflowQueueGate
{
public function __construct(
protected CareFeatures $features,
protected WorkflowEngine $engine,
) {}
public function enabled(Organization $organization): bool
{
return $this->features->enabled($organization, CareFeatures::WORKFLOW_ENGINE);
}
public function canRelease(
Organization $organization,
?Visit $visit,
string $queueContext,
): bool {
if (! $this->enabled($organization) || $visit === null) {
return true;
}
$advance = $this->engine->currentAdvance($visit)
? $this->engine->refreshGate($visit)
: $this->engine->start($visit);
$stage = $advance?->stage;
// An enabled org without a materialized workflow keeps legacy behavior.
if ($stage === null) {
return true;
}
if (! $this->contextsMatch($stage->queue_context, $stage->type, $queueContext)) {
return false;
}
if (! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) {
return true;
}
return $this->engine->isQueueReleasable($visit);
}
protected function contextsMatch(?string $stageContext, string $stageType, string $queueContext): bool
{
if ($stageContext === $queueContext) {
return true;
}
// Specialty appointments still fulfill a consultation workflow stage.
return $stageType === 'consultation'
&& CareQueueContexts::isSpecialty($queueContext)
&& $stageContext === CareQueueContexts::CONSULTATION;
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Services\Care\Workflow;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\InvestigationRequest;
use App\Models\Organization;
use App\Models\Prescription;
use App\Models\Visit;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
/**
* Releases the entity represented by the visit's current workflow stage to
* Ladill Queue after stage entry or financial clearance.
*/
class WorkflowQueueReleaseService
{
public function __construct(
protected WorkflowEngine $engine,
protected CareQueueBridge $queue,
) {}
public function releaseCurrent(Organization $organization, Visit $visit): void
{
$stage = $this->engine->currentStage($visit);
if ($stage === null || $stage->queue_context === null) {
return;
}
$context = $stage->queue_context;
if ($context === CareQueueContexts::CONSULTATION || CareQueueContexts::isSpecialty($context)) {
$appointment = Appointment::query()
->where('visit_id', $visit->id)
->with(['patient', 'organization', 'practitioner', 'visit'])
->latest('id')
->first();
if ($appointment) {
if ($appointment->status === Appointment::STATUS_COMPLETED) {
$nextPosition = ((int) Appointment::owned($appointment->owner_ref)
->where('branch_id', $appointment->branch_id)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->max('queue_position')) + 1;
$appointment->forceFill([
'status' => Appointment::STATUS_WAITING,
'waiting_at' => now(),
'started_at' => null,
'completed_at' => null,
'queue_position' => $nextPosition,
])->save();
}
if (filled($appointment->queue_ticket_uuid)
&& in_array($appointment->queue_ticket_status, ['completed', 'cancelled'], true)) {
$appointment->forceFill([
'queue_ticket_uuid' => null,
'queue_ticket_number' => null,
'queue_ticket_status' => null,
'queue_service_point_uuid' => null,
'queue_destination' => null,
'queue_staff_display_name' => null,
'queue_routing_status' => null,
])->save();
}
$this->queue->issueForAppointment($organization, $appointment);
}
return;
}
if ($context === CareQueueContexts::LABORATORY || $context === CareQueueContexts::IMAGING) {
InvestigationRequest::query()
->where('visit_id', $visit->id)
->with(['patient', 'visit'])
->get()
->each(fn (InvestigationRequest $request) => $this->queue->issueForInvestigation($organization, $request));
return;
}
if ($context === CareQueueContexts::PHARMACY) {
Prescription::query()
->where('visit_id', $visit->id)
->with(['patient', 'visit'])
->get()
->each(fn (Prescription $prescription) => $this->queue->issueForPrescription($organization, $prescription));
return;
}
if ($context === CareQueueContexts::BILLING) {
Bill::query()
->where('visit_id', $visit->id)
->whereNotIn('status', [Bill::STATUS_PAID, Bill::STATUS_VOID])
->get()
->each(fn (Bill $bill) => $this->queue->issueForBill($organization, $bill));
}
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Services\Care\Workflow;
use App\Models\InvestigationRequest;
use App\Models\Organization;
use App\Models\Prescription;
use App\Models\Visit;
use App\Models\VisitStageAdvance;
use App\Services\Care\AuditLogger;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\VisitService;
/**
* Advances a visit when the clinical entity backing its current stage is
* complete. Multi-item lab/imaging/pharmacy stages wait for all sibling work.
*/
class WorkflowStageCompletionService
{
public function __construct(
protected WorkflowEngine $engine,
protected WorkflowQueueGate $gate,
protected WorkflowQueueReleaseService $queueRelease,
protected CareQueueBridge $queue,
protected VisitService $visits,
) {}
public function complete(
Organization $organization,
Visit $visit,
string $context,
string $ownerRef,
?string $actorRef = null,
): ?VisitStageAdvance {
if (! $this->gate->enabled($organization)) {
return null;
}
$current = $this->engine->refreshGate($visit);
$stage = $current?->stage;
if ($stage === null || ! $this->contextMatches($stage->queue_context, $stage->type, $context)) {
return null;
}
if ($this->hasOutstandingWork($visit, $context)) {
return $current;
}
if (! $this->engine->canAdvance($visit)) {
return $current;
}
$next = $this->engine->advance($visit);
if ($next === null) {
return null;
}
AuditLogger::record(
$ownerRef,
'visit.workflow_advanced',
$visit->organization_id,
$actorRef,
VisitStageAdvance::class,
$next->id,
['from_context' => $context, 'stage_code' => $next->stage_code, 'status' => $next->status],
);
if ($next->stage?->type === 'exit') {
$this->visits->complete($visit, $ownerRef, $actorRef);
} else {
$this->queueRelease->releaseCurrent($organization, $visit);
}
return $next;
}
protected function hasOutstandingWork(Visit $visit, string $context): bool
{
if (in_array($context, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true)) {
return InvestigationRequest::query()
->where('visit_id', $visit->id)
->whereNotIn('status', [
InvestigationRequest::STATUS_COMPLETED,
InvestigationRequest::STATUS_DELIVERED,
InvestigationRequest::STATUS_CANCELLED,
])
->with('investigationType')
->get()
->contains(fn (InvestigationRequest $request) => $this->queue->contextForInvestigation($request) === $context);
}
if ($context === CareQueueContexts::PHARMACY) {
return Prescription::query()
->where('visit_id', $visit->id)
->whereNotIn('status', [
Prescription::STATUS_DISPENSED,
Prescription::STATUS_CANCELLED,
])
->exists();
}
return false;
}
protected function contextMatches(?string $stageContext, string $stageType, string $context): bool
{
if ($stageContext === $context) {
return true;
}
if ($stageType === 'payment' && $context === CareQueueContexts::BILLING) {
return true;
}
return $stageType === 'consultation'
&& CareQueueContexts::isSpecialty($context)
&& $stageContext === CareQueueContexts::CONSULTATION;
}
}
+3
View File
@@ -119,6 +119,7 @@ return [
'appointment.meet_scheduled' => 'Video visit scheduled',
'appointment.meet_cancelled' => 'Video visit cancelled',
'visit.checked_in' => 'Visit opened',
'visit.workflow_advanced' => 'Visit workflow advanced',
'visit.completed' => 'Visit completed',
'consultation.started' => 'Consultation started',
'consultation.updated' => 'Consultation updated',
@@ -142,6 +143,7 @@ return [
'bill.line_item_added' => 'Bill line item added',
'bill.voided' => 'Bill voided',
'payment.recorded' => 'Payment recorded',
'financial_obligation.cleared' => 'Financial obligation cleared',
'payment.checkout_started' => 'Online payment checkout started',
'integrations.gateway_connected' => 'Payment gateway connected',
'integrations.gateway_disconnected' => 'Payment gateway disconnected',
@@ -285,6 +287,7 @@ return [
],
'billing' => [
'patient_card_fee_minor' => (int) env('CARE_PATIENT_CARD_FEE_MINOR', 2000),
'consultation_fee_minor' => (int) env('CARE_CONSULTATION_FEE_MINOR', 5000),
'currency' => env('CARE_CURRENCY', 'GHS'),
],
+18
View File
@@ -100,6 +100,24 @@ return [
'digital' => 'Digital (MoMo / card / Paystack)',
],
'clearance_methods' => [
'payment' => 'Payment received',
'bank_receipt' => 'External bank receipt verified',
'insurance' => 'Insurance authorization',
'credit' => 'Approved credit',
'waiver' => 'Waiver',
'override' => 'Authorized override',
],
'obligation_statuses' => [
'pending' => 'Pending',
'authorized' => 'Insurance authorized',
'paid' => 'Paid',
'waived' => 'Waived',
'deferred' => 'Deferred / credit',
'void' => 'Void',
],
'stage_types' => [
'registration' => 'Registration',
'payment' => 'Payment',
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('care_financial_obligations', function (Blueprint $table) {
$table->unique(
['visit_id', 'workflow_stage_id'],
'care_obligations_visit_stage_unique',
);
});
}
public function down(): void
{
Schema::table('care_financial_obligations', function (Blueprint $table) {
$table->dropUnique('care_obligations_visit_stage_unique');
});
}
};
@@ -19,7 +19,7 @@
<x-btn type="submit" variant="warning">No show</x-btn>
</form>
@endif
@if ($canConsult && in_array($appointment->status, [\App\Models\Appointment::STATUS_WAITING, \App\Models\Appointment::STATUS_CHECKED_IN], true))
@if (($canStartConsultation ?? $canConsult) && in_array($appointment->status, [\App\Models\Appointment::STATUS_WAITING, \App\Models\Appointment::STATUS_CHECKED_IN], true))
<form method="POST" action="{{ route('care.queue.start', $appointment) }}">
@csrf
<button type="submit" class="btn-primary">Start consultation</button>
@@ -62,6 +62,50 @@
</div>
</div>
@if ($workflowEnabled ?? false)
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Patient journey</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">{{ $currentAdvance?->stage?->name ?? 'Workflow not started' }}</h2>
@if ($currentAdvance)
<p class="mt-1 text-sm {{ $currentAdvance->isBlocked() ? 'text-amber-700' : 'text-emerald-700' }}">
{{ $currentAdvance->isBlocked() ? 'Blocked — financial clearance required' : 'Ready for service' }}
</p>
@endif
@if ($currentObligation)
<p class="mt-2 text-sm text-slate-600">
{{ $currentObligation->label }} ·
{{ config('care.billing.currency') }} {{ number_format($currentObligation->amount_minor / 100, 2) }} ·
{{ ucfirst($currentObligation->status) }}
</p>
@endif
</div>
<div class="flex flex-wrap gap-2">
@if ($currentAdvance?->isBlocked() && ($canViewBills ?? false))
<a href="{{ route('care.obligations.index') }}" class="btn-primary">Open financial gates</a>
@endif
@if ($canAdvanceWorkflow ?? false)
<form method="POST" action="{{ route('care.visits.workflow.advance', $appointment->visit) }}">
@csrf
<button type="submit" class="btn-primary">Complete stage & continue</button>
</form>
@endif
</div>
</div>
@if (($workflowHistory ?? collect())->isNotEmpty())
<ol class="mt-5 flex flex-wrap gap-2 border-t border-slate-100 pt-4">
@foreach ($workflowHistory as $step)
<li class="rounded-full px-3 py-1 text-xs {{ in_array($step->status, ['completed', 'skipped'], true) ? 'bg-emerald-50 text-emerald-700' : ($step->status === 'blocked' ? 'bg-amber-50 text-amber-800' : 'bg-sky-50 text-sky-700') }}">
{{ $step->stage?->name ?? $step->stage_code }} · {{ str_replace('_', ' ', $step->status) }}
</li>
@endforeach
</ol>
@endif
</section>
@endif
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<section class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Details</h2>
@@ -5,6 +5,9 @@
<h1 class="text-xl font-semibold text-slate-900">Bills & invoices</h1>
<p class="mt-1 text-sm text-slate-500">Encounter billing and outstanding balances</p>
</div>
@if (app(\App\Services\Care\CareFeatures::class)->enabled($organization, \App\Services\Care\CareFeatures::WORKFLOW_ENGINE))
<a href="{{ route('care.obligations.index') }}" class="btn-primary">Financial gates</a>
@endif
</div>
<form method="GET" class="mt-4 flex gap-3 rounded-2xl border border-slate-200 bg-white p-4">
<select name="status" class="rounded-lg border-slate-300 text-sm">
@@ -0,0 +1,106 @@
@php $money = fn ($minor) => config('care.billing.currency').' '.number_format($minor / 100, 2); @endphp
<x-app-layout title="Financial gates">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 class="text-xl font-semibold text-slate-900">Financial gates</h1>
<p class="mt-1 text-sm text-slate-500">Clear payment or authorization before patients enter gated service queues.</p>
</div>
<a href="{{ route('care.bills.index') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-700">Bills & invoices</a>
</div>
<form method="GET" class="mt-4 flex flex-wrap gap-3 rounded-2xl border border-slate-200 bg-white p-4">
<select name="status" class="rounded-lg border-slate-300 text-sm">
<option value="">All statuses</option>
@foreach ($statuses as $value => $label)
<option value="{{ $value }}" @selected(request('status', 'pending') === $value)>{{ $label }}</option>
@endforeach
</select>
<button type="submit" class="btn-primary">Filter</button>
</form>
<div class="mt-4 space-y-3">
@forelse ($obligations as $obligation)
<article class="rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="font-semibold text-slate-900">{{ $obligation->patient?->fullName() ?? 'Patient' }}</h2>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{{ $statuses[$obligation->status] ?? ucfirst($obligation->status) }}
</span>
</div>
<p class="mt-1 text-sm text-slate-600">
{{ $obligation->stage?->name ?? $obligation->stage_code }}
· {{ $obligation->label ?? 'Workflow charge' }}
@if ($obligation->branch) · {{ $obligation->branch->name }} @endif
</p>
<p class="mt-1 text-xs text-slate-400">
Created {{ $obligation->created_at->format('d M Y H:i') }}
@if ($obligation->bill) · Invoice {{ $obligation->bill->invoice_number }} @endif
</p>
</div>
<p class="text-lg font-semibold text-slate-900">{{ $money($obligation->amount_minor) }}</p>
</div>
@if ($canClear && $obligation->status === \App\Models\FinancialObligation::STATUS_PENDING)
@php
$allowedPaymentModes = ! empty($obligation->stage?->payment_modes)
? array_intersect_key($paymentModes, array_flip($obligation->stage->payment_modes))
: $paymentModes;
$availableClearanceMethods = collect($clearanceMethods)->filter(function ($label, $value) use ($obligation, $allowedPaymentModes) {
return match ($value) {
'payment' => collect(array_keys($allowedPaymentModes))->intersect(['internal_cashier', 'digital'])->isNotEmpty(),
'bank_receipt' => array_key_exists('external_bank', $allowedPaymentModes),
'insurance' => (bool) $obligation->stage?->insurance_eligible,
'credit' => (bool) $obligation->stage?->credit_allowed,
'waiver', 'override' => (bool) $obligation->stage?->allow_override,
default => false,
};
});
@endphp
<form method="POST" action="{{ route('care.obligations.clear', $obligation) }}" class="mt-4 grid gap-3 border-t border-slate-100 pt-4 md:grid-cols-5">
@csrf
<div>
<label class="block text-xs font-medium text-slate-500">Clearance</label>
<select name="method" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($availableClearanceMethods as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-500">Payment mode</label>
<select name="payment_mode" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">Not applicable</option>
@foreach ($allowedPaymentModes as $value => $label)
<option value="{{ $value }}" @selected($loop->first)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-500">Tender</label>
<select name="payment_method" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($paymentMethods as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-500">Receipt / authorization ref</label>
<input name="reference" maxlength="100" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div class="flex items-end">
<button type="submit" class="btn-primary w-full">Clear gate</button>
</div>
</form>
@endif
</article>
@empty
<div class="rounded-2xl border border-slate-200 bg-white px-6 py-12 text-center text-sm text-slate-500">
No financial obligations match this filter.
</div>
@endforelse
</div>
<div class="mt-4">{{ $obligations->links() }}</div>
</x-app-layout>
@@ -134,6 +134,36 @@
</label>
</x-settings.card>
<x-settings.card title="Patient journey workflow" description="Move each visit through configured stages and control when it may enter a Ladill Queue service line.">
<div class="space-y-4">
<label class="flex items-start gap-3 text-sm">
<input type="checkbox" name="workflow_engine" value="1"
@checked(old('workflow_engine', $workflowEngineEnabled ?? false))
class="mt-0.5 rounded border-slate-300">
<span>
<span class="font-medium text-slate-800">Track visits through the facility workflow</span>
<span class="mt-0.5 block text-xs text-slate-500">
Check-in starts the selected workflow. Staff advance the visit stage-by-stage, and Queue only receives the patient at the matching service stage.
</span>
</span>
</label>
<label class="flex items-start gap-3 text-sm {{ empty($hasBillingFeature) ? 'opacity-60' : '' }}">
<input type="checkbox" name="financial_gates" value="1"
@checked(old('financial_gates', $financialGatesEnabled ?? false))
@disabled(empty($hasBillingFeature))
class="mt-0.5 rounded border-slate-300">
<span>
<span class="font-medium text-slate-800">Enforce payment and authorization gates</span>
<span class="mt-0.5 block text-xs text-slate-500">
Blocks service Queue activation until payment, insurance authorization, approved credit, waiver, or override clears the stage.
@if (empty($hasBillingFeature)) Billing on Care Pro or Enterprise is required. @endif
</span>
</span>
</label>
</div>
</x-settings.card>
<x-settings.card title="Messaging" description="Patient email and SMS use suite messaging by default — no Bird or SMS API keys required.">
<p class="text-sm text-slate-600">
Set a Ladill mailbox (From address) and optional SMS sender ID under Account Messaging.
+1 -1
View File
@@ -65,7 +65,7 @@
}
if ($permissions->can($member, 'bills.view')) {
$nav[] = ['name' => 'Billing', 'route' => route('care.bills.index'), 'active' => request()->routeIs('care.bills.*'),
$nav[] = ['name' => 'Billing', 'route' => route('care.bills.index'), 'active' => request()->routeIs('care.bills.*', 'care.obligations.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.375M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />'];
}
+13 -7
View File
@@ -2,11 +2,10 @@
use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\Care\AiController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\Care\AuditLogController;
use App\Http\Controllers\Care\AppointmentController;
use App\Http\Controllers\Care\AppointmentMeetController;
use App\Http\Controllers\Care\AssessmentController;
use App\Http\Controllers\Care\AuditLogController;
use App\Http\Controllers\Care\BillController;
use App\Http\Controllers\Care\BranchController;
use App\Http\Controllers\Care\ConsultationController;
@@ -14,25 +13,28 @@ use App\Http\Controllers\Care\DashboardController;
use App\Http\Controllers\Care\DepartmentController;
use App\Http\Controllers\Care\DeviceController;
use App\Http\Controllers\Care\DrugController;
use App\Http\Controllers\Care\FinancialObligationController;
use App\Http\Controllers\Care\IntegrationsController;
use App\Http\Controllers\Care\InvestigationController;
use App\Http\Controllers\Care\InvestigationTypeController;
use App\Http\Controllers\Care\IssueReportController;
use App\Http\Controllers\Care\MemberController;
use App\Http\Controllers\Care\OnboardingController;
use App\Http\Controllers\Care\OutcomeController;
use App\Http\Controllers\Care\PathwayController;
use App\Http\Controllers\Care\PatientController;
use App\Http\Controllers\Care\PatientMessageController;
use App\Http\Controllers\Care\PathwayController;
use App\Http\Controllers\Care\PractitionerController;
use App\Http\Controllers\Care\PrescriptionController;
use App\Http\Controllers\Care\QueueController;
use App\Http\Controllers\Care\ProController;
use App\Http\Controllers\Care\QueueController;
use App\Http\Controllers\Care\ReportController;
use App\Http\Controllers\Care\ServiceQueueController;
use App\Http\Controllers\Care\SettingsController;
use App\Http\Controllers\Care\SettingsModulesController;
use App\Http\Controllers\Care\SpecialtyModuleController;
use App\Http\Controllers\Care\IntegrationsController;
use App\Http\Controllers\Care\IssueReportController;
use App\Http\Controllers\Care\SettingsController;
use App\Http\Controllers\Care\VisitWorkflowController;
use App\Http\Controllers\NotificationController;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => auth()->check()
@@ -92,6 +94,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::post('/queue/{appointment}/start', [QueueController::class, 'start'])->name('care.queue.start');
Route::post('/queue/{appointment}/recall', [QueueController::class, 'recall'])->name('care.queue.recall');
Route::post('/queue/{appointment}/complete-ticket', [QueueController::class, 'completeTicket'])->name('care.queue.complete-ticket');
Route::post('/visits/{visit}/workflow/advance', [VisitWorkflowController::class, 'advance'])->name('care.visits.workflow.advance');
Route::middleware('care.paid')->group(function () {
Route::get('/service-queues', [ServiceQueueController::class, 'index'])->name('care.service-queues.index');
@@ -144,6 +147,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::post('/bills/{bill}/payments/gateway', [BillController::class, 'startGatewayPayment'])->name('care.bills.payments.gateway');
Route::post('/bills/{bill}/void', [BillController::class, 'void'])->name('care.bills.void');
Route::get('/financial-obligations', [FinancialObligationController::class, 'index'])->name('care.obligations.index');
Route::post('/financial-obligations/{financialObligation}/clear', [FinancialObligationController::class, 'clear'])->name('care.obligations.clear');
Route::get('/pharmacy/drugs', [DrugController::class, 'index'])->name('care.pharmacy.drugs.index');
Route::get('/pharmacy/drugs/create', [DrugController::class, 'create'])->name('care.pharmacy.drugs.create');
Route::post('/pharmacy/drugs', [DrugController::class, 'store'])->name('care.pharmacy.drugs.store');
@@ -0,0 +1,514 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\Branch;
use App\Models\FinancialObligation;
use App\Models\InvestigationRequest;
use App\Models\InvestigationType;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Payment;
use App\Models\User;
use App\Models\Visit;
use App\Models\VisitStageAdvance;
use App\Services\Care\AppointmentService;
use App\Services\Care\CareFeatures;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\VisitService;
use App\Services\Care\Workflow\FinancialGateService;
use App\Services\Care\Workflow\WorkflowEngine;
use App\Services\Care\Workflow\WorkflowQueueGate;
use App\Services\Care\Workflow\WorkflowStageCompletionService;
use App\Services\Care\Workflow\WorkflowTemplateInstaller;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CareFinancialWorkflowRuntimeTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'workflow-test-key',
]);
$this->owner = User::create([
'public_id' => 'financial-workflow-owner',
'name' => 'Workflow Admin',
'email' => 'financial-workflow@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Financial Gate Hospital',
'slug' => 'financial-gate-hospital',
'settings' => [
'onboarded' => true,
'facility_category' => 'hospital',
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'queue_integration_enabled' => true,
'rollout' => [
CareFeatures::WORKFLOW_ENGINE => true,
CareFeatures::FINANCIAL_GATES => true,
],
],
]);
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,
]);
$this->patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'WF-0001',
'first_name' => 'Akosua',
'last_name' => 'Boateng',
]);
app(WorkflowTemplateInstaller::class)
->install($this->organization, $this->branch, 'public_hospital_gh');
}
protected function checkIn(): Visit
{
return app(VisitService::class)->checkIn(
$this->organization,
$this->owner->public_id,
$this->patient,
$this->branch->id,
$this->owner->public_id,
);
}
protected function reachConsultation(Visit $visit): FinancialObligation
{
$engine = app(WorkflowEngine::class);
$gate = app(FinancialGateService::class);
$registration = FinancialObligation::query()
->where('visit_id', $visit->id)
->where('stage_code', 'registration')
->firstOrFail();
$gate->clear($registration, 'override', $this->owner->public_id);
$engine->refreshGate($visit);
$engine->advance($visit); // triage
$engine->advance($visit); // consultation
return FinancialObligation::query()
->where('visit_id', $visit->id)
->where('stage_code', 'consultation')
->firstOrFail();
}
public function test_check_in_starts_workflow_and_blocks_initial_queue_release(): void
{
$visit = $this->checkIn();
$advance = app(WorkflowEngine::class)->currentAdvance($visit);
$this->assertSame('registration', $advance->stage_code);
$this->assertSame(VisitStageAdvance::STATUS_BLOCKED, $advance->status);
$this->assertDatabaseHas('care_financial_obligations', [
'visit_id' => $visit->id,
'stage_code' => 'registration',
'status' => FinancialObligation::STATUS_PENDING,
]);
$this->assertFalse(app(WorkflowQueueGate::class)->canRelease(
$this->organization,
$visit,
CareQueueContexts::CONSULTATION,
));
}
public function test_queue_bridge_cannot_backfill_a_gated_appointment(): void
{
Http::fake();
$visit = $this->checkIn();
$appointment = Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
'checked_in_at' => now(),
'waiting_at' => now(),
'queue_position' => 1,
]);
app(CareQueueBridge::class)->issueForAppointment($this->organization, $appointment);
Http::assertNothingSent();
$this->assertNull($appointment->fresh()->queue_ticket_uuid);
}
public function test_appointment_page_shows_current_workflow_stage(): void
{
$visit = $this->checkIn();
$appointment = Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
'checked_in_at' => now(),
'waiting_at' => now(),
'queue_position' => 1,
]);
$this->actingAs($this->owner)
->get(route('care.appointments.show', $appointment))
->assertOk()
->assertSee('Patient journey')
->assertSee('Registration')
->assertSee('financial clearance required')
->assertDontSee('Start consultation');
}
public function test_blocked_visit_cannot_advance_until_current_gate_is_cleared(): void
{
$visit = $this->checkIn();
$this->actingAs($this->owner)
->from(route('care.appointments.index'))
->post(route('care.visits.workflow.advance', $visit))
->assertRedirect(route('care.appointments.index'))
->assertSessionHas('error');
$this->assertSame('registration', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code);
}
public function test_cashier_clearance_creates_bill_payment_and_releases_stage(): void
{
$visit = $this->checkIn();
$consultation = $this->reachConsultation($visit);
$this->actingAs($this->owner)
->get(route('care.obligations.index'))
->assertOk()
->assertSee('Financial gates')
->assertSee('Akosua Boateng')
->assertSee('Consultation fee');
$this->actingAs($this->owner)
->post(route('care.obligations.clear', $consultation), [
'method' => 'payment',
'payment_mode' => 'internal_cashier',
'payment_method' => 'cash',
'reference' => 'CASH-1001',
])
->assertRedirect()
->assertSessionHas('success');
$consultation->refresh();
$this->assertSame(FinancialObligation::STATUS_PAID, $consultation->status);
$this->assertNotNull($consultation->bill_id);
$this->assertDatabaseHas('care_bill_line_items', [
'bill_id' => $consultation->bill_id,
'source_type' => FinancialObligation::class,
'source_id' => $consultation->id,
'total_minor' => (int) config('care.billing.consultation_fee_minor'),
]);
$this->assertDatabaseHas('care_payments', [
'bill_id' => $consultation->bill_id,
'method' => Payment::METHOD_CASH,
'status' => Payment::STATUS_PAID,
'reference' => 'CASH-1001',
]);
$this->assertSame(Bill::STATUS_PAID, $consultation->bill->fresh()->status);
$this->assertSame(VisitStageAdvance::STATUS_ACTIVE, app(WorkflowEngine::class)->currentAdvance($visit)->status);
$this->assertTrue(app(WorkflowQueueGate::class)->canRelease(
$this->organization,
$visit,
CareQueueContexts::CONSULTATION,
));
// A repeated submission must not create a second financial payment.
$this->actingAs($this->owner)
->post(route('care.obligations.clear', $consultation), [
'method' => 'payment',
'payment_mode' => 'internal_cashier',
'payment_method' => 'cash',
])
->assertSessionHas('info');
$this->assertSame(1, Payment::query()->where('bill_id', $consultation->bill_id)->count());
}
public function test_clinical_stage_cannot_be_manually_skipped(): void
{
$visit = $this->checkIn();
$consultation = $this->reachConsultation($visit);
app(FinancialGateService::class)->clear($consultation, 'insurance', $this->owner->public_id);
app(WorkflowEngine::class)->refreshGate($visit);
$this->actingAs($this->owner)
->post(route('care.visits.workflow.advance', $visit))
->assertSessionHas('error');
$this->assertSame('consultation', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code);
}
public function test_non_insurance_stage_rejects_insurance_clearance(): void
{
app(WorkflowTemplateInstaller::class)
->install($this->organization, $this->branch, 'herbal_hospital');
$visit = $this->checkIn();
$obligation = FinancialObligation::query()->where('visit_id', $visit->id)->firstOrFail();
$this->actingAs($this->owner)
->post(route('care.obligations.clear', $obligation), [
'method' => 'insurance',
])
->assertSessionHasErrors('method');
$this->assertSame(FinancialObligation::STATUS_PENDING, $obligation->fresh()->status);
}
public function test_lab_stage_obligation_uses_requested_test_prices(): void
{
$visit = $this->checkIn();
$consultation = $this->reachConsultation($visit);
app(FinancialGateService::class)->clear($consultation, 'insurance', $this->owner->public_id);
app(WorkflowEngine::class)->refreshGate($visit);
$type = InvestigationType::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Full blood count',
'code' => 'FBC',
'category' => 'blood',
'price_minor' => 3500,
'is_active' => true,
]);
InvestigationRequest::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'visit_id' => $visit->id,
'patient_id' => $this->patient->id,
'investigation_type_id' => $type->id,
'status' => InvestigationRequest::STATUS_PENDING,
'priority' => 'routine',
]);
$advance = app(WorkflowEngine::class)->advance($visit, 'laboratory');
$this->assertSame(VisitStageAdvance::STATUS_BLOCKED, $advance->status);
$this->assertDatabaseHas('care_financial_obligations', [
'visit_id' => $visit->id,
'stage_code' => 'laboratory',
'amount_minor' => 3500,
'status' => FinancialObligation::STATUS_PENDING,
]);
}
public function test_imaging_requests_use_the_imaging_queue_context(): void
{
$visit = $this->checkIn();
$type = InvestigationType::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Chest X-ray',
'code' => 'CXR',
'category' => 'xray',
'price_minor' => 8000,
'is_active' => true,
]);
$request = InvestigationRequest::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'visit_id' => $visit->id,
'patient_id' => $this->patient->id,
'investigation_type_id' => $type->id,
'status' => InvestigationRequest::STATUS_PENDING,
'priority' => 'routine',
]);
$this->assertSame(
CareQueueContexts::IMAGING,
app(CareQueueBridge::class)->contextForInvestigation($request),
);
}
public function test_settings_exposes_workflow_controls_and_herbal_categories(): void
{
$this->actingAs($this->owner)
->get(route('care.settings'))
->assertOk()
->assertSee('Patient journey workflow')
->assertSee('Enforce payment and authorization gates')
->assertSee('Herbal hospital')
->assertSee('Herbal clinic');
}
public function test_completing_consultation_appointment_advances_instead_of_closing_visit(): void
{
Http::fake();
$visit = $this->checkIn();
$consultationObligation = $this->reachConsultation($visit);
app(FinancialGateService::class)->clear($consultationObligation, 'payment', $this->owner->public_id);
app(WorkflowEngine::class)->refreshGate($visit);
$appointment = Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_IN_CONSULTATION,
'scheduled_at' => now(),
'checked_in_at' => now(),
'waiting_at' => now(),
'started_at' => now(),
'queue_position' => 1,
]);
app(AppointmentService::class)->complete(
$appointment,
$this->owner->public_id,
$this->owner->public_id,
);
$this->assertSame(Appointment::STATUS_COMPLETED, $appointment->fresh()->status);
$this->assertNotSame(Visit::STATUS_COMPLETED, $visit->fresh()->status);
$this->assertSame('pharmacy', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code);
}
public function test_completed_lab_stage_returns_patient_to_a_new_consultation_queue(): void
{
Http::fake();
$visit = $this->checkIn();
$consultation = $this->reachConsultation($visit);
app(FinancialGateService::class)->clear($consultation, 'insurance', $this->owner->public_id);
app(WorkflowEngine::class)->refreshGate($visit);
$appointment = Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_COMPLETED,
'scheduled_at' => now(),
'checked_in_at' => now(),
'completed_at' => now(),
'queue_position' => 1,
]);
$type = InvestigationType::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Malaria test',
'code' => 'MAL',
'category' => 'blood',
'price_minor' => 3000,
'is_active' => true,
]);
InvestigationRequest::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'visit_id' => $visit->id,
'patient_id' => $this->patient->id,
'investigation_type_id' => $type->id,
'status' => InvestigationRequest::STATUS_COMPLETED,
'priority' => 'routine',
]);
$labAdvance = app(WorkflowEngine::class)->advance($visit, 'laboratory');
$labObligation = FinancialObligation::query()
->where('workflow_stage_id', $labAdvance->workflow_stage_id)
->where('visit_id', $visit->id)
->firstOrFail();
app(FinancialGateService::class)->clear($labObligation, 'insurance', $this->owner->public_id);
app(WorkflowEngine::class)->refreshGate($visit);
app(WorkflowStageCompletionService::class)->complete(
$this->organization,
$visit,
CareQueueContexts::LABORATORY,
$this->owner->public_id,
$this->owner->public_id,
);
$this->assertSame('consultation', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code);
$this->assertSame(Appointment::STATUS_WAITING, $appointment->fresh()->status);
}
public function test_disabled_workflow_keeps_legacy_check_in_behavior(): void
{
$settings = $this->organization->settings;
data_set($settings, 'rollout.'.CareFeatures::WORKFLOW_ENGINE, false);
data_set($settings, 'rollout.'.CareFeatures::FINANCIAL_GATES, false);
$this->organization->update(['settings' => $settings]);
$visit = $this->checkIn();
$this->assertDatabaseMissing('care_visit_stage_advances', ['visit_id' => $visit->id]);
$this->assertTrue(app(WorkflowQueueGate::class)->canRelease(
$this->organization->fresh(),
$visit,
CareQueueContexts::CONSULTATION,
));
}
public function test_enabling_financial_gates_rechecks_an_active_visit_stage(): void
{
$settings = $this->organization->settings;
data_set($settings, 'rollout.'.CareFeatures::FINANCIAL_GATES, false);
$this->organization->update(['settings' => $settings]);
$visit = $this->checkIn();
$this->assertSame(VisitStageAdvance::STATUS_ACTIVE, app(WorkflowEngine::class)->currentAdvance($visit)->status);
$this->assertDatabaseMissing('care_financial_obligations', ['visit_id' => $visit->id]);
data_set($settings, 'rollout.'.CareFeatures::FINANCIAL_GATES, true);
$this->organization->update(['settings' => $settings]);
$advance = app(WorkflowEngine::class)->refreshGate($visit);
$this->assertSame(VisitStageAdvance::STATUS_BLOCKED, $advance->status);
$this->assertDatabaseHas('care_financial_obligations', [
'visit_id' => $visit->id,
'stage_code' => 'registration',
'status' => FinancialObligation::STATUS_PENDING,
]);
}
}
+9 -1
View File
@@ -11,6 +11,7 @@ use App\Models\Patient;
use App\Models\User;
use App\Models\Visit;
use App\Models\VisitStageAdvance;
use App\Services\Care\CareFeatures;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\Workflow\FinancialGateService;
use App\Services\Care\Workflow\WorkflowEngine;
@@ -44,7 +45,14 @@ class CareWorkflowEngineTest extends TestCase
'owner_ref' => $this->owner->public_id,
'name' => 'Gate Hospital',
'slug' => 'gate-hospital',
'settings' => ['onboarded' => true, 'facility_category' => 'hospital'],
'settings' => [
'onboarded' => true,
'facility_category' => 'hospital',
'rollout' => [
CareFeatures::WORKFLOW_ENGINE => true,
CareFeatures::FINANCIAL_GATES => true,
],
],
]);
Member::create([