, * 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 $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 $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}"; } }