Files
ladill-care/app/Services/Care/DemoTenantSeeder.php
T
isaaccladandCursor 56a663a777
Deploy Ladill Care / deploy (push) Successful in 26s
Replace Maternity with Women's Health (OB/GYN) and configurable service lines.
Keeps Fertility as a separate specialty, adds OB/GYN and fertility staff roles, and migrates live org settings from maternity → womens_health.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 09:48:19 +00:00

4108 lines
164 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'],
'womens_health' => ['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', 'Well-child CWC', 'Nutrition follow-up'],
'ambulance' => ['Chest pain response', 'Trauma pickup', 'Facility transfer'],
];
$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);
$specialtyMeta = \App\Support\DemoWorld::SPECIALTY_DOCTORS[$key] ?? null;
$doctorName = is_array($specialtyMeta)
? (string) ($specialtyMeta['name'] ?? ('Dr. '.($definition['label'] ?? ucfirst($key))))
: (string) ($specialtyMeta ?? ('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);
$this->seedRadiologyClinicalDemo($organization, $ownerRef);
$this->seedCardiologyClinicalDemo($organization, $ownerRef);
$this->seedPsychiatryClinicalDemo($organization, $ownerRef);
$this->seedPediatricsClinicalDemo($organization, $ownerRef);
$this->seedOrthopedicsClinicalDemo($organization, $ownerRef);
$this->seedEntClinicalDemo($organization, $ownerRef);
$this->seedOncologyClinicalDemo($organization, $ownerRef);
$this->seedRenalClinicalDemo($organization, $ownerRef);
$this->seedSurgeryClinicalDemo($organization, $ownerRef);
$this->seedVaccinationClinicalDemo($organization, $ownerRef);
$this->seedPathologyClinicalDemo($organization, $ownerRef);
$this->seedInfusionClinicalDemo($organization, $ownerRef);
$this->seedDermatologyClinicalDemo($organization, $ownerRef);
$this->seedPodiatryClinicalDemo($organization, $ownerRef);
$this->seedFertilityClinicalDemo($organization, $ownerRef);
$this->seedChildWelfareClinicalDemo($organization, $ownerRef);
$this->seedAmbulanceClinicalDemo($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', 'womens_health')
->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,
'womens_health',
'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,
'womens_health',
'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,
'womens_health',
'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,
'womens_health',
'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 seedRadiologyClinicalDemo(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', 'radiology')
->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;
}
$urgent = $index === 0;
$clinical->upsert(
$organization,
$visit,
'radiology',
'imaging_request',
[
'modality' => $urgent ? 'CT' : 'X-ray',
'body_part' => $urgent ? 'Chest' : 'Left ankle',
'clinical_info' => $urgent
? 'Shortness of breath; rule out PE / infiltrates'
: 'Trauma — query fracture after fall',
'urgency' => $urgent ? 'STAT' : 'Routine',
'contrast' => $urgent ? 'IV' : 'None',
'protocol' => $urgent ? 'CTPA protocol' : 'AP / lateral',
'referrer' => 'Demo ER / Ortho',
],
$ownerRef,
$ownerRef,
'protocol',
);
$clinical->upsert(
$organization,
$visit,
'radiology',
'imaging_acquisition',
[
'technologist' => 'Demo Tech',
'equipment' => $urgent ? 'CT 1' : 'XR Bay A',
'acquisition_status' => 'Acquired',
'contrast_given' => $urgent,
'dose_notes' => $urgent ? 'Standard CTPA dose' : 'Low dose extremity',
'quality' => 'Diagnostic',
'notes' => 'Demo acquisition for radiology suite.',
],
$ownerRef,
$ownerRef,
'acquisition',
);
if ($urgent) {
$clinical->upsert(
$organization,
$visit,
'radiology',
'imaging_report',
[
'technique' => 'CT pulmonary angiogram with IV contrast',
'comparison' => 'None available',
'findings' => 'No filling defect in main or segmental pulmonary arteries. Mild basilar atelectasis.',
'impression' => 'No CT evidence of pulmonary embolism.',
'recommendations' => 'Clinical correlation; consider alternative causes of dyspnoea.',
'critical_result' => false,
],
$ownerRef,
$ownerRef,
'reporting',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $urgent ? 'reporting' : 'acquisition']);
}
$index++;
}
}
private function seedCardiologyClinicalDemo(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', 'cardiology')
->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,
'cardiology',
'cardio_history',
[
'history' => $highRisk
? 'Progressive dyspnoea and orthopnoea for 2 weeks'
: 'Palpitations intermittent for 1 month',
'past_cardiac' => $highRisk ? 'Prior MI 2019' : 'None',
'medications' => $highRisk ? 'Aspirin, atorvastatin, bisoprolol' : 'None',
'allergies' => 'NKDA',
'risk_factors' => $highRisk ? 'Hypertension, diabetes, smoking history' : 'Family history of AF',
'family_history' => $highRisk ? 'Father CAD' : 'Mother AF',
],
$ownerRef,
$ownerRef,
'history',
);
$clinical->upsert(
$organization,
$visit,
'cardiology',
'cardiac_exam',
[
'chief_complaint' => $highRisk ? 'Dyspnoea / heart failure symptoms' : 'Palpitations',
'nyha_class' => $highRisk ? 'III' : 'I',
'bp' => $highRisk ? '168/98' : '124/78',
'hr' => $highRisk ? 104 : 72,
'heart_sounds' => $highRisk ? 'S3; soft pansystolic murmur' : 'Normal S1 S2; no murmur',
'ecg_findings' => $highRisk
? 'Sinus tach; Q waves inferiorly; no acute STEMI'
: 'Sinus rhythm; occasional PACs',
'exam_findings' => $highRisk ? 'Bilateral basal crackles; mild ankle oedema' : 'CVD exam normal',
],
$ownerRef,
$ownerRef,
'exam',
);
if ($highRisk) {
$clinical->upsert(
$organization,
$visit,
'cardiology',
'cardio_plan',
[
'diagnosis' => 'Decompensated heart failure (NYHA III)',
'plan' => 'Diuresis; echo; review meds; admit if not improving',
'medications' => 'Increase loop diuretic; continue GDMT',
'procedure_planned' => false,
'follow_up' => '48-hour review or sooner if worse',
'advice' => 'Daily weights; fluid restriction counselling',
],
$ownerRef,
$ownerRef,
'plan',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']);
}
$index++;
}
}
private function seedPsychiatryClinicalDemo(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', 'psychiatry')
->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,
'psychiatry',
'mental_status',
[
'chief_complaint' => $highRisk ? 'Low mood with suicidal ideation' : 'Anxiety and poor sleep',
'history' => $highRisk
? '2-week worsening depression; sleep and appetite reduced'
: 'Generalised worry for 3 months; work stress',
'appearance' => $highRisk ? 'Dishevelled; limited eye contact' : 'Well kempt; cooperative',
'mood_affect' => $highRisk ? 'Depressed / flat' : 'Anxious / congruent',
'thought' => $highRisk ? 'Hopeless themes; no psychosis' : 'Worried ruminations; no delusions',
'perception' => 'No hallucinations',
'cognition' => 'Oriented ×3',
'insight' => $highRisk ? 'Partial' : 'Good',
'mse_summary' => $highRisk
? 'Depressed MSE with active suicidal ideation — high vigilance'
: 'Anxious MSE without psychosis or self-harm intent',
],
$ownerRef,
$ownerRef,
'history',
);
$clinical->upsert(
$organization,
$visit,
'psychiatry',
'risk_assessment',
[
'overall_risk' => $highRisk ? 'High' : 'Low',
'self_harm' => $highRisk ? 'Ideation' : 'None identified',
'harm_others' => 'None identified',
'safeguarding' => $highRisk ? 'Lives alone; limited support tonight' : 'Supportive partner',
'protective_factors' => $highRisk ? 'Engaged with clinic; no prior attempts' : 'Good insight; coping strategies',
'risk_summary' => $highRisk
? 'High risk of self-harm — safety planning and close follow-up required'
: 'Low risk; outpatient management appropriate',
'immediate_actions' => $highRisk
? 'Safety plan; remove means; same-day senior review'
: 'Psychoeducation; follow-up in clinic',
],
$ownerRef,
$ownerRef,
'risk',
);
if ($highRisk) {
$clinical->upsert(
$organization,
$visit,
'psychiatry',
'psych_plan',
[
'formulation' => 'Major depressive episode with high self-harm risk in context of isolation',
'diagnosis' => 'Major depressive disorder — severe',
'plan' => 'Crisis safety plan; consider short admission vs intensive outpatient; start/adjust antidepressant',
'medications' => 'Review current AD; discuss options',
'therapy' => 'CBT referral when risk settles',
'follow_up' => '2448 hours or sooner if crisis',
'crisis_plan' => 'Emergency contacts; crisis line; return to ED if ideation intensifies',
],
$ownerRef,
$ownerRef,
'formulation',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'formulation' : 'risk']);
}
$index++;
}
}
private function seedPediatricsClinicalDemo(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', 'pediatrics')
->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,
'pediatrics',
'ped_history',
[
'history' => $highRisk
? 'Fever and poor feeding for 3 days; reduced urine output'
: 'Mild cough for 1 week; eating well',
'birth_history' => $highRisk ? 'Term; birth weight 2.8 kg' : 'Term; uneventful',
'past_medical' => $highRisk ? 'Prior admission for pneumonia' : 'None',
'medications' => 'None',
'allergies' => 'NKDA',
'feeding' => $highRisk ? 'Breast + formula; intake reduced' : 'Age-appropriate diet',
'social' => 'Lives with parents',
],
$ownerRef,
$ownerRef,
'history',
);
$clinical->upsert(
$organization,
$visit,
'pediatrics',
'pediatric_exam',
[
'chief_complaint' => $highRisk ? 'Fever / possible sepsis' : 'Cough review',
'age_months' => $highRisk ? 18 : 48,
'weight_kg' => $highRisk ? 8.2 : 16.5,
'height_cm' => $highRisk ? 76 : 102,
'head_circumference_cm' => $highRisk ? 45 : null,
'temperature_c' => $highRisk ? 39.2 : 36.8,
'growth_concern' => $highRisk ? 'Underweight' : 'None',
'developmental' => $highRisk ? 'Walks; few words' : 'Age-appropriate',
'immunization_status' => $highRisk ? 'Up to date except measles' : 'Up to date',
'examination' => $highRisk
? 'Irritable; mild dehydration; clear chest; no rash'
: 'Well; mild rhinitis; chest clear',
],
$ownerRef,
$ownerRef,
'exam',
);
if ($highRisk) {
$clinical->upsert(
$organization,
$visit,
'pediatrics',
'ped_plan',
[
'diagnosis' => 'Febrile illness — rule out serious bacterial infection',
'plan' => 'Labs; oral fluids; antipyretic; review same day if worse',
'medications' => 'Paracetamol PRN',
'treatment_planned' => true,
'follow_up' => '24 hours or sooner if red flags',
'advice' => 'Hydration; fever advice; return if lethargy or poor feeding',
],
$ownerRef,
$ownerRef,
'plan',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']);
}
$index++;
}
}
private function seedOrthopedicsClinicalDemo(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', 'orthopedics')
->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,
'orthopedics',
'ortho_history',
[
'history' => $highRisk
? 'Fall from height; right wrist pain and deformity'
: 'Left knee pain after football twist',
'mechanism' => $highRisk ? 'FOOSH from ladder' : 'Twisting injury',
'past_ortho' => $highRisk ? 'Prior ankle sprain' : 'None',
'medications' => 'None',
'allergies' => 'NKDA',
'comorbidities' => $highRisk ? 'Diabetes' : 'None',
],
$ownerRef,
$ownerRef,
'history',
);
$clinical->upsert(
$organization,
$visit,
'orthopedics',
'ortho_exam',
[
'chief_complaint' => $highRisk ? 'Wrist deformity after fall' : 'Knee pain',
'side' => $highRisk ? 'Right' : 'Left',
'region' => $highRisk ? 'Distal radius' : 'Knee',
'rom' => $highRisk ? 'Limited wrist flexion/extension' : 'Full ROM; pain on twist',
'neurovascular' => $highRisk ? 'Sensory deficit' : 'Intact',
'swelling' => $highRisk ? 'Dinner-fork deformity' : 'Mild effusion',
'exam_findings' => $highRisk ? 'Closed injury; soft compartments' : 'McMurray negative; stable ligaments',
'fracture_classification' => $highRisk ? 'Colles-type distal radius' : 'Soft tissue strain',
],
$ownerRef,
$ownerRef,
'exam',
);
if ($highRisk) {
$clinical->upsert(
$organization,
$visit,
'orthopedics',
'ortho_imaging',
[
'modality' => 'X-ray',
'region' => 'Right wrist',
'findings' => 'Displaced distal radius fracture; ulnar styloid intact',
'impression' => 'Extra-articular distal radius fracture',
'notes' => 'AP and lateral views',
],
$ownerRef,
$ownerRef,
'imaging',
);
$clinical->upsert(
$organization,
$visit,
'orthopedics',
'ortho_plan',
[
'diagnosis' => 'Displaced distal radius fracture',
'plan' => 'Closed reduction and cast; elevate; analgesia',
'weight_bearing' => 'N/A — upper limb',
'procedure_planned' => true,
'follow_up' => '1 week with cast check / x-ray',
'advice' => 'Elevation; watch for numbness or colour change',
],
$ownerRef,
$ownerRef,
'plan',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']);
}
$index++;
}
}
private function seedEntClinicalDemo(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', 'ent')
->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,
'ent',
'ent_history',
[
'history' => $highRisk
? 'Progressive sore throat and muffled voice; difficulty swallowing'
: 'Recurrent ear discharge for 2 months',
'ear_symptoms' => $highRisk ? 'None' : 'Left otorrhoea; mild hearing loss',
'nose_symptoms' => 'None',
'throat_symptoms' => $highRisk ? 'Severe odynophagia; drooling' : 'None',
'past_ent' => $highRisk ? 'Prior tonsillitis' : 'Recurrent otitis media',
'medications' => $highRisk ? 'None recent' : 'Topical drops PRN',
'allergies' => 'NKDA',
],
$ownerRef,
$ownerRef,
'history',
);
$clinical->upsert(
$organization,
$visit,
'ent',
'ent_exam',
[
'chief_complaint' => $highRisk ? 'Sore throat / possible peritonsillar abscess' : 'Chronic ear discharge',
'ear_findings' => $highRisk ? 'Normal TMs' : 'Left canal moist; TM perforated',
'nose_findings' => 'Normal',
'throat_findings' => $highRisk
? 'Trismus; unilateral tonsillar bulge; uvula deviation'
: 'Oropharynx normal',
'hearing' => $highRisk ? 'Grossly normal' : 'Reduced left whispered voice',
'neck' => $highRisk ? 'Tender cervical nodes' : 'No lymphadenopathy',
'airway_concern' => $highRisk,
'exam_summary' => $highRisk
? 'Findings consistent with peritonsillar abscess — airway vigilance'
: 'Chronic otitis media with perforation',
],
$ownerRef,
$ownerRef,
'exam',
);
if ($highRisk) {
$clinical->upsert(
$organization,
$visit,
'ent',
'ent_plan',
[
'diagnosis' => 'Suspected peritonsillar abscess',
'plan' => 'IV antibiotics; needle aspiration / I&D; airway monitoring',
'medications' => 'IV antibiotics per protocol; analgesia',
'procedure_planned' => true,
'follow_up' => 'Inpatient review post-drainage',
'advice' => 'NPO until airway safe; urgent escalation if stridor',
],
$ownerRef,
$ownerRef,
'plan',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']);
}
$index++;
}
}
private function seedOncologyClinicalDemo(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', 'oncology')
->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, 'oncology', 'onc_history', [
'history' => $highRisk
? 'New diagnosis work-up; progressive fatigue and weight loss'
: 'On adjuvant regimen; here for cycle review',
'cancer_history' => $highRisk ? 'Breast mass biopsied last month' : 'Stage II breast Ca — surgery 4 months ago',
'medications' => $highRisk ? 'Analgesia PRN' : 'Current chemo cycle medications',
'allergies' => 'NKDA',
'comorbidities' => $highRisk ? 'Hypertension' : 'None significant',
'social' => 'Family support available',
], $ownerRef, $ownerRef, 'history');
$clinical->upsert($organization, $visit, 'oncology', 'onc_staging', [
'diagnosis' => $highRisk ? 'Suspected metastatic breast carcinoma' : 'Breast carcinoma — adjuvant',
'stage' => $highRisk ? 'cT4 N2 M1' : 'pT2 N1 M0',
'histology' => 'Invasive ductal carcinoma',
'performance_status' => $highRisk ? '3' : '1',
'exam_findings' => $highRisk ? 'Cachexia; axillary nodes; hepatic tenderness' : 'Well; surgical scar healed',
'sites' => $highRisk ? 'Breast, axilla, liver' : 'None residual known',
], $ownerRef, $ownerRef, 'staging');
if ($highRisk) {
$clinical->upsert($organization, $visit, 'oncology', 'onc_plan', [
'diagnosis' => 'Metastatic breast carcinoma',
'plan' => 'Staging imaging; MDT; supportive care; consider palliative systemic therapy',
'intent' => 'Palliative',
'medications' => 'Analgesia; antiemetics PRN',
'treatment_planned' => true,
'follow_up' => 'Chemo day-care after MDT',
'advice' => 'Return if fever or uncontrolled pain',
], $ownerRef, $ownerRef, 'plan');
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'plan' : 'staging']);
}
$index++;
}
}
private function seedRenalClinicalDemo(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', 'renal')
->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, 'renal', 'renal_history', [
'history' => $highRisk
? 'Missed last dialysis; progressive dyspnoea and oedema'
: 'Routine thrice-weekly haemodialysis review',
'ckd_stage' => $highRisk ? 'CKD 5D — 2 years on HD' : 'CKD 5D — 8 months on HD',
'access_history' => 'Left AV fistula',
'medications' => 'EPO; phosphate binders; antihypertensives',
'allergies' => 'NKDA',
'comorbidities' => $highRisk ? 'Diabetes; heart failure' : 'Hypertension',
], $ownerRef, $ownerRef, 'history');
$clinical->upsert($organization, $visit, 'renal', 'renal_exam', [
'chief_complaint' => $highRisk ? 'Fluid overload / missed dialysis' : 'Routine dialysis session',
'bp' => $highRisk ? '168/98' : '132/78',
'weight_kg' => $highRisk ? 78.5 : 65.2,
'fluid_status' => $highRisk ? 'Overload' : 'Euvolemic',
'access' => 'Left AVF thrills present',
'exam_findings' => $highRisk ? 'Bilateral basal crackles; sacral oedema' : 'Chest clear; no oedema',
'uremic_symptoms' => $highRisk ? 'Nausea; pruritus' : 'None',
], $ownerRef, $ownerRef, 'exam');
if ($highRisk) {
$clinical->upsert($organization, $visit, 'renal', 'renal_session', [
'session_type' => 'Hemodialysis',
'pre_weight_kg' => 78.5,
'uf_goal_l' => 3.5,
'access' => 'Left AVF',
'bp_pre' => '168/98',
'labs_summary' => 'K 5.8; Cr elevated; Hb 9.1',
'complications' => 'Hypotension mid-session anticipated',
'notes' => 'Urgent HD for fluid overload after missed session',
], $ownerRef, $ownerRef, 'dialysis');
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'dialysis' : 'exam']);
}
$index++;
}
}
private function seedSurgeryClinicalDemo(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', 'surgery')
->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, 'surgery', 'surg_history', [
'history' => $highRisk
? 'Right inguinal hernia with intermittent pain; electing repair'
: 'Post-appendicectomy wound check',
'past_surgical' => $highRisk ? 'None' : 'Laparoscopic appendicectomy 10 days ago',
'medications' => $highRisk ? 'None' : 'Analgesia; antibiotics completed',
'allergies' => 'NKDA',
'comorbidities' => $highRisk ? 'Well-controlled diabetes' : 'None',
'anaesthesia_history' => 'No prior issues',
], $ownerRef, $ownerRef, 'history');
$clinical->upsert($organization, $visit, 'surgery', 'surg_exam', [
'chief_complaint' => $highRisk ? 'Inguinal hernia — pre-op' : 'Wound review',
'exam_findings' => $highRisk
? 'Right reducible inguinal hernia; no strangulation'
: 'Wound clean; mild erythema; no discharge',
'asa_class' => $highRisk ? 'II' : 'I',
'fitness' => 'Fit for day-case anaesthesia',
'site_marking' => $highRisk,
], $ownerRef, $ownerRef, 'exam');
if ($highRisk) {
$clinical->upsert($organization, $visit, 'surgery', 'surg_plan', [
'diagnosis' => 'Right inguinal hernia',
'proposed_procedure' => 'Open mesh hernia repair',
'plan' => 'Day-case repair; consent discussed including mesh risks',
'consent_obtained' => true,
'procedure_planned' => true,
'follow_up' => 'Wound check 710 days',
'advice' => 'NPO from midnight; bring meds list',
], $ownerRef, $ownerRef, 'plan');
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']);
}
$index++;
}
}
private function seedVaccinationClinicalDemo(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', 'vaccination')
->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;
}
$alertCase = $index === 0;
$clinical->upsert($organization, $visit, 'vaccination', 'vax_eligibility', [
'vaccine' => $alertCase ? 'Yellow fever' : 'Tetanus toxoid',
'schedule' => $alertCase ? 'Travel' : 'Routine EPI',
'prior_doses' => $alertCase ? 'None documented' : 'Primary series complete',
'indications' => $alertCase ? 'Travel certificate required' : 'Antenatal TT booster',
'contraindications' => true,
'allergy_history' => 'NKDA',
'fever_today' => $alertCase,
'pregnancy' => ! $alertCase,
'cleared' => ! $alertCase,
], $ownerRef, $ownerRef, 'eligibility');
$clinical->upsert($organization, $visit, 'vaccination', 'vax_consent', [
'information_given' => true,
'consent_obtained' => ! $alertCase,
'consent_type' => 'Verbal',
'consented_by' => 'Patient',
'risks_discussed' => true,
'notes' => $alertCase ? 'Defer pending fever resolution' : 'Consent for TT booster',
], $ownerRef, $ownerRef, $alertCase ? 'eligibility' : 'consent');
if (! $alertCase) {
$clinical->upsert($organization, $visit, 'vaccination', 'vax_admin', [
'vaccine' => 'Tetanus toxoid',
'dose' => '0.5 mL',
'site' => 'Left arm',
'batch' => 'TT-DEMO-'.($index + 1),
'expiry' => now()->addYear()->toDateString(),
'route' => 'IM',
'administered_by' => 'Demo nurse',
'adverse_events' => 'None immediate',
'next_due' => now()->addMonths(6)->toDateString(),
'outcome' => 'Completed — observe',
], $ownerRef, $ownerRef, 'administer');
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $alertCase ? 'eligibility' : 'administer']);
}
$index++;
}
}
private function seedPathologyClinicalDemo(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', 'pathology')
->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;
}
$urgent = $index === 0;
$clinical->upsert($organization, $visit, 'pathology', 'path_request', [
'specimen_type' => $urgent ? 'Breast core biopsy' : 'Cervical cytology',
'site' => $urgent ? 'Left breast' : 'Cervix',
'clinical_history' => $urgent
? 'Palpable mass; BIRADS 4; rule out malignancy'
: 'Routine Pap smear; previous NILM',
'urgency' => $urgent ? 'Urgent' : 'Routine',
'tests_requested' => $urgent ? 'Histo + IHC if indicated' : 'Pap smear',
'requesting_clinician' => 'Demo clinician',
], $ownerRef, $ownerRef, 'request');
$clinical->upsert($organization, $visit, 'pathology', 'path_specimen', [
'received_at' => now()->subHours(2)->format('Y-m-d H:i'),
'container_label' => 'PATH-DEMO-'.($index + 1),
'adequacy' => $urgent ? 'Adequate' : 'Adequate',
'fixative' => $urgent ? '10% formalin' : 'Liquid-based cytology',
'notes' => 'Received sealed and labelled',
], $ownerRef, $ownerRef, 'specimen');
if ($urgent) {
$clinical->upsert($organization, $visit, 'pathology', 'path_processing', [
'gross' => 'Two cores, 1.2 cm aggregate',
'blocks' => 'A1A2',
'stains' => 'H&E',
'status' => 'Ready for reporting',
'notes' => 'Priority processing',
], $ownerRef, $ownerRef, 'processing');
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $urgent ? 'processing' : 'specimen']);
}
$index++;
}
}
private function seedInfusionClinicalDemo(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', 'infusion')
->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;
}
$notFit = $index === 0;
$clinical->upsert($organization, $visit, 'infusion', 'infusion_assessment', [
'indication' => $notFit ? 'IV iron — symptomatic anaemia' : 'IV antibiotics — cellulitis',
'history' => $notFit
? 'Hb 7.8; prior infusion reaction reported'
: 'Day 3 of outpatient IV ceftriaxone',
'allergies' => $notFit ? 'Possible iron sucrose reaction' : 'NKDA',
'baseline_vitals' => $notFit ? 'BP 88/54; HR 112' : 'BP 118/74; HR 78',
'access_plan' => 'Peripheral IV',
'fit_for_infusion' => ! $notFit,
], $ownerRef, $ownerRef, 'assessment');
if (! $notFit) {
$clinical->upsert($organization, $visit, 'infusion', 'infusion_protocol', [
'medication' => 'Ceftriaxone',
'dose' => '2 g',
'diluent' => '100 mL NS',
'rate' => '30 minutes',
'duration' => '30 min',
'premeds' => 'None',
'ordered_by' => 'Demo clinician',
], $ownerRef, $ownerRef, 'protocol');
$clinical->upsert($organization, $visit, 'infusion', 'infusion_session', [
'medication' => 'Ceftriaxone',
'dose' => '2 g',
'rate' => '30 min',
'access' => 'Left forearm PIV',
'start_time' => now()->subMinutes(20)->format('H:i'),
'end_time' => '',
'vitals' => 'Stable mid-infusion',
'reactions' => 'None',
'notes' => 'Infusing without incident',
'outcome' => 'In progress',
], $ownerRef, $ownerRef, 'session');
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $notFit ? 'assessment' : 'session']);
}
$index++;
}
}
private function seedDermatologyClinicalDemo(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', 'dermatology')
->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;
}
$urgent = $index === 0;
$clinical->upsert(
$organization,
$visit,
'dermatology',
'derm_history',
[
'history' => $urgent
? 'Changing pigmented lesion on forearm noticed by partner'
: 'Itchy rash on elbows for 6 weeks',
'past_skin' => $urgent ? 'Prior atypical naevus excised 2018' : 'Childhood eczema',
'medications' => $urgent ? 'None' : 'Emollients PRN',
'allergies' => 'NKDA',
'triggers' => $urgent ? 'Sun exposure' : 'Dry weather; detergents',
'family_history' => $urgent ? 'Mother melanoma' : 'None relevant',
],
$ownerRef,
$ownerRef,
'history',
);
$clinical->upsert(
$organization,
$visit,
'dermatology',
'skin_exam',
[
'chief_complaint' => $urgent ? 'Changing mole' : 'Elbow rash',
'urgency' => $urgent ? 'Suspected malignancy' : 'Routine',
'duration' => $urgent ? '3 months' : '6 weeks',
'distribution' => $urgent ? 'Left forearm' : 'Bilateral elbows',
'morphology' => $urgent
? 'Asymmetric pigmented plaque with irregular border'
: 'Erythematous plaques with silvery scale',
'itch_pain' => $urgent ? 'None' : 'Moderate itch',
'dermoscopy' => $urgent ? 'Atypical pigment network' : 'Regular scale pattern',
'differential' => $urgent ? 'Melanoma vs atypical naevus' : 'Psoriasis vs eczema',
],
$ownerRef,
$ownerRef,
'exam',
);
if ($urgent) {
$clinical->upsert(
$organization,
$visit,
'dermatology',
'derm_plan',
[
'diagnosis' => 'Suspicious pigmented lesion — rule out melanoma',
'plan' => 'Excision biopsy; sun protection counselling',
'medications' => 'None pending histology',
'procedure_planned' => true,
'follow_up' => 'Histology results in 1014 days',
'advice' => 'Avoid sunburn; photograph lesion site',
],
$ownerRef,
$ownerRef,
'plan',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $urgent ? 'plan' : 'exam']);
}
$index++;
}
}
private function seedPodiatryClinicalDemo(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', 'podiatry')
->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,
'podiatry',
'pod_history',
[
'history' => $highRisk
? 'Non-healing plantar ulcer for 3 weeks'
: 'Ingrown toenail with intermittent pain',
'diabetes_history' => $highRisk ? 'Type 2 DM 12 years; peripheral neuropathy' : 'No diabetes',
'medications' => $highRisk ? 'Metformin, insulin' : 'None',
'allergies' => 'NKDA',
'footwear' => $highRisk ? 'Ill-fitting sandals' : 'Closed shoes at work',
'prior_ulcers' => $highRisk ? 'Prior healed ulcer right foot 2022' : 'None',
],
$ownerRef,
$ownerRef,
'history',
);
$clinical->upsert(
$organization,
$visit,
'podiatry',
'foot_exam',
[
'chief_complaint' => $highRisk ? 'Plantar ulcer' : 'Ingrown toenail',
'side' => $highRisk ? 'Right' : 'Left',
'diabetes' => $highRisk ? 'Active ulcer' : 'N/A',
'pulses' => $highRisk ? 'Dorsalis pedis weak' : 'Present bilaterally',
'sensation' => $highRisk ? 'Reduced monofilament' : 'Intact',
'skin_nails' => $highRisk ? 'Callus surrounding ulcer' : 'Medial nail fold inflamed',
'ulcer' => $highRisk ? '2 cm plantar ulcer, clean base' : '',
'offloading' => $highRisk ? 'Felt pad interim' : 'Wide toe box advised',
],
$ownerRef,
$ownerRef,
'exam',
);
if ($highRisk) {
$clinical->upsert(
$organization,
$visit,
'podiatry',
'pod_plan',
[
'diagnosis' => 'Diabetic foot ulcer — high risk',
'plan' => 'Debridement; offloading; wound care; vascular review if needed',
'offloading' => 'Total contact cast or removable boot',
'medications' => 'Dressings; analgesia as needed',
'procedure_planned' => true,
'follow_up' => '1 week wound clinic',
'advice' => 'Daily foot check; avoid barefoot walking',
],
$ownerRef,
$ownerRef,
'plan',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']);
}
$index++;
}
}
private function seedFertilityClinicalDemo(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', 'fertility')
->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;
}
$priority = $index === 0;
$clinical->upsert(
$organization,
$visit,
'fertility',
'fert_history',
[
'history' => $priority
? 'Trying to conceive 28 months; age 38; prior miscarriage'
: 'Trying to conceive 14 months; regular cycles',
'trying_months' => $priority ? 28 : 14,
'obstetric_history' => $priority ? 'G1P0; miscarriage at 8 weeks 2024' : 'G0P0; cycles 2830 days',
'partner_notes' => $priority ? 'Partner semen analysis pending' : 'Partner well; no known issues',
'medications' => 'Folic acid',
'allergies' => 'NKDA',
],
$ownerRef,
$ownerRef,
'history',
);
$clinical->upsert(
$organization,
$visit,
'fertility',
'fert_exam',
[
'chief_complaint' => $priority ? 'Primary infertility — time-sensitive' : 'Fertility workup',
'priority' => $priority ? 'Time-sensitive' : 'Routine',
'partner_present' => true,
'cycle_day' => $priority ? 3 : 10,
'bmi' => $priority ? 27 : 23,
'exam_findings' => 'Pelvic exam unremarkable; uterus anteverted',
'notes' => $priority ? 'Discussed AMH and early referral pathway' : 'Baseline counselling given',
],
$ownerRef,
$ownerRef,
'exam',
);
if ($priority) {
$clinical->upsert(
$organization,
$visit,
'fertility',
'fert_plan',
[
'diagnosis' => 'Primary infertility — age-related priority',
'plan' => 'Complete AMH/hormones; semen analysis; pelvic scan; consider IUI counselling',
'medications' => 'Continue folic acid; ovulation induction if indicated',
'cycle_planned' => true,
'follow_up' => 'Cycle day 3 labs; review with results',
'advice' => 'Timed intercourse education; lifestyle counselling',
],
$ownerRef,
$ownerRef,
'plan',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $priority ? 'plan' : 'exam']);
}
$index++;
}
}
private function seedChildWelfareClinicalDemo(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', 'child_welfare')
->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;
}
$concern = $index === 0;
$clinical->upsert(
$organization,
$visit,
'child_welfare',
'cwc_intake',
[
'visit_reason' => $concern ? 'Growth monitoring' : 'Routine well-child',
'caregiver' => $concern ? 'Mother — Ama Mensah' : 'Father — Kojo Boateng',
'history' => $concern
? 'Weight faltering over 2 months; reduced appetite; breast milk + porridge'
: 'Well since last CWC; no illness; immunizations on track',
'birth_history' => $concern ? 'Term SVD; birth weight 2.8 kg' : 'Term SVD; birth weight 3.2 kg',
'immunization_status' => $concern ? 'Catch-up needed' : 'Up to date',
'feeding' => $concern ? 'Breastfeeding + thin porridge twice daily' : 'Breastfeeding; complementary feeds started',
'social_context' => $concern ? 'Single caregiver; food insecurity reported' : 'Stable household',
'safeguarding_notes' => $concern ? 'Nutrition concern; monitor home situation — clinical record access only' : '',
'allergies' => 'NKDA',
],
$ownerRef,
$ownerRef,
'intake',
);
$clinical->upsert(
$organization,
$visit,
'child_welfare',
'cwc_assessment',
[
'age_months' => $concern ? 18 : 9,
'weight_kg' => $concern ? 8.1 : 8.6,
'height_cm' => $concern ? 76 : 70,
'head_circumference_cm' => $concern ? 45 : 44,
'muac_cm' => $concern ? 11.2 : 13.5,
'growth_status' => $concern ? 'Underweight' : 'Normal',
'nutrition_risk' => $concern ? 'High' : 'Low',
'development' => $concern ? 'Walks with support; 34 words' : 'Sits well; babbling',
'exam_findings' => $concern
? 'Thin; dry skin; no oedema; alert'
: 'Well nourished; soft fontanelle; clear chest',
'safeguarding_flag' => $concern ? 'Concern' : 'None',
'assessment_summary' => $concern
? 'Underweight with high nutrition risk — counselling and close follow-up'
: 'Healthy well-child visit; continue routine CWC schedule',
],
$ownerRef,
$ownerRef,
'assessment',
);
if ($concern) {
$clinical->upsert(
$organization,
$visit,
'child_welfare',
'cwc_plan',
[
'goals' => 'Restore weight gain; improve dietary diversity; complete catch-up immunizations',
'interventions' => 'Nutrition counselling; RUTF if eligible; growth recheck',
'counseling' => 'Feeding frequency; hygiene; danger signs',
'referrals' => 'Consider nutrition clinic if no weight gain in 2 weeks',
'medications' => 'Vitamin A if due; deworming per schedule',
'follow_up' => '2 weeks CWC',
'advice' => 'Return sooner if fever, diarrhoea, or poor feeding',
],
$ownerRef,
$ownerRef,
'plan',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $concern ? 'plan' : 'assessment']);
}
$index++;
}
}
private function seedAmbulanceClinicalDemo(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', 'ambulance')
->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;
}
$critical = $index === 0;
$clinical->upsert(
$organization,
$visit,
'ambulance',
'amb_dispatch',
[
'call_nature' => $critical ? 'Medical emergency' : 'Transfer',
'priority' => $critical ? 'Immediate' : 'Routine',
'location' => $critical ? 'Kaneshie Market Road' : 'Ridge Hospital ward 3',
'caller_info' => $critical ? 'Bystander — 024XXXXXXX' : 'Ward nurse',
'crew' => $critical ? 'Paramedic + EMT' : 'EMT crew',
'vehicle' => $critical ? 'AMB-01' : 'AMB-02',
'dispatch_notes' => $critical
? 'Chest pain, diaphoretic; request ALS response'
: 'Stable inter-facility transfer for imaging',
'eta_minutes' => $critical ? 8 : 20,
],
$ownerRef,
$ownerRef,
'dispatch',
);
$clinical->upsert(
$organization,
$visit,
'ambulance',
'amb_scene',
[
'chief_complaint' => $critical ? 'Chest pain' : 'Inter-facility transfer',
'acuity' => $critical ? 'Critical' : 'Stable',
'mechanism' => $critical ? 'Sudden onset at rest' : 'Scheduled transfer',
'airway_ok' => true,
'breathing_ok' => $critical ? false : true,
'circulation_ok' => $critical ? false : true,
'gcs' => 15,
'vitals_summary' => $critical
? 'BP 88/54, HR 118, SpO2 91% on air, RR 28'
: 'BP 124/78, HR 82, SpO2 98%, RR 16',
'scene_findings' => $critical
? 'Pale, diaphoretic, ongoing central chest pain radiating to left arm'
: 'Patient comfortable on stretcher; IV in situ',
'hazards' => $critical ? 'Busy roadside' : 'None',
],
$ownerRef,
$ownerRef,
'on_scene',
);
if ($critical) {
$clinical->upsert(
$organization,
$visit,
'ambulance',
'amb_en_route',
[
'destination' => 'Korle Bu Teaching Hospital ED',
'interventions' => 'Oxygen via NRB; aspirin; IV access; cardiac monitoring',
'medications' => 'Aspirin 300 mg PO',
'response_to_treatment' => 'Pain partially eased; SpO2 improved to 95%',
'monitoring' => 'Continuous ECG; vitals q5min',
'deterioration' => false,
'eta_facility' => '12 minutes',
'notes' => 'Pre-alerted ED resus',
],
$ownerRef,
$ownerRef,
'transport',
);
}
if (! $visit->specialty_stage) {
$visit->update(['specialty_stage' => $critical ? 'transport' : 'on_scene']);
}
$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),
);
}
}