Files
ladill-care/app/Services/Care/SpecialtyShellService.php
T
isaaccladandCursor c0e5d8ef00
Deploy Ladill Care / deploy (push) Successful in 54s
Add full Ambulance and Child Welfare specialty clinical suites.
EMS clinic workflow (dispatch → scene → transport → handover) plus completed CWC suite, with shell stages, clinical records, reports/print, demo seed, and suite tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 22:25:52 +00:00

726 lines
30 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Visit;
use App\Services\Care\BloodBank\BloodBankWorkflowService;
use App\Services\Care\Emergency\EmergencyWorkflowService;
use App\Services\Care\Ophthalmology\OphthalmologyWorkflowService;
use App\Services\Care\Physiotherapy\PhysiotherapyWorkflowService;
use App\Services\Care\Maternity\MaternityWorkflowService;
use App\Services\Care\Radiology\RadiologyWorkflowService;
use App\Services\Care\Cardiology\CardiologyWorkflowService;
use App\Services\Care\Psychiatry\PsychiatryWorkflowService;
use App\Services\Care\Pediatrics\PediatricsWorkflowService;
use App\Services\Care\Orthopedics\OrthopedicsWorkflowService;
use App\Services\Care\Ent\EntWorkflowService;
use App\Services\Care\Oncology\OncologyWorkflowService;
use App\Services\Care\Renal\RenalWorkflowService;
use App\Services\Care\Surgery\SurgeryWorkflowService;
use App\Services\Care\Vaccination\VaccinationWorkflowService;
use App\Services\Care\Pathology\PathologyWorkflowService;
use App\Services\Care\Infusion\InfusionWorkflowService;
use App\Services\Care\Dermatology\DermatologyWorkflowService;
use App\Services\Care\Podiatry\PodiatryWorkflowService;
use App\Services\Care\Fertility\FertilityWorkflowService;
use App\Services\Care\ChildWelfare\ChildWelfareWorkflowService;
use App\Services\Care\Ambulance\AmbulanceWorkflowService;
use Illuminate\Support\Collection;
/**
* Shared specialty module shell — KPIs, Visit-backed lists, stage maps, service catalogs.
*/
class SpecialtyShellService
{
public function __construct(
protected SpecialtyModuleService $modules,
protected BillService $bills,
) {}
/**
* @return array<string, mixed>
*/
public function definition(string $moduleKey): array
{
$base = $this->modules->definition($moduleKey) ?? [];
$shell = config("care_specialty_shell.modules.{$moduleKey}", []);
$defaults = config('care_specialty_shell.defaults', []);
return array_merge($defaults, $base, is_array($shell) ? $shell : []);
}
/**
* @return list<array{code: string, label: string, queue_point: ?string}>
*/
public function stages(string $moduleKey): array
{
$stages = $this->definition($moduleKey)['stages'] ?? [];
return is_array($stages) ? array_values($stages) : [];
}
/**
* Named route for advancing/setting specialty visit stage, if the module has one.
*/
public function stageAdvanceRoute(string $moduleKey): ?string
{
return match ($moduleKey) {
'dentistry' => 'care.specialty.dentistry.stage',
'emergency' => 'care.specialty.emergency.stage',
'blood_bank' => 'care.specialty.blood-bank.stage',
'ophthalmology' => 'care.specialty.ophthalmology.stage',
'physiotherapy' => 'care.specialty.physiotherapy.stage',
'maternity' => 'care.specialty.maternity.stage',
'radiology' => 'care.specialty.radiology.stage',
'cardiology' => 'care.specialty.cardiology.stage',
'psychiatry' => 'care.specialty.psychiatry.stage',
'pediatrics' => 'care.specialty.pediatrics.stage',
'orthopedics' => 'care.specialty.orthopedics.stage',
'ent' => 'care.specialty.ent.stage',
'oncology' => 'care.specialty.oncology.stage',
'renal' => 'care.specialty.renal.stage',
'surgery' => 'care.specialty.surgery.stage',
'vaccination' => 'care.specialty.vaccination.stage',
'pathology' => 'care.specialty.pathology.stage',
'infusion' => 'care.specialty.infusion.stage',
'dermatology' => 'care.specialty.dermatology.stage',
'podiatry' => 'care.specialty.podiatry.stage',
'fertility' => 'care.specialty.fertility.stage',
'child_welfare' => 'care.specialty.child-welfare.stage',
'ambulance' => 'care.specialty.ambulance.stage',
default => null,
};
}
/**
* Default stage progression for Move CTAs and action menus.
*
* @return array<string, array{next: string, label: string}>
*/
public function stageFlow(string $moduleKey): array
{
return match ($moduleKey) {
'emergency' => app(EmergencyWorkflowService::class)->stageFlow(),
'blood_bank' => app(BloodBankWorkflowService::class)->stageFlow(),
'ophthalmology' => app(OphthalmologyWorkflowService::class)->stageFlow(),
'physiotherapy' => app(PhysiotherapyWorkflowService::class)->stageFlow(),
'maternity' => app(MaternityWorkflowService::class)->stageFlow(),
'radiology' => app(RadiologyWorkflowService::class)->stageFlow(),
'cardiology' => app(CardiologyWorkflowService::class)->stageFlow(),
'psychiatry' => app(PsychiatryWorkflowService::class)->stageFlow(),
'pediatrics' => app(PediatricsWorkflowService::class)->stageFlow(),
'orthopedics' => app(OrthopedicsWorkflowService::class)->stageFlow(),
'ent' => app(EntWorkflowService::class)->stageFlow(),
'oncology' => app(OncologyWorkflowService::class)->stageFlow(),
'renal' => app(RenalWorkflowService::class)->stageFlow(),
'surgery' => app(SurgeryWorkflowService::class)->stageFlow(),
'vaccination' => app(VaccinationWorkflowService::class)->stageFlow(),
'pathology' => app(PathologyWorkflowService::class)->stageFlow(),
'infusion' => app(InfusionWorkflowService::class)->stageFlow(),
'dermatology' => app(DermatologyWorkflowService::class)->stageFlow(),
'podiatry' => app(PodiatryWorkflowService::class)->stageFlow(),
'fertility' => app(FertilityWorkflowService::class)->stageFlow(),
'child_welfare' => app(ChildWelfareWorkflowService::class)->stageFlow(),
'ambulance' => app(AmbulanceWorkflowService::class)->stageFlow(),
'dentistry' => [
'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'],
'chair' => ['next' => 'procedure', 'label' => 'Start procedure'],
'procedure' => ['next' => 'recovery', 'label' => 'Move to recovery'],
'recovery' => ['next' => 'completed', 'label' => 'Complete visit'],
],
default => $this->sequentialStageFlow($moduleKey),
};
}
/**
* Build next-stage map from configured stage order when no suite workflow exists.
*
* @return array<string, array{next: string, label: string}>
*/
protected function sequentialStageFlow(string $moduleKey): array
{
$stages = $this->stages($moduleKey);
$flow = [];
for ($i = 0, $count = count($stages); $i < $count; $i++) {
$code = (string) ($stages[$i]['code'] ?? '');
if ($code === '') {
continue;
}
$next = $stages[$i + 1] ?? null;
if ($next === null) {
$flow[$code] = ['next' => $code, 'label' => 'Completed'];
continue;
}
$nextCode = (string) ($next['code'] ?? '');
if ($nextCode === '') {
continue;
}
$nextLabel = (string) ($next['label'] ?? $nextCode);
$flow[$code] = [
'next' => $nextCode,
'label' => 'Move to '.mb_strtolower($nextLabel),
];
}
return $flow;
}
/**
* Catalog from config (authoritative template).
*
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
*/
public function catalogServices(string $moduleKey): array
{
$services = $this->definition($moduleKey)['services'] ?? [];
if (! is_array($services)) {
return [];
}
return array_values(array_map(function ($row) {
return [
'code' => (string) ($row['code'] ?? ''),
'label' => (string) ($row['label'] ?? ''),
'amount_minor' => (int) ($row['amount_minor'] ?? 0),
'type' => (string) ($row['type'] ?? 'misc'),
];
}, $services));
}
/**
* Seeded services stored on the organization when the module was activated.
*
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
*/
public function provisionedServices(Organization $organization, string $moduleKey): array
{
$catalog = collect($this->catalogServices($moduleKey))->keyBy(fn ($s) => (string) ($s['code'] ?? ''));
$stored = data_get($organization->settings, "specialty_module_provisioning.{$moduleKey}.services");
if (is_array($stored)) {
foreach ($stored as $service) {
$code = (string) ($service['code'] ?? '');
if ($code === '') {
continue;
}
$prior = $catalog->get($code, []);
$catalog->put($code, array_merge(is_array($prior) ? $prior : [], $service));
}
}
return $catalog->filter(fn ($s, $code) => $code !== '')->values()->all();
}
/**
* Persist catalog services onto org settings (idempotent merge by code).
*
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
*/
public function seedServices(Organization $organization, string $moduleKey): array
{
$catalog = $this->catalogServices($moduleKey);
$settings = $organization->settings ?? [];
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
? $settings['specialty_module_provisioning']
: [];
$record = is_array($provisioning[$moduleKey] ?? null) ? $provisioning[$moduleKey] : [];
$existing = collect(is_array($record['services'] ?? null) ? $record['services'] : [])
->keyBy(fn ($s) => (string) ($s['code'] ?? ''));
foreach ($catalog as $service) {
if ($service['code'] === '') {
continue;
}
$prior = $existing->get($service['code'], []);
$existing->put($service['code'], array_merge(is_array($prior) ? $prior : [], $service));
}
$merged = $existing->filter(fn ($s, $code) => $code !== '')->values()->all();
$record['services'] = $merged;
$provisioning[$moduleKey] = $record;
$settings['specialty_module_provisioning'] = $provisioning;
$organization->update(['settings' => $settings]);
return $merged;
}
/**
* @return array<string, string>
*/
public function workspaceTabs(string $moduleKey): array
{
$tabs = $this->definition($moduleKey)['workspace_tabs'] ?? [];
if (! is_array($tabs)) {
return [];
}
if (array_key_exists('timeline', $tabs)) {
return $tabs;
}
$merged = [];
foreach ($tabs as $key => $label) {
$merged[$key] = $label;
if ($key === 'overview') {
$merged['timeline'] = 'Timeline';
}
}
if (! array_key_exists('timeline', $merged)) {
$merged['timeline'] = 'Timeline';
}
return $merged;
}
/**
* Workspace tab to open after advancing/setting a visit specialty stage.
* Uses module `stage_tabs` config, then same-named tab, then overview.
*/
public function workspaceTabForStage(string $moduleKey, string $stage): string
{
$stage = trim($stage);
$tabs = $this->workspaceTabs($moduleKey);
$map = $this->definition($moduleKey)['stage_tabs'] ?? [];
if (is_array($map) && isset($map[$stage]) && is_string($map[$stage]) && $map[$stage] !== '') {
$candidate = $map[$stage];
if (array_key_exists($candidate, $tabs)) {
return $candidate;
}
}
if ($stage !== '' && array_key_exists($stage, $tabs)) {
return $stage;
}
return array_key_exists('overview', $tabs) ? 'overview' : (array_key_first($tabs) ?: 'overview');
}
/**
* @return array<string, string>
*/
public function actions(string $moduleKey): array
{
$actions = $this->definition($moduleKey)['actions'] ?? [];
return is_array($actions) ? $actions : [];
}
/**
* @return list<int>
*/
public function departmentIds(
Organization $organization,
string $moduleKey,
string $ownerRef,
?int $branchScope = null,
): array {
$definition = $this->modules->definition($moduleKey);
if (! $definition) {
return [];
}
$fromSettings = data_get($organization->settings, "specialty_module_provisioning.{$moduleKey}.department_ids", []);
if (is_array($fromSettings) && $fromSettings !== []) {
$ids = array_map('intval', $fromSettings);
} else {
$ids = Department::owned($ownerRef)
->where('organization_id', $organization->id)
->where('type', $definition['department_type'] ?? 'general')
->where('is_active', true)
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
}
if ($branchScope) {
$ids = Department::owned($ownerRef)
->whereIn('id', $ids ?: [0])
->where('branch_id', $branchScope)
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
}
return $ids;
}
/**
* @param list<int>|null $practitionerScope
* @return array{
* waiting: int,
* in_progress: int,
* completed_today: int,
* open_visits: int,
* revenue_today_minor: int,
* stages: list<array{code: string, label: string, count: int}>
* }
*/
public function kpis(
Organization $organization,
string $moduleKey,
string $ownerRef,
?int $branchScope = null,
?array $practitionerScope = null,
): array {
$departmentIds = $this->departmentIds($organization, $moduleKey, $ownerRef, $branchScope);
$todayStart = now()->startOfDay();
$appointmentBase = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds))
->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0'))
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->when(
$practitionerScope !== null,
fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]),
);
$waiting = (clone $appointmentBase)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->count();
$inProgress = (clone $appointmentBase)
->where('status', Appointment::STATUS_IN_CONSULTATION)
->count();
$completedToday = (clone $appointmentBase)
->where('status', Appointment::STATUS_COMPLETED)
->where('completed_at', '>=', $todayStart)
->count();
$visitIds = (clone $appointmentBase)->whereNotNull('visit_id')->pluck('visit_id');
$openVisits = Visit::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds)
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
->count();
$revenueToday = (int) Bill::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds)
->where('created_at', '>=', $todayStart)
->whereNotIn('status', [Bill::STATUS_VOID])
->sum('amount_paid_minor');
$stages = collect($this->stages($moduleKey))->map(function (array $stage) use (
$waiting,
$inProgress,
$completedToday,
$moduleKey,
$organization,
$ownerRef,
$visitIds,
) {
$code = (string) ($stage['code'] ?? '');
if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery', 'vaccination', 'pathology', 'infusion', 'dermatology', 'podiatry', 'fertility', 'child_welfare', 'ambulance'], true) && $visitIds->isNotEmpty()) {
$count = Visit::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('id', $visitIds)
->where('specialty_stage', $code)
->when(
! in_array($code, ['completed', 'disposition'], true),
fn ($q) => $q->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]),
)
->count();
} else {
$count = match (true) {
in_array($code, ['waiting', 'arrival', 'request'], true) => $waiting,
in_array($code, ['in_care', 'chair', 'procedure', 'treatment', 'resus', 'crossmatch', 'issue', 'observation'], true) => $inProgress,
in_array($code, ['completed', 'disposition', 'transfusion', 'recovery'], true) => $completedToday,
default => 0,
};
}
return [
'code' => $code,
'label' => (string) ($stage['label'] ?? $code),
'count' => $count,
];
})->all();
$clinical = app(SpecialtyClinicalRecordService::class);
$clinicalOpen = match ($moduleKey) {
'emergency' => $clinical->countOpenByType($organization, 'emergency', 'triage', $branchScope),
'blood_bank' => $clinical->countOpenByType($organization, 'blood_bank', 'blood_request', $branchScope),
'ophthalmology' => $clinical->countOpenByType($organization, 'ophthalmology', 'eye_exam', $branchScope),
'physiotherapy' => $clinical->countOpenByType($organization, 'physiotherapy', 'pt_assessment', $branchScope),
'maternity' => $clinical->countOpenByType($organization, 'maternity', 'anc_history', $branchScope),
'radiology' => $clinical->countOpenByType($organization, 'radiology', 'imaging_request', $branchScope),
'cardiology' => $clinical->countOpenByType($organization, 'cardiology', 'cardiac_exam', $branchScope),
'psychiatry' => $clinical->countOpenByType($organization, 'psychiatry', 'mental_status', $branchScope),
'pediatrics' => $clinical->countOpenByType($organization, 'pediatrics', 'pediatric_exam', $branchScope),
'orthopedics' => $clinical->countOpenByType($organization, 'orthopedics', 'ortho_exam', $branchScope),
'ent' => $clinical->countOpenByType($organization, 'ent', 'ent_exam', $branchScope),
'oncology' => $clinical->countOpenByType($organization, 'oncology', 'onc_staging', $branchScope),
'renal' => $clinical->countOpenByType($organization, 'renal', 'renal_exam', $branchScope),
'surgery' => $clinical->countOpenByType($organization, 'surgery', 'surg_exam', $branchScope),
'vaccination' => $clinical->countOpenByType($organization, 'vaccination', 'vax_eligibility', $branchScope),
'pathology' => $clinical->countOpenByType($organization, 'pathology', 'path_request', $branchScope),
'infusion' => $clinical->countOpenByType($organization, 'infusion', 'infusion_assessment', $branchScope),
'dermatology' => $clinical->countOpenByType($organization, 'dermatology', 'skin_exam', $branchScope),
'podiatry' => $clinical->countOpenByType($organization, 'podiatry', 'foot_exam', $branchScope),
'fertility' => $clinical->countOpenByType($organization, 'fertility', 'fert_exam', $branchScope),
'child_welfare' => $clinical->countOpenByType($organization, 'child_welfare', 'cwc_assessment', $branchScope),
'ambulance' => $clinical->countOpenByType($organization, 'ambulance', 'amb_scene', $branchScope),
'dentistry' => \App\Models\DentalPlanItem::query()
->whereHas('plan', function ($q) use ($organization, $ownerRef) {
$q->owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('status', [
\App\Models\DentalTreatmentPlan::STATUS_ACCEPTED,
\App\Models\DentalTreatmentPlan::STATUS_IN_PROGRESS,
]);
})
->whereIn('status', [
\App\Models\DentalPlanItem::STATUS_ACCEPTED,
\App\Models\DentalPlanItem::STATUS_PROPOSED,
])
->count(),
default => 0,
};
return [
'waiting' => $waiting,
'in_progress' => $inProgress,
'completed_today' => $completedToday,
'open_visits' => $openVisits,
'revenue_today_minor' => $revenueToday,
'clinical_open' => $clinicalOpen,
'stages' => $stages,
];
}
/**
* @param list<int>|null $practitionerScope
* @return Collection<int, Visit>
*/
public function openVisits(
Organization $organization,
string $moduleKey,
string $ownerRef,
?int $branchScope = null,
?array $practitionerScope = null,
int $limit = 40,
): Collection {
$departmentIds = $this->departmentIds($organization, $moduleKey, $ownerRef, $branchScope);
return Visit::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->whereHas('appointment', function ($q) use ($departmentIds, $practitionerScope) {
$q->when($departmentIds !== [], fn ($qq) => $qq->whereIn('department_id', $departmentIds))
->when($departmentIds === [], fn ($qq) => $qq->whereRaw('1 = 0'))
->when(
$practitionerScope !== null,
fn ($qq) => $qq->whereIn('practitioner_id', $practitionerScope ?: [0]),
);
})
->with(['patient', 'appointment.practitioner', 'appointment.department', 'bill'])
->orderByDesc('checked_in_at')
->limit($limit)
->get();
}
/**
* @param list<int>|null $practitionerScope
* @return Collection<int, Visit>
*/
public function visitHistory(
Organization $organization,
string $moduleKey,
string $ownerRef,
?int $branchScope = null,
?array $practitionerScope = null,
int $limit = 40,
): Collection {
$departmentIds = $this->departmentIds($organization, $moduleKey, $ownerRef, $branchScope);
return Visit::owned($ownerRef)
->where('organization_id', $organization->id)
->where('status', Visit::STATUS_COMPLETED)
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->whereHas('appointment', function ($q) use ($departmentIds, $practitionerScope) {
$q->when($departmentIds !== [], fn ($qq) => $qq->whereIn('department_id', $departmentIds))
->when($departmentIds === [], fn ($qq) => $qq->whereRaw('1 = 0'))
->when(
$practitionerScope !== null,
fn ($qq) => $qq->whereIn('practitioner_id', $practitionerScope ?: [0]),
);
})
->with(['patient', 'appointment.practitioner', 'appointment.department', 'bill'])
->orderByDesc('completed_at')
->limit($limit)
->get();
}
/**
* @return list<array{at: string, label: string, detail: ?string}>
*/
public function patientTimeline(Patient $patient, int $limit = 20): array
{
$patient->loadMissing([
'visits' => fn ($q) => $q->orderByDesc('checked_in_at')->limit(10),
'appointments' => fn ($q) => $q->orderByDesc('scheduled_at')->limit(10),
'bills' => fn ($q) => $q->orderByDesc('created_at')->limit(10),
]);
$events = collect();
foreach ($patient->visits as $visit) {
if ($visit->checked_in_at) {
$events->push([
'at' => $visit->checked_in_at->toIso8601String(),
'label' => 'Visit opened',
'detail' => 'Visit '.$visit->uuid,
]);
}
if ($visit->completed_at) {
$events->push([
'at' => $visit->completed_at->toIso8601String(),
'label' => 'Visit completed',
'detail' => null,
]);
}
}
foreach ($patient->appointments as $appointment) {
if ($appointment->waiting_at) {
$events->push([
'at' => $appointment->waiting_at->toIso8601String(),
'label' => 'Joined waiting list',
'detail' => $appointment->queue_ticket_number
? 'Ticket '.$appointment->queue_ticket_number
: null,
]);
}
if ($appointment->started_at) {
$events->push([
'at' => $appointment->started_at->toIso8601String(),
'label' => 'Consultation started',
'detail' => $appointment->practitioner?->name,
]);
}
if ($appointment->completed_at) {
$events->push([
'at' => $appointment->completed_at->toIso8601String(),
'label' => 'Consultation completed',
'detail' => null,
]);
}
}
foreach ($patient->bills as $bill) {
$events->push([
'at' => $bill->created_at?->toIso8601String() ?? now()->toIso8601String(),
'label' => 'Invoice '.$bill->invoice_number,
'detail' => strtoupper((string) $bill->status),
]);
}
return $events
->sortByDesc('at')
->take($limit)
->values()
->all();
}
/**
* @return array{
* outstanding_minor: int,
* allergies: list<string>,
* insurance_summary: ?string,
* emergency: bool
* }
*/
public function patientHeaderMeta(Patient $patient): array
{
$patient->loadMissing(['allergies', 'insurancePolicies', 'emergencyContacts']);
$outstanding = (int) Bill::owned($patient->owner_ref)
->where('patient_id', $patient->id)
->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL, Bill::STATUS_DRAFT])
->sum('balance_minor');
$allergies = $patient->allergies->map(fn ($a) => (string) $a->allergen)->filter()->values()->all();
$policy = $patient->insurancePolicies->first();
$insurance = $policy
? trim(implode(' · ', array_filter([(string) ($policy->provider_name ?? ''), (string) ($policy->policy_number ?? '')])))
: null;
return [
'outstanding_minor' => $outstanding,
'allergies' => $allergies,
'insurance_summary' => $insurance !== '' ? $insurance : null,
'emergency' => $patient->emergencyContacts->isNotEmpty(),
];
}
public function addCatalogServiceToVisit(
Organization $organization,
Visit $visit,
string $moduleKey,
string $serviceCode,
string $ownerRef,
?string $actorRef = null,
int $quantity = 1,
): Bill {
$service = collect($this->provisionedServices($organization, $moduleKey))
->first(fn ($s) => ($s['code'] ?? '') === $serviceCode);
if (! $service) {
throw new \InvalidArgumentException("Unknown specialty service [{$serviceCode}].");
}
// Reuse an open visit bill without syncLineItemsFromVisit — that sync deletes
// prior specialty_service lines and would wipe multi-item issue/billing batches.
$bill = Bill::owned($ownerRef)
->where('visit_id', $visit->id)
->whereNotIn('status', [Bill::STATUS_VOID])
->first();
if (! $bill) {
$bill = $this->bills->generateFromVisit($visit, $ownerRef, $actorRef);
}
return $this->bills->addManualLineItem($bill, $ownerRef, [
'type' => $service['type'] ?? 'misc',
'description' => $service['label'],
'quantity' => max(1, $quantity),
'unit_price_minor' => (int) ($service['amount_minor'] ?? 0),
'source_type' => 'specialty_service',
'source_id' => null,
], $actorRef);
}
public function navItems(string $moduleKey): array
{
// Queue home is the primary surface; history/billing are secondary links in the shell header.
return [
'history' => ['label' => 'History', 'route' => 'care.specialty.history'],
'billing' => ['label' => 'Billing', 'route' => 'care.specialty.billing'],
];
}
public function memberCanAccess(Organization $organization, ?Member $member, string $moduleKey): bool
{
return $this->modules->memberCanAccess($organization, $member, $moduleKey);
}
}