Files
ladill-care/app/Services/Care/CareQueueProvisioner.php
T
isaaccladandCursor 86d79b5099
Deploy Ladill Care / deploy (push) Successful in 1m2s
Restore Care queue FIFO after demo skip left tickets Unassigned.
Provision one branch/context at a time instead of the whole org, fall back to a desk when doctor counters are catching up, sync pharmacy tickets in Care list order on page load, and seed waiting_at/queue_position for demos.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 23:05:42 +00:00

568 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Care;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Practitioner;
use App\Services\Queue\QueueClient;
use Illuminate\Support\Facades\Log;
/**
* Idempotently provisions Ladill Queue departments, queues, and service points
* (counters) from Care staff / branch configuration.
*
* Ladill Queue v2 industry packages define the Healthcare template in Queue Core.
* When Care integration is enabled, Queue skips materializing healthcare stages
* and this provisioner remains the source of truth for clinical service points
* (per-doctor rooms, pharmacy desks, lab desks, cashiers).
*/
class CareQueueProvisioner
{
public function __construct(
protected QueueClient $queue,
protected PlanService $plans,
) {}
public function shouldProvision(Organization $organization): bool
{
return $this->plans->canUseQueueIntegration($organization)
&& (bool) data_get($organization->settings, 'queue_integration_enabled')
&& $this->queue->configured();
}
/**
* @return array<string, mixed>
*/
public function provisionOrganization(Organization $organization): array
{
if (! $this->shouldProvision($organization)) {
return (array) data_get($organization->settings, 'department_queue_provisioning', []);
}
$owner = (string) $organization->owner_ref;
$branches = Branch::owned($owner)
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
$provisioning = (array) data_get($organization->settings, 'department_queue_provisioning', []);
foreach (CareQueueContexts::departments() as $context => $meta) {
$queues = is_array($provisioning[$context]['queues'] ?? null)
? $provisioning[$context]['queues']
: [];
$byBranch = collect($queues)->keyBy(fn ($q) => (string) ($q['branch_id'] ?? ''));
foreach ($branches as $branch) {
$prior = $byBranch->get((string) $branch->id, []);
$stub = $this->provisionBranchDepartment(
$organization,
$owner,
$context,
$meta,
$branch,
is_array($prior) ? $prior : [],
);
$byBranch->put((string) $branch->id, $stub);
}
$provisioning[$context] = [
'queues' => $byBranch->values()->all(),
];
}
$settings = $organization->settings ?? [];
$settings['department_queue_provisioning'] = $provisioning;
$organization->update(['settings' => $settings]);
return $provisioning;
}
/**
* @param array<string, mixed> $meta
* @param array<string, mixed> $prior
* @return array<string, mixed>
*/
protected function provisionBranchDepartment(
Organization $organization,
string $owner,
string $context,
array $meta,
Branch $branch,
array $prior,
): array {
$stub = [
'name' => $meta['name'],
'prefix' => $meta['prefix'],
'module' => $context,
'branch_id' => $branch->id,
'branch_name' => $branch->name,
'active' => true,
'routing_mode' => $meta['routing_mode'],
'queue_external_key' => CareQueueContexts::queueExternalKey($context, $branch->id),
'department_external_key' => CareQueueContexts::departmentExternalKey($context, $branch->id),
'queue_uuid' => $prior['queue_uuid'] ?? null,
'department_id' => $prior['department_id'] ?? null,
'counter_uuid' => $prior['counter_uuid'] ?? null,
'counter_external_key' => $prior['counter_external_key']
?? CareQueueContexts::counterExternalKey($context, $branch->id),
'points' => is_array($prior['points'] ?? null) ? $prior['points'] : [],
'synced' => false,
];
try {
$this->ensureBranchName($owner, $branch->name);
$department = $this->queue->ensureDepartment($owner, [
'name' => $meta['name'],
'type' => $meta['type'] ?? 'general',
'branch_name' => $branch->name,
'external_key' => $stub['department_external_key'],
'is_active' => true,
]);
$stub['department_id'] = $department['id'] ?? $stub['department_id'];
$queue = $this->queue->ensureQueue($owner, [
'name' => $meta['name'],
'prefix' => $meta['prefix'],
'branch_name' => $branch->name,
'strategy' => 'fifo',
'external_key' => $stub['queue_external_key'],
'routing_mode' => $meta['routing_mode'],
'department_id' => $stub['department_id'],
'is_active' => true,
]);
$stub['queue_uuid'] = $queue['uuid'] ?? $stub['queue_uuid'];
$points = $this->buildPointsForContext($organization, $context, $branch, $stub);
$syncedPoints = [];
foreach ($points as $point) {
try {
$counter = $this->queue->ensureCounter($owner, [
'name' => $point['name'],
'destination' => $point['destination'],
'staff_ref' => $point['staff_ref'],
'staff_display_name' => $point['staff_display_name'],
'branch_name' => $branch->name,
'department_id' => $stub['department_id'],
'queue_uuids' => array_values(array_filter([(string) $stub['queue_uuid']])),
'external_key' => $point['external_key'],
'is_active' => true,
]);
$point['counter_uuid'] = $counter['uuid'] ?? null;
$point['synced'] = ! empty($point['counter_uuid']);
} catch (\Throwable $e) {
Log::warning('care.queue_point_provision_failed', [
'context' => $context,
'branch_id' => $branch->id,
'point' => $point['external_key'] ?? null,
'message' => $e->getMessage(),
]);
$point['synced'] = false;
}
$syncedPoints[] = $point;
}
$stub['points'] = $syncedPoints;
// Legacy single counter: first point (or dedicated default) for older callers.
$first = collect($syncedPoints)->first(fn ($p) => ! empty($p['counter_uuid']));
if ($first) {
$stub['counter_uuid'] = $first['counter_uuid'];
$stub['counter_external_key'] = $first['external_key'];
}
$stub['synced'] = ! empty($stub['queue_uuid']) && collect($syncedPoints)->contains(fn ($p) => ! empty($p['synced']));
} catch (\Throwable $e) {
Log::warning('care.queue_department_provision_failed', [
'context' => $context,
'branch_id' => $branch->id,
'message' => $e->getMessage(),
]);
}
return $stub;
}
/**
* @param array<string, mixed> $stub
* @return list<array<string, mixed>>
*/
protected function buildPointsForContext(
Organization $organization,
string $context,
Branch $branch,
array $stub,
): array {
$owner = (string) $organization->owner_ref;
$priorByKey = collect($stub['points'] ?? [])->keyBy(fn ($p) => (string) ($p['external_key'] ?? ''));
$points = match ($context) {
CareQueueContexts::CONSULTATION => $this->practitionerPoints($organization, $branch, $priorByKey),
CareQueueContexts::TRIAGE => $this->memberRolePoints(
$organization,
$branch,
['nurse'],
$context,
'Nurse station',
$priorByKey,
),
CareQueueContexts::PHARMACY => $this->memberRolePoints(
$organization,
$branch,
['pharmacist'],
$context,
'Pharmacy counter',
$priorByKey,
),
CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING => $this->memberRolePoints(
$organization,
$branch,
['lab_technician'],
$context,
$context === CareQueueContexts::IMAGING ? 'Imaging room' : 'Lab desk',
$priorByKey,
),
CareQueueContexts::BILLING => $this->memberRolePoints(
$organization,
$branch,
['cashier'],
$context,
'Cashier desk',
$priorByKey,
),
CareQueueContexts::RECEPTION => $this->memberRolePoints(
$organization,
$branch,
['receptionist'],
$context,
'Reception',
$priorByKey,
),
default => [],
};
// Always keep at least one default point so call-next / least-load have a target.
if ($points === []) {
$key = CareQueueContexts::pointExternalKey($context, $branch->id, 'default', 0);
$prior = $priorByKey->get($key, []);
$points[] = [
'kind' => 'default',
'ref_id' => 0,
'name' => ($stub['name'] ?? ucfirst($context)).' desk',
'destination' => ($stub['name'] ?? ucfirst($context)).' desk',
'staff_ref' => null,
'staff_display_name' => null,
'external_key' => $key,
'counter_uuid' => $prior['counter_uuid'] ?? null,
'synced' => false,
];
}
return $points;
}
/**
* @param \Illuminate\Support\Collection<string, array<string, mixed>> $priorByKey
* @return list<array<string, mixed>>
*/
protected function practitionerPoints(Organization $organization, Branch $branch, $priorByKey): array
{
$practitioners = Practitioner::owned($organization->owner_ref)
->where('organization_id', $organization->id)
->where('branch_id', $branch->id)
->where('is_active', true)
->orderBy('name')
->get();
$points = [];
$roomIndex = 1;
foreach ($practitioners as $practitioner) {
$key = CareQueueContexts::pointExternalKey(
CareQueueContexts::CONSULTATION,
$branch->id,
'practitioner',
$practitioner->id,
);
$prior = $priorByKey->get($key, []);
$room = trim((string) ($practitioner->room ?? ''));
if ($room === '') {
$room = 'Consultation Room '.$roomIndex;
}
$roomIndex++;
$points[] = [
'kind' => 'practitioner',
'ref_id' => $practitioner->id,
'name' => $practitioner->name,
'destination' => $room,
'staff_ref' => $practitioner->user_ref ?: ('practitioner:'.$practitioner->id),
'staff_display_name' => $practitioner->name,
'specialty' => $practitioner->specialty,
'external_key' => $key,
'counter_uuid' => $prior['counter_uuid'] ?? null,
'synced' => false,
];
}
return $points;
}
/**
* @param list<string> $roles
* @param \Illuminate\Support\Collection<string, array<string, mixed>> $priorByKey
* @return list<array<string, mixed>>
*/
protected function memberRolePoints(
Organization $organization,
Branch $branch,
array $roles,
string $context,
string $destinationPrefix,
$priorByKey,
): array {
$members = Member::owned($organization->owner_ref)
->where('organization_id', $organization->id)
->whereIn('role', $roles)
->where(function ($q) use ($branch) {
$q->whereNull('branch_id')->orWhere('branch_id', $branch->id);
})
->orderBy('id')
->get();
$points = [];
$index = 1;
foreach ($members as $member) {
$key = CareQueueContexts::pointExternalKey($context, $branch->id, 'member', $member->id);
$prior = $priorByKey->get($key, []);
$label = $destinationPrefix.' '.$index;
$points[] = [
'kind' => 'member',
'ref_id' => $member->id,
'name' => $label,
'destination' => $label,
'staff_ref' => $member->user_ref,
'staff_display_name' => null,
'external_key' => $key,
'counter_uuid' => $prior['counter_uuid'] ?? null,
'synced' => false,
];
$index++;
}
return $points;
}
protected function ensureBranchName(string $owner, string $branchName): void
{
try {
$this->queue->ensureBranch($owner, ['name' => $branchName]);
} catch (\Illuminate\Http\Client\RequestException $e) {
if ($e->response?->status() !== 422) {
throw $e;
}
}
}
/**
* @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool, points: list<array<string, mixed>>, routing_mode: string}|null
*/
public function resourcesFor(
Organization $organization,
string $context,
int $branchId,
): ?array {
if (CareQueueContexts::isSpecialty($context)) {
$queues = data_get($organization->settings, "specialty_module_provisioning.{$context}.queues", []);
} else {
$queues = data_get($organization->settings, "department_queue_provisioning.{$context}.queues", []);
}
if (! is_array($queues)) {
return null;
}
$match = collect($queues)->first(
fn ($q) => (int) ($q['branch_id'] ?? 0) === $branchId && ($q['active'] ?? true)
);
if (! $match) {
return null;
}
$meta = CareQueueContexts::departments()[$context] ?? null;
return [
'queue_uuid' => $match['queue_uuid'] ?? null,
'counter_uuid' => $match['counter_uuid'] ?? null,
'queue_external_key' => (string) ($match['queue_external_key']
?? CareQueueContexts::queueExternalKey($context, $branchId)),
'counter_external_key' => (string) ($match['counter_external_key']
?? CareQueueContexts::counterExternalKey($context, $branchId)),
'synced' => (bool) ($match['synced'] ?? false),
'points' => is_array($match['points'] ?? null) ? $match['points'] : [],
'routing_mode' => (string) ($match['routing_mode'] ?? $meta['routing_mode'] ?? 'shared_pool'),
];
}
/**
* Resolve a service point for a practitioner (consultation) or member staff_ref.
*
* @return array<string, mixed>|null
*/
public function pointForPractitioner(Organization $organization, int $branchId, int $practitionerId): ?array
{
$resources = $this->resourcesFor($organization, CareQueueContexts::CONSULTATION, $branchId);
if (! $resources) {
return null;
}
return collect($resources['points'])->first(
fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId
);
}
/**
* @return array<string, mixed>|null
*/
public function pointForMember(Organization $organization, string $context, int $branchId, Member $member): ?array
{
$resources = $this->resourcesFor($organization, $context, $branchId);
if (! $resources) {
return null;
}
$byMember = collect($resources['points'])->first(
fn ($p) => ($p['kind'] ?? '') === 'member' && (int) ($p['ref_id'] ?? 0) === $member->id
);
if ($byMember) {
return $byMember;
}
return collect($resources['points'])->first(
fn ($p) => ($p['staff_ref'] ?? null) === $member->user_ref
);
}
/**
* Deterministic least-load / first active point when Care has no explicit assignment.
*
* @return array<string, mixed>|null
*/
public function defaultPoint(Organization $organization, string $context, int $branchId): ?array
{
$resources = $this->resourcesFor($organization, $context, $branchId);
if (! $resources) {
return null;
}
$points = collect($resources['points'])->filter(fn ($p) => ! empty($p['counter_uuid']) && ($p['synced'] ?? true));
if ($points->isEmpty()) {
if (! empty($resources['counter_uuid'])) {
return [
'kind' => 'legacy',
'counter_uuid' => $resources['counter_uuid'],
'external_key' => $resources['counter_external_key'],
'destination' => null,
'staff_display_name' => null,
];
}
return null;
}
return $points->sortBy('ref_id')->first();
}
/**
* Ensure Queue resources exist for one context + branch only.
* Never fans out across every department × branch (that hung demo queues).
*
* @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool, points: list<array<string, mixed>>, routing_mode: string}|null
*/
public function ensure(
Organization $organization,
string $context,
int $branchId,
): ?array {
$existing = $this->resourcesFor($organization, $context, $branchId);
$expectedMode = CareQueueContexts::departments()[$context]['routing_mode']
?? (CareQueueContexts::isSpecialty($context) ? 'assigned_only' : null);
$modeMatches = ! $expectedMode
|| ($existing['routing_mode'] ?? null) === $expectedMode;
if ($existing && ! empty($existing['queue_uuid']) && (
! empty($existing['points']) || ! empty($existing['counter_uuid'])
) && $modeMatches) {
return $existing;
}
if (! $this->shouldProvision($organization)) {
return $existing;
}
if (CareQueueContexts::isSpecialty($context)) {
return $existing;
}
if (! CareQueueContexts::isDepartment($context)) {
return null;
}
$this->provisionContextBranch($organization->fresh(), $context, $branchId);
return $this->resourcesFor($organization->fresh(), $context, $branchId);
}
/**
* Provision a single department queue for one branch (idempotent).
*/
public function provisionContextBranch(Organization $organization, string $context, int $branchId): ?array
{
if (! $this->shouldProvision($organization)) {
return $this->resourcesFor($organization, $context, $branchId);
}
$meta = CareQueueContexts::departments()[$context] ?? null;
if ($meta === null) {
return null;
}
$branch = Branch::owned((string) $organization->owner_ref)
->where('organization_id', $organization->id)
->whereKey($branchId)
->first();
if (! $branch) {
return null;
}
$provisioning = (array) data_get($organization->settings, 'department_queue_provisioning', []);
$queues = is_array($provisioning[$context]['queues'] ?? null)
? $provisioning[$context]['queues']
: [];
$byBranch = collect($queues)->keyBy(fn ($q) => (string) ($q['branch_id'] ?? ''));
$prior = $byBranch->get((string) $branch->id, []);
$stub = $this->provisionBranchDepartment(
$organization,
(string) $organization->owner_ref,
$context,
$meta,
$branch,
is_array($prior) ? $prior : [],
);
$byBranch->put((string) $branch->id, $stub);
$provisioning[$context] = [
'queues' => $byBranch->values()->all(),
];
$settings = $organization->settings ?? [];
$settings['department_queue_provisioning'] = $provisioning;
$organization->update(['settings' => $settings]);
return $stub;
}
}