*/ private const VOLUMES = [ 'free' => [ 'branches' => 1, 'queues_per_branch' => 5, 'counters_per_branch' => 3, 'tickets_per_queue' => 8, 'history_tickets' => 12, ], 'pro' => [ 'branches' => 3, 'queues_per_branch' => 4, 'counters_per_branch' => 4, 'tickets_per_queue' => 10, 'history_tickets' => 40, ], 'enterprise' => [ 'branches' => 4, 'queues_per_branch' => 5, 'counters_per_branch' => 5, 'tickets_per_queue' => 12, 'history_tickets' => 80, ], ]; private const QUEUE_NAMES = [ ['Reception', 'A'], ['Customer Service', 'B'], ['Payments', 'C'], ['Collections', 'D'], ['Support', 'E'], ]; public function __construct(private readonly OrganizationResolver $orgs) {} /** * @return array{user: User, organization: Organization, plan: string, counts: array} */ public function seed(string $identity, string $plan, bool $reset = false): array { $plan = $this->normalizePlan($plan); $user = $this->resolveUser($identity); // Always materialize the onboarded org shell before any wipe so concurrent // SSO redirects never land on the blank onboarding form. $this->ensureOnboardedShell($user, $plan); if ($reset) { $this->resetForOwner($user->ownerRef()); $this->ensureOnboardedShell($user, $plan); } $organization = $this->ensureOrganization($user, $plan); $this->assignPlan($organization, $plan); $volumes = self::VOLUMES[$plan]; $branches = $this->seedBranches($user->ownerRef(), $organization, $plan, $volumes['branches']); $this->seedStaffMembers($user->ownerRef(), $organization, $plan, $branches); $queues = []; $counters = []; $people = DemoWorld::people(); $personCursor = 0; foreach ($branches as $branchIndex => $branch) { $dept = $this->ensureDepartment($user->ownerRef(), $branch); $branchQueues = $this->seedQueues( $user->ownerRef(), $organization, $branch, $dept, $volumes['queues_per_branch'], $plan, ); $branchCounters = $this->seedCounters( $user->ownerRef(), $organization, $branch, $dept, $branchQueues, $volumes['counters_per_branch'], ); $this->seedDisplay($user->ownerRef(), $organization, $branch, $branchQueues); $this->seedDevice($user->ownerRef(), $organization, $branch); foreach ($branchQueues as $qi => $queue) { $personCursor = $this->seedLiveTickets( $user->ownerRef(), $organization, $branch, $queue, $branchCounters[$qi % max(1, count($branchCounters))] ?? null, $volumes['tickets_per_queue'], $people, $personCursor, ); } if ($plan !== 'free' && count($branchQueues) >= 2) { $this->seedRouting($user->ownerRef(), $branchQueues); $this->seedTransfers($user->ownerRef(), $organization, $branch, $branchQueues, $branchCounters); } if ($branchIndex === 0 && $plan !== 'free') { $this->seedWorkflow($user->ownerRef(), $organization, $branch, $branchQueues); } $queues = array_merge($queues, $branchQueues); $counters = array_merge($counters, $branchCounters); } $this->seedHistoryTickets( $user->ownerRef(), $organization, $branches[0], $queues[0] ?? null, $counters[0] ?? null, $volumes['history_tickets'], $people, ); $organization->refresh(); return [ 'user' => $user, 'organization' => $organization, 'plan' => $plan, 'counts' => [ 'branches' => Branch::query()->where('organization_id', $organization->id)->whereNull('deleted_at')->count(), 'queues' => ServiceQueue::query()->where('organization_id', $organization->id)->whereNull('deleted_at')->count(), 'counters' => Counter::query()->where('organization_id', $organization->id)->whereNull('deleted_at')->count(), 'tickets' => Ticket::query()->where('organization_id', $organization->id)->whereNull('deleted_at')->count(), 'displays' => DisplayScreen::query()->where('organization_id', $organization->id)->whereNull('deleted_at')->count(), 'rules' => QueueRule::query()->where('owner_ref', $user->ownerRef())->count(), ], ]; } /** * Ensure the demo tenant has an onboarded organization + owner member. * Safe to call on the SSO request path before redirecting. */ public function ensureOnboardedShell(User $user, string $plan): Organization { $plan = $this->normalizePlan($plan); $organization = $this->ensureOrganization($user, $plan); $this->assignPlan($organization, $plan); return $organization->fresh() ?? $organization; } public function resolveUser(string $identity): User { $identity = trim($identity); $user = User::query() ->where(function ($q) use ($identity) { $q->where('email', $identity)->orWhere('public_id', $identity); }) ->first(); if ($user) { return $user; } $email = str_contains($identity, '@') ? $identity : $identity.'@demo.ladill.local'; $publicId = str_contains($identity, '@') ? (string) Str::uuid() : $identity; return User::query()->create([ 'public_id' => $publicId, 'name' => 'Demo User', 'email' => $email, 'password' => bcrypt('password'), 'email_verified_at' => now(), ]); } public function assignPlan(Organization $organization, string $plan): void { $settings = $organization->settings ?? []; $settings['onboarded'] = true; $settings['plan'] = $plan; $settings['plan_expires_at'] = $plan === 'free' ? null : now()->addYear()->toIso8601String(); $settings['billed_branches'] = max(1, Branch::query() ->where('organization_id', $organization->id) ->where('is_active', true) ->count()); $settings['demo'] = true; $organization->forceFill(['settings' => $settings])->save(); } public function resetForOwner(string $ownerRef): void { DB::transaction(function () use ($ownerRef) { $orgIds = Organization::withTrashed()->where('owner_ref', $ownerRef)->pluck('id'); if ($orgIds->isEmpty()) { return; } $ticketIds = Ticket::withTrashed()->where('owner_ref', $ownerRef)->pluck('id'); $queueIds = ServiceQueue::withTrashed()->where('owner_ref', $ownerRef)->pluck('id'); $counterIds = Counter::withTrashed()->where('owner_ref', $ownerRef)->pluck('id'); $workflowIds = Workflow::withTrashed()->where('owner_ref', $ownerRef)->pluck('id'); CustomerFeedback::query()->where('owner_ref', $ownerRef)->delete(); VoiceAnnouncement::query()->where('owner_ref', $ownerRef)->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('owner_ref', $ownerRef)->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('owner_ref', $ownerRef)->forceDelete(); Device::withTrashed()->where('owner_ref', $ownerRef)->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)->forceDelete(); // Keep the owner member + organization so SSO redirects never see // "not onboarded" while afterResponse() reseeding is still running. Member::query() ->where('owner_ref', $ownerRef) ->where('user_ref', '!=', $ownerRef) ->delete(); AuditLog::query()->where('owner_ref', $ownerRef)->delete(); Branch::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); foreach ($orgIds as $orgId) { $organization = Organization::withTrashed()->find($orgId); if (! $organization) { continue; } if ($organization->trashed()) { $organization->restore(); } $settings = $organization->settings ?? []; $settings['onboarded'] = true; $organization->forceFill(['settings' => $settings])->save(); } }); } private function ensureOrganization(User $user, string $plan): Organization { $business = DemoWorld::business($plan); $existing = $this->orgs->resolveForUser($user); if ($existing) { $settings = $existing->settings ?? []; $settings['onboarded'] = true; $existing->forceFill([ 'name' => $business['name'], 'settings' => $settings, ])->save(); $this->orgs->ensureOwnerMember($user, $existing); return $existing; } $hq = DemoWorld::branches($plan, 1)[0]; return $this->orgs->completeOnboarding($user, [ 'organization_name' => $business['name'], 'industry' => 'retail', 'appointment_mode' => 'hybrid', 'branch_name' => $hq['name'], 'branch_address' => $hq['address'], 'branch_phone' => $hq['phone'], 'timezone' => $business['timezone'] ?? 'Africa/Accra', ]); } /** @return list */ private function seedBranches(string $ownerRef, Organization $organization, string $plan, int $count): array { $catalog = DemoWorld::branches($plan, $count); $existing = Branch::query() ->where('organization_id', $organization->id) ->whereNull('deleted_at') ->orderBy('id') ->get() ->all(); $branches = []; foreach ($catalog as $i => $spec) { $attrs = [ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'name' => $spec['name'], 'code' => $spec['code'], 'address' => $spec['address'], 'phone' => $spec['phone'], 'is_active' => true, ]; $branch = $existing[$i] ?? null; if ($branch) { $branch->fill($attrs)->save(); } else { $branch = Branch::query()->create($attrs); } $branches[] = $branch; } return $branches; } /** * Local Member rows for DemoWorld staff with a queue role (user_ref = email * until SSO remaps to public_id). * * @param list $branches */ private function seedStaffMembers(string $ownerRef, Organization $organization, string $plan, array $branches): void { $hq = $branches[0] ?? null; foreach (DemoWorld::staff($plan) as $staff) { $role = $staff['roles']['queue'] ?? null; if (! is_string($role) || $role === '') { continue; } Member::query()->updateOrCreate( [ 'organization_id' => $organization->id, 'user_ref' => strtolower($staff['email']), ], [ 'owner_ref' => $ownerRef, 'role' => $role, 'branch_id' => $hq?->id, ], ); } } private function ensureDepartment(string $ownerRef, Branch $branch): Department { return Department::query()->firstOrCreate( ['branch_id' => $branch->id, 'name' => 'General Reception'], [ 'owner_ref' => $ownerRef, 'type' => 'reception', 'is_active' => true, ], ); } /** * @return list */ private function seedQueues( string $ownerRef, Organization $organization, Branch $branch, Department $department, int $count, string $plan, ): array { $queues = ServiceQueue::query() ->where('branch_id', $branch->id) ->whereNull('deleted_at') ->orderBy('id') ->get() ->all(); for ($i = count($queues); $i < $count; $i++) { [$name, $prefix] = self::QUEUE_NAMES[$i % count(self::QUEUE_NAMES)]; $strategy = $plan === 'free' ? 'fifo' : ['fifo', 'priority', 'vip', 'overflow', 'dynamic'][$i % 5]; $queues[] = ServiceQueue::query()->create([ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'branch_id' => $branch->id, 'department_id' => $department->id, 'name' => $name.($i >= count(self::QUEUE_NAMES) ? ' '.($i + 1) : ''), 'prefix' => $prefix.($i >= count(self::QUEUE_NAMES) ? (string) ($i + 1) : ''), 'strategy' => $strategy, 'avg_service_seconds' => 240 + ($i * 30), 'is_active' => true, 'ticket_sequence' => 0, ]); } return array_slice($queues, 0, $count); } /** * @param list $queues * @return list */ private function seedCounters( string $ownerRef, Organization $organization, Branch $branch, Department $department, array $queues, int $count, ): array { $counters = Counter::query() ->where('branch_id', $branch->id) ->whereNull('deleted_at') ->orderBy('id') ->get() ->all(); for ($i = count($counters); $i < $count; $i++) { $counter = Counter::query()->create([ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'branch_id' => $branch->id, 'department_id' => $department->id, 'name' => 'Counter '.($i + 1), 'code' => 'C'.($i + 1), 'status' => $i === 0 ? 'available' : 'busy', 'is_active' => true, ]); if ($queues !== []) { $queue = $queues[$i % count($queues)]; $counter->serviceQueues()->syncWithoutDetaching([$queue->id => ['priority' => 0]]); } $counters[] = $counter; } return array_slice($counters, 0, $count); } /** @param list $queues */ private function seedDisplay(string $ownerRef, Organization $organization, Branch $branch, array $queues): void { if (DisplayScreen::query()->where('branch_id', $branch->id)->whereNull('deleted_at')->exists()) { return; } DisplayScreen::query()->create([ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'branch_id' => $branch->id, 'name' => $branch->name.' Display', 'layout' => 'standard', 'service_queue_ids' => array_map(fn (ServiceQueue $q) => $q->id, $queues), 'is_active' => true, ]); } private function seedDevice(string $ownerRef, Organization $organization, Branch $branch): void { if (Device::query()->where('branch_id', $branch->id)->whereNull('deleted_at')->exists()) { return; } Device::query()->create([ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'branch_id' => $branch->id, 'name' => $branch->name.' Kiosk', 'type' => 'kiosk', 'status' => 'online', 'device_token' => Str::random(40), 'last_online_at' => now(), ]); } /** * @param list $people * @return int next person cursor for the caller to continue from */ private function seedLiveTickets( string $ownerRef, Organization $organization, Branch $branch, ServiceQueue $queue, ?Counter $counter, int $count, array $people, int $personCursor, ): int { $existing = Ticket::query() ->where('service_queue_id', $queue->id) ->whereIn('status', ['waiting', 'called', 'serving']) ->count(); $storyStart = DemoWorld::storyDay()->copy()->addHours(9); for ($i = $existing; $i < $count; $i++) { $seq = $queue->ticket_sequence + 1; $queue->forceFill(['ticket_sequence' => $seq])->save(); $status = ['waiting', 'waiting', 'called', 'serving'][$i % 4]; $person = $people[$personCursor % count($people)]; $personCursor++; Ticket::query()->create([ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'branch_id' => $branch->id, 'service_queue_id' => $queue->id, 'counter_id' => in_array($status, ['called', 'serving'], true) ? $counter?->id : null, 'ticket_number' => $queue->prefix.str_pad((string) $seq, 3, '0', STR_PAD_LEFT), 'status' => $status, 'priority' => ['walk_in', 'vip', 'elderly', 'appointment'][$i % 4], 'position' => $i + 1, 'customer_name' => DemoWorld::fullName($person), 'customer_phone' => $person['phone'], 'source' => ['kiosk', 'reception', 'web'][$i % 3], 'issued_at' => $storyStart->copy()->addMinutes($i * 3), 'called_at' => in_array($status, ['called', 'serving'], true) ? $storyStart->copy()->addMinutes($i * 3 + 5) : null, 'serving_started_at' => $status === 'serving' ? $storyStart->copy()->addMinutes($i * 3 + 7) : null, ]); } return $personCursor; } /** @param list $people */ private function seedHistoryTickets( string $ownerRef, Organization $organization, Branch $branch, ?ServiceQueue $queue, ?Counter $counter, int $count, array $people, ): void { if (! $queue) { return; } $existing = Ticket::query() ->where('service_queue_id', $queue->id) ->whereIn('status', ['completed', 'no_show', 'cancelled']) ->count(); $storyDay = DemoWorld::storyDay(); for ($i = $existing; $i < $count; $i++) { $seq = $queue->ticket_sequence + 1; $queue->forceFill(['ticket_sequence' => $seq])->save(); $status = ['completed', 'completed', 'completed', 'no_show', 'cancelled'][$i % 5]; $person = $people[$i % count($people)]; $day = $storyDay->copy()->subDays(($i % 14) + 1); $ticket = Ticket::query()->create([ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'branch_id' => $branch->id, 'service_queue_id' => $queue->id, 'counter_id' => $counter?->id, 'ticket_number' => $queue->prefix.str_pad((string) $seq, 3, '0', STR_PAD_LEFT), 'status' => $status, 'priority' => 'walk_in', 'customer_name' => DemoWorld::fullName($person), 'customer_phone' => $person['phone'], 'source' => 'kiosk', 'issued_at' => $day->copy()->subMinutes(40), 'called_at' => $day->copy()->subMinutes(20), 'serving_started_at' => $status === 'completed' ? $day->copy()->subMinutes(15) : null, 'completed_at' => $status === 'completed' ? $day->copy()->subMinutes(5) : null, ]); TicketEvent::query()->create([ 'owner_ref' => $ownerRef, 'ticket_id' => $ticket->id, 'event' => 'issued', 'actor_ref' => $ownerRef, 'created_at' => $ticket->issued_at, ]); if ($status === 'completed') { TicketEvent::query()->create([ 'owner_ref' => $ownerRef, 'ticket_id' => $ticket->id, 'event' => 'completed', 'actor_ref' => $ownerRef, 'counter_id' => $counter?->id, 'created_at' => $ticket->completed_at, ]); } } } /** @param list $queues */ private function seedRouting(string $ownerRef, array $queues): void { $from = $queues[0]; if (QueueRule::query()->where('service_queue_id', $from->id)->exists()) { return; } QueueRule::query()->create([ 'owner_ref' => $ownerRef, 'service_queue_id' => $from->id, 'rule_type' => 'overflow', 'conditions' => ['waiting_above' => 15], 'actions' => ['route_to_queue_id' => $queues[1]->id], 'sort_order' => 1, 'is_active' => true, ]); QueueRule::query()->create([ 'owner_ref' => $ownerRef, 'service_queue_id' => $from->id, 'rule_type' => 'routing', 'conditions' => ['priority' => 'vip'], 'actions' => ['prefer_counter' => true], 'sort_order' => 2, 'is_active' => true, ]); } /** * @param list $queues * @param list $counters */ private function seedTransfers( string $ownerRef, Organization $organization, Branch $branch, array $queues, array $counters, ): void { if (count($queues) < 2 || TicketTransfer::query()->where('owner_ref', $ownerRef)->exists()) { return; } $ticket = Ticket::query()->create([ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'branch_id' => $branch->id, 'service_queue_id' => $queues[1]->id, 'counter_id' => $counters[0]->id ?? null, 'ticket_number' => $queues[1]->prefix.'T01', 'status' => 'transferred', 'priority' => 'walk_in', 'customer_name' => 'Transferred Guest', 'source' => 'reception', 'issued_at' => now()->subHour(), ]); TicketTransfer::query()->create([ 'owner_ref' => $ownerRef, 'ticket_id' => $ticket->id, 'from_service_queue_id' => $queues[0]->id, 'to_service_queue_id' => $queues[1]->id, 'from_counter_id' => $counters[0]->id ?? null, 'reason' => 'Needs specialist desk', 'transferred_by' => $ownerRef, 'transferred_at' => now()->subMinutes(30), ]); } /** @param list $queues */ private function seedWorkflow(string $ownerRef, Organization $organization, Branch $branch, array $queues): void { if (Workflow::query()->where('organization_id', $organization->id)->whereNull('deleted_at')->exists()) { return; } $workflow = Workflow::query()->create([ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'branch_id' => $branch->id, 'name' => 'Demo service journey', 'industry_template' => 'retail', 'is_active' => true, ]); foreach (array_slice($queues, 0, min(3, count($queues))) as $i => $queue) { WorkflowStep::query()->create([ 'workflow_id' => $workflow->id, 'service_queue_id' => $queue->id, 'name' => $queue->name, 'sort_order' => $i + 1, ]); } } private function normalizePlan(string $plan): string { $plan = strtolower(trim($plan)); if (! in_array($plan, ['free', 'pro', 'enterprise'], true)) { throw new \InvalidArgumentException('Plan must be free, pro, or enterprise.'); } return $plan; } }