Hard-delete Care remote Queue adapter (Phase 5).
Deploy Ladill Care / deploy (push) Failing after 1m13s
Deploy Ladill Care / deploy (push) Failing after 1m13s
Care Queue Engine is the only path; remove QueueClient, driver config, and remote HTTP tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,19 +11,15 @@ use App\Models\Patient;
|
||||
use App\Models\Practitioner;
|
||||
use App\Models\Prescription;
|
||||
use App\Services\Care\Workflow\WorkflowQueueGate;
|
||||
use App\Services\Queue\QueueClient;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Bridges Care workflow entities to service-queue tickets (native Care Queue
|
||||
* Engine by default; optional legacy Ladill Queue HTTP when driver=remote).
|
||||
* Bridges Care workflow entities to Care Queue Engine tickets.
|
||||
*/
|
||||
class CareQueueBridge
|
||||
{
|
||||
public function __construct(
|
||||
protected QueueClient $queue,
|
||||
protected CareQueueEngine $engine,
|
||||
protected CareQueueProvisioner $provisioner,
|
||||
protected PlanService $plans,
|
||||
@@ -31,21 +27,10 @@ class CareQueueBridge
|
||||
protected WorkflowQueueGate $workflowGate,
|
||||
) {}
|
||||
|
||||
public function usesNative(): bool
|
||||
{
|
||||
return config('care.queue.driver', 'native') === 'native';
|
||||
}
|
||||
|
||||
public function isEnabled(Organization $organization): bool
|
||||
{
|
||||
$enabled = $this->plans->canUseQueueIntegration($organization)
|
||||
return $this->plans->canUseQueueIntegration($organization)
|
||||
&& (bool) data_get($organization->settings, 'queue_integration_enabled');
|
||||
|
||||
if (! $enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->usesNative() || $this->queue->configured();
|
||||
}
|
||||
|
||||
public function contextForAppointment(Organization $organization, Appointment $appointment): string
|
||||
@@ -380,25 +365,11 @@ class CareQueueBridge
|
||||
protected function attemptCallNext(Organization $organization, array $resources): ?array
|
||||
{
|
||||
try {
|
||||
if ($this->usesNative()) {
|
||||
return $this->engine->callNext(
|
||||
$organization,
|
||||
(string) $resources['queue_uuid'],
|
||||
(string) $resources['counter_uuid'],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->queue->callNext(
|
||||
(string) $organization->owner_ref,
|
||||
return $this->engine->callNext(
|
||||
$organization,
|
||||
(string) $resources['queue_uuid'],
|
||||
(string) $resources['counter_uuid'],
|
||||
);
|
||||
} catch (RequestException $e) {
|
||||
Log::warning('care.queue_call_next_failed', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('care.queue_call_next_failed', [
|
||||
'message' => $e->getMessage(),
|
||||
@@ -583,7 +554,6 @@ class CareQueueBridge
|
||||
return null;
|
||||
}
|
||||
|
||||
$owner = (string) $organization->owner_ref;
|
||||
$payload = [
|
||||
'service_queue_id' => $resources['queue_uuid'],
|
||||
'customer_name' => $patient?->fullName(),
|
||||
@@ -597,11 +567,7 @@ class CareQueueBridge
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->usesNative()) {
|
||||
return $this->engine->issueTicket($organization, $payload);
|
||||
}
|
||||
|
||||
return $this->queue->issueTicket($owner, $payload);
|
||||
return $this->engine->issueTicket($organization, $payload);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('care.queue_issue_failed', [
|
||||
'context' => $context,
|
||||
@@ -650,15 +616,9 @@ class CareQueueBridge
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->usesNative()) {
|
||||
$ticket = $this->engine->ticketAction($organization, $uuid, [
|
||||
'action' => $action,
|
||||
]);
|
||||
} else {
|
||||
$ticket = $this->queue->ticketAction((string) $organization->owner_ref, $uuid, [
|
||||
'action' => $action,
|
||||
]);
|
||||
}
|
||||
$ticket = $this->engine->ticketAction($organization, $uuid, [
|
||||
'action' => $action,
|
||||
]);
|
||||
$this->applyTicket($entity, $ticket);
|
||||
|
||||
return $ticket;
|
||||
|
||||
@@ -8,36 +8,22 @@ 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).
|
||||
* Idempotently provisions Care Queue Engine service queues and service points
|
||||
* from Care staff / branch configuration.
|
||||
*/
|
||||
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)
|
||||
return $this->plans->canUseQueueIntegration($organization)
|
||||
&& (bool) data_get($organization->settings, 'queue_integration_enabled');
|
||||
|
||||
if (! $enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->usesNative() || $this->queue->configured();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,95 +107,6 @@ class CareQueueProvisioner
|
||||
'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(
|
||||
[
|
||||
@@ -264,7 +161,6 @@ class CareQueueProvisioner
|
||||
$syncedPoints[] = $point;
|
||||
}
|
||||
|
||||
// Soft-deactivate points no longer in the staff roster.
|
||||
CareServicePoint::query()
|
||||
->where('service_queue_id', $queue->id)
|
||||
->when(
|
||||
@@ -302,7 +198,6 @@ class CareQueueProvisioner
|
||||
Branch $branch,
|
||||
array $stub,
|
||||
): array {
|
||||
$owner = (string) $organization->owner_ref;
|
||||
$priorByKey = collect($stub['points'] ?? [])->keyBy(fn ($p) => (string) ($p['external_key'] ?? ''));
|
||||
|
||||
$points = match (true) {
|
||||
@@ -362,7 +257,6 @@ class CareQueueProvisioner
|
||||
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, []);
|
||||
@@ -487,17 +381,6 @@ class CareQueueProvisioner
|
||||
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
|
||||
*/
|
||||
@@ -632,8 +515,6 @@ class CareQueueProvisioner
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -734,16 +734,8 @@ class DemoTenantSeeder
|
||||
|
||||
private function seedSpecialtyModules(Organization $organization, string $ownerRef): void
|
||||
{
|
||||
// Remote Queue HTTP during per-module activate can hang Enterprise demos.
|
||||
// Native Care Queue Engine is local — keep integration on so desks provision.
|
||||
$organization = $organization->fresh();
|
||||
$settings = $organization->settings ?? [];
|
||||
$queueEnabled = (bool) data_get($settings, 'queue_integration_enabled');
|
||||
$native = config('care.queue.driver', 'native') === 'native';
|
||||
if ($queueEnabled && ! $native) {
|
||||
data_set($settings, 'queue_integration_enabled', false);
|
||||
$organization->update(['settings' => $settings]);
|
||||
}
|
||||
$queueEnabled = (bool) data_get($organization->settings, 'queue_integration_enabled');
|
||||
|
||||
$service = app(SpecialtyModuleService::class);
|
||||
foreach (array_keys($service->catalog()) as $key) {
|
||||
@@ -754,13 +746,7 @@ class DemoTenantSeeder
|
||||
}
|
||||
}
|
||||
|
||||
if ($queueEnabled && ! $native) {
|
||||
$organization = $organization->fresh();
|
||||
$settings = $organization->settings ?? [];
|
||||
data_set($settings, 'queue_integration_enabled', true);
|
||||
$organization->update(['settings' => $settings]);
|
||||
// Skip CareQueueProvisioner here — same Queue HTTP storm as ticket sync.
|
||||
} elseif ($queueEnabled && $native) {
|
||||
if ($queueEnabled) {
|
||||
try {
|
||||
app(CareQueueProvisioner::class)->provisionOrganization($organization->fresh());
|
||||
} catch (\Throwable) {
|
||||
|
||||
@@ -42,7 +42,7 @@ class PlanService
|
||||
return in_array($this->planKey($organization), ['pro', 'enterprise'], true);
|
||||
}
|
||||
|
||||
/** Lab, pharmacy, billing, and Ladill Queue integration require Pro or Enterprise. */
|
||||
/** Lab, pharmacy, billing, and in-app service queues require Pro or Enterprise. */
|
||||
public function canUseProModules(Organization $organization): bool
|
||||
{
|
||||
return $this->hasPaidPlan($organization);
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Services\Queue\QueueClient;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Loads Ladill Queue console state for embedding on role-native pages
|
||||
* (clinical queue, pharmacy, lab, specialty modules) instead of a standalone Service Queues nav.
|
||||
*/
|
||||
class ServiceQueuePresenter
|
||||
{
|
||||
/** @var array<string, list<string>> */
|
||||
protected array $contextKeywords = [
|
||||
'clinical' => ['reception', 'consult', 'outpatient', 'general', 'doctor', 'nurse', 'triage', 'waiting'],
|
||||
'pharmacy' => ['pharm', 'dispens', 'drug', 'rx'],
|
||||
'laboratory' => ['lab', 'sample', 'patholog', 'investig'],
|
||||
'dentistry' => ['dent', 'dental', 'oral'],
|
||||
'ophthalmology' => ['eye', 'ophthal', 'optom'],
|
||||
'physiotherapy' => ['physio', 'rehab', 'therapy'],
|
||||
'maternity' => ['matern', 'antenatal', 'obstetric'],
|
||||
'radiology' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected QueueClient $queue,
|
||||
protected CarePermissions $permissions,
|
||||
protected PlanService $plans,
|
||||
protected OrganizationResolver $resolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* enabled: bool,
|
||||
* counters: list<array<string, mixed>>,
|
||||
* counter_uuid: ?string,
|
||||
* state: ?array<string, mixed>,
|
||||
* stubs: list<array<string, mixed>>,
|
||||
* error: ?string,
|
||||
* context: string
|
||||
* }|null
|
||||
*/
|
||||
public function panel(
|
||||
Request $request,
|
||||
Organization $organization,
|
||||
?Member $member,
|
||||
string $context = 'clinical',
|
||||
?string $preferredCounterUuid = null,
|
||||
): ?array {
|
||||
if (! $this->shouldShow($organization, $member)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$owner = (string) $organization->owner_ref;
|
||||
$branchScope = $this->resolver->branchScope($member);
|
||||
$branchName = null;
|
||||
if ($branchScope) {
|
||||
$branchName = Branch::owned($owner)->where('id', $branchScope)->value('name');
|
||||
}
|
||||
|
||||
$stubs = $this->stubsForContext($organization, $context, $branchScope);
|
||||
|
||||
if (config('care.queue.driver', 'native') === 'native') {
|
||||
$counters = $this->nativeCountersFromStubs($stubs, $context);
|
||||
$stubCounterUuid = collect($stubs)
|
||||
->map(fn ($s) => (string) ($s['counter_uuid'] ?? ''))
|
||||
->first(fn ($uuid) => $uuid !== '');
|
||||
$counterUuid = $preferredCounterUuid
|
||||
?: ($stubCounterUuid ?: '')
|
||||
?: (string) ($counters[0]['uuid'] ?? '');
|
||||
|
||||
return [
|
||||
'enabled' => true,
|
||||
'counters' => $counters,
|
||||
'counter_uuid' => $counterUuid !== '' ? $counterUuid : null,
|
||||
'state' => null,
|
||||
'stubs' => $stubs,
|
||||
'error' => null,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
if (! $this->queue->configured()) {
|
||||
return [
|
||||
'enabled' => true,
|
||||
'counters' => [],
|
||||
'counter_uuid' => null,
|
||||
'state' => null,
|
||||
'stubs' => $stubs,
|
||||
'error' => 'Ladill Queue API is not configured on this Care instance.',
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$counters = $this->queue->counters($owner);
|
||||
} catch (RequestException $e) {
|
||||
if ($e->response?->status() === 403) {
|
||||
try {
|
||||
$this->queue->syncIntegration($owner, true);
|
||||
$counters = $this->queue->counters($owner);
|
||||
} catch (RequestException) {
|
||||
return [
|
||||
'enabled' => true,
|
||||
'counters' => [],
|
||||
'counter_uuid' => null,
|
||||
'state' => null,
|
||||
'stubs' => $stubs,
|
||||
'error' => 'Queue integration is not linked. Re-save Settings with Queue enabled.',
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
return [
|
||||
'enabled' => true,
|
||||
'counters' => [],
|
||||
'counter_uuid' => null,
|
||||
'state' => null,
|
||||
'stubs' => $stubs,
|
||||
'error' => 'Could not reach Ladill Queue.',
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$stubCounterUuid = collect($stubs)
|
||||
->map(fn ($s) => (string) ($s['counter_uuid'] ?? ''))
|
||||
->first(fn ($uuid) => $uuid !== '');
|
||||
|
||||
$counters = $this->filterCounters($counters, $branchName, $context, $stubCounterUuid);
|
||||
$counterUuid = $preferredCounterUuid
|
||||
?: ($stubCounterUuid ?: '')
|
||||
?: (string) ($counters[0]['uuid'] ?? '');
|
||||
|
||||
if ($counterUuid !== '' && ! collect($counters)->contains(fn ($c) => ($c['uuid'] ?? '') === $counterUuid)) {
|
||||
$counterUuid = (string) ($counters[0]['uuid'] ?? '');
|
||||
}
|
||||
|
||||
$state = null;
|
||||
if ($counterUuid !== '') {
|
||||
try {
|
||||
$state = $this->queue->console($owner, $counterUuid);
|
||||
} catch (RequestException) {
|
||||
return [
|
||||
'enabled' => true,
|
||||
'counters' => $counters,
|
||||
'counter_uuid' => $counterUuid,
|
||||
'state' => null,
|
||||
'stubs' => $stubs,
|
||||
'error' => 'Counter console unavailable.',
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => true,
|
||||
'counters' => $counters,
|
||||
'counter_uuid' => $counterUuid !== '' ? $counterUuid : null,
|
||||
'state' => $state,
|
||||
'stubs' => $stubs,
|
||||
'error' => null,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
public function shouldShow(Organization $organization, ?Member $member): bool
|
||||
{
|
||||
if (! $member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->plans->canUseQueueIntegration($organization)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! data_get($organization->settings, 'queue_integration_enabled')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->permissions->can($member, 'service_queues.console');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $counters
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
protected function filterCounters(
|
||||
array $counters,
|
||||
?string $branchName,
|
||||
string $context,
|
||||
?string $preferredUuid = null,
|
||||
): array {
|
||||
$all = collect($counters);
|
||||
$filtered = $all;
|
||||
|
||||
if ($preferredUuid) {
|
||||
$linked = $all->first(fn ($c) => ($c['uuid'] ?? '') === $preferredUuid);
|
||||
if ($linked) {
|
||||
return [$linked];
|
||||
}
|
||||
}
|
||||
|
||||
if ($branchName) {
|
||||
$branchFiltered = $filtered->filter(function ($counter) use ($branchName) {
|
||||
$counterBranch = (string) ($counter['branch'] ?? '');
|
||||
|
||||
return $counterBranch === '' || strcasecmp($counterBranch, $branchName) === 0;
|
||||
});
|
||||
if ($branchFiltered->isNotEmpty()) {
|
||||
$filtered = $branchFiltered;
|
||||
}
|
||||
}
|
||||
|
||||
$keywords = $this->keywordsFor($context);
|
||||
if ($keywords !== []) {
|
||||
$keywordFiltered = $filtered->filter(function ($counter) use ($keywords) {
|
||||
$queueNames = collect($counter['queues'] ?? [])->map(function ($q) {
|
||||
return is_array($q) ? (string) ($q['name'] ?? '') : (string) $q;
|
||||
})->implode(' ');
|
||||
|
||||
$haystack = strtolower(implode(' ', [
|
||||
(string) ($counter['name'] ?? ''),
|
||||
$queueNames,
|
||||
]));
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
if ($keyword !== '' && str_contains($haystack, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Fall back to branch-scoped counters when no keyword match (demo / generic counters).
|
||||
if ($keywordFiltered->isNotEmpty()) {
|
||||
$filtered = $keywordFiltered;
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $stubs
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
protected function nativeCountersFromStubs(array $stubs, string $context): array
|
||||
{
|
||||
$counters = [];
|
||||
foreach ($stubs as $stub) {
|
||||
$branchName = $stub['branch_name'] ?? null;
|
||||
$queueName = $stub['name'] ?? $context;
|
||||
$points = is_array($stub['points'] ?? null) ? $stub['points'] : [];
|
||||
|
||||
foreach ($points as $point) {
|
||||
$uuid = (string) ($point['counter_uuid'] ?? '');
|
||||
if ($uuid === '') {
|
||||
continue;
|
||||
}
|
||||
$counters[] = [
|
||||
'uuid' => $uuid,
|
||||
'name' => (string) ($point['name'] ?? $point['destination'] ?? 'Desk'),
|
||||
'branch' => $branchName,
|
||||
'queues' => [['name' => $queueName]],
|
||||
];
|
||||
}
|
||||
|
||||
if ($points === [] && ! empty($stub['counter_uuid'])) {
|
||||
$counters[] = [
|
||||
'uuid' => (string) $stub['counter_uuid'],
|
||||
'name' => (string) $queueName,
|
||||
'branch' => $branchName,
|
||||
'queues' => [['name' => $queueName]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $counters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function keywordsFor(string $context): array
|
||||
{
|
||||
if (isset($this->contextKeywords[$context])) {
|
||||
return $this->contextKeywords[$context];
|
||||
}
|
||||
|
||||
$module = config("care.specialty_modules.{$context}.queue_keywords");
|
||||
|
||||
return is_array($module) ? $module : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
protected function stubsForContext(Organization $organization, string $context, ?int $branchId): array
|
||||
{
|
||||
if (! array_key_exists($context, config('care.specialty_modules', []))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$queues = data_get($organization->settings, "specialty_module_provisioning.{$context}.queues", []);
|
||||
if (! is_array($queues)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($queues, function ($queue) use ($branchId) {
|
||||
if (! ($queue['active'] ?? false)) {
|
||||
return false;
|
||||
}
|
||||
if ($branchId !== null && (int) ($queue['branch_id'] ?? 0) !== $branchId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,18 @@
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\CareServiceQueue;
|
||||
use App\Models\Department;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Practitioner;
|
||||
use App\Services\Queue\QueueClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Activate/deactivate specialty practice modules (dentistry, eye care, …).
|
||||
*
|
||||
* Persistence: organization.settings.specialty_modules[key] = bool
|
||||
* Provisioning: departments per branch + Care-side queue stubs (and best-effort Queue API sync).
|
||||
* Provisioning: departments per branch + Care Queue Engine stubs/points.
|
||||
* Deactivate hides UI and marks queues inactive; does not destroy clinical history.
|
||||
*
|
||||
* Plan gate: Pro/Enterprise via PlanService feature specialty_modules (same tier as queue_integration).
|
||||
@@ -23,7 +23,6 @@ class SpecialtyModuleService
|
||||
{
|
||||
public function __construct(
|
||||
protected PlanService $plans,
|
||||
protected QueueClient $queue,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -332,38 +331,18 @@ class SpecialtyModuleService
|
||||
|
||||
$fresh = $organization->fresh();
|
||||
if (data_get($fresh?->settings, 'queue_integration_enabled')) {
|
||||
if (config('care.queue.driver', 'native') === 'native') {
|
||||
$provisioner = app(CareQueueProvisioner::class);
|
||||
$branches = Branch::owned($ownerRef)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->pluck('id');
|
||||
foreach ($branches as $branchId) {
|
||||
try {
|
||||
$provisioner->ensure($fresh, $key, (int) $branchId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('specialty_module.native_queue_provision_failed', [
|
||||
'key' => $key,
|
||||
'branch_id' => $branchId,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} elseif ($this->queue->configured()) {
|
||||
$provisioner = app(CareQueueProvisioner::class);
|
||||
$branches = Branch::owned($ownerRef)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->pluck('id');
|
||||
foreach ($branches as $branchId) {
|
||||
try {
|
||||
$synced = $this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs);
|
||||
$settings = $fresh->settings ?? [];
|
||||
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
|
||||
? $settings['specialty_module_provisioning']
|
||||
: [];
|
||||
$provisioning[$key] = array_merge($provisioning[$key] ?? [], [
|
||||
'queues' => $synced,
|
||||
]);
|
||||
$settings['specialty_module_provisioning'] = $provisioning;
|
||||
$organization->update(['settings' => $settings]);
|
||||
$provisioner->ensure($fresh, $key, (int) $branchId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('specialty_module.queue_sync_failed', [
|
||||
Log::warning('specialty_module.native_queue_provision_failed', [
|
||||
'key' => $key,
|
||||
'branch_id' => $branchId,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
@@ -399,27 +378,21 @@ class SpecialtyModuleService
|
||||
}
|
||||
|
||||
$queues = is_array($record['queues'] ?? null) ? $record['queues'] : [];
|
||||
foreach ($queues as $i => $queue) {
|
||||
$queues[$i]['active'] = false;
|
||||
}
|
||||
|
||||
if (
|
||||
data_get($settings, 'queue_integration_enabled')
|
||||
&& config('care.queue.driver', 'native') !== 'native'
|
||||
&& $this->queue->configured()
|
||||
&& $queues !== []
|
||||
) {
|
||||
if (data_get($settings, 'queue_integration_enabled') && $queues !== []) {
|
||||
try {
|
||||
$queues = $this->queue->deactivateSpecialtyQueueStubs($ownerRef, $queues);
|
||||
CareServiceQueue::query()
|
||||
->where('organization_id', $organization->id)
|
||||
->where('context', $key)
|
||||
->update(['is_active' => false]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('specialty_module.queue_deactivate_failed', [
|
||||
'key' => $key,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
foreach ($queues as $i => $queue) {
|
||||
$queues[$i]['active'] = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($queues as $i => $queue) {
|
||||
$queues[$i]['active'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Queue;
|
||||
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class QueueClient
|
||||
{
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('care.queue.api_url'), '/');
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return (string) (config('care.queue.api_key') ?? '');
|
||||
}
|
||||
|
||||
private function http(string $owner)
|
||||
{
|
||||
return Http::withToken($this->token())
|
||||
->acceptJson()
|
||||
->timeout(12)
|
||||
->withQueryParameters(['owner' => $owner]);
|
||||
}
|
||||
|
||||
public function configured(): bool
|
||||
{
|
||||
return $this->token() !== '' && $this->base() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function status(string $owner): array
|
||||
{
|
||||
$response = $this->http($owner)->get($this->base().'/integrations/status');
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function enable(string $owner, string $organizationName, ?string $branchName = null): array
|
||||
{
|
||||
$this->http($owner)->post($this->base().'/integrations/provision', array_filter([
|
||||
'organization_name' => $organizationName,
|
||||
'branch_name' => $branchName,
|
||||
'industry' => 'healthcare',
|
||||
]))->throw();
|
||||
|
||||
return $this->syncIntegration($owner, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function syncIntegration(string $owner, bool $enabled = true): array
|
||||
{
|
||||
$response = $this->http($owner)->patch($this->base().'/integrations', [
|
||||
'care_enabled' => $enabled,
|
||||
]);
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
public function disable(string $owner): void
|
||||
{
|
||||
// Local Care setting only; Queue must be disabled separately in Queue settings.
|
||||
}
|
||||
|
||||
public function isLinked(string $owner): bool
|
||||
{
|
||||
if (! $this->configured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$status = $this->status($owner);
|
||||
|
||||
return (bool) ($status['provisioned'] ?? false)
|
||||
&& (bool) data_get($status, 'integrations.care', false);
|
||||
} catch (RequestException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function counters(string $owner): array
|
||||
{
|
||||
$response = $this->http($owner)->get($this->base().'/counters');
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function branches(string $owner): array
|
||||
{
|
||||
$response = $this->http($owner)->get($this->base().'/branches');
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function ensureDepartment(string $owner, array $payload): array
|
||||
{
|
||||
$response = $this->http($owner)->post($this->base().'/departments', $payload);
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function ensureBranch(string $owner, array $payload): array
|
||||
{
|
||||
$response = $this->http($owner)->post($this->base().'/branches', $payload);
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function ensureQueue(string $owner, array $payload): array
|
||||
{
|
||||
$response = $this->http($owner)->post($this->base().'/queues', $payload);
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function ensureCounter(string $owner, array $payload): array
|
||||
{
|
||||
$response = $this->http($owner)->post($this->base().'/counters', $payload);
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deactivate a Queue counter without destroying history.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updateCounter(string $owner, string $counterUuid, array $payload): array
|
||||
{
|
||||
$response = $this->http($owner)->patch($this->base().'/counters/'.$counterUuid, $payload);
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deactivate a Queue service queue without destroying history.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updateQueue(string $owner, string $queueUuid, array $payload): array
|
||||
{
|
||||
$response = $this->http($owner)->patch($this->base().'/queues/'.$queueUuid, $payload);
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function console(string $owner, string $counterUuid): array
|
||||
{
|
||||
$response = $this->http($owner)->get($this->base().'/counters/'.$counterUuid.'/console');
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function consoleAction(string $owner, string $counterUuid, array $payload): array
|
||||
{
|
||||
$response = $this->http($owner)->post($this->base().'/counters/'.$counterUuid.'/console', $payload);
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function issueTicket(string $owner, array $payload): array
|
||||
{
|
||||
$response = $this->http($owner)->post($this->base().'/tickets', $payload);
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function callNext(string $owner, string $queueUuid, string $counterUuid): ?array
|
||||
{
|
||||
$response = $this->http($owner)->post($this->base().'/queues/'.$queueUuid.'/call-next', [
|
||||
'counter_id' => $counterUuid,
|
||||
]);
|
||||
$response->throw();
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function ticketAction(string $owner, string $ticketUuid, array $payload): array
|
||||
{
|
||||
$response = $this->http($owner)->post($this->base().'/tickets/'.$ticketUuid.'/action', $payload);
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or link real Queue queues/counters for Care specialty stubs.
|
||||
* Idempotent via external_key (and Queue-side name+branch fallback).
|
||||
*
|
||||
* @param list<array<string, mixed>> $stubs
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function syncSpecialtyQueueStubs(string $owner, array $stubs): array
|
||||
{
|
||||
if (! $this->configured() || $stubs === []) {
|
||||
return $stubs;
|
||||
}
|
||||
|
||||
try {
|
||||
if (! $this->isLinked($owner)) {
|
||||
return $stubs;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return $stubs;
|
||||
}
|
||||
|
||||
foreach ($stubs as $i => $stub) {
|
||||
if (! ($stub['active'] ?? true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$stubs[$i] = $this->ensureSpecialtyStub($owner, $stub);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('queue.specialty_stub_sync_failed', [
|
||||
'owner' => $owner,
|
||||
'stub' => $stub['name'] ?? null,
|
||||
'branch' => $stub['branch_name'] ?? null,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
$stubs[$i]['synced'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $stubs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deactivate Queue resources linked on Care stubs (leaves history intact).
|
||||
*
|
||||
* @param list<array<string, mixed>> $stubs
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function deactivateSpecialtyQueueStubs(string $owner, array $stubs): array
|
||||
{
|
||||
if (! $this->configured() || $stubs === []) {
|
||||
return $stubs;
|
||||
}
|
||||
|
||||
foreach ($stubs as $i => $stub) {
|
||||
try {
|
||||
if (! empty($stub['counter_uuid'])) {
|
||||
$this->updateCounter($owner, (string) $stub['counter_uuid'], ['is_active' => false]);
|
||||
}
|
||||
if (! empty($stub['queue_uuid'])) {
|
||||
$this->updateQueue($owner, (string) $stub['queue_uuid'], ['is_active' => false]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('queue.specialty_stub_deactivate_failed', [
|
||||
'owner' => $owner,
|
||||
'counter_uuid' => $stub['counter_uuid'] ?? null,
|
||||
'queue_uuid' => $stub['queue_uuid'] ?? null,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$stubs[$i]['active'] = false;
|
||||
}
|
||||
|
||||
return $stubs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $stub
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function ensureSpecialtyStub(string $owner, array $stub): array
|
||||
{
|
||||
$branchName = trim((string) ($stub['branch_name'] ?? ''));
|
||||
$name = trim((string) ($stub['name'] ?? ''));
|
||||
$prefix = trim((string) ($stub['prefix'] ?? 'A'));
|
||||
$module = (string) ($stub['module'] ?? 'specialty');
|
||||
$careBranchId = (string) ($stub['branch_id'] ?? '0');
|
||||
|
||||
if ($branchName === '' || $name === '') {
|
||||
$stub['synced'] = false;
|
||||
|
||||
return $stub;
|
||||
}
|
||||
|
||||
$queueKey = (string) ($stub['queue_external_key'] ?? "care:specialty:{$module}:queue:{$careBranchId}");
|
||||
$counterKey = (string) ($stub['counter_external_key'] ?? "care:specialty:{$module}:counter:{$careBranchId}");
|
||||
|
||||
try {
|
||||
$this->ensureBranch($owner, ['name' => $branchName]);
|
||||
} catch (RequestException $e) {
|
||||
// Free Queue plans may already be at branch cap — continue if a branch can be resolved.
|
||||
if ($e->response?->status() !== 422) {
|
||||
throw $e;
|
||||
}
|
||||
$branches = collect($this->branches($owner));
|
||||
$existing = $branches->first(
|
||||
fn ($b) => strcasecmp((string) ($b['name'] ?? ''), $branchName) === 0
|
||||
);
|
||||
if (! $existing && $branches->count() === 1) {
|
||||
// Single-location Queue orgs often use a different default name (e.g. "Main Branch").
|
||||
$existing = $branches->first();
|
||||
$branchName = (string) ($existing['name'] ?? $branchName);
|
||||
}
|
||||
if (! $existing) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$queue = $this->ensureQueue($owner, [
|
||||
'name' => $name,
|
||||
'prefix' => $prefix !== '' ? $prefix : 'A',
|
||||
'branch_name' => $branchName,
|
||||
'strategy' => 'fifo',
|
||||
'external_key' => $queueKey,
|
||||
'routing_mode' => (string) ($stub['routing_mode'] ?? 'shared_pool'),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$counter = $this->ensureCounter($owner, [
|
||||
'name' => $name.' counter',
|
||||
'destination' => $name,
|
||||
'branch_name' => $branchName,
|
||||
'queue_uuids' => array_values(array_filter([(string) ($queue['uuid'] ?? '')])),
|
||||
'external_key' => $counterKey,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$stub['queue_external_key'] = $queueKey;
|
||||
$stub['counter_external_key'] = $counterKey;
|
||||
$stub['queue_uuid'] = $queue['uuid'] ?? null;
|
||||
$stub['counter_uuid'] = $counter['uuid'] ?? null;
|
||||
$stub['synced'] = ! empty($stub['queue_uuid']) && ! empty($stub['counter_uuid']);
|
||||
|
||||
return $stub;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user