Files
ladill-care/app/Services/Care/DemoTenantSeeder.php
T
isaaccladandCursor cfa71c2c15
Deploy Ladill Care / deploy (push) Successful in 52s
Add full Physiotherapy and Maternity specialty clinical suites.
Mirror Eye Care depth with stages, workspace tabs, clinical JSON, stage Move→tab routing, reports/print, demo seeds, and suite feature tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:39:22 +00:00

2355 lines
94 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Assessment;
use App\Models\AssessmentAnswer;
use App\Models\AssessmentQuestion;
use App\Models\AssessmentScore;
use App\Models\AssessmentTemplate;
use App\Models\Bill;
use App\Models\BillLineItem;
use App\Models\Branch;
use App\Models\Consultation;
use App\Models\Department;
use App\Models\Device;
use App\Models\Drug;
use App\Models\DrugBatch;
use App\Models\InvestigationRequest;
use App\Models\InvestigationType;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Payment;
use App\Models\Practitioner;
use App\Models\Prescription;
use App\Models\User;
use App\Models\Visit;
use App\Models\VitalSign;
use App\Support\DemoWorld;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* Idempotent, plan-aware demo data for a Care tenant (keyed by owner_ref / public_id).
*/
class DemoTenantSeeder
{
public const DEMO_TEMPLATE_CODE = 'demo_triage';
/** @var list<string> */
private const FIRST_NAMES = [
'Ama', 'Kofi', 'Abena', 'Kwame', 'Akosua', 'Yaw', 'Efua', 'Kojo',
'Adwoa', 'Kwesi', 'Afia', 'Kwadwo', 'Esi', 'Fiifi', 'Mansa', 'Nana',
];
/** @var list<string> */
private const LAST_NAMES = [
'Mensah', 'Asante', 'Osei', 'Boateng', 'Owusu', 'Adjei', 'Appiah', 'Darko',
'Agyeman', 'Sarpong', 'Nyarko', 'Ankrah', 'Tetteh', 'Quaye', 'Addo', 'Frimpong',
];
/** @var list<string> */
private const BRANCH_NAMES = [
'Ridge Clinic', 'Osu Medical Centre', 'East Legon Care', 'Tema Harbour Clinic',
'Kaneshie Health Hub', 'Spintex Wellness', 'Madina Family Clinic', 'Airport Residential Care',
];
/** @var list<array{name: string, type: string}> */
private const CORE_DEPARTMENTS = [
['name' => 'Outpatient', 'type' => 'outpatient'],
['name' => 'General Practice', 'type' => 'general'],
['name' => 'Nursing', 'type' => 'nursing'],
['name' => 'Specialist Clinic', 'type' => 'specialist'],
];
/** @var list<array{name: string, type: string}> */
private const PAID_DEPARTMENTS = [
['name' => 'Laboratory', 'type' => 'laboratory'],
['name' => 'Pharmacy', 'type' => 'pharmacy'],
];
/** @var list<string> */
private const SPECIALTIES = [
'General Practice', 'Internal Medicine', 'Paediatrics', 'Obstetrics',
'Emergency', 'Nursing', 'Laboratory', 'Pharmacy',
];
/**
* @return array{
* organization: Organization,
* branches: int,
* patients: int,
* appointments: int,
* plan: string
* }
*/
public function seed(User $user, string $plan, bool $reset = false): array
{
$plan = $this->normalizePlan($plan);
$ownerRef = $user->ownerRef();
$volumes = $this->volumes($plan);
// Always materialize the onboarded org shell before any wipe so concurrent
// SSO redirects never land on the blank onboarding form.
$this->ensureOnboardedShell($user, $plan);
if ($reset) {
$this->resetTenant($ownerRef);
$this->ensureOnboardedShell($user, $plan);
}
// No outer transaction — Enterprise volumes can hold row locks for minutes
// and 500 concurrent settings/login requests (messaging credentials, org updates).
$organization = $this->upsertOrganization($user, $ownerRef, $plan, $volumes['branches']);
$this->upsertOwnerMember($organization, $ownerRef);
$branches = $this->seedBranches($organization, $ownerRef, $volumes);
// Lab catalog early — survives if later clinical seeding fails mid-run.
$investigationTypes = $volumes['paid_modules']
? $this->seedInvestigationCatalog($organization, $ownerRef)
: [];
$this->seedStaffMembers($organization, $ownerRef, $plan, $branches);
$departments = $this->seedDepartments($branches, $ownerRef, $volumes);
if (in_array($plan, ['pro', 'enterprise'], true)) {
$this->seedSpecialtyModules($organization, $ownerRef);
// Keep $departments as core/paid only. Specialty departments must not feed
// general GP desks or appointments — those waiters live in seedSpecialtyDemoData.
}
$practitioners = $this->seedPractitioners($organization, $branches, $departments, $ownerRef, $volumes);
$this->linkStaffDoctorsToPractitioners($organization, $ownerRef, $plan, $branches, $practitioners);
$patients = $this->seedPatients($organization, $branches, $ownerRef, $volumes);
$appointmentCount = $this->seedAppointmentsAndClinical(
$organization,
$branches,
$departments,
$practitioners,
$patients,
$ownerRef,
$volumes,
);
if (in_array($plan, ['pro', 'enterprise'], true)) {
$appointmentCount += $this->seedSpecialtyDemoData(
$organization->fresh(),
$branches,
$practitioners,
$patients,
$ownerRef,
$plan,
);
}
$this->assignAllDemoWaiters($organization, $ownerRef, $branches, $practitioners);
$this->renumberWaitingQueuePositions($organization, $ownerRef, $branches);
// Catalog first so a later lab-request failure never leaves Lab → Catalog empty.
if ($volumes['paid_modules']) {
if ($investigationTypes === []) {
$investigationTypes = $this->seedInvestigationCatalog($organization, $ownerRef);
}
$this->seedPaidModules(
$organization,
$branches,
$practitioners,
$patients,
$ownerRef,
$volumes,
$investigationTypes,
);
$this->seedDevices($organization, $branches, $ownerRef);
}
if ($volumes['assessments'] > 0) {
$this->seedAssessments(
$organization,
$branches,
$practitioners,
$patients,
$ownerRef,
$volumes['assessments'],
);
}
$this->syncQueueTicketsForDemo($organization->fresh(), $branches);
return [
'organization' => $organization->fresh(),
'branches' => count($branches),
'patients' => count($patients),
'appointments' => $appointmentCount,
'plan' => $plan,
];
}
/**
* @param list<Branch> $branches
*/
private function syncQueueTicketsForDemo(Organization $organization, array $branches): void
{
if (! data_get($organization->settings, 'queue_integration_enabled')) {
return;
}
try {
$bridge = app(CareQueueBridge::class);
if (! $bridge->isEnabled($organization)) {
return;
}
// Scoped per branch (not full-org) so demos finish quickly while
// still leaving waiters with real tickets instead of local positions only.
foreach ($branches as $branch) {
try {
$bridge->syncMissingAppointmentTickets($organization, (int) $branch->id, 100);
} catch (\Throwable) {
// Continue — page load will retry.
}
foreach ([
CareQueueContexts::PHARMACY,
CareQueueContexts::LABORATORY,
CareQueueContexts::BILLING,
] as $context) {
try {
$bridge->syncMissingTickets($organization, $context, (int) $branch->id, 50);
} catch (\Throwable) {
// Continue — page load will retry for that context.
}
}
}
} catch (\Throwable) {
// Demo seed should not fail if Queue API is unreachable.
}
}
/**
* Point DemoWorld doctor staff at a real practitioner so Call next / rooms work.
*
* @param list<Branch> $branches
* @param list<Practitioner> $practitioners
*/
private function linkStaffDoctorsToPractitioners(
Organization $organization,
string $ownerRef,
string $plan,
array $branches,
array $practitioners,
): void {
$doctorEmail = null;
$doctorName = null;
foreach (DemoWorld::staff($plan) as $staff) {
if (($staff['roles']['care'] ?? null) === 'doctor') {
$doctorEmail = strtolower((string) $staff['email']);
$doctorName = (string) $staff['name'];
break;
}
}
if ($doctorEmail === null || $practitioners === []) {
return;
}
$member = $this->findStaffMemberByEmail($organization, $doctorEmail);
// Link the demo doctor to a single desk on their home branch (not every site).
$homeBranchId = (int) ($member?->branch_id ?: ($branches[0]->id ?? 0));
$practitioner = collect($practitioners)->first(
fn (Practitioner $p) => (int) $p->branch_id === $homeBranchId,
) ?? ($practitioners[0] ?? null);
if (! $practitioner) {
return;
}
$practitioner->forceFill([
'user_ref' => $member?->user_ref ?: $doctorEmail,
'member_id' => $member?->id,
'name' => $doctorName !== '' ? $doctorName : $practitioner->name,
])->save();
if ($homeBranchId > 0) {
$practitioner->syncAssignedBranches([$homeBranchId]);
}
}
/**
* Every waiting / checked-in demo appointment gets a doctor and a clean routing
* state so Queue sync can issue tickets (no amber Unassigned leftovers).
*
* @param list<Branch> $branches
* @param list<Practitioner> $practitioners
*/
private function assignAllDemoWaiters(
Organization $organization,
string $ownerRef,
array $branches,
array $practitioners,
): void {
if ($practitioners === []) {
return;
}
$byBranch = [];
foreach ($practitioners as $practitioner) {
$byBranch[(int) $practitioner->branch_id][] = $practitioner;
}
$waiters = Appointment::withTrashed()
->owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->where(function ($q) {
$q->whereNull('practitioner_id')->orWhere('practitioner_id', 0);
})
->get();
foreach ($waiters as $index => $appointment) {
$branchPracs = $byBranch[(int) $appointment->branch_id] ?? $practitioners;
$practitioner = $branchPracs[$index % max(1, count($branchPracs))] ?? $practitioners[0];
$appointment->forceFill([
'practitioner_id' => $practitioner->id,
'queue_routing_status' => null,
'deleted_at' => null,
])->save();
}
}
/**
* Give each waiting/checked-in appointment a unique 1..n position per branch.
*
* @param list<Branch> $branches
*/
private function renumberWaitingQueuePositions(
Organization $organization,
string $ownerRef,
array $branches,
): void {
foreach ($branches as $branch) {
$waiters = Appointment::withTrashed()
->owned($ownerRef)
->where('organization_id', $organization->id)
->where('branch_id', $branch->id)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->orderByRaw('COALESCE(waiting_at, checked_in_at, scheduled_at) asc')
->orderBy('id')
->get();
foreach ($waiters as $index => $appointment) {
$position = $index + 1;
if ((int) $appointment->queue_position === $position) {
continue;
}
$appointment->forceFill([
'queue_position' => $position,
'deleted_at' => null,
])->save();
}
}
}
/**
* Ensure the demo tenant has an onboarded organization + owner member.
* Safe to call on the SSO request path before redirecting.
*/
public function ensureOnboardedShell(User $user, string $plan): Organization
{
$plan = $this->normalizePlan($plan);
$ownerRef = $user->ownerRef();
$volumes = $this->volumes($plan);
return DB::transaction(function () use ($user, $ownerRef, $plan, $volumes) {
$organization = $this->upsertOrganization($user, $ownerRef, $plan, $volumes['branches']);
$this->upsertOwnerMember($organization, $ownerRef);
return $organization;
});
}
public function resetTenant(string $ownerRef): void
{
$orgIds = DB::table('care_organizations')->where('owner_ref', $ownerRef)->pluck('id');
if (Schema::hasTable('care_financial_obligations')) {
DB::table('care_financial_obligations')->where('owner_ref', $ownerRef)->delete();
DB::table('care_visit_stage_advances')->where('owner_ref', $ownerRef)->delete();
$workflowIds = DB::table('care_facility_workflows')->where('owner_ref', $ownerRef)->pluck('id');
if ($workflowIds->isNotEmpty()) {
DB::table('care_workflow_stages')->whereIn('workflow_id', $workflowIds)->delete();
DB::table('care_facility_workflows')->whereIn('id', $workflowIds)->delete();
}
}
DB::table('care_assessment_answers')->where('owner_ref', $ownerRef)->delete();
DB::table('care_assessment_scores')->where('owner_ref', $ownerRef)->delete();
DB::table('care_assessments')->where('owner_ref', $ownerRef)->update(['patient_pathway_id' => null]);
DB::table('care_patient_pathways')->where('owner_ref', $ownerRef)->delete();
DB::table('care_assessments')->where('owner_ref', $ownerRef)->delete();
if ($orgIds->isNotEmpty()) {
$templateIds = DB::table('care_assessment_templates')
->whereIn('organization_id', $orgIds)
->pluck('id');
if ($templateIds->isNotEmpty()) {
DB::table('care_assessment_questions')->whereIn('template_id', $templateIds)->delete();
DB::table('care_assessment_templates')->whereIn('id', $templateIds)->delete();
}
}
// Orphaned demo templates left behind by nullOnDelete on prior resets.
$orphanIds = DB::table('care_assessment_templates')
->where('code', self::DEMO_TEMPLATE_CODE)
->whereNull('organization_id')
->pluck('id');
if ($orphanIds->isNotEmpty()) {
DB::table('care_assessment_questions')->whereIn('template_id', $orphanIds)->delete();
DB::table('care_assessment_templates')->whereIn('id', $orphanIds)->delete();
}
DB::table('care_dispensing_records')->where('owner_ref', $ownerRef)->delete();
DB::table('care_payments')->where('owner_ref', $ownerRef)->delete();
DB::table('care_bill_line_items')->where('owner_ref', $ownerRef)->delete();
DB::table('care_bills')->where('owner_ref', $ownerRef)->delete();
DB::table('care_investigation_result_values')->where('owner_ref', $ownerRef)->delete();
DB::table('care_investigation_attachments')->where('owner_ref', $ownerRef)->delete();
DB::table('care_investigation_results')->where('owner_ref', $ownerRef)->delete();
DB::table('care_investigation_requests')->where('owner_ref', $ownerRef)->delete();
DB::table('care_prescription_items')->where('owner_ref', $ownerRef)->delete();
DB::table('care_prescriptions')->where('owner_ref', $ownerRef)->delete();
DB::table('care_drug_batches')->where('owner_ref', $ownerRef)->delete();
DB::table('care_drugs')->where('owner_ref', $ownerRef)->delete();
DB::table('care_investigation_types')->where('owner_ref', $ownerRef)->delete();
DB::table('care_consultation_documents')->where('owner_ref', $ownerRef)->delete();
DB::table('care_diagnoses')->where('owner_ref', $ownerRef)->delete();
DB::table('care_vital_signs')->where('owner_ref', $ownerRef)->delete();
DB::table('care_consultations')->where('owner_ref', $ownerRef)->delete();
DB::table('care_devices')->where('owner_ref', $ownerRef)->delete();
DB::table('care_appointments')->where('owner_ref', $ownerRef)->delete();
DB::table('care_visits')->where('owner_ref', $ownerRef)->delete();
DB::table('care_patient_allergies')->where('owner_ref', $ownerRef)->delete();
DB::table('care_patient_conditions')->where('owner_ref', $ownerRef)->delete();
DB::table('care_patient_family_history')->where('owner_ref', $ownerRef)->delete();
DB::table('care_emergency_contacts')->where('owner_ref', $ownerRef)->delete();
DB::table('care_insurance_policies')->where('owner_ref', $ownerRef)->delete();
DB::table('care_patient_attachments')->where('owner_ref', $ownerRef)->delete();
DB::table('care_patients')->where('owner_ref', $ownerRef)->delete();
DB::table('care_practitioner_schedules')->where('owner_ref', $ownerRef)->delete();
if (Schema::hasTable('care_practitioner_branch')) {
$practitionerIds = DB::table('care_practitioners')->where('owner_ref', $ownerRef)->pluck('id');
if ($practitionerIds->isNotEmpty()) {
DB::table('care_practitioner_branch')->whereIn('practitioner_id', $practitionerIds)->delete();
}
}
DB::table('care_practitioners')->where('owner_ref', $ownerRef)->delete();
// Keep the owner member + organization so SSO redirects never see
// "not onboarded" while afterResponse() reseeding is still running.
DB::table('care_members')
->where('owner_ref', $ownerRef)
->where('user_ref', '!=', $ownerRef)
->delete();
DB::table('care_departments')->where('owner_ref', $ownerRef)->delete();
DB::table('care_branches')->where('owner_ref', $ownerRef)->delete();
DB::table('care_messaging_credentials')->where('owner_ref', $ownerRef)->delete();
DB::table('care_payment_gateway_settings')->where('owner_ref', $ownerRef)->delete();
DB::table('care_audit_logs')->where('owner_ref', $ownerRef)->delete();
$organization = Organization::withTrashed()->where('owner_ref', $ownerRef)->first();
if ($organization) {
if ($organization->trashed()) {
$organization->restore();
}
$settings = $organization->settings ?? [];
$settings['onboarded'] = true;
$organization->forceFill(['settings' => $settings])->save();
}
}
/**
* @return array{
* branches: int,
* departments_per_branch: int,
* practitioners: int,
* patients: int,
* appointments: int,
* paid_modules: bool,
* assessments: int,
* lab_requests: int,
* prescriptions: int,
* bills: int
* }
*/
public function volumes(string $plan): array
{
$testing = app()->runningUnitTests();
return match ($this->normalizePlan($plan)) {
'free' => [
'branches' => 1,
'departments_per_branch' => 2,
'practitioners' => 3,
'patients' => $testing ? 12 : 30,
'appointments' => $testing ? 20 : 50,
'paid_modules' => false,
'assessments' => 0,
'lab_requests' => 0,
'prescriptions' => 0,
'bills' => 0,
],
'pro' => [
'branches' => 3,
'departments_per_branch' => 3,
'practitioners' => 8,
'patients' => $testing ? 24 : 200,
'appointments' => $testing ? 40 : 300,
'paid_modules' => true,
'assessments' => 0,
'lab_requests' => $testing ? 8 : 40,
'prescriptions' => $testing ? 8 : 40,
'bills' => $testing ? 10 : 60,
],
'enterprise' => [
'branches' => 6,
'departments_per_branch' => 4,
'practitioners' => 16,
'patients' => $testing ? 36 : 500,
'appointments' => $testing ? 60 : 800,
'paid_modules' => true,
'assessments' => $testing ? 12 : 80,
'lab_requests' => $testing ? 12 : 80,
'prescriptions' => $testing ? 12 : 80,
'bills' => $testing ? 15 : 120,
],
default => throw new \InvalidArgumentException("Unsupported plan [{$plan}]"),
};
}
private function normalizePlan(string $plan): string
{
$plan = strtolower(trim($plan));
return in_array($plan, ['free', 'pro', 'enterprise'], true) ? $plan : 'free';
}
private function upsertOrganization(User $user, string $ownerRef, string $plan, int $billedBranches): Organization
{
$slug = 'demo-'.Str::slug(Str::limit($user->email ?: $ownerRef, 40, ''));
if ($slug === 'demo-' || $slug === 'demo') {
$slug = 'demo-'.Str::lower(Str::substr($ownerRef, 0, 8));
}
$name = DemoWorld::business($plan)['name'];
$organization = Organization::withTrashed()
->where('owner_ref', $ownerRef)
->first();
$facilityType = $plan === 'enterprise' ? 'hospital' : 'clinic';
$settings = array_merge($organization?->settings ?? [], [
'onboarded' => true,
'facility_type' => $facilityType,
'facility_category' => $facilityType,
'plan' => $plan,
'plan_expires_at' => now()->addYear()->toIso8601String(),
'billed_branches' => max(1, $billedBranches),
'demo' => true,
// Pro/Enterprise demos: Queue on natural role pages + every specialty module.
// Free / non-demo tenants leave specialty_modules empty (opt-in via Settings).
'queue_integration_enabled' => in_array($plan, ['pro', 'enterprise'], true),
'specialty_modules' => in_array($plan, ['pro', 'enterprise'], true)
? $this->allSpecialtyFlagsEnabled()
: [],
// Demo seed uses the legacy clinical path. Explicitly clear leftover
// onboarding rollout so a failed race that created a real workflow
// shell cannot leave an empty gated tenant.
'rollout' => [
CareFeatures::WORKFLOW_ENGINE => false,
CareFeatures::FINANCIAL_GATES => false,
],
]);
unset($settings['workflow_template']);
if ($organization) {
if ($organization->trashed()) {
$organization->restore();
}
$organization->update([
'name' => $name,
'slug' => $slug,
'timezone' => 'Africa/Accra',
'settings' => $settings,
]);
return $organization->fresh();
}
return Organization::create([
'owner_ref' => $ownerRef,
'name' => $name,
'slug' => $slug,
'timezone' => 'Africa/Accra',
'settings' => $settings,
]);
}
private function upsertOwnerMember(Organization $organization, string $ownerRef): Member
{
return Member::query()->updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $ownerRef,
],
[
'owner_ref' => $ownerRef,
'role' => 'hospital_admin',
'branch_id' => null,
],
);
}
/**
* @param array<string, mixed> $volumes
* @return list<Branch>
*/
private function seedBranches(Organization $organization, string $ownerRef, array $volumes): array
{
$catalog = DemoWorld::branches($this->normalizePlan(
(string) data_get($organization->settings, 'plan', 'free')
), $volumes['branches']);
$branches = [];
foreach ($catalog as $i => $spec) {
$code = $spec['code'] !== '' ? $spec['code'] : sprintf('DEMO-BR-%02d', $i + 1);
$branches[] = Branch::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'code' => $code,
],
[
'owner_ref' => $ownerRef,
'name' => $spec['name'],
'address' => $spec['address'],
'phone' => $spec['phone'],
'is_active' => true,
'deleted_at' => null,
],
);
}
return $branches;
}
/**
* Find a demo staff Member by login email, including SSO-remapped public_id user_ref.
*/
private function findStaffMemberByEmail(Organization $organization, string $email): ?Member
{
$email = strtolower(trim($email));
if ($email === '') {
return null;
}
$publicId = User::query()
->whereRaw('LOWER(email) = ?', [$email])
->value('public_id');
return Member::query()
->where('organization_id', $organization->id)
->where(function ($query) use ($email, $publicId) {
$query->where('user_ref', $email);
if (is_string($publicId) && $publicId !== '') {
$query->orWhere('user_ref', $publicId);
}
})
->orderByRaw('CASE WHEN user_ref = ? THEN 0 ELSE 1 END', [
is_string($publicId) && $publicId !== '' ? $publicId : $email,
])
->first();
}
/**
* Local Member rows for DemoWorld staff (user_ref = email until SSO remaps to public_id).
* Multi-branch demos spread staff across branches so each site has a role roster.
*
* @param list<Branch> $branches
*/
private function seedStaffMembers(Organization $organization, string $ownerRef, string $plan, array $branches): void
{
$hq = $branches[0] ?? null;
$staffList = DemoWorld::staff($plan);
foreach ($staffList as $index => $staff) {
$role = $staff['roles']['care'] ?? null;
if (! is_string($role) || $role === '') {
continue;
}
$branch = $hq;
if ($role !== 'hospital_admin' && $branches !== []) {
// Cashiers always staff HQ so demo unpaid invoices on branch[0] are collectible.
$branch = $role === 'cashier'
? $hq
: $branches[$index % count($branches)];
}
$email = strtolower((string) $staff['email']);
// Prefer remapped public_id when the staff user already exists locally,
// so reseeds do not orphan SSO sessions keyed by public_id.
$publicId = User::query()
->whereRaw('LOWER(email) = ?', [$email])
->value('public_id');
$userRef = is_string($publicId) && $publicId !== '' ? $publicId : $email;
$existing = Member::query()
->where('organization_id', $organization->id)
->where(function ($query) use ($email, $publicId) {
$query->where('user_ref', $email);
if (is_string($publicId) && $publicId !== '') {
$query->orWhere('user_ref', $publicId);
}
})
->orderByRaw('CASE WHEN user_ref = ? THEN 0 ELSE 1 END', [$userRef])
->first();
if ($existing) {
$existing->forceFill([
'user_ref' => $userRef,
'owner_ref' => $ownerRef,
'role' => $role,
'branch_id' => $role === 'hospital_admin' ? null : $branch?->id,
])->save();
Member::query()
->where('organization_id', $organization->id)
->where('id', '!=', $existing->id)
->where(function ($query) use ($email, $publicId) {
$query->where('user_ref', $email);
if (is_string($publicId) && $publicId !== '') {
$query->orWhere('user_ref', $publicId);
}
})
->delete();
continue;
}
Member::query()->create([
'organization_id' => $organization->id,
'user_ref' => $userRef,
'owner_ref' => $ownerRef,
'role' => $role,
'branch_id' => $role === 'hospital_admin' ? null : $branch?->id,
]);
}
}
/**
* @param list<Branch> $branches
* @param array<string, mixed> $volumes
* @return array<int, list<Department>> keyed by branch id
*/
private function seedDepartments(array $branches, string $ownerRef, array $volumes): array
{
$catalog = self::CORE_DEPARTMENTS;
if ($volumes['paid_modules']) {
$catalog = array_merge($catalog, self::PAID_DEPARTMENTS);
}
$byBranch = [];
foreach ($branches as $branch) {
$byBranch[$branch->id] = [];
$count = min($volumes['departments_per_branch'], count($catalog));
for ($i = 0; $i < $count; $i++) {
$spec = $catalog[$i];
$byBranch[$branch->id][] = Department::withTrashed()->updateOrCreate(
[
'branch_id' => $branch->id,
'name' => $spec['name'],
],
[
'owner_ref' => $ownerRef,
'type' => $spec['type'],
'is_active' => true,
'deleted_at' => null,
],
);
}
}
return $byBranch;
}
/**
* @return array<string, bool>
*/
private function allSpecialtyFlagsEnabled(): array
{
$flags = [];
foreach (array_keys(config('care.specialty_modules', [])) as $key) {
$flags[$key] = true;
}
return $flags;
}
private function seedSpecialtyModules(Organization $organization, string $ownerRef): void
{
$organization = $organization->fresh();
$queueEnabled = (bool) data_get($organization->settings, 'queue_integration_enabled');
$service = app(SpecialtyModuleService::class);
foreach (array_keys($service->catalog()) as $key) {
try {
$service->activate($organization->fresh(), $ownerRef, $key);
} catch (\Throwable) {
// Demo seed should not fail if activation hits an unexpected error.
}
}
if ($queueEnabled) {
try {
app(CareQueueProvisioner::class)->provisionOrganization($organization->fresh());
} catch (\Throwable) {
// Demo seed continues; pages lazy-provision on use.
}
}
}
/**
* @param list<Branch> $branches
* @return array<int, list<Department>> keyed by branch id
*/
private function departmentsByBranch(array $branches, string $ownerRef): array
{
$byBranch = [];
foreach ($branches as $branch) {
$byBranch[$branch->id] = Department::withTrashed()
->owned($ownerRef)
->where('branch_id', $branch->id)
->where('is_active', true)
->orderBy('id')
->get()
->all();
}
return $byBranch;
}
/**
* Waiting / completed specialty appointments so each module page has demo traffic.
*
* @param list<Branch> $branches
* @param list<Practitioner> $practitioners
* @param list<Patient> $patients
*/
private function seedSpecialtyDemoData(
Organization $organization,
array $branches,
array $practitioners,
array $patients,
string $ownerRef,
string $plan = 'pro',
): int {
if ($branches === [] || $patients === [] || $practitioners === []) {
return 0;
}
$reasons = [
'emergency' => ['Chest pain', 'Trauma review', 'Acute fever'],
'blood_bank' => ['Cross-match request', 'Transfusion review', 'Blood product issue'],
'dentistry' => ['Toothache', 'Dental cleaning', 'Extraction review'],
'ophthalmology' => ['Blurry vision', 'Eye exam', 'Contact lens fitting'],
'physiotherapy' => ['Back pain rehab', 'Post-injury physio', 'Mobility session'],
'maternity' => ['Antenatal visit', 'Pregnancy check', 'Postnatal review'],
'radiology' => ['X-ray referral', 'Ultrasound', 'Imaging review'],
'cardiology' => ['Chest pain review', 'ECG follow-up', 'Hypertension clinic'],
'psychiatry' => ['Mental health review', 'Counselling session', 'Medication review'],
'pediatrics' => ['Well-child visit', 'Fever in child', 'Growth check'],
'orthopedics' => ['Fracture follow-up', 'Joint pain', 'Cast review'],
'ent' => ['Ear infection', 'Sore throat', 'Sinus review'],
'oncology' => ['Chemo day-care', 'Oncology review', 'Symptom management'],
'renal' => ['Dialysis session', 'Nephrology review', 'Fluid assessment'],
'surgery' => ['Pre-op assessment', 'Surgical review', 'Wound check'],
'vaccination' => ['Routine immunization', 'Catch-up vaccine', 'Travel vaccine'],
'pathology' => ['Biopsy review', 'Histology result', 'Cytology sample'],
'infusion' => ['IV infusion', 'Iron infusion', 'Day-care therapy'],
'dermatology' => ['Skin rash', 'Mole review', 'Dermatology procedure'],
'podiatry' => ['Diabetic foot care', 'Nail procedure', 'Foot pain'],
'fertility' => ['Fertility consult', 'Cycle review', 'IVF follow-up'],
'child_welfare' => ['Growth monitoring', 'Immunization CWC', 'Well-baby visit'],
];
$count = 0;
$catalog = config('care.specialty_modules', []);
/** @var array<int, int> $nextPositionByBranch */
$nextPositionByBranch = [];
/** @var array<string, Member|null> $moduleMembers */
$moduleMembers = [];
foreach ($catalog as $key => $definition) {
$type = (string) ($definition['department_type'] ?? '');
if ($type === '') {
continue;
}
$moduleReasons = $reasons[$key] ?? ['Specialty visit'];
$staffEmail = sprintf('demo-%s-%s@ladill.com', $plan, $key);
// Resolve by email or SSO-remapped public_id (seedStaffMembers prefers public_id).
$moduleMembers[$key] = $this->findStaffMemberByEmail($organization, $staffEmail);
foreach ($branches as $branchIndex => $branch) {
$department = Department::owned($ownerRef)
->where('branch_id', $branch->id)
->where('type', $type)
->where('is_active', true)
->first();
if (! $department) {
continue;
}
$member = $moduleMembers[$key];
$staff = $member ? \App\Support\DemoWorld::staffByEmail($staffEmail) : null;
$homeBranchId = (int) ($member?->branch_id ?? 0);
// One doctor = one branch. Only link the specialty desk on their home site.
$assignMember = $member && $homeBranchId > 0 && (int) $branch->id === $homeBranchId;
$stableRef = sprintf('demo-specialty-%s-%s', $key, $branch->id);
$doctorName = (string) (\App\Support\DemoWorld::SPECIALTY_DOCTORS[$key]
?? ('Dr. '.($definition['label'] ?? ucfirst($key))));
if ($assignMember && is_array($staff) && filled($staff['name'] ?? null)) {
$practitionerName = (string) $staff['name'];
} elseif ($assignMember) {
$practitionerName = $doctorName;
} else {
// Cover desk for demo traffic at other sites — still a clinician, never a "Clinic" entity.
$practitionerName = $doctorName.' · '.$branch->name;
}
$practitioner = Practitioner::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $stableRef,
],
[
'owner_ref' => $ownerRef,
'branch_id' => $branch->id,
'department_id' => $department->id,
'member_id' => $assignMember ? $member->id : null,
'name' => $practitionerName,
'specialty' => (string) ($definition['label'] ?? $key),
'room' => 'Bay '.(($branchIndex % 4) + 1),
'is_active' => true,
'deleted_at' => null,
],
);
$practitioner->syncAssignedBranches([(int) $branch->id]);
foreach ([Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN, Appointment::STATUS_COMPLETED] as $slot => $status) {
$patient = $patients[($branchIndex + $slot + $count) % count($patients)];
$scheduledAt = now()->subHours(($branchIndex * 3) + $slot + 1);
$token = "specialty|{$key}|{$branch->id}|{$slot}";
$visit = null;
if (in_array($status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_WAITING, Appointment::STATUS_COMPLETED], true)) {
$visit = Visit::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("visit|{$ownerRef}|{$token}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => $status === Appointment::STATUS_COMPLETED
? Visit::STATUS_COMPLETED
: Visit::STATUS_IN_PROGRESS,
'checked_in_at' => $scheduledAt->copy()->addMinutes(5),
'completed_at' => $status === Appointment::STATUS_COMPLETED
? $scheduledAt->copy()->addHour()
: null,
'checked_in_by' => $ownerRef,
'deleted_at' => null,
],
);
}
$inQueue = in_array($status, [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN], true);
$queuePosition = null;
if ($inQueue) {
$nextPositionByBranch[$branch->id] = ($nextPositionByBranch[$branch->id] ?? 0) + 1;
$queuePosition = $nextPositionByBranch[$branch->id];
}
Appointment::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("appt|{$ownerRef}|{$token}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'practitioner_id' => $practitioner->id,
'department_id' => $department->id,
'visit_id' => $visit?->id,
'type' => Appointment::TYPE_SCHEDULED,
'status' => $status,
'scheduled_at' => $scheduledAt,
'checked_in_at' => $visit?->checked_in_at,
'waiting_at' => $inQueue ? $scheduledAt->copy()->addMinutes(5) : null,
'completed_at' => $status === Appointment::STATUS_COMPLETED
? $scheduledAt->copy()->addHour()
: null,
'cancelled_at' => null,
'queue_position' => $queuePosition,
'reason' => $moduleReasons[$slot % count($moduleReasons)],
'created_by' => $ownerRef,
'deleted_at' => null,
],
);
$count++;
}
}
}
$this->seedDentistryClinicalDemo($organization, $ownerRef);
$this->seedBloodBankInventoryDemo($organization, $ownerRef);
$this->seedOphthalmologyClinicalDemo($organization, $ownerRef);
$this->seedPhysiotherapyClinicalDemo($organization, $ownerRef);
$this->seedMaternityClinicalDemo($organization, $ownerRef);
return $count;
}
/**
* Visit-scoped facility stock for Blood Bank Inventory tab / reports.
* Seeds inventory_note on each open BB visit so findForVisit shows stock without manual entry.
*/
private function seedBloodBankInventoryDemo(Organization $organization, string $ownerRef): void
{
// Specialty departments are provisioned without organization_id; scope via branches.
$branchIds = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->pluck('id');
if ($branchIds->isEmpty()) {
return;
}
$departmentIds = Department::owned($ownerRef)
->whereIn('branch_id', $branchIds)
->where('type', 'blood_bank')
->where('is_active', true)
->pluck('id');
if ($departmentIds->isEmpty()) {
return;
}
$appointments = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('department_id', $departmentIds)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->whereNotNull('visit_id')
->with('visit')
->orderBy('branch_id')
->orderBy('id')
->get();
if ($appointments->isEmpty()) {
return;
}
$clinical = app(SpecialtyClinicalRecordService::class);
/** @var array<int, int> $branchOrder */
$branchOrder = [];
foreach ($appointments as $appointment) {
$visit = $appointment->visit;
if (! $visit) {
continue;
}
$branchId = (int) $appointment->branch_id;
if (! array_key_exists($branchId, $branchOrder)) {
$branchOrder[$branchId] = count($branchOrder);
}
$clinical->upsert(
$organization,
$visit,
'blood_bank',
'inventory_note',
$this->demoBloodBankInventoryPayload($branchOrder[$branchId]),
$ownerRef,
$ownerRef,
);
}
}
/**
* @return array<string, mixed>
*/
private function demoBloodBankInventoryPayload(int $branchIndex): array
{
// First branch: low whole-blood + alert flag so Inventory / reports demo alerts.
if ($branchIndex === 0) {
return [
'whole_blood_units' => 1,
'packed_rbc_units' => 14,
'platelet_units' => 7,
'ffp_units' => 9,
'cryo_units' => 4,
'low_stock_alert' => true,
'notes' => 'Demo morning stock count — whole blood critically low; replenishment requested.',
];
}
return [
'whole_blood_units' => 8 + ($branchIndex * 2),
'packed_rbc_units' => 18 + $branchIndex,
'platelet_units' => 10 + $branchIndex,
'ffp_units' => 12 + $branchIndex,
'cryo_units' => 5,
'low_stock_alert' => false,
'notes' => 'Demo facility stock count for blood bank.',
];
}
/**
* Seed refraction + eye exam (+ optional plan) on open ophthalmology visits.
*/
private function seedOphthalmologyClinicalDemo(Organization $organization, string $ownerRef): void
{
$branchIds = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->pluck('id');
if ($branchIds->isEmpty()) {
return;
}
$departmentIds = Department::owned($ownerRef)
->whereIn('branch_id', $branchIds)
->where('type', 'ophthalmology')
->where('is_active', true)
->pluck('id');
if ($departmentIds->isEmpty()) {
return;
}
$appointments = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('department_id', $departmentIds)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->whereNotNull('visit_id')
->with('visit')
->orderBy('branch_id')
->orderBy('id')
->get();
if ($appointments->isEmpty()) {
return;
}
$clinical = app(SpecialtyClinicalRecordService::class);
$index = 0;
foreach ($appointments as $appointment) {
$visit = $appointment->visit;
if (! $visit) {
continue;
}
$elevated = $index === 0;
$clinical->upsert(
$organization,
$visit,
'ophthalmology',
'refraction',
[
'va_od' => $elevated ? '6/60' : '6/9',
'va_os' => $elevated ? '6/36' : '6/6',
'va_od_near' => 'N8',
'va_os_near' => 'N6',
'sphere_od' => $elevated ? '-2.50' : '-0.75',
'cylinder_od' => '-0.50',
'axis_od' => '90',
'sphere_os' => $elevated ? '-1.75' : '-0.50',
'cylinder_os' => '-0.25',
'axis_os' => '85',
'add' => '+1.50',
'pd' => 62,
'notes' => 'Demo refraction for eye care suite.',
],
$ownerRef,
$ownerRef,
'refraction',
);
$clinical->upsert(
$organization,
$visit,
'ophthalmology',
'eye_exam',
[
'chief_complaint' => $elevated ? 'Blurry vision and headache' : 'Routine eye exam',
'history' => $elevated
? 'Gradual reduction in vision over 2 weeks; intermittent haloes.'
: 'Annual review; no acute symptoms.',
'va_od' => $elevated ? '6/60' : '6/9',
'va_os' => $elevated ? '6/36' : '6/6',
'iop_od' => $elevated ? 32 : 16,
'iop_os' => $elevated ? 28 : 15,
'pupils' => 'Equal, reactive',
'extraocular' => 'Full',
'anterior_segment' => $elevated ? 'Quiet; possible early cataract OD' : 'Quiet',
'fundus' => $elevated ? 'Cup:disc elevated OD; review for glaucoma' : 'Healthy discs',
'findings' => $elevated
? 'Elevated IOP with reduced acuity — urgent glaucoma work-up.'
: 'Normal examination; refraction updated.',
],
$ownerRef,
$ownerRef,
'exam',
);
if ($elevated) {
$clinical->upsert(
$organization,
$visit,
'ophthalmology',
'eye_plan',
[
'diagnosis' => 'Suspected primary open-angle glaucoma',
'laterality' => 'OU',
'plan' => 'Start topical IOP-lowering drops; arrange OCT and visual fields.',
'medications' => 'Latanoprost 0.005% OU nocte',
'procedure_planned' => false,
'follow_up' => '2 weeks',
'advice' => 'Return sooner if vision worsens or severe eye pain.',
],
$ownerRef,
$ownerRef,
'plan',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $elevated ? 'plan' : 'exam']);
}
$index++;
}
}
/**
* Seed assessment + plan (+ optional session) on open physiotherapy visits.
*/
private function seedPhysiotherapyClinicalDemo(Organization $organization, string $ownerRef): void
{
$branchIds = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->pluck('id');
if ($branchIds->isEmpty()) {
return;
}
$departmentIds = Department::owned($ownerRef)
->whereIn('branch_id', $branchIds)
->where('type', 'physiotherapy')
->where('is_active', true)
->pluck('id');
if ($departmentIds->isEmpty()) {
return;
}
$appointments = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('department_id', $departmentIds)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->whereNotNull('visit_id')
->with('visit')
->orderBy('branch_id')
->orderBy('id')
->get();
if ($appointments->isEmpty()) {
return;
}
$clinical = app(SpecialtyClinicalRecordService::class);
$index = 0;
foreach ($appointments as $appointment) {
$visit = $appointment->visit;
if (! $visit) {
continue;
}
$highPain = $index === 0;
$clinical->upsert(
$organization,
$visit,
'physiotherapy',
'pt_assessment',
[
'chief_complaint' => $highPain ? 'Severe low back pain after lifting' : 'Knee stiffness post-injury',
'onset' => $highPain ? 'Acute — 3 days ago' : 'Subacute — 3 weeks',
'pain_score' => $highPain ? 9 : 4,
'body_region' => $highPain ? 'Back / lumbar' : 'Knee',
'rom' => $highPain ? 'Lumbar flexion limited, guarded' : 'Knee flexion 90°, extension -5°',
'strength' => $highPain ? 'Core activation poor' : 'Quadriceps 4/5',
'special_tests' => $highPain ? 'SLR positive right' : 'McMurray negative',
'red_flags' => $highPain ? 'Night pain; no saddle anaesthesia' : '',
'goals' => $highPain ? 'Reduce pain and restore safe lifting' : 'Restore full ROM and return to sport',
],
$ownerRef,
$ownerRef,
'assessment',
);
$clinical->upsert(
$organization,
$visit,
'physiotherapy',
'pt_plan',
[
'diagnosis' => $highPain ? 'Acute lumbar strain' : 'Post-traumatic knee stiffness',
'frequency' => '23× / week',
'duration_weeks' => $highPain ? 4 : 6,
'interventions' => $highPain
? 'Manual therapy, core activation, graded activity'
: 'ROM drills, strengthening, proprioception',
'precautions' => $highPain ? 'Avoid heavy flexion under load' : 'No pivoting until week 4',
'goals' => 'Pain <3/10 and functional mobility',
'outcome_measures' => 'VAS, Oswestry / LEFS',
],
$ownerRef,
$ownerRef,
'treatment_plan',
);
if ($highPain) {
$clinical->upsert(
$organization,
$visit,
'physiotherapy',
'pt_session',
[
'session_number' => 1,
'modalities' => 'Heat, soft-tissue mobilisation',
'exercises' => 'Pelvic tilts, bird-dog, walking',
'pain_before' => 9,
'pain_after' => 6,
'response' => 'Tolerated well; pain eased post-session',
'outcome' => 'Completed — continue plan',
'next_review' => '2 days',
'notes' => 'Demo physio session.',
],
$ownerRef,
$ownerRef,
'session',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highPain ? 'session' : 'treatment_plan']);
}
$index++;
}
}
/**
* Seed ANC history + exam (+ optional plan) on open maternity visits.
*/
private function seedMaternityClinicalDemo(Organization $organization, string $ownerRef): void
{
$branchIds = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->pluck('id');
if ($branchIds->isEmpty()) {
return;
}
$departmentIds = Department::owned($ownerRef)
->whereIn('branch_id', $branchIds)
->where('type', 'maternity')
->where('is_active', true)
->pluck('id');
if ($departmentIds->isEmpty()) {
return;
}
$appointments = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('department_id', $departmentIds)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->whereNotNull('visit_id')
->with('visit')
->orderBy('branch_id')
->orderBy('id')
->get();
if ($appointments->isEmpty()) {
return;
}
$clinical = app(SpecialtyClinicalRecordService::class);
$index = 0;
foreach ($appointments as $appointment) {
$visit = $appointment->visit;
if (! $visit) {
continue;
}
$highRisk = $index === 0;
$clinical->upsert(
$organization,
$visit,
'maternity',
'anc_history',
[
'gravida' => $highRisk ? 3 : 1,
'para' => $highRisk ? 1 : 0,
'lmp' => '12 Jan 2026',
'edd' => '19 Oct 2026',
'gestational_age_weeks' => $highRisk ? 34 : 22,
'anc_visit_number' => $highRisk ? 6 : 3,
'obstetric_history' => $highRisk ? 'Previous CS; one miscarriage' : 'Primigravida',
'medical_history' => $highRisk ? 'Hypertension in pregnancy' : 'None significant',
'allergies' => 'None known',
'medications' => 'IFAS, calcium',
'social_history' => 'Lives with partner; support available',
],
$ownerRef,
$ownerRef,
'history',
);
$clinical->upsert(
$organization,
$visit,
'maternity',
'obstetric_exam',
[
'bp' => $highRisk ? '158/102' : '118/74',
'pulse' => $highRisk ? 92 : 78,
'weight_kg' => 72,
'fundal_height' => $highRisk ? 33 : 22,
'presentation' => 'Cephalic',
'lie' => 'Longitudinal',
'engagement' => $highRisk ? '3/5' : 'Free',
'oedema' => $highRisk ? 'Moderate' : 'None',
'urine' => $highRisk ? 'Protein +1' : 'NAD',
'danger_signs' => $highRisk ? 'Headache; visual spots' : '',
'findings' => $highRisk
? 'Hypertension with oedema — escalate high-risk ANC'
: 'Normal ANC examination',
],
$ownerRef,
$ownerRef,
'exam',
);
$clinical->upsert(
$organization,
$visit,
'maternity',
'fetal_notes',
[
'fetal_heart_rate' => $highRisk ? 168 : 142,
'fetal_movements' => $highRisk ? 'Reduced' : 'Normal',
'presentation' => 'Cephalic',
'liquor' => 'Adequate clinically',
'ctg_summary' => $highRisk ? 'Baseline raised; observe' : 'Not indicated',
'notes' => 'Demo fetal notes for maternity suite.',
],
$ownerRef,
$ownerRef,
'exam',
);
if ($highRisk) {
$clinical->upsert(
$organization,
$visit,
'maternity',
'mat_plan',
[
'risk_category' => 'High risk',
'plan' => 'Urgent senior review; BP control; fetal monitoring; consider admission.',
'medications' => 'Continue antihypertensives as prescribed',
'education' => 'Return immediately if headache, bleeding, or reduced movements',
'postnatal_needed' => false,
'follow_up' => 'Same day review',
'referral' => 'Obstetric high-risk clinic',
],
$ownerRef,
$ownerRef,
'plan',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']);
}
$index++;
}
}
private function seedDentistryClinicalDemo(Organization $organization, string $ownerRef): void
{
// Specialty departments are provisioned without organization_id; scope via branches.
$branchIds = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->pluck('id');
if ($branchIds->isEmpty()) {
return;
}
$departmentIds = Department::owned($ownerRef)
->whereIn('branch_id', $branchIds)
->where('type', 'dental')
->where('is_active', true)
->pluck('id');
if ($departmentIds->isEmpty()) {
return;
}
$appointment = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('department_id', $departmentIds)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN, Appointment::STATUS_IN_CONSULTATION])
->whereNotNull('visit_id')
->with(['patient', 'visit'])
->latest('id')
->first();
if (! $appointment?->patient || ! $appointment->visit) {
return;
}
$charts = app(\App\Services\Care\Dentistry\DentalChartService::class);
$plans = app(\App\Services\Care\Dentistry\DentalTreatmentPlanService::class);
$procedures = app(\App\Services\Care\Dentistry\DentalProcedureService::class);
$chart = $charts->chartForPatient($organization, $appointment->patient, $ownerRef);
$charts->updateTooth($chart, '16', [
'status' => 'present',
'surfaces' => ['O'],
'conditions' => ['caries'],
'notes' => 'Demo occlusal caries',
], $ownerRef, $ownerRef, $appointment->visit);
$plan = $plans->ensurePlan($organization, $appointment->patient, $ownerRef, $appointment->visit, $ownerRef);
if ($plan->items()->count() === 0) {
$plans->addItem($plan, $organization, [
'procedure_code' => 'den.fill',
'tooth_code' => '16',
'surfaces' => ['O'],
'priority' => 10,
'notes' => 'Demo filling',
], $ownerRef, $ownerRef);
$plans->accept($plan->fresh('items'), $ownerRef, $ownerRef);
}
$completedPatient = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('department_id', $departmentIds)
->where('status', Appointment::STATUS_COMPLETED)
->whereNotNull('visit_id')
->with('visit')
->latest('id')
->first();
if ($completedPatient?->visit && ! \App\Models\DentalProcedure::owned($ownerRef)->where('visit_id', $completedPatient->visit_id)->exists()) {
try {
$procedures->complete($organization, $completedPatient->visit, [
'procedure_code' => 'den.fill',
'tooth_code' => '26',
'surfaces' => ['O'],
'anesthesia' => 'Local',
'bill' => true,
'notes' => 'Demo completed fill',
], $ownerRef, $ownerRef);
} catch (\Throwable) {
// Billing may be constrained in partial seeds — chart/plan still valuable.
}
}
$perio = app(\App\Services\Care\Dentistry\DentalPerioService::class);
if (! \App\Models\DentalPerioExam::owned($ownerRef)->where('patient_id', $appointment->patient_id)->exists()) {
$perio->saveExam($organization, $appointment->patient, $ownerRef, [
['tooth_code' => '16', 'surface' => 'B', 'probing_mm' => 3, 'bleeding' => true, 'plaque' => false],
['tooth_code' => '16', 'surface' => 'L', 'probing_mm' => 2, 'bleeding' => false, 'plaque' => true],
], $appointment->visit, 'Demo perio chart', $ownerRef);
}
$lab = app(\App\Services\Care\Dentistry\DentalLabCaseService::class);
if (! \App\Models\DentalLabCase::owned($ownerRef)->where('patient_id', $appointment->patient_id)->exists()) {
$lab->create($organization, $appointment->patient, $ownerRef, [
'case_type' => 'crown',
'lab_name' => 'Demo Dental Lab',
'tooth_codes' => ['16'],
'status' => 'ordered',
'due_at' => now()->addDays(14)->toDateString(),
'notes' => 'Demo crown case',
], $appointment->visit, $ownerRef);
}
$recalls = app(\App\Services\Care\Dentistry\DentalRecallService::class);
if (! \App\Models\DentalRecall::owned($ownerRef)->where('patient_id', $appointment->patient_id)->exists()) {
$recalls->schedule($organization, $appointment->patient, $ownerRef, [
'due_on' => now()->addMonths(6)->toDateString(),
'reason' => '6-month check',
'notes' => 'Demo recall',
], $ownerRef);
}
if (! $appointment->visit->specialty_stage) {
$appointment->visit->update(['specialty_stage' => 'chair']);
}
}
/**
* @param list<Branch> $branches
* @param array<int, list<Department>> $departments
* @param array<string, mixed> $volumes
* @return list<Practitioner>
*/
private function seedPractitioners(
Organization $organization,
array $branches,
array $departments,
string $ownerRef,
array $volumes,
): array {
$practitioners = [];
for ($i = 0; $i < $volumes['practitioners']; $i++) {
$branch = $branches[$i % count($branches)];
$branchDepts = $departments[$branch->id] ?? [];
$department = $branchDepts[$i % max(1, count($branchDepts))] ?? null;
$key = sprintf('demo-prac-%s-%02d', $ownerRef, $i + 1);
$practitioners[] = Practitioner::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $key,
],
[
'owner_ref' => $ownerRef,
'branch_id' => $branch->id,
'department_id' => $department?->id,
'member_id' => null,
'name' => 'Dr. '.self::FIRST_NAMES[$i % count(self::FIRST_NAMES)].' '.self::LAST_NAMES[$i % count(self::LAST_NAMES)],
'specialty' => self::SPECIALTIES[$i % count(self::SPECIALTIES)],
'room' => 'Consultation Room '.($i + 1),
'is_active' => true,
'deleted_at' => null,
],
);
$practitioners[array_key_last($practitioners)]->syncAssignedBranches([(int) $branch->id]);
}
return $practitioners;
}
/**
* @param list<Branch> $branches
* @param array<string, mixed> $volumes
* @return list<Patient>
*/
private function seedPatients(
Organization $organization,
array $branches,
string $ownerRef,
array $volumes,
): array {
$worldPeople = DemoWorld::people();
$patients = [];
for ($i = 0; $i < $volumes['patients']; $i++) {
$branch = $branches[$i % count($branches)];
$number = sprintf('DEMO-%s-%05d', Str::upper(Str::substr(md5($ownerRef), 0, 4)), $i + 1);
$person = $worldPeople[$i] ?? null;
if ($person === null) {
$base = $worldPeople[$i % count($worldPeople)];
$seq = $i + 1;
$person = [
'first_name' => $base['first_name'],
'last_name' => $base['last_name'],
'gender' => $base['gender'],
'dob' => $base['dob'],
'phone' => '+23324'.str_pad((string) (1000000 + $seq), 7, '0', STR_PAD_LEFT),
'email' => sprintf('patient.%d@demo.ladill.com', $seq),
'national_id' => sprintf('GHA-729%06d', $seq),
'city' => $base['city'],
'address' => $base['address'],
];
}
$patients[] = Patient::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'patient_number' => $number,
],
[
'uuid' => $this->demoUuid("patient|{$ownerRef}|{$i}"),
'owner_ref' => $ownerRef,
'branch_id' => $branch->id,
'first_name' => $person['first_name'],
'last_name' => $person['last_name'],
'gender' => $person['gender'],
'date_of_birth' => $person['dob'],
'phone' => $person['phone'],
'email' => $person['email'],
'national_id' => $person['national_id'],
'city' => $person['city'],
'region' => 'Greater Accra',
'address' => $person['address'],
'deleted_at' => null,
],
);
}
return $patients;
}
/**
* @param list<Branch> $branches
* @param array<int, list<Department>> $departments
* @param list<Practitioner> $practitioners
* @param list<Patient> $patients
* @param array<string, mixed> $volumes
*/
private function seedAppointmentsAndClinical(
Organization $organization,
array $branches,
array $departments,
array $practitioners,
array $patients,
string $ownerRef,
array $volumes,
): int {
$statuses = [
Appointment::STATUS_SCHEDULED,
Appointment::STATUS_CHECKED_IN,
Appointment::STATUS_WAITING,
Appointment::STATUS_COMPLETED,
Appointment::STATUS_COMPLETED,
Appointment::STATUS_CANCELLED,
];
$reasons = ['Follow-up', 'New complaint', 'Review labs', 'Chronic care'];
$symptoms = ['Fever', 'Cough', 'Headache', 'Fatigue'];
$count = 0;
for ($i = 0; $i < $volumes['appointments']; $i++) {
$patient = $patients[$i % count($patients)];
$practitioner = $practitioners[$i % count($practitioners)];
$branch = collect($branches)->first(
fn (Branch $b) => (int) $b->id === (int) $practitioner->branch_id,
) ?? $branches[$i % count($branches)];
$branchDepts = $departments[(int) $practitioner->branch_id] ?? ($departments[$branch->id] ?? []);
// Align department to the practitioner desk so Queue tickets stay consultation-context.
$department = collect($branchDepts)->first(
fn (Department $dept) => (int) $dept->id === (int) ($practitioner->department_id ?? 0),
) ?? ($branchDepts[$i % max(1, count($branchDepts))] ?? null);
$status = $statuses[$i % count($statuses)];
$scheduledAt = now()->subDays(14)->addHours($i * 3);
$visit = null;
if (in_array($status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_WAITING, Appointment::STATUS_COMPLETED], true)) {
$visit = Visit::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("visit|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => $status === Appointment::STATUS_COMPLETED
? Visit::STATUS_COMPLETED
: Visit::STATUS_IN_PROGRESS,
'checked_in_at' => $scheduledAt->copy()->addMinutes(5),
'completed_at' => $status === Appointment::STATUS_COMPLETED
? $scheduledAt->copy()->addHour()
: null,
'checked_in_by' => $ownerRef,
'deleted_at' => null,
],
);
}
Appointment::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("appt|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'practitioner_id' => $practitioner->id,
'department_id' => $department?->id,
'visit_id' => $visit?->id,
'type' => $i % 5 === 0 ? Appointment::TYPE_WALK_IN : Appointment::TYPE_SCHEDULED,
'status' => $status,
'scheduled_at' => $scheduledAt,
'checked_in_at' => $visit?->checked_in_at,
'waiting_at' => in_array($status, [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN], true)
? $scheduledAt->copy()->addMinutes(5)
: null,
'queue_position' => in_array($status, [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN], true)
? ($i + 1)
: null,
'completed_at' => $status === Appointment::STATUS_COMPLETED
? $scheduledAt->copy()->addHour()
: null,
'cancelled_at' => $status === Appointment::STATUS_CANCELLED
? $scheduledAt->copy()->subHour()
: null,
'reason' => $reasons[$i % count($reasons)],
'created_by' => $ownerRef,
'deleted_at' => null,
],
);
if ($visit && $status === Appointment::STATUS_COMPLETED) {
$consultation = Consultation::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("consult|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'visit_id' => $visit->id,
'appointment_id' => null,
'practitioner_id' => $practitioner->id,
'patient_id' => $patient->id,
'status' => Consultation::STATUS_COMPLETED,
'symptoms' => $symptoms[$i % count($symptoms)],
'clinical_notes' => 'Demo consultation notes.',
'started_at' => $scheduledAt->copy()->addMinutes(15),
'completed_at' => $scheduledAt->copy()->addHour(),
'completed_by' => $ownerRef,
'deleted_at' => null,
],
);
VitalSign::query()->updateOrCreate(
[
'consultation_id' => $consultation->id,
'recorded_at' => $scheduledAt->copy()->addMinutes(10),
],
[
'owner_ref' => $ownerRef,
'bp_systolic' => 110 + ($i % 30),
'bp_diastolic' => 70 + ($i % 15),
'pulse' => 60 + ($i % 40),
'temperature' => 36.5 + (($i % 10) / 10),
'weight_kg' => 55 + ($i % 40),
'height_cm' => 150 + ($i % 40),
'spo2' => 95 + ($i % 5),
'respiratory_rate' => 14 + ($i % 6),
'recorded_by' => $ownerRef,
],
);
}
$count++;
}
return $count;
}
/**
* @param list<Branch> $branches
* @param list<Practitioner> $practitioners
* @param list<Patient> $patients
* @param array<string, mixed> $volumes
*/
/**
* Demo hardware inventory: browser wedge scanner + agent thermometer per branch (Pro+).
*
* @param list<Branch> $branches
*/
private function seedDevices(Organization $organization, array $branches, string $ownerRef): void
{
$deviceService = app(DeviceService::class);
foreach ($branches as $i => $branch) {
Device::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'name' => 'Demo barcode scanner',
],
[
'owner_ref' => $ownerRef,
'type' => 'barcode_scanner',
'connection_mode' => Device::MODE_BROWSER_WEDGE,
'status' => Device::STATUS_OFFLINE,
'device_token_hash' => null,
'metadata' => ['demo' => true],
'deleted_at' => null,
],
);
$thermo = Device::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'name' => 'Demo thermometer',
],
[
'owner_ref' => $ownerRef,
'type' => 'thermometer',
'connection_mode' => Device::MODE_AGENT,
'status' => Device::STATUS_OFFLINE,
'metadata' => ['demo' => true, 'note' => 'Token issued on first seed only if missing'],
'deleted_at' => null,
],
);
if (! $thermo->device_token_hash) {
// Deterministic demo token so sales can reuse docs examples after reseed.
$plain = 'demo-care-device-'.$organization->id.'-'.$branch->id.'-thermo';
$thermo->update(['device_token_hash' => $deviceService->hashToken($plain)]);
}
}
}
/**
* @return list<InvestigationType>
*/
private function seedInvestigationCatalog(Organization $organization, string $ownerRef): array
{
$types = [];
foreach ([
['name' => 'Fasting Blood Glucose', 'code' => 'FBG', 'category' => 'blood', 'unit' => 'mmol/L', 'low' => 3.9, 'high' => 6.1, 'price' => 2500],
['name' => 'Full Blood Count', 'code' => 'FBC', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 4500],
['name' => 'Malaria Parasite', 'code' => 'MP', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 1500],
['name' => 'Urinalysis', 'code' => 'UA', 'category' => 'urine', 'unit' => null, 'low' => null, 'high' => null, 'price' => 2000],
['name' => 'HbA1c', 'code' => 'HBA1C', 'category' => 'blood', 'unit' => '%', 'low' => null, 'high' => 5.7, 'price' => 6000],
['name' => 'Lipid Profile', 'code' => 'LIPID', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 5500],
['name' => 'Liver Function Test', 'code' => 'LFT', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 5000],
['name' => 'Kidney Function Test', 'code' => 'KFT', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 5000],
['name' => 'Blood Group & Rh', 'code' => 'BGRH', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 1800],
['name' => 'Pregnancy Test (Urine)', 'code' => 'HCG', 'category' => 'urine', 'unit' => null, 'low' => null, 'high' => null, 'price' => 1200],
['name' => 'Stool Routine Examination', 'code' => 'SRE', 'category' => 'stool', 'unit' => null, 'low' => null, 'high' => null, 'price' => 2200],
['name' => 'Chest X-Ray', 'code' => 'CXR', 'category' => 'xray', 'unit' => null, 'low' => null, 'high' => null, 'price' => 8000],
] as $spec) {
$types[] = InvestigationType::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'code' => $spec['code'],
],
[
'owner_ref' => $ownerRef,
'name' => $spec['name'],
'category' => $spec['category'],
'unit' => $spec['unit'],
'reference_low' => $spec['low'],
'reference_high' => $spec['high'],
'price_minor' => $spec['price'],
'is_active' => true,
'deleted_at' => null,
],
);
}
return $types;
}
/**
* @param list<Branch> $branches
* @param list<Practitioner> $practitioners
* @param list<Patient> $patients
* @param list<InvestigationType> $types
*/
private function seedPaidModules(
Organization $organization,
array $branches,
array $practitioners,
array $patients,
string $ownerRef,
array $volumes,
array $types = [],
): void {
if ($types === []) {
$types = $this->seedInvestigationCatalog($organization, $ownerRef);
}
$drugs = [];
foreach ([
['name' => 'Amoxicillin 500mg', 'unit' => 'capsule', 'price' => 1500],
['name' => 'Paracetamol 500mg', 'unit' => 'tablet', 'price' => 50],
['name' => 'Metformin 500mg', 'unit' => 'tablet', 'price' => 80],
['name' => 'Amlodipine 5mg', 'unit' => 'tablet', 'price' => 120],
] as $i => $spec) {
$drug = Drug::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'sku' => 'DEMO-DRUG-'.($i + 1),
],
[
'owner_ref' => $ownerRef,
'branch_id' => $branches[0]->id,
'name' => $spec['name'],
'generic_name' => $spec['name'],
'unit' => $spec['unit'],
'unit_price_minor' => $spec['price'],
'reorder_level' => 20,
'is_active' => true,
'deleted_at' => null,
],
);
DrugBatch::query()->updateOrCreate(
[
'drug_id' => $drug->id,
'batch_number' => 'DEMO-BATCH-'.($i + 1),
],
[
'owner_ref' => $ownerRef,
'expiry_date' => now()->addYear()->toDateString(),
'quantity_on_hand' => 200,
'cost_minor' => (int) ($spec['price'] * 0.6),
'received_at' => now()->subDays(10),
],
);
$drugs[] = $drug;
}
if ($patients === [] || $practitioners === [] || $types === [] || $branches === []) {
return;
}
for ($i = 0; $i < $volumes['lab_requests']; $i++) {
$patient = $patients[$i % count($patients)];
$branch = $branches[$i % count($branches)];
$practitioner = $practitioners[$i % count($practitioners)];
$type = $types[$i % count($types)];
$visit = Visit::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("lab-visit|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => Visit::STATUS_IN_PROGRESS,
'checked_in_at' => now()->subDays($i % 10),
'checked_in_by' => $ownerRef,
'deleted_at' => null,
],
);
$consultation = Consultation::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("lab-consult|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'visit_id' => $visit->id,
'practitioner_id' => $practitioner->id,
'patient_id' => $patient->id,
'status' => Consultation::STATUS_DRAFT,
'started_at' => now()->subDays($i % 10),
'deleted_at' => null,
],
);
InvestigationRequest::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("lab-req|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'visit_id' => $visit->id,
'consultation_id' => $consultation->id,
'patient_id' => $patient->id,
'investigation_type_id' => $type->id,
'practitioner_id' => $practitioner->id,
'status' => InvestigationRequest::STATUS_PENDING,
'priority' => 'routine',
'clinical_notes' => 'Demo lab request',
'requested_by' => $ownerRef,
'deleted_at' => null,
],
);
}
for ($i = 0; $i < $volumes['prescriptions']; $i++) {
$patient = $patients[$i % count($patients)];
$branch = $branches[$i % count($branches)];
$practitioner = $practitioners[$i % count($practitioners)];
$drug = $drugs[$i % count($drugs)];
$visit = Visit::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("rx-visit|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => Visit::STATUS_IN_PROGRESS,
'checked_in_at' => now()->subDays($i % 7),
'checked_in_by' => $ownerRef,
'deleted_at' => null,
],
);
$consultation = Consultation::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("rx-consult|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'visit_id' => $visit->id,
'practitioner_id' => $practitioner->id,
'patient_id' => $patient->id,
'status' => Consultation::STATUS_DRAFT,
'started_at' => now()->subDays($i % 7),
'deleted_at' => null,
],
);
$prescription = Prescription::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("rx|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'visit_id' => $visit->id,
'consultation_id' => $consultation->id,
'patient_id' => $patient->id,
'practitioner_id' => $practitioner->id,
'status' => Prescription::STATUS_ACTIVE,
'prescribed_by' => $ownerRef,
'deleted_at' => null,
],
);
$prescription->items()->updateOrCreate(
[
'prescription_id' => $prescription->id,
'name' => $drug->name,
],
[
'owner_ref' => $ownerRef,
'drug_id' => $drug->id,
'dosage' => '1 '.$drug->unit,
'frequency' => 'BD',
'duration' => '5 days',
'quantity' => 10,
'sort_order' => 0,
],
);
}
$branchCount = max(1, count($branches));
for ($i = 0; $i < $volumes['bills']; $i++) {
$patient = $patients[$i % count($patients)];
$branch = $branches[$i % $branchCount];
$fee = 5000 + (($i % 5) * 1000);
// Payment mix is per-branch (not i%3 with branchCount=3), otherwise HQ
// only ever gets paid invoices and the Ridge cashier has nothing to collect.
$slot = intdiv($i, $branchCount) % 3;
if ($slot === 0) {
$status = Bill::STATUS_PAID;
$amountPaid = $fee;
$balance = 0;
} elseif ($slot === 1) {
$status = Bill::STATUS_PARTIAL;
$amountPaid = (int) floor($fee / 2);
$balance = $fee - $amountPaid;
} else {
$status = Bill::STATUS_OPEN;
$amountPaid = 0;
$balance = $fee;
}
$visit = Visit::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("bill-visit|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => Visit::STATUS_COMPLETED,
'checked_in_at' => now()->subDays($i % 12),
'completed_at' => now()->subDays($i % 12)->addHour(),
'checked_in_by' => $ownerRef,
'deleted_at' => null,
],
);
$bill = Bill::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("bill|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'visit_id' => $visit->id,
'patient_id' => $patient->id,
'invoice_number' => sprintf('DEMO-INV-%s-%04d', Str::upper(Str::substr(md5($ownerRef), 0, 4)), $i + 1),
'status' => $status,
'subtotal_minor' => $fee,
'discount_minor' => 0,
'tax_minor' => 0,
'total_minor' => $fee,
'amount_paid_minor' => $amountPaid,
'balance_minor' => $balance,
'created_by' => $ownerRef,
'finalized_at' => now()->subDays($i % 12),
'deleted_at' => null,
],
);
BillLineItem::query()->updateOrCreate(
[
'bill_id' => $bill->id,
'description' => 'Consultation fee',
],
[
'owner_ref' => $ownerRef,
'type' => 'consultation',
'quantity' => 1,
'unit_price_minor' => $fee,
'total_minor' => $fee,
],
);
$paymentUuid = $this->demoUuid("pay|{$ownerRef}|{$i}");
if ($amountPaid > 0) {
Payment::query()->updateOrCreate(
['uuid' => $paymentUuid],
[
'owner_ref' => $ownerRef,
'bill_id' => $bill->id,
'amount_minor' => $amountPaid,
'method' => Payment::METHOD_CASH,
'status' => Payment::STATUS_PAID,
'reference' => 'DEMO-PAY-'.($i + 1),
'paid_at' => now()->subDays($i % 12),
'recorded_by' => $ownerRef,
],
);
} else {
Payment::query()->where('uuid', $paymentUuid)->delete();
}
}
}
/**
* @param list<Branch> $branches
* @param list<Practitioner> $practitioners
* @param list<Patient> $patients
*/
private function seedAssessments(
Organization $organization,
array $branches,
array $practitioners,
array $patients,
string $ownerRef,
int $count,
): void {
$template = AssessmentTemplate::withTrashed()->updateOrCreate(
[
'code' => self::DEMO_TEMPLATE_CODE,
'version' => 1,
],
[
'organization_id' => $organization->id,
'name' => 'Demo Triage Screen',
'category' => AssessmentTemplate::CATEGORY_SCREENING,
'description' => 'Lightweight demo assessment for enterprise analytics.',
'is_current' => true,
'is_active' => true,
'scoring_strategy' => 'sum_items',
'deleted_at' => null,
],
);
$question = AssessmentQuestion::query()->updateOrCreate(
[
'template_id' => $template->id,
'code' => 'severity',
],
[
'section' => 'Triage',
'label' => 'Severity (0-10)',
'answer_type' => AssessmentQuestion::TYPE_SCALE,
'is_required' => true,
'sort_order' => 1,
'score_key' => 'severity',
'options' => ['min' => 0, 'max' => 10],
],
);
for ($i = 0; $i < $count; $i++) {
$patient = $patients[$i % count($patients)];
$branch = $branches[$i % count($branches)];
$practitioner = $practitioners[$i % count($practitioners)];
$scoreValue = (float) ($i % 11);
$assessment = Assessment::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("assess|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'template_id' => $template->id,
'practitioner_id' => $practitioner->id,
'status' => Assessment::STATUS_COMPLETED,
'assessed_at' => now()->subDays($i % 30),
'completed_at' => now()->subDays($i % 30),
'completed_by' => $ownerRef,
'started_by' => $ownerRef,
'notes' => 'Demo assessment',
'deleted_at' => null,
],
);
AssessmentAnswer::query()->updateOrCreate(
[
'assessment_id' => $assessment->id,
'question_id' => $question->id,
],
[
'owner_ref' => $ownerRef,
'value_number' => $scoreValue,
],
);
AssessmentScore::query()->updateOrCreate(
['assessment_id' => $assessment->id],
[
'owner_ref' => $ownerRef,
'template_code' => self::DEMO_TEMPLATE_CODE,
'total_score' => $scoreValue,
'max_score' => 10,
'subscores' => ['severity' => $scoreValue],
'severity_label' => $scoreValue >= 7 ? 'high' : ($scoreValue >= 4 ? 'moderate' : 'low'),
'computed_at' => now()->subDays($i % 30),
],
);
}
}
private function demoUuid(string $key): string
{
$hex = md5('ladill-care-demo|'.$key);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12),
);
}
}