Files
ladill-care/app/Services/Care/CareQueueProvisioner.php
T
isaaccladandCursor ce782382c5
Deploy Ladill Care / deploy (push) Successful in 31s
Complete Dentistry commercial suite with PMS depth.
Add plan lifecycle, visit stages with chair/recovery points, primary dentition charting, imaging void, revenue reports, perio exams, lab cases, and recalls.

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

703 lines
25 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) => array_values(array_merge(
$this->practitionerPoints(
$organization,
$branch,
$context,
$priorByKey,
data_get($organization->settings, "specialty_module_provisioning.{$context}.department_ids", []),
),
$this->specialtyStagePoints($organization, $branch, $context, $priorByKey),
)),
$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;
}
/**
* Fixed stage points for specialty modules that define distinct queue_point values
* (e.g. dentistry waiting / chair / recovery).
*
* @param \Illuminate\Support\Collection<string, array<string, mixed>> $priorByKey
* @return list<array<string, mixed>>
*/
protected function specialtyStagePoints(
Organization $organization,
Branch $branch,
string $context,
$priorByKey,
): array {
if ($context !== 'dentistry') {
return [];
}
$stages = app(SpecialtyShellService::class)->stages($context);
$seen = [];
$points = [];
foreach ($stages as $stage) {
$queuePoint = $stage['queue_point'] ?? null;
if (! is_string($queuePoint) || $queuePoint === '' || isset($seen[$queuePoint])) {
continue;
}
$seen[$queuePoint] = true;
$key = CareQueueContexts::pointExternalKey($context, $branch->id, 'stage', $queuePoint);
$prior = $priorByKey->get($key, []);
$label = (string) ($stage['label'] ?? ucfirst($queuePoint));
$points[] = [
'kind' => 'stage',
'stage' => $queuePoint,
'ref_id' => 0,
'name' => $label,
'destination' => $label,
'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;
}
}