Files
ladill-care/app/Services/Care/CareQueueProvisioner.php
T
isaaccladandCursor ec9135c59d
Deploy Ladill Care / deploy (push) Successful in 36s
Give specialty modules the same Queue UI as GPs.
Specialty homes now use the shared Waiting/In care board with Call next, Start routes specialty tickets into specialty workspaces, and redundant Dentistry/Specialty module chrome is removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 15:52:00 +00:00

654 lines
23 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 Illuminate\Support\Facades\Log;
/**
* Idempotently provisions Care Queue Engine service queues and service points
* from Care staff / branch configuration.
*/
class CareQueueProvisioner
{
public function __construct(
protected PlanService $plans,
) {}
public function shouldProvision(Organization $organization): bool
{
return $this->plans->canUseQueueIntegration($organization)
&& (bool) data_get($organization->settings, 'queue_integration_enabled');
}
/**
* @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 {
$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;
}
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 {
$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 => [],
};
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)
? 'Bay'
: '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;
}
/**
* @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) {
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;
}
}