Require package reinstall after industry change and wipe old queues.
Deploy Ladill Queue / deploy (push) Successful in 1m5s
Deploy Ladill Queue / deploy (push) Successful in 1m5s
Changing industry now flags a pending reinstall with an app banner; reinstall clears queues, tickets, and service points before applying the new template. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -35,6 +35,8 @@ class SettingsController extends Controller
|
||||
?? data_get($organization->settings, 'industry')
|
||||
?? 'custom';
|
||||
$activePackage = $packages->get($packageKey);
|
||||
$selectedIndustry = data_get($organization->settings, 'industry', $packageKey);
|
||||
$selectedPackage = $packages->get($selectedIndustry) ?? $activePackage;
|
||||
|
||||
return view('qms.settings.edit', [
|
||||
'organization' => $organization,
|
||||
@@ -43,7 +45,9 @@ class SettingsController extends Controller
|
||||
'appointmentModes' => config('qms.appointment_modes'),
|
||||
'industries' => $packages->labels(),
|
||||
'activePackage' => $activePackage,
|
||||
'selectedPackage' => $selectedPackage,
|
||||
'packageMeta' => data_get($organization->settings, 'industry_package'),
|
||||
'needsPackageReinstall' => IndustryPackageInstaller::needsReinstall($organization),
|
||||
'hasPaidPlan' => $plans->hasPaidPlan($organization),
|
||||
'isPro' => $plans->isPro($organization),
|
||||
'isEnterprise' => $plans->isEnterprise($organization),
|
||||
@@ -82,6 +86,7 @@ class SettingsController extends Controller
|
||||
|
||||
$settings['appointment_mode'] = $validated['appointment_mode'];
|
||||
$previousIndustry = $settings['industry'] ?? null;
|
||||
$installedPackage = data_get($settings, 'industry_package.key');
|
||||
if ($industry !== null) {
|
||||
$settings['industry'] = $industry;
|
||||
}
|
||||
@@ -95,6 +100,17 @@ class SettingsController extends Controller
|
||||
}
|
||||
$settings['integrations'] = $integrations;
|
||||
|
||||
$industryChanged = $industry !== null
|
||||
&& $industry !== $previousIndustry;
|
||||
$outOfSyncWithPackage = $industry !== null
|
||||
&& is_string($installedPackage)
|
||||
&& $installedPackage !== ''
|
||||
&& $industry !== $installedPackage;
|
||||
|
||||
if ($industryChanged || $outOfSyncWithPackage) {
|
||||
$settings['industry_package_needs_reinstall'] = true;
|
||||
}
|
||||
|
||||
$logoPath = $organization->logo_path;
|
||||
|
||||
if ($request->boolean('remove_logo')) {
|
||||
@@ -115,28 +131,21 @@ class SettingsController extends Controller
|
||||
|
||||
AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id);
|
||||
|
||||
$industryChanged = ($settings['industry'] ?? null) && ($settings['industry'] ?? null) !== $previousIndustry;
|
||||
$packageNote = '';
|
||||
if ($industryChanged) {
|
||||
$branch = Branch::query()
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
if ($branch) {
|
||||
try {
|
||||
app(IndustryPackageInstaller::class)->install($organization->fresh(), $branch, $settings['industry'], [
|
||||
'actor_ref' => $owner,
|
||||
]);
|
||||
$packageNote = ' Industry package re-provisioned.';
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$packageNote = ' Settings saved, but industry package re-provision failed — try Reinstall package.';
|
||||
}
|
||||
}
|
||||
if ($industryChanged || $outOfSyncWithPackage) {
|
||||
$label = $registry->get($industry)?->label() ?? $industry;
|
||||
$packageNote = " Industry changed to {$label} — reinstall the industry package to replace queues and service points.";
|
||||
}
|
||||
|
||||
return back()->with('success', 'Settings saved.'.$packageNote);
|
||||
$redirect = back()->with('success', 'Settings saved.'.$packageNote);
|
||||
if ($industryChanged || $outOfSyncWithPackage) {
|
||||
$redirect->with(
|
||||
'warning',
|
||||
'Reinstall the industry package to apply the new template. This will clear existing queues, tickets, and service points.'
|
||||
);
|
||||
}
|
||||
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
public function reinstallPackage(Request $request, IndustryPackageInstaller $installer): RedirectResponse
|
||||
@@ -159,9 +168,13 @@ class SettingsController extends Controller
|
||||
$result = $installer->install($organization, $branch, $key, [
|
||||
'actor_ref' => $owner,
|
||||
'include_optional' => true,
|
||||
'replace' => true,
|
||||
]);
|
||||
|
||||
$message = 'Industry package synced.';
|
||||
$message = ($result['replaced'] ?? false)
|
||||
? 'Industry package reinstalled. Previous queues, tickets, and service points were cleared.'
|
||||
: 'Industry package synced.';
|
||||
|
||||
if (($result['skipped_reason'] ?? null) === 'plan_queue_limit') {
|
||||
$message .= ' Some stages were skipped because of your plan queue limit — upgrade to Pro for the full template.';
|
||||
} elseif (($result['skipped_reason'] ?? null) === 'stages_managed_by_integration') {
|
||||
|
||||
@@ -4,9 +4,19 @@ 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;
|
||||
@@ -27,7 +37,7 @@ class IndustryPackageInstaller
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{skip_stages?: bool, include_optional?: bool, actor_ref?: string|null} $options
|
||||
* @param array{skip_stages?: bool, include_optional?: bool, actor_ref?: string|null, replace?: bool} $options
|
||||
* @return array{
|
||||
* package: string,
|
||||
* departments: int,
|
||||
@@ -35,7 +45,8 @@ class IndustryPackageInstaller
|
||||
* service_points: int,
|
||||
* workflow_id: int|null,
|
||||
* skipped_stages: list<string>,
|
||||
* skipped_reason: string|null
|
||||
* skipped_reason: string|null,
|
||||
* replaced: bool
|
||||
* }
|
||||
*/
|
||||
public function install(Organization $organization, Branch $branch, ?string $packageKey = null, array $options = []): array
|
||||
@@ -49,6 +60,7 @@ class IndustryPackageInstaller
|
||||
|
||||
$skipStages = (bool) ($options['skip_stages'] ?? false);
|
||||
$includeOptional = (bool) ($options['include_optional'] ?? true);
|
||||
$replace = (bool) ($options['replace'] ?? false);
|
||||
$actor = $options['actor_ref'] ?? $organization->owner_ref;
|
||||
|
||||
// Care (and similar) own clinical stage provisioning — still apply terminology.
|
||||
@@ -64,9 +76,18 @@ class IndustryPackageInstaller
|
||||
'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, $actor, &$result) {
|
||||
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.
|
||||
// Care-managed healthcare keeps clinical queues (skip_stages).
|
||||
if ($replace && ! $skipStages) {
|
||||
$this->clearOperationalData($organization);
|
||||
$result['replaced'] = true;
|
||||
$organization->refresh();
|
||||
}
|
||||
|
||||
$this->applyPackageSettings($organization, $package);
|
||||
|
||||
$departmentsByCode = [];
|
||||
@@ -122,6 +143,7 @@ class IndustryPackageInstaller
|
||||
|
||||
$settings = $organization->settings ?? [];
|
||||
$settings['industry'] = $key;
|
||||
unset($settings['industry_package_needs_reinstall']);
|
||||
$settings['industry_package'] = [
|
||||
'key' => $key,
|
||||
'version' => (int) config('industry_packages.version', 1),
|
||||
@@ -152,6 +174,78 @@ class IndustryPackageInstaller
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -22,6 +22,38 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@auth
|
||||
@php
|
||||
$industryBannerOrg = app(\App\Services\Qms\OrganizationResolver::class)->resolveForUser(auth()->user());
|
||||
$industryNeedsReinstall = $industryBannerOrg
|
||||
&& \App\Services\Qms\Industry\IndustryPackageInstaller::needsReinstall($industryBannerOrg);
|
||||
$industryCanManage = $industryNeedsReinstall
|
||||
&& app(\App\Services\Qms\QmsPermissions::class)->can(
|
||||
app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user(), $industryBannerOrg),
|
||||
'settings.manage'
|
||||
);
|
||||
$industrySelectedLabel = $industryNeedsReinstall
|
||||
? (app(\App\Services\Qms\Industry\IndustryPackageRegistry::class)
|
||||
->get((string) data_get($industryBannerOrg->settings, 'industry'))
|
||||
?->label() ?? data_get($industryBannerOrg->settings, 'industry'))
|
||||
: null;
|
||||
@endphp
|
||||
@if ($industryCanManage && ! request()->routeIs('qms.settings.*'))
|
||||
<div class="mb-4 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="font-semibold">Reinstall industry package required</p>
|
||||
<p class="mt-1">
|
||||
Industry is set to <span class="font-medium">{{ $industrySelectedLabel }}</span>, but the installed queues still match the previous template.
|
||||
Reinstall to clear old queues and apply the new package.
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ route('qms.settings.edit') }}" class="btn-primary shrink-0 text-sm">Open settings</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<p class="text-sm font-medium text-red-800">Please fix the following:</p>
|
||||
|
||||
@@ -153,18 +153,34 @@
|
||||
</form>
|
||||
|
||||
{{-- Separate form: nested forms break Save settings in browsers --}}
|
||||
@if ($canManage && $activePackage)
|
||||
@if ($canManage && ($selectedPackage ?? $activePackage))
|
||||
@php $templatePackage = $selectedPackage ?? $activePackage; @endphp
|
||||
<div class="mt-6">
|
||||
<x-settings.card title="Industry template" description="Re-sync departments, stages, service points, workflow, and announcement copy from the selected industry package. Existing tickets are kept.">
|
||||
@if ($needsPackageReinstall ?? false)
|
||||
<div class="mb-4 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<p class="font-semibold">Industry package reinstall required</p>
|
||||
<p class="mt-1">
|
||||
You selected <span class="font-medium">{{ $templatePackage->label() }}</span>, but the installed template still matches
|
||||
<span class="font-medium">{{ $activePackage?->label() ?? 'the previous industry' }}</span>.
|
||||
Reinstall to clear old queues and apply the new service points.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
<x-settings.card
|
||||
title="Industry template"
|
||||
description="Reinstall replaces departments, stages, service points, and workflow from the selected industry package. Existing queues, tickets, and related data are cleared."
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-sm text-slate-600">
|
||||
{{ $activePackage->label() }} uses
|
||||
<span class="font-medium text-slate-900">{{ count($activePackage->stages()) }}</span> workflow stages and
|
||||
industry-specific terminology ({{ $activePackage->terminology()['ticket'] ?? 'Ticket' }}).
|
||||
{{ $templatePackage->label() }} uses
|
||||
<span class="font-medium text-slate-900">{{ count($templatePackage->stages()) }}</span> workflow stages and
|
||||
industry-specific terminology ({{ $templatePackage->terminology()['ticket'] ?? 'Ticket' }}).
|
||||
</p>
|
||||
<form method="POST" action="{{ route('qms.settings.package.reinstall') }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn-secondary shrink-0 text-sm">Reinstall package</button>
|
||||
<button type="submit" class="{{ ($needsPackageReinstall ?? false) ? 'btn-primary' : 'btn-secondary' }} shrink-0 text-sm">
|
||||
{{ ($needsPackageReinstall ?? false) ? 'Reinstall package now' : 'Reinstall package' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-settings.card>
|
||||
|
||||
@@ -206,4 +206,96 @@ class IndustryPackageTest extends TestCase
|
||||
$this->assertSame($before, ServiceQueue::where('organization_id', $org->id)->count());
|
||||
$this->assertSame('patient', data_get($org->fresh()->settings, 'industry_package.ticket_entity'));
|
||||
}
|
||||
|
||||
public function test_replace_install_clears_previous_queues_and_tickets(): void
|
||||
{
|
||||
$user = User::create([
|
||||
'public_id' => 'pkg-replace-owner',
|
||||
'name' => 'Replace Owner',
|
||||
'email' => 'replace-owner@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
|
||||
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
|
||||
'organization_name' => 'Cafe',
|
||||
'industry' => 'restaurant',
|
||||
'appointment_mode' => 'hybrid',
|
||||
'branch_name' => 'Main',
|
||||
'timezone' => 'UTC',
|
||||
]);
|
||||
$org->update([
|
||||
'settings' => array_merge($org->settings ?? [], [
|
||||
'plan' => 'pro',
|
||||
'plan_expires_at' => now()->addMonth()->toIso8601String(),
|
||||
]),
|
||||
]);
|
||||
|
||||
$branch = Branch::where('organization_id', $org->id)->first();
|
||||
$oldQueue = ServiceQueue::where('organization_id', $org->id)->first();
|
||||
$this->assertNotNull($oldQueue);
|
||||
|
||||
app(QueueEngine::class)->issueTicket($oldQueue, $user->public_id, [
|
||||
'customer_name' => 'Old Ticket',
|
||||
'source' => 'reception',
|
||||
]);
|
||||
$this->assertGreaterThan(0, \App\Models\Ticket::where('organization_id', $org->id)->count());
|
||||
|
||||
$result = app(IndustryPackageInstaller::class)->install($org->fresh(), $branch, 'banking', [
|
||||
'replace' => true,
|
||||
'include_optional' => true,
|
||||
]);
|
||||
|
||||
$this->assertTrue($result['replaced']);
|
||||
$this->assertSame(0, \App\Models\Ticket::where('organization_id', $org->id)->count());
|
||||
$this->assertFalse(
|
||||
ServiceQueue::where('organization_id', $org->id)->where('id', $oldQueue->id)->exists()
|
||||
);
|
||||
$this->assertSame('banking', data_get($org->fresh()->settings, 'industry_package.key'));
|
||||
$this->assertNull(data_get($org->fresh()->settings, 'industry_package_needs_reinstall'));
|
||||
$this->assertGreaterThanOrEqual(3, ServiceQueue::where('organization_id', $org->id)->count());
|
||||
}
|
||||
|
||||
public function test_changing_industry_requires_reinstall_without_auto_provision(): void
|
||||
{
|
||||
$this->withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class);
|
||||
|
||||
$user = User::create([
|
||||
'public_id' => 'pkg-settings-owner',
|
||||
'name' => 'Settings Owner',
|
||||
'email' => 'settings-pkg@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
|
||||
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
|
||||
'organization_name' => 'Shop',
|
||||
'industry' => 'restaurant',
|
||||
'appointment_mode' => 'hybrid',
|
||||
'branch_name' => 'Main',
|
||||
'timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
$beforeQueues = ServiceQueue::where('organization_id', $org->id)->count();
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('qms.settings.update'), [
|
||||
'name' => $org->name,
|
||||
'timezone' => $org->timezone,
|
||||
'appointment_mode' => 'hybrid',
|
||||
'industry' => 'banking',
|
||||
'notifications_enabled' => '1',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$org->refresh();
|
||||
$this->assertSame('banking', data_get($org->settings, 'industry'));
|
||||
$this->assertSame('restaurant', data_get($org->settings, 'industry_package.key'));
|
||||
$this->assertTrue((bool) data_get($org->settings, 'industry_package_needs_reinstall'));
|
||||
$this->assertTrue(IndustryPackageInstaller::needsReinstall($org));
|
||||
$this->assertSame($beforeQueues, ServiceQueue::where('organization_id', $org->id)->count());
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('qms.dashboard'))
|
||||
->assertOk()
|
||||
->assertSee('Reinstall industry package required');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user