Files
ladill-care/app/Services/Care/DemoTenantSeeder.php
T
isaaccladandCursor 88b5a4e3ab
Deploy Ladill Care / deploy (push) Successful in 1m33s
Skip Queue HTTP entirely during Care demo seed.
Enterprise reseeds were blocked for minutes on queue.ladill.com calls; local specialty data still seeds, and Queue tickets can backfill on first visit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 22:50:26 +00:00

1390 lines
56 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) {
DB::transaction(fn () => $this->resetTenant($ownerRef));
$this->ensureOnboardedShell($user, $plan);
}
return DB::transaction(function () use ($user, $ownerRef, $plan, $volumes) {
$organization = $this->upsertOrganization($user, $ownerRef, $plan, $volumes['branches']);
$this->upsertOwnerMember($organization, $ownerRef);
$branches = $this->seedBranches($organization, $ownerRef, $volumes);
$this->seedStaffMembers($organization, $ownerRef, $plan, $branches);
$departments = $this->seedDepartments($branches, $ownerRef, $volumes);
if (in_array($plan, ['pro', 'enterprise'], true)) {
$this->seedSpecialtyModules($organization, $ownerRef);
$departments = $this->departmentsByBranch($branches, $ownerRef);
}
$practitioners = $this->seedPractitioners($organization, $branches, $departments, $ownerRef, $volumes);
$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,
);
}
if ($volumes['paid_modules']) {
$this->seedPaidModules(
$organization,
$branches,
$practitioners,
$patients,
$ownerRef,
$volumes,
);
$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
{
// Intentionally no-op during demo seed. Queue HTTP (branch × context) can
// take many minutes on Enterprise volumes and hung production reseeds.
// Specialty/care waiting lists still come from local appointments; Queue
// tickets backfill lazily on first Ops / specialty page visit.
}
/**
* 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();
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;
}
/**
* Local Member rows for DemoWorld staff (user_ref = email until SSO remaps to public_id).
*
* @param list<Branch> $branches
*/
private function seedStaffMembers(Organization $organization, string $ownerRef, string $plan, array $branches): void
{
$hq = $branches[0] ?? null;
foreach (DemoWorld::staff($plan) as $staff) {
$role = $staff['roles']['care'] ?? null;
if (! is_string($role) || $role === '') {
continue;
}
Member::query()->updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => strtolower($staff['email']),
],
[
'owner_ref' => $ownerRef,
'role' => $role,
'branch_id' => in_array($role, ['hospital_admin'], true) ? null : $hq?->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
{
// Pause Queue HTTP during per-module activate so Enterprise demos (many
// branches × specialties) do not spend minutes on remote timeouts.
$organization = $organization->fresh();
$settings = $organization->settings ?? [];
$queueEnabled = (bool) data_get($settings, 'queue_integration_enabled');
if ($queueEnabled) {
data_set($settings, 'queue_integration_enabled', false);
$organization->update(['settings' => $settings]);
}
$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) {
$organization = $organization->fresh();
$settings = $organization->settings ?? [];
data_set($settings, 'queue_integration_enabled', true);
$organization->update(['settings' => $settings]);
// Skip CareQueueProvisioner here — same Queue HTTP storm as ticket sync.
}
}
/**
* @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,
): int {
if ($branches === [] || $patients === [] || $practitioners === []) {
return 0;
}
$reasons = [
'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'],
];
$count = 0;
$catalog = config('care.specialty_modules', []);
foreach ($catalog as $key => $definition) {
$type = (string) ($definition['department_type'] ?? '');
if ($type === '') {
continue;
}
$moduleReasons = $reasons[$key] ?? ['Specialty visit'];
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;
}
$practitioner = Practitioner::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => sprintf('demo-specialty-%s-%s', $key, $branch->id),
],
[
'owner_ref' => $ownerRef,
'branch_id' => $branch->id,
'department_id' => $department->id,
'member_id' => null,
'name' => ($definition['label'] ?? ucfirst($key)).' Clinic',
'specialty' => (string) ($definition['label'] ?? $key),
'room' => ($definition['label'] ?? 'Specialty').' Bay',
'is_active' => true,
'deleted_at' => null,
],
);
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,
],
);
}
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,
'completed_at' => $status === Appointment::STATUS_COMPLETED
? $scheduledAt->copy()->addHour()
: null,
'cancelled_at' => null,
'queue_position' => $slot + 1,
'reason' => $moduleReasons[$slot % count($moduleReasons)],
'created_by' => $ownerRef,
'deleted_at' => null,
],
);
$count++;
}
}
}
return $count;
}
/**
* @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,
],
);
}
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 = $branches[$i % count($branches)];
$branchDepts = $departments[$branch->id] ?? [];
$department = $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,
'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)]);
}
}
}
private function seedPaidModules(
Organization $organization,
array $branches,
array $practitioners,
array $patients,
string $ownerRef,
array $volumes,
): void {
$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],
] as $i => $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,
],
);
}
$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;
}
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,
],
);
}
for ($i = 0; $i < $volumes['bills']; $i++) {
$patient = $patients[$i % count($patients)];
$branch = $branches[$i % count($branches)];
$fee = 5000 + (($i % 5) * 1000);
$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' => $i % 3 === 0 ? Bill::STATUS_PAID : Bill::STATUS_OPEN,
'subtotal_minor' => $fee,
'discount_minor' => 0,
'tax_minor' => 0,
'total_minor' => $fee,
'amount_paid_minor' => $i % 3 === 0 ? $fee : 0,
'balance_minor' => $i % 3 === 0 ? 0 : $fee,
'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,
],
);
if ($bill->status === Bill::STATUS_PAID) {
Payment::query()->updateOrCreate(
['uuid' => $this->demoUuid("pay|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'bill_id' => $bill->id,
'amount_minor' => $fee,
'method' => Payment::METHOD_CASH,
'status' => Payment::STATUS_PAID,
'reference' => 'DEMO-PAY-'.($i + 1),
'paid_at' => now()->subDays($i % 12),
'recorded_by' => $ownerRef,
],
);
}
}
}
/**
* @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),
);
}
}