Deploy Ladill Queue / deploy (push) Successful in 3m12s
Queue is general-purpose QMS only; Care runs its own native engine. Co-authored-by: Cursor <cursoragent@cursor.com>
494 lines
18 KiB
PHP
494 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Qms\Industry;
|
|
|
|
use App\Models\Branch;
|
|
use App\Models\Counter;
|
|
use App\Models\CustomerFeedback;
|
|
use App\Models\Department;
|
|
use App\Models\Device;
|
|
use App\Models\DisplayScreen;
|
|
use App\Models\Organization;
|
|
use App\Models\QueueAppointment;
|
|
use App\Models\QueueRule;
|
|
use App\Models\ServiceQueue;
|
|
use App\Models\ServiceSession;
|
|
use App\Models\Ticket;
|
|
use App\Models\TicketEvent;
|
|
use App\Models\TicketTransfer;
|
|
use App\Models\VoiceAnnouncement;
|
|
use App\Models\Workflow;
|
|
use App\Models\WorkflowStep;
|
|
use App\Services\Qms\AuditLogger;
|
|
use App\Services\Qms\PlanService;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Materializes an industry package onto Queue Core entities.
|
|
*
|
|
* Stage → ServiceQueue, Service Point → Counter, Worker remains Member/staff_ref.
|
|
* Idempotent via settings.external_key / departments.external_key.
|
|
*/
|
|
class IndustryPackageInstaller
|
|
{
|
|
public function __construct(
|
|
protected IndustryPackageRegistry $registry,
|
|
protected PlanService $plans,
|
|
) {}
|
|
|
|
/**
|
|
* @param array{skip_stages?: bool, include_optional?: bool, actor_ref?: string|null, replace?: bool} $options
|
|
* @return array{
|
|
* package: string,
|
|
* departments: int,
|
|
* stages: int,
|
|
* service_points: int,
|
|
* workflow_id: int|null,
|
|
* skipped_stages: list<string>,
|
|
* skipped_reason: string|null,
|
|
* replaced: bool
|
|
* }
|
|
*/
|
|
public function install(Organization $organization, Branch $branch, ?string $packageKey = null, array $options = []): array
|
|
{
|
|
$key = $this->registry->resolveKey($packageKey ?? data_get($organization->settings, 'industry'));
|
|
$package = $this->registry->get($key);
|
|
if (! $package) {
|
|
$package = $this->registry->get('custom');
|
|
$key = 'custom';
|
|
}
|
|
|
|
$skipStages = (bool) ($options['skip_stages'] ?? false);
|
|
$includeOptional = (bool) ($options['include_optional'] ?? true);
|
|
$replace = (bool) ($options['replace'] ?? false);
|
|
$actor = $options['actor_ref'] ?? $organization->owner_ref;
|
|
|
|
$result = [
|
|
'package' => $key,
|
|
'departments' => 0,
|
|
'stages' => 0,
|
|
'service_points' => 0,
|
|
'workflow_id' => null,
|
|
'skipped_stages' => [],
|
|
'skipped_reason' => $skipStages ? 'stages_managed_by_integration' : null,
|
|
'replaced' => false,
|
|
];
|
|
|
|
return DB::transaction(function () use ($organization, $branch, $package, $key, $skipStages, $includeOptional, $replace, $actor, &$result) {
|
|
// Replacing a package wipes queues/tickets/points so the new template is clean.
|
|
if ($replace && ! $skipStages) {
|
|
$this->clearOperationalData($organization);
|
|
$result['replaced'] = true;
|
|
$organization->refresh();
|
|
}
|
|
|
|
$this->applyPackageSettings($organization, $package);
|
|
|
|
$departmentsByCode = [];
|
|
foreach ($package->departments() as $deptDef) {
|
|
$department = $this->upsertDepartment($organization, $branch, $key, $deptDef);
|
|
$departmentsByCode[$deptDef['code']] = $department;
|
|
$result['departments']++;
|
|
}
|
|
|
|
$queuesByCode = [];
|
|
if (! $skipStages) {
|
|
$maxQueues = $this->plans->maxQueues($organization);
|
|
$existingCount = ServiceQueue::query()
|
|
->where('organization_id', $organization->id)
|
|
->whereNull('deleted_at')
|
|
->count();
|
|
|
|
foreach ($package->stages() as $stageDef) {
|
|
if (! $includeOptional && ($stageDef['optional'] ?? false)) {
|
|
$result['skipped_stages'][] = $stageDef['code'];
|
|
continue;
|
|
}
|
|
|
|
$already = ServiceQueue::query()
|
|
->where('organization_id', $organization->id)
|
|
->where('branch_id', $branch->id)
|
|
->where('settings->external_key', $this->stageExternalKey($key, $stageDef['code'], $branch->id))
|
|
->exists();
|
|
|
|
if (! $already && $maxQueues !== null && $existingCount >= $maxQueues) {
|
|
$result['skipped_stages'][] = $stageDef['code'];
|
|
$result['skipped_reason'] = 'plan_queue_limit';
|
|
continue;
|
|
}
|
|
|
|
$department = $departmentsByCode[$stageDef['department']] ?? null;
|
|
$queue = $this->upsertStage($organization, $branch, $department, $key, $stageDef);
|
|
if (! $already) {
|
|
$existingCount++;
|
|
}
|
|
$queuesByCode[$stageDef['code']] = $queue;
|
|
$result['stages']++;
|
|
|
|
foreach ($stageDef['service_points'] ?? [] as $index => $pointDef) {
|
|
$this->upsertServicePoint($organization, $branch, $department, $queue, $key, $stageDef['code'], $pointDef, $index);
|
|
$result['service_points']++;
|
|
}
|
|
}
|
|
|
|
$workflow = $this->upsertWorkflow($organization, $branch, $package, $key, $queuesByCode);
|
|
$result['workflow_id'] = $workflow?->id;
|
|
}
|
|
|
|
$settings = $organization->settings ?? [];
|
|
$settings['industry'] = $key;
|
|
unset($settings['industry_package_needs_reinstall']);
|
|
$settings['industry_package'] = [
|
|
'key' => $key,
|
|
'version' => (int) config('industry_packages.version', 1),
|
|
'installed_at' => now()->toIso8601String(),
|
|
'branch_id' => $branch->id,
|
|
'stages_installed' => array_keys($queuesByCode),
|
|
'skipped_stages' => $result['skipped_stages'],
|
|
'skipped_reason' => $result['skipped_reason'],
|
|
'ticket_entity' => $package->ticketEntity(),
|
|
'terminology' => $package->terminology(),
|
|
'priorities' => $package->priorities(),
|
|
'announcement' => $package->announcement(),
|
|
'display_layout' => $package->displayLayout(),
|
|
'kpis' => $package->kpis(),
|
|
];
|
|
$organization->update(['settings' => $settings]);
|
|
|
|
AuditLogger::record(
|
|
$organization->owner_ref,
|
|
'organization.updated',
|
|
$organization->id,
|
|
$actor,
|
|
Organization::class,
|
|
$organization->id,
|
|
['industry_package' => $key, 'install' => $result],
|
|
);
|
|
|
|
return $result;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whether the selected industry differs from the installed package template.
|
|
*/
|
|
public static function needsReinstall(Organization $organization): bool
|
|
{
|
|
if ((bool) data_get($organization->settings, 'industry_package_needs_reinstall')) {
|
|
return true;
|
|
}
|
|
|
|
$selected = data_get($organization->settings, 'industry');
|
|
$installed = data_get($organization->settings, 'industry_package.key');
|
|
|
|
return is_string($selected)
|
|
&& $selected !== ''
|
|
&& is_string($installed)
|
|
&& $installed !== ''
|
|
&& $selected !== $installed;
|
|
}
|
|
|
|
/**
|
|
* Wipe queues, service points, tickets, workflows, and related operational data.
|
|
* Keeps organization, branches, and members.
|
|
*/
|
|
public function clearOperationalData(Organization $organization): void
|
|
{
|
|
$orgId = (int) $organization->id;
|
|
$ownerRef = (string) $organization->owner_ref;
|
|
|
|
$ticketIds = Ticket::withTrashed()->where('organization_id', $orgId)->pluck('id');
|
|
$queueIds = ServiceQueue::withTrashed()->where('organization_id', $orgId)->pluck('id');
|
|
$counterIds = Counter::withTrashed()->where('organization_id', $orgId)->pluck('id');
|
|
$workflowIds = Workflow::withTrashed()->where('organization_id', $orgId)->pluck('id');
|
|
|
|
CustomerFeedback::query()->where('organization_id', $orgId)->delete();
|
|
VoiceAnnouncement::query()->where('organization_id', $orgId)->delete();
|
|
ServiceSession::query()->where('owner_ref', $ownerRef)->delete();
|
|
TicketTransfer::query()->where('owner_ref', $ownerRef)->delete();
|
|
TicketEvent::query()->where('owner_ref', $ownerRef)->delete();
|
|
QueueAppointment::withTrashed()->where('organization_id', $orgId)->forceDelete();
|
|
|
|
if ($ticketIds->isNotEmpty()) {
|
|
Ticket::withTrashed()->whereIn('id', $ticketIds)->forceDelete();
|
|
}
|
|
|
|
QueueRule::query()->where('owner_ref', $ownerRef)->delete();
|
|
|
|
if ($workflowIds->isNotEmpty()) {
|
|
WorkflowStep::query()->whereIn('workflow_id', $workflowIds)->delete();
|
|
Workflow::withTrashed()->whereIn('id', $workflowIds)->forceDelete();
|
|
}
|
|
|
|
DisplayScreen::withTrashed()->where('organization_id', $orgId)->forceDelete();
|
|
Device::withTrashed()->where('organization_id', $orgId)->forceDelete();
|
|
|
|
DB::table('queue_staff_sessions')->where('owner_ref', $ownerRef)->delete();
|
|
DB::table('queue_counter_assignments')->where('owner_ref', $ownerRef)->delete();
|
|
|
|
if ($counterIds->isNotEmpty()) {
|
|
DB::table('queue_counter_service_queue')->whereIn('counter_id', $counterIds)->delete();
|
|
Counter::withTrashed()->whereIn('id', $counterIds)->forceDelete();
|
|
}
|
|
|
|
if ($queueIds->isNotEmpty()) {
|
|
ServiceQueue::withTrashed()->whereIn('id', $queueIds)->forceDelete();
|
|
}
|
|
|
|
Department::withTrashed()
|
|
->where('owner_ref', $ownerRef)
|
|
->whereIn('branch_id', Branch::query()->where('organization_id', $orgId)->pluck('id'))
|
|
->forceDelete();
|
|
}
|
|
|
|
protected function applyPackageSettings(Organization $organization, IndustryPackage $package): void
|
|
{
|
|
// Persisted fully after install; noop placeholder for future hooks.
|
|
unset($organization, $package);
|
|
}
|
|
|
|
/**
|
|
* @param array{code: string, name: string, type?: string} $deptDef
|
|
*/
|
|
protected function upsertDepartment(Organization $organization, Branch $branch, string $packageKey, array $deptDef): Department
|
|
{
|
|
$externalKey = $this->departmentExternalKey($packageKey, $deptDef['code'], $branch->id);
|
|
|
|
$department = Department::query()
|
|
->where('branch_id', $branch->id)
|
|
->where('external_key', $externalKey)
|
|
->first();
|
|
|
|
if (! $department) {
|
|
$department = Department::query()
|
|
->where('branch_id', $branch->id)
|
|
->where('name', $deptDef['name'])
|
|
->whereNull('external_key')
|
|
->first();
|
|
}
|
|
|
|
if ($department) {
|
|
$department->update([
|
|
'name' => $deptDef['name'],
|
|
'type' => $deptDef['type'] ?? 'general',
|
|
'external_key' => $externalKey,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
return $department->fresh();
|
|
}
|
|
|
|
return Department::create([
|
|
'owner_ref' => $organization->owner_ref,
|
|
'branch_id' => $branch->id,
|
|
'name' => $deptDef['name'],
|
|
'type' => $deptDef['type'] ?? 'general',
|
|
'external_key' => $externalKey,
|
|
'is_active' => true,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $stageDef
|
|
*/
|
|
protected function upsertStage(
|
|
Organization $organization,
|
|
Branch $branch,
|
|
?Department $department,
|
|
string $packageKey,
|
|
array $stageDef,
|
|
): ServiceQueue {
|
|
$externalKey = $this->stageExternalKey($packageKey, $stageDef['code'], $branch->id);
|
|
|
|
$queue = ServiceQueue::query()
|
|
->where('organization_id', $organization->id)
|
|
->where('branch_id', $branch->id)
|
|
->where('settings->external_key', $externalKey)
|
|
->first();
|
|
|
|
$settings = [
|
|
'external_key' => $externalKey,
|
|
'routing_mode' => $stageDef['routing_mode'] ?? 'shared_pool',
|
|
'routing_strategy' => $stageDef['routing_strategy'] ?? 'round_robin',
|
|
'industry_package' => $packageKey,
|
|
'stage_code' => $stageDef['code'],
|
|
'optional' => (bool) ($stageDef['optional'] ?? false),
|
|
];
|
|
|
|
if ($queue) {
|
|
$queue->update([
|
|
'department_id' => $department?->id,
|
|
'name' => $stageDef['name'],
|
|
'prefix' => $stageDef['prefix'],
|
|
'strategy' => $stageDef['strategy'] ?? 'fifo',
|
|
'settings' => array_merge($queue->settings ?? [], $settings),
|
|
'is_active' => true,
|
|
]);
|
|
|
|
return $queue->fresh();
|
|
}
|
|
|
|
return ServiceQueue::create([
|
|
'owner_ref' => $organization->owner_ref,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'department_id' => $department?->id,
|
|
'name' => $stageDef['name'],
|
|
'prefix' => $stageDef['prefix'],
|
|
'strategy' => $stageDef['strategy'] ?? 'fifo',
|
|
'settings' => $settings,
|
|
'is_active' => true,
|
|
'is_paused' => false,
|
|
'ticket_sequence' => 0,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array{name: string, destination?: string, code?: string} $pointDef
|
|
*/
|
|
protected function upsertServicePoint(
|
|
Organization $organization,
|
|
Branch $branch,
|
|
?Department $department,
|
|
ServiceQueue $queue,
|
|
string $packageKey,
|
|
string $stageCode,
|
|
array $pointDef,
|
|
int $index,
|
|
): Counter {
|
|
$code = $pointDef['code'] ?? strtoupper(substr($stageCode, 0, 2)).($index + 1);
|
|
$externalKey = $this->pointExternalKey($packageKey, $stageCode, $code, $branch->id);
|
|
|
|
$counter = Counter::query()
|
|
->where('organization_id', $organization->id)
|
|
->where('branch_id', $branch->id)
|
|
->where('settings->external_key', $externalKey)
|
|
->first();
|
|
|
|
$attributes = [
|
|
'department_id' => $department?->id,
|
|
'name' => $pointDef['name'],
|
|
'code' => $code,
|
|
'destination' => $pointDef['destination'] ?? $pointDef['name'],
|
|
'status' => 'available',
|
|
'settings' => [
|
|
'external_key' => $externalKey,
|
|
'industry_package' => $packageKey,
|
|
'stage_code' => $stageCode,
|
|
],
|
|
'is_active' => true,
|
|
];
|
|
|
|
if ($counter) {
|
|
$counter->update(array_merge($attributes, [
|
|
'settings' => array_merge($counter->settings ?? [], $attributes['settings']),
|
|
]));
|
|
$counter = $counter->fresh();
|
|
} else {
|
|
$counter = Counter::create(array_merge($attributes, [
|
|
'owner_ref' => $organization->owner_ref,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
]));
|
|
}
|
|
|
|
if (! $queue->counters()->where('queue_counters.id', $counter->id)->exists()) {
|
|
$queue->counters()->attach($counter->id, ['priority' => $index]);
|
|
}
|
|
|
|
return $counter;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, ServiceQueue> $queuesByCode
|
|
*/
|
|
protected function upsertWorkflow(
|
|
Organization $organization,
|
|
Branch $branch,
|
|
IndustryPackage $package,
|
|
string $packageKey,
|
|
array $queuesByCode,
|
|
): ?Workflow {
|
|
if ($queuesByCode === []) {
|
|
return null;
|
|
}
|
|
|
|
$meta = $package->workflowMeta();
|
|
$externalKey = "industry:{$packageKey}:workflow:{$branch->id}";
|
|
|
|
$workflow = Workflow::query()
|
|
->where('organization_id', $organization->id)
|
|
->where('branch_id', $branch->id)
|
|
->where('settings->external_key', $externalKey)
|
|
->first();
|
|
|
|
if (! $workflow) {
|
|
$workflow = Workflow::create([
|
|
'owner_ref' => $organization->owner_ref,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'name' => $meta['name'] ?? $package->label().' workflow',
|
|
'description' => $meta['description'] ?? null,
|
|
'industry_template' => $packageKey,
|
|
'is_active' => true,
|
|
'settings' => ['external_key' => $externalKey],
|
|
]);
|
|
} else {
|
|
$workflow->update([
|
|
'name' => $meta['name'] ?? $workflow->name,
|
|
'description' => $meta['description'] ?? $workflow->description,
|
|
'industry_template' => $packageKey,
|
|
'is_active' => true,
|
|
'settings' => array_merge($workflow->settings ?? [], ['external_key' => $externalKey]),
|
|
]);
|
|
}
|
|
|
|
$sort = 1;
|
|
foreach ($package->stages() as $stageDef) {
|
|
$queue = $queuesByCode[$stageDef['code']] ?? null;
|
|
if (! $queue) {
|
|
continue;
|
|
}
|
|
|
|
$step = WorkflowStep::query()
|
|
->where('workflow_id', $workflow->id)
|
|
->where('service_queue_id', $queue->id)
|
|
->first();
|
|
|
|
if ($step) {
|
|
$step->update([
|
|
'name' => $stageDef['name'],
|
|
'sort_order' => $sort,
|
|
'settings' => ['stage_code' => $stageDef['code']],
|
|
]);
|
|
} else {
|
|
WorkflowStep::create([
|
|
'workflow_id' => $workflow->id,
|
|
'service_queue_id' => $queue->id,
|
|
'name' => $stageDef['name'],
|
|
'sort_order' => $sort,
|
|
'settings' => ['stage_code' => $stageDef['code']],
|
|
]);
|
|
}
|
|
$sort++;
|
|
}
|
|
|
|
return $workflow->fresh(['steps']);
|
|
}
|
|
|
|
public function departmentExternalKey(string $packageKey, string $code, int $branchId): string
|
|
{
|
|
return "industry:{$packageKey}:dept:{$code}:{$branchId}";
|
|
}
|
|
|
|
public function stageExternalKey(string $packageKey, string $code, int $branchId): string
|
|
{
|
|
return "industry:{$packageKey}:stage:{$code}:{$branchId}";
|
|
}
|
|
|
|
public function pointExternalKey(string $packageKey, string $stageCode, string $pointCode, int $branchId): string
|
|
{
|
|
return "industry:{$packageKey}:point:{$stageCode}:{$pointCode}:{$branchId}";
|
|
}
|
|
}
|