Activate Care workflows across check-in and financial clearance.
Deploy Ladill Care / deploy (push) Failing after 1m9s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> */
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user