Embed Ladill Queue on role pages and add specialty modules under Settings.
Deploy Ladill Care / deploy (push) Successful in 53s
Deploy Ladill Care / deploy (push) Successful in 53s
Remove the standalone Service Queues nav so call-next/now-serving lives on clinical queue, appointments, pharmacy, lab, and specialty surfaces; Pro orgs can activate dentistry and related modules with branch departments and queue stubs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\Department;
|
||||
use App\Models\Organization;
|
||||
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).
|
||||
* 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).
|
||||
*/
|
||||
class SpecialtyModuleService
|
||||
{
|
||||
public function __construct(
|
||||
protected PlanService $plans,
|
||||
protected QueueClient $queue,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public function catalog(): array
|
||||
{
|
||||
return config('care.specialty_modules', []);
|
||||
}
|
||||
|
||||
public function definition(string $key): ?array
|
||||
{
|
||||
$catalog = $this->catalog();
|
||||
|
||||
return $catalog[$key] ?? null;
|
||||
}
|
||||
|
||||
public function isEnabled(Organization $organization, string $key): bool
|
||||
{
|
||||
if (! $this->definition($key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) data_get($organization->settings, "specialty_modules.{$key}", false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function enabledKeys(Organization $organization): array
|
||||
{
|
||||
$enabled = [];
|
||||
foreach (array_keys($this->catalog()) as $key) {
|
||||
if ($this->isEnabled($organization, $key)) {
|
||||
$enabled[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return $enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, definition: array<string, mixed>}>
|
||||
*/
|
||||
public function enabledModules(Organization $organization): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->enabledKeys($organization) as $key) {
|
||||
$definition = $this->definition($key);
|
||||
if ($definition) {
|
||||
$out[] = ['key' => $key, 'definition' => $definition];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function canManage(Organization $organization): bool
|
||||
{
|
||||
return $this->plans->hasFeature($organization, 'specialty_modules');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, bool> $desired module key => enabled
|
||||
* @return array{activated: list<string>, deactivated: list<string>, errors: list<string>}
|
||||
*/
|
||||
public function sync(Organization $organization, string $ownerRef, array $desired): array
|
||||
{
|
||||
$activated = [];
|
||||
$deactivated = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($this->catalog() as $key => $definition) {
|
||||
$want = (bool) ($desired[$key] ?? false);
|
||||
$have = $this->isEnabled($organization, $key);
|
||||
|
||||
if ($want === $have) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($want) {
|
||||
$this->activate($organization, $ownerRef, $key);
|
||||
$activated[] = $key;
|
||||
} else {
|
||||
$this->deactivate($organization, $ownerRef, $key);
|
||||
$deactivated[] = $key;
|
||||
}
|
||||
$organization->refresh();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('specialty_module.sync_failed', [
|
||||
'key' => $key,
|
||||
'want' => $want,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
$errors[] = ($definition['label'] ?? $key).': '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return compact('activated', 'deactivated', 'errors');
|
||||
}
|
||||
|
||||
public function activate(Organization $organization, string $ownerRef, string $key): void
|
||||
{
|
||||
$definition = $this->definition($key);
|
||||
if (! $definition) {
|
||||
throw new \InvalidArgumentException("Unknown specialty module [{$key}].");
|
||||
}
|
||||
|
||||
if (! $this->canManage($organization)) {
|
||||
throw new \RuntimeException('Specialty modules require Care Pro or Enterprise.');
|
||||
}
|
||||
|
||||
$settings = $organization->settings ?? [];
|
||||
$modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : [];
|
||||
$modules[$key] = true;
|
||||
$settings['specialty_modules'] = $modules;
|
||||
|
||||
$departments = $this->provisionDepartments($organization, $ownerRef, $definition);
|
||||
$queueStubs = $this->provisionQueueStubs($organization, $ownerRef, $key, $definition);
|
||||
|
||||
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
|
||||
? $settings['specialty_module_provisioning']
|
||||
: [];
|
||||
$provisioning[$key] = [
|
||||
'active' => true,
|
||||
'department_ids' => $departments,
|
||||
'queues' => $queueStubs,
|
||||
'activated_at' => now()->toIso8601String(),
|
||||
];
|
||||
$settings['specialty_module_provisioning'] = $provisioning;
|
||||
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
if (data_get($organization->fresh()->settings, 'queue_integration_enabled') && $this->queue->configured()) {
|
||||
$this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs);
|
||||
}
|
||||
}
|
||||
|
||||
public function deactivate(Organization $organization, string $ownerRef, string $key): void
|
||||
{
|
||||
if (! $this->definition($key)) {
|
||||
throw new \InvalidArgumentException("Unknown specialty module [{$key}].");
|
||||
}
|
||||
|
||||
$settings = $organization->settings ?? [];
|
||||
$modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : [];
|
||||
$modules[$key] = false;
|
||||
$settings['specialty_modules'] = $modules;
|
||||
|
||||
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
|
||||
? $settings['specialty_module_provisioning']
|
||||
: [];
|
||||
$record = is_array($provisioning[$key] ?? null) ? $provisioning[$key] : [];
|
||||
|
||||
$departmentIds = $record['department_ids'] ?? [];
|
||||
if (is_array($departmentIds) && $departmentIds !== []) {
|
||||
Department::owned($ownerRef)
|
||||
->whereIn('id', $departmentIds)
|
||||
->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
$queues = is_array($record['queues'] ?? null) ? $record['queues'] : [];
|
||||
foreach ($queues as $i => $queue) {
|
||||
$queues[$i]['active'] = false;
|
||||
}
|
||||
|
||||
$provisioning[$key] = array_merge($record, [
|
||||
'active' => false,
|
||||
'queues' => $queues,
|
||||
'deactivated_at' => now()->toIso8601String(),
|
||||
]);
|
||||
$settings['specialty_module_provisioning'] = $provisioning;
|
||||
|
||||
$organization->update(['settings' => $settings]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $definition
|
||||
* @return list<int>
|
||||
*/
|
||||
protected function provisionDepartments(Organization $organization, string $ownerRef, array $definition): array
|
||||
{
|
||||
$type = (string) ($definition['department_type'] ?? 'general');
|
||||
$name = (string) ($definition['department_name'] ?? $definition['label'] ?? 'Specialty');
|
||||
|
||||
$branches = Branch::owned($ownerRef)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
$ids = [];
|
||||
foreach ($branches as $branch) {
|
||||
$department = Department::withTrashed()
|
||||
->owned($ownerRef)
|
||||
->where('branch_id', $branch->id)
|
||||
->where('type', $type)
|
||||
->first();
|
||||
|
||||
if ($department) {
|
||||
if ($department->trashed()) {
|
||||
$department->restore();
|
||||
}
|
||||
$department->update([
|
||||
'name' => $name,
|
||||
'is_active' => true,
|
||||
]);
|
||||
} else {
|
||||
$department = Department::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'branch_id' => $branch->id,
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$ids[] = (int) $department->id;
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Care-side queue stubs (branch-aware). Full counter creation lives in Queue admin;
|
||||
* stubs document intended queues and feed embedded panels / future API sync.
|
||||
*
|
||||
* @param array<string, mixed> $definition
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
protected function provisionQueueStubs(
|
||||
Organization $organization,
|
||||
string $ownerRef,
|
||||
string $key,
|
||||
array $definition,
|
||||
): array {
|
||||
$branches = Branch::owned($ownerRef)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
$stubs = [];
|
||||
foreach ($branches as $branch) {
|
||||
$stubs[] = [
|
||||
'module' => $key,
|
||||
'branch_id' => $branch->id,
|
||||
'branch_name' => $branch->name,
|
||||
'name' => (string) ($definition['queue_name'] ?? $definition['label']),
|
||||
'prefix' => (string) ($definition['queue_prefix'] ?? strtoupper(substr($key, 0, 3))),
|
||||
'active' => true,
|
||||
'synced' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $stubs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function queueStubsFor(Organization $organization, string $key): array
|
||||
{
|
||||
$queues = data_get($organization->settings, "specialty_module_provisioning.{$key}.queues", []);
|
||||
|
||||
return is_array($queues) ? array_values(array_filter($queues, fn ($q) => (bool) ($q['active'] ?? false))) : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user