Files
ladill-care/app/Services/Care/CareQueueProvisioner.php
T
isaaccladandCursor 6cf5a24019
Deploy Ladill Care / deploy (push) Successful in 1m28s
Add in-app Care Queue Engine (Phase 1 MVP).
Issue, call-next, serve, and complete tickets locally so Care Pro no longer
needs QUEUE_API_* when CARE_QUEUE_DRIVER=native (default). Remote Ladill Queue
remains available for cutover; PHPUnit keeps the remote driver for existing fakes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 09:54:06 +00:00

773 lines
28 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\CareServicePoint;
use App\Models\CareServiceQueue;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Practitioner;
use App\Services\Queue\QueueClient;
use Illuminate\Support\Facades\Log;
/**
* Idempotently provisions service queues and service points from Care staff /
* branch configuration. Native driver writes Care Queue Engine tables; remote
* driver syncs to Ladill Queue over HTTP (legacy cutover).
*/
class CareQueueProvisioner
{
public function __construct(
protected QueueClient $queue,
protected PlanService $plans,
) {}
public function usesNative(): bool
{
return config('care.queue.driver', 'native') === 'native';
}
public function shouldProvision(Organization $organization): bool
{
$enabled = $this->plans->canUseQueueIntegration($organization)
&& (bool) data_get($organization->settings, 'queue_integration_enabled');
if (! $enabled) {
return false;
}
return $this->usesNative() || $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,
];
if ($this->usesNative()) {
return $this->provisionBranchDepartmentNative($organization, $owner, $context, $meta, $branch, $stub);
}
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> $meta
* @param array<string, mixed> $stub
* @return array<string, mixed>
*/
protected function provisionBranchDepartmentNative(
Organization $organization,
string $owner,
string $context,
array $meta,
Branch $branch,
array $stub,
): array {
try {
$queue = CareServiceQueue::query()->updateOrCreate(
[
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'context' => $context,
],
[
'owner_ref' => $owner,
'name' => $meta['name'],
'prefix' => $meta['prefix'],
'routing_mode' => $meta['routing_mode'],
'external_key' => $stub['queue_external_key'],
'is_active' => true,
],
);
$stub['queue_uuid'] = $queue->uuid;
$stub['department_id'] = null;
$points = $this->buildPointsForContext($organization, $context, $branch, $stub);
$syncedPoints = [];
$seenExternalKeys = [];
foreach ($points as $point) {
$externalKey = (string) ($point['external_key'] ?? '');
if ($externalKey === '') {
continue;
}
$seenExternalKeys[] = $externalKey;
$row = CareServicePoint::query()->updateOrCreate(
[
'service_queue_id' => $queue->id,
'external_key' => $externalKey,
],
[
'owner_ref' => $owner,
'organization_id' => $organization->id,
'name' => (string) ($point['name'] ?? $point['destination'] ?? 'Desk'),
'destination' => $point['destination'] ?? null,
'kind' => (string) ($point['kind'] ?? 'default'),
'ref_id' => isset($point['ref_id']) ? (int) $point['ref_id'] : null,
'staff_ref' => $point['staff_ref'] ?? null,
'staff_display_name' => $point['staff_display_name'] ?? null,
'is_active' => true,
],
);
$point['counter_uuid'] = $row->uuid;
$point['synced'] = true;
$syncedPoints[] = $point;
}
// Soft-deactivate points no longer in the staff roster.
CareServicePoint::query()
->where('service_queue_id', $queue->id)
->when(
$seenExternalKeys !== [],
fn ($q) => $q->whereNotIn('external_key', $seenExternalKeys),
fn ($q) => $q->whereRaw('1 = 1'),
)
->update(['is_active' => false]);
$stub['points'] = $syncedPoints;
$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)->isNotEmpty();
} catch (\Throwable $e) {
Log::warning('care.native_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 (true) {
$context === CareQueueContexts::CONSULTATION => $this->practitionerPoints(
$organization,
$branch,
CareQueueContexts::CONSULTATION,
$priorByKey,
),
CareQueueContexts::isSpecialty($context) => $this->practitionerPoints(
$organization,
$branch,
$context,
$priorByKey,
data_get($organization->settings, "specialty_module_provisioning.{$context}.department_ids", []),
),
$context === CareQueueContexts::TRIAGE => $this->memberRolePoints(
$organization,
$branch,
['nurse'],
$context,
'Nurse station',
$priorByKey,
),
$context === CareQueueContexts::PHARMACY => $this->memberRolePoints(
$organization,
$branch,
['pharmacist'],
$context,
'Pharmacy counter',
$priorByKey,
),
in_array($context, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true) => $this->memberRolePoints(
$organization,
$branch,
['lab_technician'],
$context,
$context === CareQueueContexts::IMAGING ? 'Imaging room' : 'Lab desk',
$priorByKey,
),
$context === CareQueueContexts::BILLING => $this->memberRolePoints(
$organization,
$branch,
['cashier'],
$context,
'Cashier desk',
$priorByKey,
),
$context === 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 list<int|string>|null $departmentIds
* @param \Illuminate\Support\Collection<string, array<string, mixed>> $priorByKey
* @return list<array<string, mixed>>
*/
protected function practitionerPoints(
Organization $organization,
Branch $branch,
string $context,
$priorByKey,
?array $departmentIds = null,
): array {
$practitioners = Practitioner::owned($organization->owner_ref)
->where('organization_id', $organization->id)
->where('branch_id', $branch->id)
->where('is_active', true)
->when(
is_array($departmentIds) && $departmentIds !== [],
fn ($q) => $q->whereIn('department_id', array_map('intval', $departmentIds)),
)
->orderBy('name')
->get();
$points = [];
$roomIndex = 1;
$roomPrefix = CareQueueContexts::isSpecialty($context)
? (string) (config("care.specialty_modules.{$context}.label") ?? 'Room')
: 'Consultation Room';
foreach ($practitioners as $practitioner) {
$key = CareQueueContexts::pointExternalKey(
$context,
$branch->id,
'practitioner',
$practitioner->id,
);
$prior = $priorByKey->get($key, []);
$room = trim((string) ($practitioner->room ?? ''));
if ($room === '') {
$room = $roomPrefix.' '.$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 on a queue context.
*
* @return array<string, mixed>|null
*/
public function pointForPractitioner(
Organization $organization,
int $branchId,
int $practitionerId,
string $context = CareQueueContexts::CONSULTATION,
): ?array {
$resources = $this->resourcesFor($organization, $context, $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) {
// Specialty module stubs often only store a legacy counter — still provision
// practitioner points so check-in / sync can assign real desks.
if (! CareQueueContexts::isSpecialty($context) || ! empty($existing['points'])) {
return $existing;
}
}
if (! $this->shouldProvision($organization)) {
return $existing;
}
if (CareQueueContexts::isSpecialty($context)) {
return $this->provisionSpecialtyContextBranch($organization->fresh(), $context, $branchId)
?? $existing;
}
if (! CareQueueContexts::isDepartment($context)) {
return null;
}
$this->provisionContextBranch($organization->fresh(), $context, $branchId);
return $this->resourcesFor($organization->fresh(), $context, $branchId);
}
/**
* Provision a single specialty queue for one branch (idempotent).
*
* @return array<string, mixed>|null
*/
public function provisionSpecialtyContextBranch(Organization $organization, string $context, int $branchId): ?array
{
if (! CareQueueContexts::isSpecialty($context) || ! $this->shouldProvision($organization)) {
return $this->resourcesFor($organization, $context, $branchId);
}
$definition = config("care.specialty_modules.{$context}");
if (! is_array($definition)) {
return null;
}
$branch = Branch::owned((string) $organization->owner_ref)
->where('organization_id', $organization->id)
->whereKey($branchId)
->first();
if (! $branch) {
return null;
}
$meta = [
'name' => (string) ($definition['queue_name'] ?? $definition['label'] ?? $context),
'prefix' => (string) ($definition['queue_prefix'] ?? strtoupper(substr($context, 0, 3))),
'routing_mode' => 'assigned_only',
'type' => 'consultation',
];
$provisioning = (array) data_get($organization->settings, 'specialty_module_provisioning', []);
$record = is_array($provisioning[$context] ?? null) ? $provisioning[$context] : [];
$queues = is_array($record['queues'] ?? null) ? $record['queues'] : [];
$byBranch = collect($queues)->keyBy(fn ($q) => (string) ($q['branch_id'] ?? ''));
$prior = $byBranch->get((string) $branch->id, []);
if (! is_array($prior)) {
$prior = [];
}
$prior['module'] = $context;
$stub = $this->provisionBranchDepartment(
$organization,
(string) $organization->owner_ref,
$context,
$meta,
$branch,
$prior,
);
$byBranch->put((string) $branch->id, $stub);
$provisioning[$context] = array_merge($record, [
'active' => true,
'queues' => $byBranch->values()->all(),
]);
$settings = $organization->settings ?? [];
$settings['specialty_module_provisioning'] = $provisioning;
$organization->update(['settings' => $settings]);
return $stub;
}
/**
* 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;
}
}