Deploy Ladill Care / deploy (push) Failing after 1m13s
Care Queue Engine is the only path; remove QueueClient, driver config, and remote HTTP tests. Co-authored-by: Cursor <cursoragent@cursor.com>
510 lines
19 KiB
PHP
510 lines
19 KiB
PHP
<?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);
|
|
|
|
$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,
|
|
]);
|
|
}
|
|
}
|