Deploy Ladill Care / deploy (push) Successful in 1m28s
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>
773 lines
28 KiB
PHP
773 lines
28 KiB
PHP
<?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;
|
||
}
|
||
}
|