Add industry packages that provision workflow stages on onboarding.
Deploy Ladill Queue / deploy (push) Successful in 1m39s
Deploy Ladill Queue / deploy (push) Successful in 1m39s
Queue Core stays generic; selecting Healthcare, Banking, Restaurant, and other industries installs departments, stages (queues), service points, workflows, terminology, and announcement profiles without code changes per vertical. Care-linked orgs skip stage materialization so Care remains the clinical provisioner. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,9 +5,10 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Branch;
|
use App\Models\Branch;
|
||||||
use App\Models\Department;
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Services\Qms\AuditLogger;
|
use App\Services\Qms\AuditLogger;
|
||||||
|
use App\Services\Qms\Industry\IndustryPackageInstaller;
|
||||||
|
use App\Services\Qms\Industry\IndustryPackageRegistry;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -36,6 +37,8 @@ class IntegrationController extends Controller
|
|||||||
'provisioned' => true,
|
'provisioned' => true,
|
||||||
'onboarded' => (bool) data_get($organization->settings, 'onboarded', false),
|
'onboarded' => (bool) data_get($organization->settings, 'onboarded', false),
|
||||||
'organization_name' => $organization->name,
|
'organization_name' => $organization->name,
|
||||||
|
'industry' => data_get($organization->settings, 'industry'),
|
||||||
|
'industry_package' => data_get($organization->settings, 'industry_package.key'),
|
||||||
'integrations' => [
|
'integrations' => [
|
||||||
'care' => (bool) data_get($organization->settings, 'integrations.care_enabled', false),
|
'care' => (bool) data_get($organization->settings, 'integrations.care_enabled', false),
|
||||||
'frontdesk' => (bool) data_get($organization->settings, 'integrations.frontdesk_enabled', false),
|
'frontdesk' => (bool) data_get($organization->settings, 'integrations.frontdesk_enabled', false),
|
||||||
@@ -48,6 +51,7 @@ class IntegrationController extends Controller
|
|||||||
{
|
{
|
||||||
$owner = $this->ownerRef($request);
|
$owner = $this->ownerRef($request);
|
||||||
$caller = (string) $request->attributes->get('service_caller', '');
|
$caller = (string) $request->attributes->get('service_caller', '');
|
||||||
|
$registry = app(IndustryPackageRegistry::class);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'organization_name' => ['nullable', 'string', 'max:255'],
|
'organization_name' => ['nullable', 'string', 'max:255'],
|
||||||
@@ -56,8 +60,17 @@ class IntegrationController extends Controller
|
|||||||
'industry' => ['nullable', 'string'],
|
'industry' => ['nullable', 'string'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$defaultIndustry = match ($caller) {
|
||||||
|
'care' => 'healthcare',
|
||||||
|
'frontdesk' => 'visitor',
|
||||||
|
'pos' => 'restaurant',
|
||||||
|
default => 'custom',
|
||||||
|
};
|
||||||
|
$industry = $registry->resolveKey($validated['industry'] ?? $defaultIndustry);
|
||||||
|
|
||||||
$organization = Organization::owned($owner)->first();
|
$organization = Organization::owned($owner)->first();
|
||||||
$created = false;
|
$created = false;
|
||||||
|
$branch = null;
|
||||||
|
|
||||||
if (! $organization) {
|
if (! $organization) {
|
||||||
$created = true;
|
$created = true;
|
||||||
@@ -69,7 +82,7 @@ class IntegrationController extends Controller
|
|||||||
'timezone' => $validated['timezone'] ?? config('app.timezone', 'UTC'),
|
'timezone' => $validated['timezone'] ?? config('app.timezone', 'UTC'),
|
||||||
'settings' => [
|
'settings' => [
|
||||||
'onboarded' => true,
|
'onboarded' => true,
|
||||||
'industry' => $validated['industry'] ?? ($caller === 'care' ? 'healthcare' : 'visitor'),
|
'industry' => $industry,
|
||||||
'appointment_mode' => 'hybrid',
|
'appointment_mode' => 'hybrid',
|
||||||
'integrations' => [
|
'integrations' => [
|
||||||
'care_enabled' => false,
|
'care_enabled' => false,
|
||||||
@@ -85,25 +98,47 @@ class IntegrationController extends Controller
|
|||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Department::create([
|
AuditLogger::record($owner, 'organization.created', $organization->id, $owner, Organization::class, $organization->id);
|
||||||
|
} else {
|
||||||
|
$branch = Branch::query()
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $branch) {
|
||||||
|
$branch = Branch::create([
|
||||||
'owner_ref' => $owner,
|
'owner_ref' => $owner,
|
||||||
'branch_id' => $branch->id,
|
'organization_id' => $organization->id,
|
||||||
'name' => 'Reception',
|
'name' => $validated['branch_name'] ?? 'Main branch',
|
||||||
'type' => 'reception',
|
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
AuditLogger::record($owner, 'organization.created', $organization->id, $owner, Organization::class, $organization->id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($caller, ['care', 'frontdesk'], true)) {
|
if (in_array($caller, ['care', 'frontdesk'], true)) {
|
||||||
$this->syncIntegrationFlag($organization->fresh(), $caller, true);
|
$this->syncIntegrationFlag($organization->fresh(), $caller, true);
|
||||||
|
$organization = $organization->fresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Care owns clinical stage graphs; still apply healthcare terminology/package metadata.
|
||||||
|
$packageResult = app(IndustryPackageInstaller::class)->install(
|
||||||
|
$organization->fresh(),
|
||||||
|
$branch,
|
||||||
|
$industry,
|
||||||
|
[
|
||||||
|
'actor_ref' => $owner,
|
||||||
|
'skip_stages' => $caller === 'care',
|
||||||
|
'include_optional' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => [
|
'data' => [
|
||||||
'provisioned' => true,
|
'provisioned' => true,
|
||||||
'organization_name' => $organization->fresh()->name,
|
'organization_name' => $organization->fresh()->name,
|
||||||
|
'industry' => $industry,
|
||||||
|
'industry_package' => $packageResult,
|
||||||
'integrations' => [
|
'integrations' => [
|
||||||
'care' => (bool) data_get($organization->fresh()->settings, 'integrations.care_enabled', false),
|
'care' => (bool) data_get($organization->fresh()->settings, 'integrations.care_enabled', false),
|
||||||
'frontdesk' => (bool) data_get($organization->fresh()->settings, 'integrations.frontdesk_enabled', false),
|
'frontdesk' => (bool) data_get($organization->fresh()->settings, 'integrations.frontdesk_enabled', false),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Qms;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
|
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
|
||||||
|
use App\Services\Qms\Industry\IndustryPackageRegistry;
|
||||||
use App\Services\Qms\OrganizationResolver;
|
use App\Services\Qms\OrganizationResolver;
|
||||||
use App\Support\OrganizationBranding;
|
use App\Support\OrganizationBranding;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -16,6 +17,7 @@ class OnboardingController extends Controller
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected OrganizationResolver $organizations,
|
protected OrganizationResolver $organizations,
|
||||||
|
protected IndustryPackageRegistry $packages,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function show(Request $request): View|RedirectResponse
|
public function show(Request $request): View|RedirectResponse
|
||||||
@@ -24,10 +26,18 @@ class OnboardingController extends Controller
|
|||||||
return redirect()->route('qms.dashboard');
|
return redirect()->route('qms.dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$industries = [];
|
||||||
|
foreach ($this->packages->all() as $package) {
|
||||||
|
$industries[$package->key] = [
|
||||||
|
'label' => $package->label(),
|
||||||
|
'description' => $package->description(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return view('qms.onboarding.show', [
|
return view('qms.onboarding.show', [
|
||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
'timezones' => timezone_identifiers_list(),
|
'timezones' => timezone_identifiers_list(),
|
||||||
'industries' => config('qms.industry_templates'),
|
'industries' => $industries,
|
||||||
'appointmentModes' => config('qms.appointment_modes'),
|
'appointmentModes' => config('qms.appointment_modes'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -40,7 +50,7 @@ class OnboardingController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'organization_name' => ['required', 'string', 'max:255'],
|
'organization_name' => ['required', 'string', 'max:255'],
|
||||||
'industry' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.industry_templates')))],
|
'industry' => ['required', 'string', 'in:'.implode(',', array_keys(config('industry_packages.packages', [])))],
|
||||||
'appointment_mode' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
|
'appointment_mode' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
|
||||||
'branch_name' => ['required', 'string', 'max:255'],
|
'branch_name' => ['required', 'string', 'max:255'],
|
||||||
'branch_address' => ['nullable', 'string', 'max:500'],
|
'branch_address' => ['nullable', 'string', 'max:500'],
|
||||||
@@ -57,6 +67,11 @@ class OnboardingController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->route('qms.dashboard')->with('success', 'Welcome to Ladill Queue!');
|
$packageLabel = $this->packages->get($validated['industry'])?->label() ?? 'your industry';
|
||||||
|
|
||||||
|
return redirect()->route('qms.dashboard')->with(
|
||||||
|
'success',
|
||||||
|
"Welcome to Ladill Queue — {$packageLabel} workflow provisioned."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
|
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
|
||||||
use App\Models\Branch;
|
use App\Models\Branch;
|
||||||
use App\Services\Qms\AuditLogger;
|
use App\Services\Qms\AuditLogger;
|
||||||
|
use App\Services\Qms\Industry\IndustryPackageInstaller;
|
||||||
|
use App\Services\Qms\Industry\IndustryPackageRegistry;
|
||||||
use App\Services\Qms\PlanService;
|
use App\Services\Qms\PlanService;
|
||||||
use App\Services\Qms\QmsPermissions;
|
use App\Services\Qms\QmsPermissions;
|
||||||
use App\Support\OrganizationBranding;
|
use App\Support\OrganizationBranding;
|
||||||
@@ -17,7 +19,7 @@ class SettingsController extends Controller
|
|||||||
{
|
{
|
||||||
use ScopesToAccount;
|
use ScopesToAccount;
|
||||||
|
|
||||||
public function edit(Request $request, PlanService $plans): View
|
public function edit(Request $request, PlanService $plans, IndustryPackageRegistry $packages): View
|
||||||
{
|
{
|
||||||
$this->authorizeAbility($request, 'settings.view');
|
$this->authorizeAbility($request, 'settings.view');
|
||||||
$organization = $this->organization($request);
|
$organization = $this->organization($request);
|
||||||
@@ -29,12 +31,19 @@ class SettingsController extends Controller
|
|||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
|
$packageKey = data_get($organization->settings, 'industry_package.key')
|
||||||
|
?? data_get($organization->settings, 'industry')
|
||||||
|
?? 'custom';
|
||||||
|
$activePackage = $packages->get($packageKey);
|
||||||
|
|
||||||
return view('qms.settings.edit', [
|
return view('qms.settings.edit', [
|
||||||
'organization' => $organization,
|
'organization' => $organization,
|
||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
'branchCount' => $branchCount,
|
'branchCount' => $branchCount,
|
||||||
'appointmentModes' => config('qms.appointment_modes'),
|
'appointmentModes' => config('qms.appointment_modes'),
|
||||||
'industries' => config('qms.industry_templates'),
|
'industries' => $packages->labels(),
|
||||||
|
'activePackage' => $activePackage,
|
||||||
|
'packageMeta' => data_get($organization->settings, 'industry_package'),
|
||||||
'hasPaidPlan' => $plans->hasPaidPlan($organization),
|
'hasPaidPlan' => $plans->hasPaidPlan($organization),
|
||||||
'isPro' => $plans->isPro($organization),
|
'isPro' => $plans->isPro($organization),
|
||||||
'isEnterprise' => $plans->isEnterprise($organization),
|
'isEnterprise' => $plans->isEnterprise($organization),
|
||||||
@@ -57,7 +66,7 @@ class SettingsController extends Controller
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'timezone' => ['required', 'timezone'],
|
'timezone' => ['required', 'timezone'],
|
||||||
'appointment_mode' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
|
'appointment_mode' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
|
||||||
'industry' => ['nullable', 'string'],
|
'industry' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('industry_packages.packages', [])))],
|
||||||
'notifications_enabled' => ['boolean'],
|
'notifications_enabled' => ['boolean'],
|
||||||
'care_integration_enabled' => ['nullable', 'boolean'],
|
'care_integration_enabled' => ['nullable', 'boolean'],
|
||||||
'frontdesk_integration_enabled' => ['nullable', 'boolean'],
|
'frontdesk_integration_enabled' => ['nullable', 'boolean'],
|
||||||
@@ -67,6 +76,7 @@ class SettingsController extends Controller
|
|||||||
|
|
||||||
$settings = $organization->settings ?? [];
|
$settings = $organization->settings ?? [];
|
||||||
$settings['appointment_mode'] = $validated['appointment_mode'];
|
$settings['appointment_mode'] = $validated['appointment_mode'];
|
||||||
|
$previousIndustry = $settings['industry'] ?? null;
|
||||||
$settings['industry'] = $validated['industry'] ?? $settings['industry'] ?? null;
|
$settings['industry'] = $validated['industry'] ?? $settings['industry'] ?? null;
|
||||||
$settings['notifications_enabled'] = $request->boolean('notifications_enabled', true);
|
$settings['notifications_enabled'] = $request->boolean('notifications_enabled', true);
|
||||||
$integrations = $settings['integrations'] ?? [];
|
$integrations = $settings['integrations'] ?? [];
|
||||||
@@ -98,6 +108,54 @@ class SettingsController extends Controller
|
|||||||
|
|
||||||
AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id);
|
AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id);
|
||||||
|
|
||||||
return back()->with('success', 'Settings saved.');
|
$industryChanged = $settings['industry'] && $settings['industry'] !== $previousIndustry;
|
||||||
|
if ($industryChanged) {
|
||||||
|
$branch = Branch::query()
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
if ($branch) {
|
||||||
|
app(IndustryPackageInstaller::class)->install($organization->fresh(), $branch, $settings['industry'], [
|
||||||
|
'actor_ref' => $owner,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', $industryChanged
|
||||||
|
? 'Settings saved. Industry package re-provisioned.'
|
||||||
|
: 'Settings saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reinstallPackage(Request $request, IndustryPackageInstaller $installer): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'settings.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$branch = Branch::query()
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $branch) {
|
||||||
|
return back()->with('error', 'Add a branch before reinstalling the industry package.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = data_get($organization->settings, 'industry', 'custom');
|
||||||
|
$result = $installer->install($organization, $branch, $key, [
|
||||||
|
'actor_ref' => $owner,
|
||||||
|
'include_optional' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$message = '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') {
|
||||||
|
$message .= ' Stages are managed by Ladill Care; terminology and announcements were updated.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', $message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms\Industry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view of a configured industry package.
|
||||||
|
*
|
||||||
|
* @phpstan-type StageDef array{
|
||||||
|
* code: string,
|
||||||
|
* department: string,
|
||||||
|
* name: string,
|
||||||
|
* prefix: string,
|
||||||
|
* strategy?: string,
|
||||||
|
* routing_mode?: string,
|
||||||
|
* routing_strategy?: string,
|
||||||
|
* optional?: bool,
|
||||||
|
* service_points?: list<array{name: string, destination?: string, code?: string}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
class IndustryPackage
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $definition
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $key,
|
||||||
|
public readonly array $definition,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->definition['label'] ?? $this->key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->definition['description'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketEntity(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->definition['ticket_entity'] ?? 'customer');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function displayLayout(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->definition['display_layout'] ?? 'standard');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function terminology(): array
|
||||||
|
{
|
||||||
|
return (array) ($this->definition['terminology'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function announcement(): array
|
||||||
|
{
|
||||||
|
return (array) ($this->definition['announcement'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function kpis(): array
|
||||||
|
{
|
||||||
|
return array_values((array) ($this->definition['kpis'] ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{name: string, description?: string}
|
||||||
|
*/
|
||||||
|
public function workflowMeta(): array
|
||||||
|
{
|
||||||
|
return (array) ($this->definition['workflow'] ?? ['name' => $this->label().' workflow']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{code: string, name: string, type: string}>
|
||||||
|
*/
|
||||||
|
public function departments(): array
|
||||||
|
{
|
||||||
|
return array_values((array) ($this->definition['departments'] ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<StageDef>
|
||||||
|
*/
|
||||||
|
public function stages(): array
|
||||||
|
{
|
||||||
|
return array_values((array) ($this->definition['stages'] ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function integrations(): array
|
||||||
|
{
|
||||||
|
return array_values((array) ($this->definition['integrations'] ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms\Industry;
|
||||||
|
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Counter;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
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} $options
|
||||||
|
* @return array{
|
||||||
|
* package: string,
|
||||||
|
* departments: int,
|
||||||
|
* stages: int,
|
||||||
|
* service_points: int,
|
||||||
|
* workflow_id: int|null,
|
||||||
|
* skipped_stages: list<string>,
|
||||||
|
* skipped_reason: string|null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
$actor = $options['actor_ref'] ?? $organization->owner_ref;
|
||||||
|
|
||||||
|
// Care (and similar) own clinical stage provisioning — still apply terminology.
|
||||||
|
if (! $skipStages && data_get($organization->settings, 'integrations.care_enabled') && $key === 'healthcare') {
|
||||||
|
$skipStages = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'package' => $key,
|
||||||
|
'departments' => 0,
|
||||||
|
'stages' => 0,
|
||||||
|
'service_points' => 0,
|
||||||
|
'workflow_id' => null,
|
||||||
|
'skipped_stages' => [],
|
||||||
|
'skipped_reason' => $skipStages ? 'stages_managed_by_integration' : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($organization, $branch, $package, $key, $skipStages, $includeOptional, $actor, &$result) {
|
||||||
|
$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;
|
||||||
|
$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(),
|
||||||
|
'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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms\Industry;
|
||||||
|
|
||||||
|
class IndustryPackageRegistry
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, string> key => label
|
||||||
|
*/
|
||||||
|
public function labels(): array
|
||||||
|
{
|
||||||
|
$labels = [];
|
||||||
|
foreach (array_keys(config('industry_packages.packages', [])) as $key) {
|
||||||
|
$package = $this->get($key);
|
||||||
|
if ($package) {
|
||||||
|
$labels[$key] = $package->label();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key): ?IndustryPackage
|
||||||
|
{
|
||||||
|
$definition = config("industry_packages.packages.{$key}");
|
||||||
|
if (! is_array($definition)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IndustryPackage($key, $definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(string $key): bool
|
||||||
|
{
|
||||||
|
return $this->get($key) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<IndustryPackage>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
$packages = [];
|
||||||
|
foreach (array_keys(config('industry_packages.packages', [])) as $key) {
|
||||||
|
$package = $this->get($key);
|
||||||
|
if ($package) {
|
||||||
|
$packages[] = $package;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveKey(?string $key): string
|
||||||
|
{
|
||||||
|
$key = $key ?: 'custom';
|
||||||
|
|
||||||
|
return $this->has($key) ? $key : 'custom';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,11 @@
|
|||||||
namespace App\Services\Qms;
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
use App\Models\Branch;
|
use App\Models\Branch;
|
||||||
use App\Models\Department;
|
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Qms\Industry\IndustryPackageInstaller;
|
||||||
|
use App\Services\Qms\Industry\IndustryPackageRegistry;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class OrganizationResolver
|
class OrganizationResolver
|
||||||
@@ -69,6 +70,8 @@ class OrganizationResolver
|
|||||||
public function completeOnboarding(User $user, array $data): Organization
|
public function completeOnboarding(User $user, array $data): Organization
|
||||||
{
|
{
|
||||||
$ref = $user->ownerRef();
|
$ref = $user->ownerRef();
|
||||||
|
$registry = app(IndustryPackageRegistry::class);
|
||||||
|
$industry = $registry->resolveKey($data['industry'] ?? 'custom');
|
||||||
|
|
||||||
$organization = Organization::create([
|
$organization = Organization::create([
|
||||||
'owner_ref' => $ref,
|
'owner_ref' => $ref,
|
||||||
@@ -77,7 +80,7 @@ class OrganizationResolver
|
|||||||
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
|
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
|
||||||
'settings' => [
|
'settings' => [
|
||||||
'onboarded' => true,
|
'onboarded' => true,
|
||||||
'industry' => $data['industry'] ?? 'custom',
|
'industry' => $industry,
|
||||||
'appointment_mode' => $data['appointment_mode'] ?? 'hybrid',
|
'appointment_mode' => $data['appointment_mode'] ?? 'hybrid',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
@@ -93,18 +96,15 @@ class OrganizationResolver
|
|||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Department::create([
|
|
||||||
'owner_ref' => $ref,
|
|
||||||
'branch_id' => $branch->id,
|
|
||||||
'name' => 'General Reception',
|
|
||||||
'type' => 'reception',
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id);
|
AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id);
|
||||||
AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id);
|
AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id);
|
||||||
|
|
||||||
return $organization;
|
app(IndustryPackageInstaller::class)->install($organization->fresh(), $branch, $industry, [
|
||||||
|
'actor_ref' => $ref,
|
||||||
|
'include_optional' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $organization->fresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function branchScope(?Member $member): ?int
|
public function branchScope(?Member $member): ?int
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class VoiceAnnouncementService
|
|||||||
public function announceCalled(Ticket $ticket, Counter $counter, ?string $locale = null): VoiceAnnouncement
|
public function announceCalled(Ticket $ticket, Counter $counter, ?string $locale = null): VoiceAnnouncement
|
||||||
{
|
{
|
||||||
$locale ??= data_get($ticket->serviceQueue?->settings, 'announcement_locale', 'en');
|
$locale ??= data_get($ticket->serviceQueue?->settings, 'announcement_locale', 'en');
|
||||||
|
$ticket->loadMissing(['organization', 'serviceQueue']);
|
||||||
$message = $this->buildMessage($ticket, $counter, $locale);
|
$message = $this->buildMessage($ticket, $counter, $locale);
|
||||||
|
|
||||||
return VoiceAnnouncement::create([
|
return VoiceAnnouncement::create([
|
||||||
@@ -29,27 +30,37 @@ class VoiceAnnouncementService
|
|||||||
|
|
||||||
protected function buildMessage(Ticket $ticket, Counter $counter, string $locale): string
|
protected function buildMessage(Ticket $ticket, Counter $counter, string $locale): string
|
||||||
{
|
{
|
||||||
$templates = config('qms.announcement_templates');
|
|
||||||
$template = $templates[$locale] ?? $templates['en'];
|
|
||||||
$destination = $counter->displayDestination();
|
$destination = $counter->displayDestination();
|
||||||
$staff = trim((string) ($counter->staff_display_name ?? ''));
|
$staff = trim((string) ($counter->staff_display_name ?? ''));
|
||||||
$queueName = trim((string) ($ticket->serviceQueue?->name ?? ''));
|
$queueName = trim((string) ($ticket->serviceQueue?->name ?? ''));
|
||||||
|
|
||||||
// Prefer rich healthcare-style copy when staff is known:
|
$industryAnnouncement = (array) data_get($ticket->organization?->settings, 'industry_package.announcement', []);
|
||||||
// "A005, Dr. Mensah, Consultation Room 4."
|
$global = config('qms.announcement_templates', []);
|
||||||
if ($staff !== '') {
|
|
||||||
$parts = array_values(array_filter([
|
|
||||||
$ticket->ticket_number,
|
|
||||||
$staff,
|
|
||||||
$queueName !== '' ? $queueName.' '.$destination : $destination,
|
|
||||||
]));
|
|
||||||
|
|
||||||
return implode(', ', $parts).'.';
|
if ($staff !== '') {
|
||||||
|
$richDestination = $queueName !== '' && ! str_contains(strtolower($destination), strtolower($queueName))
|
||||||
|
? trim($queueName.' '.$destination)
|
||||||
|
: $destination;
|
||||||
|
$template = $industryAnnouncement['with_staff']
|
||||||
|
?? $global['with_staff']
|
||||||
|
?? ':ticket, :staff, :destination.';
|
||||||
|
|
||||||
|
return rtrim(str_replace(
|
||||||
|
[':ticket', ':staff', ':destination', ':counter'],
|
||||||
|
[$ticket->ticket_number, $staff, $richDestination, $richDestination],
|
||||||
|
$template,
|
||||||
|
), '.').'.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$template = $industryAnnouncement[$locale]
|
||||||
|
?? $industryAnnouncement['en']
|
||||||
|
?? $global[$locale]
|
||||||
|
?? $global['en']
|
||||||
|
?? 'Ticket :ticket, please proceed to :destination.';
|
||||||
|
|
||||||
return str_replace(
|
return str_replace(
|
||||||
[':ticket', ':counter'],
|
[':ticket', ':destination', ':counter'],
|
||||||
[$ticket->ticket_number, $destination],
|
[$ticket->ticket_number, $destination, $destination],
|
||||||
$template,
|
$template,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+45
-9
@@ -26,6 +26,10 @@ return [
|
|||||||
'payments' => 'Payments',
|
'payments' => 'Payments',
|
||||||
'collections' => 'Collections',
|
'collections' => 'Collections',
|
||||||
'technical_support' => 'Technical Support',
|
'technical_support' => 'Technical Support',
|
||||||
|
'security' => 'Security',
|
||||||
|
'triage' => 'Triage',
|
||||||
|
'imaging' => 'Imaging',
|
||||||
|
'billing' => 'Billing',
|
||||||
],
|
],
|
||||||
|
|
||||||
'queue_strategies' => [
|
'queue_strategies' => [
|
||||||
@@ -100,22 +104,47 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
'announcement_templates' => [
|
'announcement_templates' => [
|
||||||
'en' => 'Ticket :ticket, please proceed to :counter.',
|
'en' => 'Ticket :ticket, please proceed to :destination.',
|
||||||
'fr' => 'Ticket :ticket, veuillez vous rendre au :counter.',
|
'fr' => 'Ticket :ticket, veuillez vous rendre au :destination.',
|
||||||
'tw' => 'Ticket :ticket, mesrɛ kɔ :counter.',
|
'tw' => 'Ticket :ticket, mesrɛ kɔ :destination.',
|
||||||
|
'with_staff' => ':ticket, :staff, :destination.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Industry labels (package definitions live in config/industry_packages.php)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
'industry_templates' => [
|
'industry_templates' => [
|
||||||
'healthcare' => 'Healthcare',
|
'healthcare' => 'Healthcare',
|
||||||
|
'restaurant' => 'Restaurant',
|
||||||
'banking' => 'Banking',
|
'banking' => 'Banking',
|
||||||
'government' => 'Government Office',
|
'government' => 'Government Office',
|
||||||
'visitor' => 'Visitor Management',
|
'retail_service' => 'Retail Service Centre',
|
||||||
|
'vehicle' => 'Vehicle Service Centre',
|
||||||
|
'hospitality' => 'Hotel / Hospitality',
|
||||||
|
'logistics' => 'Logistics',
|
||||||
'university' => 'University',
|
'university' => 'University',
|
||||||
'retail' => 'Retail',
|
|
||||||
'telecom' => 'Telecom Service Centre',
|
'telecom' => 'Telecom Service Centre',
|
||||||
|
'events' => 'Events',
|
||||||
|
'church' => 'Church',
|
||||||
|
'visitor' => 'Visitor Management',
|
||||||
|
'retail' => 'Retail',
|
||||||
'custom' => 'Custom',
|
'custom' => 'Custom',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'routing_strategies' => [
|
||||||
|
'round_robin' => 'Round robin',
|
||||||
|
'least_busy' => 'Least busy',
|
||||||
|
'skill_based' => 'Skill based',
|
||||||
|
'doctor_assignment' => 'Doctor assignment',
|
||||||
|
'manual_assignment' => 'Manual assignment',
|
||||||
|
'priority_assignment' => 'Priority assignment',
|
||||||
|
'appointment_based' => 'Appointment based',
|
||||||
|
'random' => 'Random',
|
||||||
|
'department_rules' => 'Department rules',
|
||||||
|
],
|
||||||
|
|
||||||
'audit_actions' => [
|
'audit_actions' => [
|
||||||
'organization.created' => 'Organization created',
|
'organization.created' => 'Organization created',
|
||||||
'organization.updated' => 'Organization updated',
|
'organization.updated' => 'Organization updated',
|
||||||
@@ -247,12 +276,17 @@ return [
|
|||||||
'capacity' => 'Capacity limit',
|
'capacity' => 'Capacity limit',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Legacy thin workflow name lists — prefer industry_packages.stages for installs.
|
||||||
|
*/
|
||||||
'workflow_templates' => [
|
'workflow_templates' => [
|
||||||
'healthcare' => [
|
'healthcare' => [
|
||||||
['name' => 'Registration', 'sort_order' => 1],
|
['name' => 'Registration', 'sort_order' => 1],
|
||||||
['name' => 'Triage', 'sort_order' => 2],
|
['name' => 'Triage', 'sort_order' => 2],
|
||||||
['name' => 'Consultation', 'sort_order' => 3],
|
['name' => 'Consultation', 'sort_order' => 3],
|
||||||
['name' => 'Pharmacy', 'sort_order' => 4],
|
['name' => 'Laboratory', 'sort_order' => 4],
|
||||||
|
['name' => 'Pharmacy', 'sort_order' => 5],
|
||||||
|
['name' => 'Billing', 'sort_order' => 6],
|
||||||
],
|
],
|
||||||
'banking' => [
|
'banking' => [
|
||||||
['name' => 'Reception', 'sort_order' => 1],
|
['name' => 'Reception', 'sort_order' => 1],
|
||||||
@@ -260,9 +294,11 @@ return [
|
|||||||
['name' => 'Customer service', 'sort_order' => 3],
|
['name' => 'Customer service', 'sort_order' => 3],
|
||||||
],
|
],
|
||||||
'government' => [
|
'government' => [
|
||||||
['name' => 'Document check', 'sort_order' => 1],
|
['name' => 'Reception', 'sort_order' => 1],
|
||||||
['name' => 'Processing', 'sort_order' => 2],
|
['name' => 'Verification', 'sort_order' => 2],
|
||||||
['name' => 'Collection', 'sort_order' => 3],
|
['name' => 'Processing', 'sort_order' => 3],
|
||||||
|
['name' => 'Payment', 'sort_order' => 4],
|
||||||
|
['name' => 'Collection', 'sort_order' => 5],
|
||||||
],
|
],
|
||||||
'custom' => [],
|
'custom' => [],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<x-app-layout title="Set up Queue">
|
<x-app-layout title="Set up Queue">
|
||||||
<div class="mx-auto max-w-xl">
|
<div class="mx-auto max-w-xl">
|
||||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">Welcome to Ladill Queue</h1>
|
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">Welcome to Ladill Queue</h1>
|
||||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Configure your organization to start managing customer flow.</p>
|
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Choose your industry and we will provision departments, workflow stages, service points, announcements, and dashboards for you.
|
||||||
|
</p>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('qms.onboarding.store') }}" enctype="multipart/form-data" class="mt-8 space-y-5 rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
<form method="POST" action="{{ route('qms.onboarding.store') }}" enctype="multipart/form-data" class="mt-8 space-y-5 rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||||
@csrf
|
@csrf
|
||||||
@@ -14,11 +16,14 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Industry</label>
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Industry</label>
|
||||||
<select name="industry" class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
<select name="industry" id="industry" class="mt-1 w-full rounded-lg border-slate-300 text-sm dark:border-slate-600 dark:bg-slate-800">
|
||||||
@foreach ($industries as $value => $label)
|
@foreach ($industries as $value => $meta)
|
||||||
<option value="{{ $value }}" @selected(old('industry', 'custom') === $value)>{{ $label }}</option>
|
<option value="{{ $value }}"
|
||||||
|
data-description="{{ $meta['description'] }}"
|
||||||
|
@selected(old('industry', 'custom') === $value)>{{ $meta['label'] }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
|
<p id="industry-description" class="mt-2 text-xs text-slate-500 dark:text-slate-400"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -68,4 +73,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const select = document.getElementById('industry');
|
||||||
|
const desc = document.getElementById('industry-description');
|
||||||
|
if (!select || !desc) return;
|
||||||
|
const sync = () => {
|
||||||
|
const option = select.options[select.selectedIndex];
|
||||||
|
desc.textContent = option?.dataset?.description || '';
|
||||||
|
};
|
||||||
|
select.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -43,12 +43,22 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">Industry</label>
|
<label class="block text-sm font-medium text-slate-700">Industry package</label>
|
||||||
<select name="industry" @disabled(! $canManage) class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="industry" @disabled(! $canManage) class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
@foreach ($industries as $v => $l)
|
@foreach ($industries as $v => $l)
|
||||||
<option value="{{ $v }}" @selected(($organization->settings['industry'] ?? '') === $v)>{{ $l }}</option>
|
<option value="{{ $v }}" @selected(($organization->settings['industry'] ?? '') === $v)>{{ $l }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
|
@if ($activePackage)
|
||||||
|
<p class="mt-2 text-xs text-slate-500">{{ $activePackage->description() }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Ticket entity: <span class="font-medium text-slate-700">{{ $activePackage->ticketEntity() }}</span>
|
||||||
|
· Stages installed: {{ count(data_get($packageMeta, 'stages_installed', [])) }}
|
||||||
|
@if (data_get($packageMeta, 'skipped_stages'))
|
||||||
|
· Skipped: {{ implode(', ', data_get($packageMeta, 'skipped_stages', [])) }}
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" name="notifications_enabled" value="1" @checked($organization->settings['notifications_enabled'] ?? true) @disabled(! $canManage)>
|
<input type="checkbox" name="notifications_enabled" value="1" @checked($organization->settings['notifications_enabled'] ?? true) @disabled(! $canManage)>
|
||||||
@@ -111,6 +121,22 @@
|
|||||||
</x-settings.card>
|
</x-settings.card>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($canManage && $activePackage)
|
||||||
|
<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.">
|
||||||
|
<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' }}).
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="{{ route('qms.settings.package.reinstall') }}">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="btn-secondary shrink-0 text-sm">Reinstall package</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</x-settings.card>
|
||||||
|
@endif
|
||||||
|
|
||||||
<x-settings.card title="Integrations" description="Allow Ladill Care and Frontdesk to manage queues in-app via the Queue API. Both apps must also enable integration in their settings.">
|
<x-settings.card title="Integrations" description="Allow Ladill Care and Frontdesk to manage queues in-app via the Queue API. Both apps must also enable integration in their settings.">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
|||||||
|
|
||||||
Route::get('/settings', [SettingsController::class, 'edit'])->name('qms.settings.edit');
|
Route::get('/settings', [SettingsController::class, 'edit'])->name('qms.settings.edit');
|
||||||
Route::put('/settings', [SettingsController::class, 'update'])->name('qms.settings.update');
|
Route::put('/settings', [SettingsController::class, 'update'])->name('qms.settings.update');
|
||||||
|
Route::post('/settings/industry-package', [SettingsController::class, 'reinstallPackage'])->name('qms.settings.package.reinstall');
|
||||||
|
|
||||||
Route::get('/settings/branches', [BranchController::class, 'index'])->name('qms.branches.index');
|
Route::get('/settings/branches', [BranchController::class, 'index'])->name('qms.branches.index');
|
||||||
Route::get('/settings/branches/create', [BranchController::class, 'create'])->name('qms.branches.create');
|
Route::get('/settings/branches/create', [BranchController::class, 'create'])->name('qms.branches.create');
|
||||||
|
|||||||
@@ -33,12 +33,19 @@ class DisplayVoiceTest extends TestCase
|
|||||||
$resolver = app(OrganizationResolver::class);
|
$resolver = app(OrganizationResolver::class);
|
||||||
$org = $resolver->completeOnboarding($user, [
|
$org = $resolver->completeOnboarding($user, [
|
||||||
'organization_name' => 'Test Org',
|
'organization_name' => 'Test Org',
|
||||||
'industry' => 'retail',
|
'industry' => 'custom',
|
||||||
'appointment_mode' => 'hybrid',
|
'appointment_mode' => 'hybrid',
|
||||||
'branch_name' => 'Main',
|
'branch_name' => 'Main',
|
||||||
'timezone' => 'UTC',
|
'timezone' => 'UTC',
|
||||||
]);
|
]);
|
||||||
$branch = Branch::first();
|
$branch = Branch::first();
|
||||||
|
$queue = ServiceQueue::query()
|
||||||
|
->where('organization_id', $org->id)
|
||||||
|
->where('branch_id', $branch->id)
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $queue) {
|
||||||
$queue = ServiceQueue::create([
|
$queue = ServiceQueue::create([
|
||||||
'owner_ref' => $user->public_id,
|
'owner_ref' => $user->public_id,
|
||||||
'organization_id' => $org->id,
|
'organization_id' => $org->id,
|
||||||
@@ -48,6 +55,10 @@ class DisplayVoiceTest extends TestCase
|
|||||||
'strategy' => 'fifo',
|
'strategy' => 'fifo',
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
} else {
|
||||||
|
$queue->update(['name' => 'Reception', 'prefix' => 'A']);
|
||||||
|
$queue = $queue->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
return [$user, $org, $branch, $queue];
|
return [$user, $org, $branch, $queue];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Counter;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workflow;
|
||||||
|
use App\Services\Qms\Industry\IndustryPackageInstaller;
|
||||||
|
use App\Services\Qms\Industry\IndustryPackageRegistry;
|
||||||
|
use App\Services\Qms\OrganizationResolver;
|
||||||
|
use App\Services\Qms\QueueEngine;
|
||||||
|
use App\Services\Qms\VoiceAnnouncementService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class IndustryPackageTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_registry_lists_core_packages(): void
|
||||||
|
{
|
||||||
|
$registry = app(IndustryPackageRegistry::class);
|
||||||
|
|
||||||
|
$this->assertTrue($registry->has('healthcare'));
|
||||||
|
$this->assertTrue($registry->has('banking'));
|
||||||
|
$this->assertTrue($registry->has('restaurant'));
|
||||||
|
$this->assertTrue($registry->has('custom'));
|
||||||
|
$this->assertSame('Healthcare', $registry->get('healthcare')->label());
|
||||||
|
$this->assertSame('patient', $registry->get('healthcare')->ticketEntity());
|
||||||
|
$this->assertSame('order', $registry->get('restaurant')->ticketEntity());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_onboarding_banking_provisions_stages_and_workflow(): void
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'public_id' => 'pkg-bank-owner',
|
||||||
|
'name' => 'Bank Owner',
|
||||||
|
'email' => 'bank-owner@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
|
||||||
|
'organization_name' => 'Ridge Bank',
|
||||||
|
'industry' => 'banking',
|
||||||
|
'appointment_mode' => 'hybrid',
|
||||||
|
'branch_name' => 'Main Branch',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('banking', data_get($org->settings, 'industry'));
|
||||||
|
$this->assertSame('banking', data_get($org->settings, 'industry_package.key'));
|
||||||
|
$this->assertSame('customer', data_get($org->settings, 'industry_package.ticket_entity'));
|
||||||
|
|
||||||
|
$branch = Branch::where('organization_id', $org->id)->first();
|
||||||
|
$this->assertNotNull($branch);
|
||||||
|
|
||||||
|
$queues = ServiceQueue::where('organization_id', $org->id)->where('branch_id', $branch->id)->get();
|
||||||
|
$this->assertGreaterThanOrEqual(3, $queues->count());
|
||||||
|
$this->assertTrue($queues->contains(fn (ServiceQueue $q) => $q->prefix === 'T'));
|
||||||
|
|
||||||
|
$teller = $queues->firstWhere('prefix', 'T');
|
||||||
|
$this->assertSame('shared_pool', $teller->routingMode());
|
||||||
|
$this->assertGreaterThanOrEqual(1, $teller->counters()->count());
|
||||||
|
|
||||||
|
$workflow = Workflow::where('organization_id', $org->id)->where('industry_template', 'banking')->first();
|
||||||
|
$this->assertNotNull($workflow);
|
||||||
|
$this->assertGreaterThanOrEqual(3, $workflow->steps()->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_healthcare_uses_assigned_only_for_consultation(): void
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'public_id' => 'pkg-care-owner',
|
||||||
|
'name' => 'Clinic Owner',
|
||||||
|
'email' => 'clinic-owner@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Pro plan so all healthcare stages fit under queue caps.
|
||||||
|
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
|
||||||
|
'organization_name' => 'Ridge Clinic',
|
||||||
|
'industry' => 'custom',
|
||||||
|
'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();
|
||||||
|
$result = app(IndustryPackageInstaller::class)->install($org->fresh(), $branch, 'healthcare');
|
||||||
|
|
||||||
|
$this->assertSame('healthcare', $result['package']);
|
||||||
|
$this->assertGreaterThanOrEqual(5, $result['stages']);
|
||||||
|
|
||||||
|
$consultation = ServiceQueue::query()
|
||||||
|
->where('organization_id', $org->id)
|
||||||
|
->where('settings->stage_code', 'consultation')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$this->assertNotNull($consultation);
|
||||||
|
$this->assertSame('assigned_only', $consultation->routingMode());
|
||||||
|
$this->assertSame('C', $consultation->prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_package_install_is_idempotent(): void
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'public_id' => 'pkg-idem-owner',
|
||||||
|
'name' => 'Idem Owner',
|
||||||
|
'email' => 'idem@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
|
||||||
|
'organization_name' => 'Idem Org',
|
||||||
|
'industry' => 'visitor',
|
||||||
|
'appointment_mode' => 'hybrid',
|
||||||
|
'branch_name' => 'Main',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
]);
|
||||||
|
$branch = Branch::where('organization_id', $org->id)->first();
|
||||||
|
$installer = app(IndustryPackageInstaller::class);
|
||||||
|
|
||||||
|
$first = $installer->install($org, $branch, 'visitor');
|
||||||
|
$second = $installer->install($org->fresh(), $branch, 'visitor');
|
||||||
|
|
||||||
|
$this->assertSame($first['stages'], $second['stages']);
|
||||||
|
$this->assertSame(
|
||||||
|
ServiceQueue::where('organization_id', $org->id)->count(),
|
||||||
|
ServiceQueue::where('organization_id', $org->id)->count(),
|
||||||
|
);
|
||||||
|
$this->assertSame(2, ServiceQueue::where('organization_id', $org->id)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_industry_announcement_uses_package_copy(): void
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'public_id' => 'pkg-announce-owner',
|
||||||
|
'name' => 'Announce Owner',
|
||||||
|
'email' => 'announce@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
|
||||||
|
'organization_name' => 'Kitchen Co',
|
||||||
|
'industry' => 'restaurant',
|
||||||
|
'appointment_mode' => 'walk_in_only',
|
||||||
|
'branch_name' => 'Main',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$queue = ServiceQueue::where('organization_id', $org->id)->where('prefix', 'U')->first()
|
||||||
|
?? ServiceQueue::where('organization_id', $org->id)->first();
|
||||||
|
$counter = $queue->counters()->first() ?? Counter::where('organization_id', $org->id)->first();
|
||||||
|
|
||||||
|
$ticket = app(QueueEngine::class)->issueTicket($queue, $user->public_id, [
|
||||||
|
'source' => 'api',
|
||||||
|
'customer_name' => 'Table 4',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$announcement = app(VoiceAnnouncementService::class)->announceCalled($ticket->fresh(['organization', 'serviceQueue']), $counter);
|
||||||
|
|
||||||
|
$this->assertStringContainsString($ticket->ticket_number, $announcement->message);
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains(strtolower($announcement->message), 'order')
|
||||||
|
|| str_contains(strtolower($announcement->message), 'pickup')
|
||||||
|
|| str_contains($announcement->message, $counter->displayDestination()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_care_integration_skips_stage_materialization(): void
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'public_id' => 'pkg-care-skip',
|
||||||
|
'name' => 'Care Skip',
|
||||||
|
'email' => 'care-skip@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
|
||||||
|
'organization_name' => 'Care Linked',
|
||||||
|
'industry' => 'custom',
|
||||||
|
'appointment_mode' => 'hybrid',
|
||||||
|
'branch_name' => 'Main',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
]);
|
||||||
|
$org->update([
|
||||||
|
'settings' => array_merge($org->settings ?? [], [
|
||||||
|
'integrations' => ['care_enabled' => true],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
$before = ServiceQueue::where('organization_id', $org->id)->count();
|
||||||
|
$branch = Branch::where('organization_id', $org->id)->first();
|
||||||
|
|
||||||
|
$result = app(IndustryPackageInstaller::class)->install($org->fresh(), $branch, 'healthcare');
|
||||||
|
|
||||||
|
$this->assertSame('stages_managed_by_integration', $result['skipped_reason']);
|
||||||
|
$this->assertSame(0, $result['stages']);
|
||||||
|
$this->assertSame($before, ServiceQueue::where('organization_id', $org->id)->count());
|
||||||
|
$this->assertSame('patient', data_get($org->fresh()->settings, 'industry_package.ticket_entity'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user