Deploy Ladill Care / deploy (push) Successful in 37s
Give nurses, lab techs, pharmacists, and GPs broad access to day-to-day modules, keep restricted specialties for specialists (with support nurses), and let GPs view results or refer without full clinical edit. Co-authored-by: Cursor <cursoragent@cursor.com>
690 lines
23 KiB
PHP
690 lines
23 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Care;
|
|
|
|
use App\Models\Branch;
|
|
use App\Models\CareServiceQueue;
|
|
use App\Models\Department;
|
|
use App\Models\Member;
|
|
use App\Models\Organization;
|
|
use App\Models\Practitioner;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Activate/deactivate specialty practice modules (dentistry, eye care, …).
|
|
*
|
|
* Persistence: organization.settings.specialty_modules[key] = bool
|
|
* Provisioning: departments per branch + Care Queue Engine stubs/points.
|
|
* Deactivate hides UI and marks queues inactive; does not destroy clinical history.
|
|
*
|
|
* Plan gate: Pro/Enterprise via PlanService feature specialty_modules (same tier as queue_integration).
|
|
*/
|
|
class SpecialtyModuleService
|
|
{
|
|
public function __construct(
|
|
protected PlanService $plans,
|
|
) {}
|
|
|
|
/**
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
public function catalog(): array
|
|
{
|
|
return config('care.specialty_modules', []);
|
|
}
|
|
|
|
public function definition(string $key): ?array
|
|
{
|
|
$catalog = $this->catalog();
|
|
|
|
return $catalog[$key] ?? null;
|
|
}
|
|
|
|
public function isDefaultOnPaidPlans(string $key): bool
|
|
{
|
|
return (bool) ($this->definition($key)['default_on_paid_plans'] ?? false);
|
|
}
|
|
|
|
public function isEnabled(Organization $organization, string $key): bool
|
|
{
|
|
if (! $this->definition($key)) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->isDefaultOnPaidPlans($key) && $this->plans->hasPaidPlan($organization)) {
|
|
return true;
|
|
}
|
|
|
|
return (bool) data_get($organization->settings, "specialty_modules.{$key}", false);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public function defaultKeysForPaidPlans(): array
|
|
{
|
|
$keys = [];
|
|
foreach (array_keys($this->catalog()) as $key) {
|
|
if ($this->isDefaultOnPaidPlans($key)) {
|
|
$keys[] = $key;
|
|
}
|
|
}
|
|
|
|
return $keys;
|
|
}
|
|
|
|
/**
|
|
* Provision Emergency / Blood Bank (and any other default_on_paid_plans modules).
|
|
*/
|
|
public function ensureDefaultModulesProvisioned(Organization $organization, string $ownerRef): void
|
|
{
|
|
if (! $this->plans->hasPaidPlan($organization)) {
|
|
return;
|
|
}
|
|
|
|
foreach ($this->defaultKeysForPaidPlans() as $key) {
|
|
$provisioned = data_get(
|
|
$organization->settings,
|
|
"specialty_module_provisioning.{$key}.active",
|
|
);
|
|
if ($provisioned) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$this->activate($organization->fresh(), $ownerRef, $key);
|
|
$organization->refresh();
|
|
} catch (\Throwable $e) {
|
|
Log::warning('specialty_module.default_provision_failed', [
|
|
'key' => $key,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public function enabledKeys(Organization $organization): array
|
|
{
|
|
$enabled = [];
|
|
foreach (array_keys($this->catalog()) as $key) {
|
|
if ($this->isEnabled($organization, $key)) {
|
|
$enabled[] = $key;
|
|
}
|
|
}
|
|
|
|
return $enabled;
|
|
}
|
|
|
|
/**
|
|
* @return list<array{key: string, definition: array<string, mixed>}>
|
|
*/
|
|
public function enabledModules(Organization $organization): array
|
|
{
|
|
$out = [];
|
|
foreach ($this->enabledKeys($organization) as $key) {
|
|
$definition = $this->definition($key);
|
|
if ($definition) {
|
|
$out[] = ['key' => $key, 'definition' => $definition];
|
|
}
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Enabled modules the member may open (manage, view, or refer).
|
|
*
|
|
* @return list<array{key: string, definition: array<string, mixed>, access_level: string}>
|
|
*/
|
|
public function enabledModulesForMember(Organization $organization, ?Member $member): array
|
|
{
|
|
$out = [];
|
|
foreach ($this->enabledModules($organization) as $item) {
|
|
$level = $this->memberAccessLevel($organization, $member, $item['key']);
|
|
if ($level === 'none') {
|
|
continue;
|
|
}
|
|
$out[] = array_merge($item, ['access_level' => $level]);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* @return 'general'|'limited'|'restricted'
|
|
*/
|
|
public function accessTier(string $key): string
|
|
{
|
|
$tier = (string) ($this->definition($key)['access'] ?? 'general');
|
|
|
|
return in_array($tier, ['general', 'limited', 'restricted'], true) ? $tier : 'general';
|
|
}
|
|
|
|
/**
|
|
* Highest access for this member on the module.
|
|
*
|
|
* @return 'none'|'view'|'refer'|'manage'
|
|
*/
|
|
public function memberAccessLevel(Organization $organization, ?Member $member, string $key): string
|
|
{
|
|
if (! $this->isEnabled($organization, $key)) {
|
|
return 'none';
|
|
}
|
|
|
|
$definition = $this->definition($key);
|
|
if (! $definition || ! $member) {
|
|
return 'none';
|
|
}
|
|
|
|
if (app(CarePermissions::class)->isAdmin($member)) {
|
|
return 'manage';
|
|
}
|
|
|
|
if ($this->memberCanManage($organization, $member, $key)) {
|
|
return 'manage';
|
|
}
|
|
|
|
$canRefer = $this->roleListed($member, $definition['refer_roles'] ?? []);
|
|
$canView = $this->roleListed($member, $definition['view_roles'] ?? []);
|
|
|
|
if ($canRefer && $canView) {
|
|
return 'refer';
|
|
}
|
|
if ($canRefer) {
|
|
return 'refer';
|
|
}
|
|
if ($canView) {
|
|
return 'view';
|
|
}
|
|
|
|
return 'none';
|
|
}
|
|
|
|
/**
|
|
* Whether the member may see this specialty module in nav / open workspace (any level).
|
|
*/
|
|
public function memberCanAccess(Organization $organization, ?Member $member, string $key): bool
|
|
{
|
|
return $this->memberAccessLevel($organization, $member, $key) !== 'none';
|
|
}
|
|
|
|
/**
|
|
* Full clinical / queue edit access for the module.
|
|
*/
|
|
public function memberCanManage(Organization $organization, ?Member $member, string $key): bool
|
|
{
|
|
if (! $this->isEnabled($organization, $key) || ! $member) {
|
|
return false;
|
|
}
|
|
|
|
if (app(CarePermissions::class)->isAdmin($member)) {
|
|
return true;
|
|
}
|
|
|
|
$definition = $this->definition($key);
|
|
if (! $definition) {
|
|
return false;
|
|
}
|
|
|
|
$tier = $this->accessTier($key);
|
|
$role = (string) $member->role;
|
|
$manageRoles = $definition['roles'] ?? [];
|
|
$supportRoles = $definition['support_roles'] ?? [];
|
|
|
|
if ($tier === 'restricted') {
|
|
if ($this->roleListed($member, $supportRoles)) {
|
|
return true;
|
|
}
|
|
if ($role === 'doctor' && $this->roleListed($member, $manageRoles)) {
|
|
return $this->doctorAssignedToModule($organization, $member, $key)
|
|
|| $this->doctorSpecialtyMatchesModule($organization, $member, $key);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// general + limited: role allowlist is enough (GPs = doctor without specialty desk).
|
|
return $this->roleListed($member, $manageRoles);
|
|
}
|
|
|
|
/**
|
|
* Read results / history / workspace without clinical edit.
|
|
*/
|
|
public function memberCanView(Organization $organization, ?Member $member, string $key): bool
|
|
{
|
|
if ($this->memberCanManage($organization, $member, $key)) {
|
|
return true;
|
|
}
|
|
|
|
if (! $this->isEnabled($organization, $key) || ! $member) {
|
|
return false;
|
|
}
|
|
|
|
$definition = $this->definition($key);
|
|
if (! $definition) {
|
|
return false;
|
|
}
|
|
|
|
return $this->roleListed($member, $definition['view_roles'] ?? []);
|
|
}
|
|
|
|
/**
|
|
* Refer a patient into the specialty queue without full clinical edit.
|
|
*/
|
|
public function memberCanRefer(Organization $organization, ?Member $member, string $key): bool
|
|
{
|
|
if ($this->memberCanManage($organization, $member, $key)) {
|
|
return true;
|
|
}
|
|
|
|
if (! $this->isEnabled($organization, $key) || ! $member) {
|
|
return false;
|
|
}
|
|
|
|
$definition = $this->definition($key);
|
|
if (! $definition) {
|
|
return false;
|
|
}
|
|
|
|
return $this->roleListed($member, $definition['refer_roles'] ?? []);
|
|
}
|
|
|
|
/**
|
|
* @param list<string>|mixed $roles
|
|
*/
|
|
protected function roleListed(Member $member, mixed $roles): bool
|
|
{
|
|
return is_array($roles) && $roles !== [] && in_array($member->role, $roles, true);
|
|
}
|
|
|
|
public function doctorAssignedToModule(Organization $organization, Member $member, string $key): bool
|
|
{
|
|
$definition = $this->definition($key);
|
|
if (! $definition) {
|
|
return false;
|
|
}
|
|
|
|
$departmentType = (string) ($definition['department_type'] ?? '');
|
|
$provisionedIds = data_get(
|
|
$organization->settings,
|
|
"specialty_module_provisioning.{$key}.department_ids",
|
|
[],
|
|
);
|
|
$provisionedIds = is_array($provisionedIds)
|
|
? array_map('intval', $provisionedIds)
|
|
: [];
|
|
|
|
foreach ($this->practitionersForMember($organization, $member) as $practitioner) {
|
|
if ($provisionedIds !== [] && in_array((int) $practitioner->department_id, $provisionedIds, true)) {
|
|
return true;
|
|
}
|
|
if ($departmentType !== '' && $practitioner->department?->type === $departmentType) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Match practitioner.specialty text to module specialist_keywords (restricted specialists).
|
|
*/
|
|
public function doctorSpecialtyMatchesModule(Organization $organization, Member $member, string $key): bool
|
|
{
|
|
$definition = $this->definition($key);
|
|
if (! $definition) {
|
|
return false;
|
|
}
|
|
|
|
$keywords = $definition['specialist_keywords'] ?? [];
|
|
if (! is_array($keywords) || $keywords === []) {
|
|
$keywords = $definition['queue_keywords'] ?? [];
|
|
}
|
|
if (! is_array($keywords) || $keywords === []) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($this->practitionersForMember($organization, $member) as $practitioner) {
|
|
$hay = strtolower(trim((string) ($practitioner->specialty ?? '')));
|
|
if ($hay === '' || $this->isGeneralPracticeSpecialty($hay)) {
|
|
continue;
|
|
}
|
|
foreach ($keywords as $keyword) {
|
|
$needle = strtolower((string) $keyword);
|
|
if ($needle !== '' && str_contains($hay, $needle)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected function isGeneralPracticeSpecialty(string $specialtyLower): bool
|
|
{
|
|
if (in_array($specialtyLower, [
|
|
'general practice',
|
|
'general',
|
|
'gp',
|
|
'family medicine',
|
|
'internal medicine',
|
|
'nursing',
|
|
], true)) {
|
|
return true;
|
|
}
|
|
|
|
return str_contains($specialtyLower, 'general practice')
|
|
|| str_contains($specialtyLower, 'family medicine');
|
|
}
|
|
|
|
/**
|
|
* @return \Illuminate\Support\Collection<int, Practitioner>
|
|
*/
|
|
protected function practitionersForMember(Organization $organization, Member $member)
|
|
{
|
|
$branchId = app(OrganizationResolver::class)->branchScope($member);
|
|
|
|
return Practitioner::owned((string) $organization->owner_ref)
|
|
->where('organization_id', $organization->id)
|
|
->where('is_active', true)
|
|
->where(function ($query) use ($member) {
|
|
$query->where('member_id', $member->id)
|
|
->orWhere('user_ref', $member->user_ref);
|
|
})
|
|
->with(['department', 'branches'])
|
|
->get()
|
|
->filter(function (Practitioner $practitioner) use ($branchId) {
|
|
if (! $branchId) {
|
|
return true;
|
|
}
|
|
|
|
return in_array((int) $branchId, $practitioner->assignedBranchIds(), true);
|
|
});
|
|
}
|
|
|
|
public function canManage(Organization $organization): bool
|
|
{
|
|
return $this->plans->hasFeature($organization, 'specialty_modules');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, bool> $desired module key => enabled
|
|
* @return array{activated: list<string>, deactivated: list<string>, errors: list<string>}
|
|
*/
|
|
public function sync(Organization $organization, string $ownerRef, array $desired): array
|
|
{
|
|
$activated = [];
|
|
$deactivated = [];
|
|
$errors = [];
|
|
|
|
foreach ($this->catalog() as $key => $definition) {
|
|
$want = (bool) ($desired[$key] ?? false);
|
|
if ($this->isDefaultOnPaidPlans($key) && $this->plans->hasPaidPlan($organization)) {
|
|
$want = true;
|
|
}
|
|
$have = $this->isEnabled($organization, $key);
|
|
|
|
if ($want === $have) {
|
|
// Still ensure defaults are provisioned even when already "enabled".
|
|
if ($want && $this->isDefaultOnPaidPlans($key)
|
|
&& ! data_get($organization->settings, "specialty_module_provisioning.{$key}.active")) {
|
|
try {
|
|
$this->activate($organization, $ownerRef, $key);
|
|
$organization->refresh();
|
|
} catch (\Throwable $e) {
|
|
$errors[] = ($definition['label'] ?? $key).': '.$e->getMessage();
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if ($want) {
|
|
$this->activate($organization, $ownerRef, $key);
|
|
$activated[] = $key;
|
|
} else {
|
|
$this->deactivate($organization, $ownerRef, $key);
|
|
$deactivated[] = $key;
|
|
}
|
|
$organization->refresh();
|
|
} catch (\Throwable $e) {
|
|
Log::warning('specialty_module.sync_failed', [
|
|
'key' => $key,
|
|
'want' => $want,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
$errors[] = ($definition['label'] ?? $key).': '.$e->getMessage();
|
|
}
|
|
}
|
|
|
|
return compact('activated', 'deactivated', 'errors');
|
|
}
|
|
|
|
public function activate(Organization $organization, string $ownerRef, string $key): void
|
|
{
|
|
$definition = $this->definition($key);
|
|
if (! $definition) {
|
|
throw new \InvalidArgumentException("Unknown specialty module [{$key}].");
|
|
}
|
|
|
|
if (! $this->canManage($organization)) {
|
|
throw new \RuntimeException('Specialty modules require Care Pro or Enterprise.');
|
|
}
|
|
|
|
$settings = $organization->settings ?? [];
|
|
$modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : [];
|
|
$modules[$key] = true;
|
|
$settings['specialty_modules'] = $modules;
|
|
|
|
$departments = $this->provisionDepartments($organization, $ownerRef, $definition);
|
|
$queueStubs = $this->provisionQueueStubs($organization, $ownerRef, $key, $definition);
|
|
|
|
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
|
|
? $settings['specialty_module_provisioning']
|
|
: [];
|
|
$provisioning[$key] = [
|
|
'active' => true,
|
|
'department_ids' => $departments,
|
|
'queues' => $queueStubs,
|
|
'activated_at' => now()->toIso8601String(),
|
|
];
|
|
$settings['specialty_module_provisioning'] = $provisioning;
|
|
|
|
$organization->update(['settings' => $settings]);
|
|
|
|
// Seed billable specialty services into provisioning (Billing Engine catalog).
|
|
try {
|
|
app(SpecialtyShellService::class)->seedServices($organization->fresh(), $key);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('specialty_module.service_catalog_seed_failed', [
|
|
'key' => $key,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
$fresh = $organization->fresh();
|
|
if (data_get($fresh?->settings, 'queue_integration_enabled')) {
|
|
$provisioner = app(CareQueueProvisioner::class);
|
|
$branches = Branch::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->where('is_active', true)
|
|
->pluck('id');
|
|
foreach ($branches as $branchId) {
|
|
try {
|
|
$provisioner->ensure($fresh, $key, (int) $branchId);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('specialty_module.native_queue_provision_failed', [
|
|
'key' => $key,
|
|
'branch_id' => $branchId,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public function deactivate(Organization $organization, string $ownerRef, string $key): void
|
|
{
|
|
if (! $this->definition($key)) {
|
|
throw new \InvalidArgumentException("Unknown specialty module [{$key}].");
|
|
}
|
|
|
|
if ($this->isDefaultOnPaidPlans($key) && $this->plans->hasPaidPlan($organization)) {
|
|
throw new \RuntimeException(($this->definition($key)['label'] ?? $key).' stays enabled on Pro and Enterprise.');
|
|
}
|
|
|
|
$settings = $organization->settings ?? [];
|
|
$modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : [];
|
|
$modules[$key] = false;
|
|
$settings['specialty_modules'] = $modules;
|
|
|
|
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
|
|
? $settings['specialty_module_provisioning']
|
|
: [];
|
|
$record = is_array($provisioning[$key] ?? null) ? $provisioning[$key] : [];
|
|
|
|
$departmentIds = $record['department_ids'] ?? [];
|
|
if (is_array($departmentIds) && $departmentIds !== []) {
|
|
Department::owned($ownerRef)
|
|
->whereIn('id', $departmentIds)
|
|
->update(['is_active' => false]);
|
|
}
|
|
|
|
$queues = is_array($record['queues'] ?? null) ? $record['queues'] : [];
|
|
foreach ($queues as $i => $queue) {
|
|
$queues[$i]['active'] = false;
|
|
}
|
|
|
|
if (data_get($settings, 'queue_integration_enabled') && $queues !== []) {
|
|
try {
|
|
CareServiceQueue::query()
|
|
->where('organization_id', $organization->id)
|
|
->where('context', $key)
|
|
->update(['is_active' => false]);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('specialty_module.queue_deactivate_failed', [
|
|
'key' => $key,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
$provisioning[$key] = array_merge($record, [
|
|
'active' => false,
|
|
'queues' => $queues,
|
|
'deactivated_at' => now()->toIso8601String(),
|
|
]);
|
|
$settings['specialty_module_provisioning'] = $provisioning;
|
|
|
|
$organization->update(['settings' => $settings]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $definition
|
|
* @return list<int>
|
|
*/
|
|
protected function provisionDepartments(Organization $organization, string $ownerRef, array $definition): array
|
|
{
|
|
$type = (string) ($definition['department_type'] ?? 'general');
|
|
$name = (string) ($definition['department_name'] ?? $definition['label'] ?? 'Specialty');
|
|
|
|
$branches = Branch::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->where('is_active', true)
|
|
->get();
|
|
|
|
$ids = [];
|
|
foreach ($branches as $branch) {
|
|
$department = Department::withTrashed()
|
|
->owned($ownerRef)
|
|
->where('branch_id', $branch->id)
|
|
->where('type', $type)
|
|
->first();
|
|
|
|
if ($department) {
|
|
if ($department->trashed()) {
|
|
$department->restore();
|
|
}
|
|
$department->update([
|
|
'name' => $name,
|
|
'is_active' => true,
|
|
]);
|
|
} else {
|
|
$department = Department::create([
|
|
'owner_ref' => $ownerRef,
|
|
'branch_id' => $branch->id,
|
|
'name' => $name,
|
|
'type' => $type,
|
|
'is_active' => true,
|
|
]);
|
|
}
|
|
|
|
$ids[] = (int) $department->id;
|
|
}
|
|
|
|
return $ids;
|
|
}
|
|
|
|
/**
|
|
* Care-side queue stubs (branch-aware). When Queue integration is enabled,
|
|
* activate() creates/links real Queue queues + counters and stores UUIDs here.
|
|
*
|
|
* @param array<string, mixed> $definition
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
protected function provisionQueueStubs(
|
|
Organization $organization,
|
|
string $ownerRef,
|
|
string $key,
|
|
array $definition,
|
|
): array {
|
|
$branches = Branch::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->where('is_active', true)
|
|
->get();
|
|
|
|
$existingByBranch = collect(
|
|
data_get($organization->settings, "specialty_module_provisioning.{$key}.queues", [])
|
|
)->keyBy(fn ($q) => (string) ($q['branch_id'] ?? ''));
|
|
|
|
$stubs = [];
|
|
foreach ($branches as $branch) {
|
|
$prior = is_array($existingByBranch->get((string) $branch->id))
|
|
? $existingByBranch->get((string) $branch->id)
|
|
: [];
|
|
|
|
$stubs[] = [
|
|
'module' => $key,
|
|
'branch_id' => $branch->id,
|
|
'branch_name' => $branch->name,
|
|
'name' => (string) ($definition['queue_name'] ?? $definition['label']),
|
|
'prefix' => (string) ($definition['queue_prefix'] ?? strtoupper(substr($key, 0, 3))),
|
|
'active' => true,
|
|
'synced' => false,
|
|
'queue_uuid' => $prior['queue_uuid'] ?? null,
|
|
'counter_uuid' => $prior['counter_uuid'] ?? null,
|
|
'queue_external_key' => $prior['queue_external_key']
|
|
?? "care:specialty:{$key}:queue:{$branch->id}",
|
|
'counter_external_key' => $prior['counter_external_key']
|
|
?? "care:specialty:{$key}:counter:{$branch->id}",
|
|
];
|
|
}
|
|
|
|
return $stubs;
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public function queueStubsFor(Organization $organization, string $key): array
|
|
{
|
|
$queues = data_get($organization->settings, "specialty_module_provisioning.{$key}.queues", []);
|
|
|
|
return is_array($queues) ? array_values(array_filter($queues, fn ($q) => (bool) ($q['active'] ?? false))) : [];
|
|
}
|
|
}
|