Add industry packages that provision workflow stages on onboarding.
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:
isaacclad
2026-07-17 18:00:31 +00:00
co-authored by Cursor
parent 0854b431bc
commit a4d8775a82
15 changed files with 2302 additions and 65 deletions
@@ -5,9 +5,10 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Organization;
use App\Services\Qms\AuditLogger;
use App\Services\Qms\Industry\IndustryPackageInstaller;
use App\Services\Qms\Industry\IndustryPackageRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -36,6 +37,8 @@ class IntegrationController extends Controller
'provisioned' => true,
'onboarded' => (bool) data_get($organization->settings, 'onboarded', false),
'organization_name' => $organization->name,
'industry' => data_get($organization->settings, 'industry'),
'industry_package' => data_get($organization->settings, 'industry_package.key'),
'integrations' => [
'care' => (bool) data_get($organization->settings, 'integrations.care_enabled', false),
'frontdesk' => (bool) data_get($organization->settings, 'integrations.frontdesk_enabled', false),
@@ -48,6 +51,7 @@ class IntegrationController extends Controller
{
$owner = $this->ownerRef($request);
$caller = (string) $request->attributes->get('service_caller', '');
$registry = app(IndustryPackageRegistry::class);
$validated = $request->validate([
'organization_name' => ['nullable', 'string', 'max:255'],
@@ -56,8 +60,17 @@ class IntegrationController extends Controller
'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();
$created = false;
$branch = null;
if (! $organization) {
$created = true;
@@ -69,7 +82,7 @@ class IntegrationController extends Controller
'timezone' => $validated['timezone'] ?? config('app.timezone', 'UTC'),
'settings' => [
'onboarded' => true,
'industry' => $validated['industry'] ?? ($caller === 'care' ? 'healthcare' : 'visitor'),
'industry' => $industry,
'appointment_mode' => 'hybrid',
'integrations' => [
'care_enabled' => false,
@@ -85,25 +98,47 @@ class IntegrationController extends Controller
'is_active' => true,
]);
Department::create([
'owner_ref' => $owner,
'branch_id' => $branch->id,
'name' => 'Reception',
'type' => 'reception',
'is_active' => true,
]);
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,
'organization_id' => $organization->id,
'name' => $validated['branch_name'] ?? 'Main branch',
'is_active' => true,
]);
}
}
if (in_array($caller, ['care', 'frontdesk'], 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([
'data' => [
'provisioned' => true,
'organization_name' => $organization->fresh()->name,
'industry' => $industry,
'industry_package' => $packageResult,
'integrations' => [
'care' => (bool) data_get($organization->fresh()->settings, 'integrations.care_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\Qms\Concerns\ScopesToAccount;
use App\Services\Qms\Industry\IndustryPackageRegistry;
use App\Services\Qms\OrganizationResolver;
use App\Support\OrganizationBranding;
use Illuminate\Http\RedirectResponse;
@@ -16,6 +17,7 @@ class OnboardingController extends Controller
public function __construct(
protected OrganizationResolver $organizations,
protected IndustryPackageRegistry $packages,
) {}
public function show(Request $request): View|RedirectResponse
@@ -24,10 +26,18 @@ class OnboardingController extends Controller
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', [
'user' => $request->user(),
'timezones' => timezone_identifiers_list(),
'industries' => config('qms.industry_templates'),
'industries' => $industries,
'appointmentModes' => config('qms.appointment_modes'),
]);
}
@@ -40,7 +50,7 @@ class OnboardingController extends Controller
$validated = $request->validate([
'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')))],
'branch_name' => ['required', 'string', 'max:255'],
'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\Models\Branch;
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\QmsPermissions;
use App\Support\OrganizationBranding;
@@ -17,7 +19,7 @@ class SettingsController extends Controller
{
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');
$organization = $this->organization($request);
@@ -29,12 +31,19 @@ class SettingsController extends Controller
->where('organization_id', $organization->id)
->count();
$packageKey = data_get($organization->settings, 'industry_package.key')
?? data_get($organization->settings, 'industry')
?? 'custom';
$activePackage = $packages->get($packageKey);
return view('qms.settings.edit', [
'organization' => $organization,
'canManage' => $canManage,
'branchCount' => $branchCount,
'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),
'isPro' => $plans->isPro($organization),
'isEnterprise' => $plans->isEnterprise($organization),
@@ -57,7 +66,7 @@ class SettingsController extends Controller
'name' => ['required', 'string', 'max:255'],
'timezone' => ['required', 'timezone'],
'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'],
'care_integration_enabled' => ['nullable', 'boolean'],
'frontdesk_integration_enabled' => ['nullable', 'boolean'],
@@ -67,6 +76,7 @@ class SettingsController extends Controller
$settings = $organization->settings ?? [];
$settings['appointment_mode'] = $validated['appointment_mode'];
$previousIndustry = $settings['industry'] ?? null;
$settings['industry'] = $validated['industry'] ?? $settings['industry'] ?? null;
$settings['notifications_enabled'] = $request->boolean('notifications_enabled', true);
$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);
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';
}
}
+11 -11
View File
@@ -3,10 +3,11 @@
namespace App\Services\Qms;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Services\Qms\Industry\IndustryPackageInstaller;
use App\Services\Qms\Industry\IndustryPackageRegistry;
use Illuminate\Support\Str;
class OrganizationResolver
@@ -69,6 +70,8 @@ class OrganizationResolver
public function completeOnboarding(User $user, array $data): Organization
{
$ref = $user->ownerRef();
$registry = app(IndustryPackageRegistry::class);
$industry = $registry->resolveKey($data['industry'] ?? 'custom');
$organization = Organization::create([
'owner_ref' => $ref,
@@ -77,7 +80,7 @@ class OrganizationResolver
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
'settings' => [
'onboarded' => true,
'industry' => $data['industry'] ?? 'custom',
'industry' => $industry,
'appointment_mode' => $data['appointment_mode'] ?? 'hybrid',
],
]);
@@ -93,18 +96,15 @@ class OrganizationResolver
'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, '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
+24 -13
View File
@@ -11,6 +11,7 @@ class VoiceAnnouncementService
public function announceCalled(Ticket $ticket, Counter $counter, ?string $locale = null): VoiceAnnouncement
{
$locale ??= data_get($ticket->serviceQueue?->settings, 'announcement_locale', 'en');
$ticket->loadMissing(['organization', 'serviceQueue']);
$message = $this->buildMessage($ticket, $counter, $locale);
return VoiceAnnouncement::create([
@@ -29,27 +30,37 @@ class VoiceAnnouncementService
protected function buildMessage(Ticket $ticket, Counter $counter, string $locale): string
{
$templates = config('qms.announcement_templates');
$template = $templates[$locale] ?? $templates['en'];
$destination = $counter->displayDestination();
$staff = trim((string) ($counter->staff_display_name ?? ''));
$queueName = trim((string) ($ticket->serviceQueue?->name ?? ''));
// Prefer rich healthcare-style copy when staff is known:
// "A005, Dr. Mensah, Consultation Room 4."
if ($staff !== '') {
$parts = array_values(array_filter([
$ticket->ticket_number,
$staff,
$queueName !== '' ? $queueName.' '.$destination : $destination,
]));
$industryAnnouncement = (array) data_get($ticket->organization?->settings, 'industry_package.announcement', []);
$global = config('qms.announcement_templates', []);
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(
[':ticket', ':counter'],
[$ticket->ticket_number, $destination],
[':ticket', ':destination', ':counter'],
[$ticket->ticket_number, $destination, $destination],
$template,
);
}