Files
ladill-care/app/Services/Care/DemoTenantSeeder.php
T
isaaccladandCursor 4e910a1db7
Deploy Ladill Care / deploy (push) Successful in 1m44s
Fix patient queue showing position 1 for every waiter.
Specialty demo seed reused slot+1 per module; repair duplicates on queue load and renumber after demo seed so waiting lists show 1..n.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 05:54:47 +00:00

1565 lines
62 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);
$this->seedStaffMembers($organization, $ownerRef, $plan, $branches);
$departments = $this->seedDepartments($branches, $ownerRef, $volumes);
if (in_array($plan, ['pro', 'enterprise'], true)) {
$this->seedSpecialtyModules($organization, $ownerRef);
$departments = $this->departmentsByBranch($branches, $ownerRef);
}
$practitioners = $this->seedPractitioners($organization, $branches, $departments, $ownerRef, $volumes);
$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,
);
}
$this->assignAllDemoWaiters($organization, $ownerRef, $branches, $practitioners);
$this->renumberWaitingQueuePositions($organization, $ownerRef, $branches);
if ($volumes['paid_modules']) {
$this->seedPaidModules(
$organization,
$branches,
$practitioners,
$patients,
$ownerRef,
$volumes,
);
$this->seedDevices($organization, $branches, $ownerRef);
}
if ($volumes['assessments'] > 0) {
$this->seedAssessments(
$organization,
$branches,
$practitioners,
$patients,
$ownerRef,
$volumes['assessments'],
);
}
$this->syncQueueTicketsForDemo($organization->fresh(), $branches);
return [
'organization' => $organization->fresh(),
'branches' => count($branches),
'patients' => count($patients),
'appointments' => $appointmentCount,
'plan' => $plan,
];
}
/**
* @param list<Branch> $branches
*/
private function syncQueueTicketsForDemo(Organization $organization, array $branches): void
{
if (! data_get($organization->settings, 'queue_integration_enabled')) {
return;
}
try {
$bridge = app(CareQueueBridge::class);
if (! $bridge->isEnabled($organization)) {
return;
}
// Scoped per branch/context (not full-org) so demos finish quickly while
// still leaving waiters with real tickets instead of "Unassigned".
$contexts = [
CareQueueContexts::CONSULTATION,
CareQueueContexts::PHARMACY,
CareQueueContexts::LABORATORY,
CareQueueContexts::BILLING,
];
foreach ($branches as $branch) {
foreach ($contexts 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 = Member::query()
->where('organization_id', $organization->id)
->where('user_ref', $doctorEmail)
->first();
$byBranch = [];
foreach ($practitioners as $practitioner) {
$byBranch[(int) $practitioner->branch_id] ??= $practitioner;
}
foreach ($branches as $branch) {
$practitioner = $byBranch[(int) $branch->id] ?? null;
if (! $practitioner) {
continue;
}
$practitioner->forceFill([
'user_ref' => $doctorEmail,
'member_id' => $member?->id,
'name' => $doctorName !== '' ? $doctorName : $practitioner->name,
])->save();
}
}
/**
* 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])
->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();
DB::table('care_practitioners')->where('owner_ref', $ownerRef)->delete();
// Keep the owner member + organization so SSO redirects never see
// "not onboarded" while afterResponse() reseeding is still running.
DB::table('care_members')
->where('owner_ref', $ownerRef)
->where('user_ref', '!=', $ownerRef)
->delete();
DB::table('care_departments')->where('owner_ref', $ownerRef)->delete();
DB::table('care_branches')->where('owner_ref', $ownerRef)->delete();
DB::table('care_messaging_credentials')->where('owner_ref', $ownerRef)->delete();
DB::table('care_payment_gateway_settings')->where('owner_ref', $ownerRef)->delete();
DB::table('care_audit_logs')->where('owner_ref', $ownerRef)->delete();
$organization = Organization::withTrashed()->where('owner_ref', $ownerRef)->first();
if ($organization) {
if ($organization->trashed()) {
$organization->restore();
}
$settings = $organization->settings ?? [];
$settings['onboarded'] = true;
$organization->forceFill(['settings' => $settings])->save();
}
}
/**
* @return array{
* branches: int,
* departments_per_branch: int,
* practitioners: int,
* patients: int,
* appointments: int,
* paid_modules: bool,
* assessments: int,
* lab_requests: int,
* prescriptions: int,
* bills: int
* }
*/
public function volumes(string $plan): array
{
$testing = app()->runningUnitTests();
return match ($this->normalizePlan($plan)) {
'free' => [
'branches' => 1,
'departments_per_branch' => 2,
'practitioners' => 3,
'patients' => $testing ? 12 : 30,
'appointments' => $testing ? 20 : 50,
'paid_modules' => false,
'assessments' => 0,
'lab_requests' => 0,
'prescriptions' => 0,
'bills' => 0,
],
'pro' => [
'branches' => 3,
'departments_per_branch' => 3,
'practitioners' => 8,
'patients' => $testing ? 24 : 200,
'appointments' => $testing ? 40 : 300,
'paid_modules' => true,
'assessments' => 0,
'lab_requests' => $testing ? 8 : 40,
'prescriptions' => $testing ? 8 : 40,
'bills' => $testing ? 10 : 60,
],
'enterprise' => [
'branches' => 6,
'departments_per_branch' => 4,
'practitioners' => 16,
'patients' => $testing ? 36 : 500,
'appointments' => $testing ? 60 : 800,
'paid_modules' => true,
'assessments' => $testing ? 12 : 80,
'lab_requests' => $testing ? 12 : 80,
'prescriptions' => $testing ? 12 : 80,
'bills' => $testing ? 15 : 120,
],
default => throw new \InvalidArgumentException("Unsupported plan [{$plan}]"),
};
}
private function normalizePlan(string $plan): string
{
$plan = strtolower(trim($plan));
return in_array($plan, ['free', 'pro', 'enterprise'], true) ? $plan : 'free';
}
private function upsertOrganization(User $user, string $ownerRef, string $plan, int $billedBranches): Organization
{
$slug = 'demo-'.Str::slug(Str::limit($user->email ?: $ownerRef, 40, ''));
if ($slug === 'demo-' || $slug === 'demo') {
$slug = 'demo-'.Str::lower(Str::substr($ownerRef, 0, 8));
}
$name = DemoWorld::business($plan)['name'];
$organization = Organization::withTrashed()
->where('owner_ref', $ownerRef)
->first();
$facilityType = $plan === 'enterprise' ? 'hospital' : 'clinic';
$settings = array_merge($organization?->settings ?? [], [
'onboarded' => true,
'facility_type' => $facilityType,
'facility_category' => $facilityType,
'plan' => $plan,
'plan_expires_at' => now()->addYear()->toIso8601String(),
'billed_branches' => max(1, $billedBranches),
'demo' => true,
// Pro/Enterprise demos: Queue on natural role pages + every specialty module.
// Free / non-demo tenants leave specialty_modules empty (opt-in via Settings).
'queue_integration_enabled' => in_array($plan, ['pro', 'enterprise'], true),
'specialty_modules' => in_array($plan, ['pro', 'enterprise'], true)
? $this->allSpecialtyFlagsEnabled()
: [],
// Demo seed uses the legacy clinical path. Explicitly clear leftover
// onboarding rollout so a failed race that created a real workflow
// shell cannot leave an empty gated tenant.
'rollout' => [
CareFeatures::WORKFLOW_ENGINE => false,
CareFeatures::FINANCIAL_GATES => false,
],
]);
unset($settings['workflow_template']);
if ($organization) {
if ($organization->trashed()) {
$organization->restore();
}
$organization->update([
'name' => $name,
'slug' => $slug,
'timezone' => 'Africa/Accra',
'settings' => $settings,
]);
return $organization->fresh();
}
return Organization::create([
'owner_ref' => $ownerRef,
'name' => $name,
'slug' => $slug,
'timezone' => 'Africa/Accra',
'settings' => $settings,
]);
}
private function upsertOwnerMember(Organization $organization, string $ownerRef): Member
{
return Member::query()->updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $ownerRef,
],
[
'owner_ref' => $ownerRef,
'role' => 'hospital_admin',
'branch_id' => null,
],
);
}
/**
* @param array<string, mixed> $volumes
* @return list<Branch>
*/
private function seedBranches(Organization $organization, string $ownerRef, array $volumes): array
{
$catalog = DemoWorld::branches($this->normalizePlan(
(string) data_get($organization->settings, 'plan', 'free')
), $volumes['branches']);
$branches = [];
foreach ($catalog as $i => $spec) {
$code = $spec['code'] !== '' ? $spec['code'] : sprintf('DEMO-BR-%02d', $i + 1);
$branches[] = Branch::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'code' => $code,
],
[
'owner_ref' => $ownerRef,
'name' => $spec['name'],
'address' => $spec['address'],
'phone' => $spec['phone'],
'is_active' => true,
'deleted_at' => null,
],
);
}
return $branches;
}
/**
* Local Member rows for DemoWorld staff (user_ref = email until SSO remaps to public_id).
* 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 !== []) {
$branch = $branches[$index % count($branches)];
}
Member::query()->updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => strtolower($staff['email']),
],
[
'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
{
// Pause Queue HTTP during per-module activate so Enterprise demos (many
// branches × specialties) do not spend minutes on remote timeouts.
$organization = $organization->fresh();
$settings = $organization->settings ?? [];
$queueEnabled = (bool) data_get($settings, 'queue_integration_enabled');
if ($queueEnabled) {
data_set($settings, 'queue_integration_enabled', false);
$organization->update(['settings' => $settings]);
}
$service = app(SpecialtyModuleService::class);
foreach (array_keys($service->catalog()) as $key) {
try {
$service->activate($organization->fresh(), $ownerRef, $key);
} catch (\Throwable) {
// Demo seed should not fail if activation hits an unexpected error.
}
}
if ($queueEnabled) {
$organization = $organization->fresh();
$settings = $organization->settings ?? [];
data_set($settings, 'queue_integration_enabled', true);
$organization->update(['settings' => $settings]);
// Skip CareQueueProvisioner here — same Queue HTTP storm as ticket sync.
}
}
/**
* @param list<Branch> $branches
* @return array<int, list<Department>> keyed by branch id
*/
private function departmentsByBranch(array $branches, string $ownerRef): array
{
$byBranch = [];
foreach ($branches as $branch) {
$byBranch[$branch->id] = Department::withTrashed()
->owned($ownerRef)
->where('branch_id', $branch->id)
->where('is_active', true)
->orderBy('id')
->get()
->all();
}
return $byBranch;
}
/**
* Waiting / completed specialty appointments so each module page has demo traffic.
*
* @param list<Branch> $branches
* @param list<Practitioner> $practitioners
* @param list<Patient> $patients
*/
private function seedSpecialtyDemoData(
Organization $organization,
array $branches,
array $practitioners,
array $patients,
string $ownerRef,
): int {
if ($branches === [] || $patients === [] || $practitioners === []) {
return 0;
}
$reasons = [
'dentistry' => ['Toothache', 'Dental cleaning', 'Extraction review'],
'ophthalmology' => ['Blurry vision', 'Eye exam', 'Contact lens fitting'],
'physiotherapy' => ['Back pain rehab', 'Post-injury physio', 'Mobility session'],
'maternity' => ['Antenatal visit', 'Pregnancy check', 'Postnatal review'],
'radiology' => ['X-ray referral', 'Ultrasound', 'Imaging review'],
];
$count = 0;
$catalog = config('care.specialty_modules', []);
/** @var array<int, int> $nextPositionByBranch */
$nextPositionByBranch = [];
foreach ($catalog as $key => $definition) {
$type = (string) ($definition['department_type'] ?? '');
if ($type === '') {
continue;
}
$moduleReasons = $reasons[$key] ?? ['Specialty visit'];
foreach ($branches as $branchIndex => $branch) {
$department = Department::owned($ownerRef)
->where('branch_id', $branch->id)
->where('type', $type)
->where('is_active', true)
->first();
if (! $department) {
continue;
}
$practitioner = Practitioner::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => sprintf('demo-specialty-%s-%s', $key, $branch->id),
],
[
'owner_ref' => $ownerRef,
'branch_id' => $branch->id,
'department_id' => $department->id,
'member_id' => null,
'name' => ($definition['label'] ?? ucfirst($key)).' Clinic',
'specialty' => (string) ($definition['label'] ?? $key),
'room' => ($definition['label'] ?? 'Specialty').' Bay',
'is_active' => true,
'deleted_at' => null,
],
);
foreach ([Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN, Appointment::STATUS_COMPLETED] as $slot => $status) {
$patient = $patients[($branchIndex + $slot + $count) % count($patients)];
$scheduledAt = now()->subHours(($branchIndex * 3) + $slot + 1);
$token = "specialty|{$key}|{$branch->id}|{$slot}";
$visit = null;
if (in_array($status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_WAITING, Appointment::STATUS_COMPLETED], true)) {
$visit = Visit::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("visit|{$ownerRef}|{$token}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => $status === Appointment::STATUS_COMPLETED
? Visit::STATUS_COMPLETED
: Visit::STATUS_IN_PROGRESS,
'checked_in_at' => $scheduledAt->copy()->addMinutes(5),
'completed_at' => $status === Appointment::STATUS_COMPLETED
? $scheduledAt->copy()->addHour()
: null,
'checked_in_by' => $ownerRef,
'deleted_at' => null,
],
);
}
$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++;
}
}
}
return $count;
}
/**
* @param list<Branch> $branches
* @param array<int, list<Department>> $departments
* @param array<string, mixed> $volumes
* @return list<Practitioner>
*/
private function seedPractitioners(
Organization $organization,
array $branches,
array $departments,
string $ownerRef,
array $volumes,
): array {
$practitioners = [];
for ($i = 0; $i < $volumes['practitioners']; $i++) {
$branch = $branches[$i % count($branches)];
$branchDepts = $departments[$branch->id] ?? [];
$department = $branchDepts[$i % max(1, count($branchDepts))] ?? null;
$key = sprintf('demo-prac-%s-%02d', $ownerRef, $i + 1);
$practitioners[] = Practitioner::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $key,
],
[
'owner_ref' => $ownerRef,
'branch_id' => $branch->id,
'department_id' => $department?->id,
'member_id' => null,
'name' => 'Dr. '.self::FIRST_NAMES[$i % count(self::FIRST_NAMES)].' '.self::LAST_NAMES[$i % count(self::LAST_NAMES)],
'specialty' => self::SPECIALTIES[$i % count(self::SPECIALTIES)],
'room' => 'Consultation Room '.($i + 1),
'is_active' => true,
'deleted_at' => null,
],
);
}
return $practitioners;
}
/**
* @param list<Branch> $branches
* @param array<string, mixed> $volumes
* @return list<Patient>
*/
private function seedPatients(
Organization $organization,
array $branches,
string $ownerRef,
array $volumes,
): array {
$worldPeople = DemoWorld::people();
$patients = [];
for ($i = 0; $i < $volumes['patients']; $i++) {
$branch = $branches[$i % count($branches)];
$number = sprintf('DEMO-%s-%05d', Str::upper(Str::substr(md5($ownerRef), 0, 4)), $i + 1);
$person = $worldPeople[$i] ?? null;
if ($person === null) {
$base = $worldPeople[$i % count($worldPeople)];
$seq = $i + 1;
$person = [
'first_name' => $base['first_name'],
'last_name' => $base['last_name'],
'gender' => $base['gender'],
'dob' => $base['dob'],
'phone' => '+23324'.str_pad((string) (1000000 + $seq), 7, '0', STR_PAD_LEFT),
'email' => sprintf('patient.%d@demo.ladill.com', $seq),
'national_id' => sprintf('GHA-729%06d', $seq),
'city' => $base['city'],
'address' => $base['address'],
];
}
$patients[] = Patient::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'patient_number' => $number,
],
[
'uuid' => $this->demoUuid("patient|{$ownerRef}|{$i}"),
'owner_ref' => $ownerRef,
'branch_id' => $branch->id,
'first_name' => $person['first_name'],
'last_name' => $person['last_name'],
'gender' => $person['gender'],
'date_of_birth' => $person['dob'],
'phone' => $person['phone'],
'email' => $person['email'],
'national_id' => $person['national_id'],
'city' => $person['city'],
'region' => 'Greater Accra',
'address' => $person['address'],
'deleted_at' => null,
],
);
}
return $patients;
}
/**
* @param list<Branch> $branches
* @param array<int, list<Department>> $departments
* @param list<Practitioner> $practitioners
* @param list<Patient> $patients
* @param array<string, mixed> $volumes
*/
private function seedAppointmentsAndClinical(
Organization $organization,
array $branches,
array $departments,
array $practitioners,
array $patients,
string $ownerRef,
array $volumes,
): int {
$statuses = [
Appointment::STATUS_SCHEDULED,
Appointment::STATUS_CHECKED_IN,
Appointment::STATUS_WAITING,
Appointment::STATUS_COMPLETED,
Appointment::STATUS_COMPLETED,
Appointment::STATUS_CANCELLED,
];
$reasons = ['Follow-up', 'New complaint', 'Review labs', 'Chronic care'];
$symptoms = ['Fever', 'Cough', 'Headache', 'Fatigue'];
$count = 0;
for ($i = 0; $i < $volumes['appointments']; $i++) {
$patient = $patients[$i % count($patients)];
$practitioner = $practitioners[$i % count($practitioners)];
$branch = $branches[$i % count($branches)];
$branchDepts = $departments[$branch->id] ?? [];
$department = $branchDepts[$i % max(1, count($branchDepts))] ?? null;
$status = $statuses[$i % count($statuses)];
$scheduledAt = now()->subDays(14)->addHours($i * 3);
$visit = null;
if (in_array($status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_WAITING, Appointment::STATUS_COMPLETED], true)) {
$visit = Visit::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("visit|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => $status === Appointment::STATUS_COMPLETED
? Visit::STATUS_COMPLETED
: Visit::STATUS_IN_PROGRESS,
'checked_in_at' => $scheduledAt->copy()->addMinutes(5),
'completed_at' => $status === Appointment::STATUS_COMPLETED
? $scheduledAt->copy()->addHour()
: null,
'checked_in_by' => $ownerRef,
'deleted_at' => null,
],
);
}
Appointment::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("appt|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'practitioner_id' => $practitioner->id,
'department_id' => $department?->id,
'visit_id' => $visit?->id,
'type' => $i % 5 === 0 ? Appointment::TYPE_WALK_IN : Appointment::TYPE_SCHEDULED,
'status' => $status,
'scheduled_at' => $scheduledAt,
'checked_in_at' => $visit?->checked_in_at,
'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)]);
}
}
}
private function seedPaidModules(
Organization $organization,
array $branches,
array $practitioners,
array $patients,
string $ownerRef,
array $volumes,
): void {
$types = [];
foreach ([
['name' => 'Fasting Blood Glucose', 'code' => 'FBG', 'category' => 'blood', 'unit' => 'mmol/L', 'low' => 3.9, 'high' => 6.1, 'price' => 2500],
['name' => 'Full Blood Count', 'code' => 'FBC', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 4500],
['name' => 'Malaria Parasite', 'code' => 'MP', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 1500],
['name' => 'Urinalysis', 'code' => 'UA', 'category' => 'urine', 'unit' => null, 'low' => null, 'high' => null, 'price' => 2000],
] as $i => $spec) {
$types[] = InvestigationType::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'code' => $spec['code'],
],
[
'owner_ref' => $ownerRef,
'name' => $spec['name'],
'category' => $spec['category'],
'unit' => $spec['unit'],
'reference_low' => $spec['low'],
'reference_high' => $spec['high'],
'price_minor' => $spec['price'],
'is_active' => true,
'deleted_at' => null,
],
);
}
$drugs = [];
foreach ([
['name' => 'Amoxicillin 500mg', 'unit' => 'capsule', 'price' => 1500],
['name' => 'Paracetamol 500mg', 'unit' => 'tablet', 'price' => 50],
['name' => 'Metformin 500mg', 'unit' => 'tablet', 'price' => 80],
['name' => 'Amlodipine 5mg', 'unit' => 'tablet', 'price' => 120],
] as $i => $spec) {
$drug = Drug::withTrashed()->updateOrCreate(
[
'organization_id' => $organization->id,
'sku' => 'DEMO-DRUG-'.($i + 1),
],
[
'owner_ref' => $ownerRef,
'branch_id' => $branches[0]->id,
'name' => $spec['name'],
'generic_name' => $spec['name'],
'unit' => $spec['unit'],
'unit_price_minor' => $spec['price'],
'reorder_level' => 20,
'is_active' => true,
'deleted_at' => null,
],
);
DrugBatch::query()->updateOrCreate(
[
'drug_id' => $drug->id,
'batch_number' => 'DEMO-BATCH-'.($i + 1),
],
[
'owner_ref' => $ownerRef,
'expiry_date' => now()->addYear()->toDateString(),
'quantity_on_hand' => 200,
'cost_minor' => (int) ($spec['price'] * 0.6),
'received_at' => now()->subDays(10),
],
);
$drugs[] = $drug;
}
for ($i = 0; $i < $volumes['lab_requests']; $i++) {
$patient = $patients[$i % count($patients)];
$branch = $branches[$i % count($branches)];
$practitioner = $practitioners[$i % count($practitioners)];
$type = $types[$i % count($types)];
$visit = Visit::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("lab-visit|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => Visit::STATUS_IN_PROGRESS,
'checked_in_at' => now()->subDays($i % 10),
'checked_in_by' => $ownerRef,
'deleted_at' => null,
],
);
$consultation = Consultation::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("lab-consult|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'visit_id' => $visit->id,
'practitioner_id' => $practitioner->id,
'patient_id' => $patient->id,
'status' => Consultation::STATUS_DRAFT,
'started_at' => now()->subDays($i % 10),
'deleted_at' => null,
],
);
InvestigationRequest::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("lab-req|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'visit_id' => $visit->id,
'consultation_id' => $consultation->id,
'patient_id' => $patient->id,
'investigation_type_id' => $type->id,
'practitioner_id' => $practitioner->id,
'status' => InvestigationRequest::STATUS_PENDING,
'priority' => 'routine',
'clinical_notes' => 'Demo lab request',
'requested_by' => $ownerRef,
'deleted_at' => null,
],
);
}
for ($i = 0; $i < $volumes['prescriptions']; $i++) {
$patient = $patients[$i % count($patients)];
$branch = $branches[$i % count($branches)];
$practitioner = $practitioners[$i % count($practitioners)];
$drug = $drugs[$i % count($drugs)];
$visit = Visit::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("rx-visit|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => Visit::STATUS_IN_PROGRESS,
'checked_in_at' => now()->subDays($i % 7),
'checked_in_by' => $ownerRef,
'deleted_at' => null,
],
);
$consultation = Consultation::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("rx-consult|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'visit_id' => $visit->id,
'practitioner_id' => $practitioner->id,
'patient_id' => $patient->id,
'status' => Consultation::STATUS_DRAFT,
'started_at' => now()->subDays($i % 7),
'deleted_at' => null,
],
);
$prescription = Prescription::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("rx|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'visit_id' => $visit->id,
'consultation_id' => $consultation->id,
'patient_id' => $patient->id,
'practitioner_id' => $practitioner->id,
'status' => Prescription::STATUS_ACTIVE,
'prescribed_by' => $ownerRef,
'deleted_at' => null,
],
);
$prescription->items()->updateOrCreate(
[
'prescription_id' => $prescription->id,
'name' => $drug->name,
],
[
'owner_ref' => $ownerRef,
'drug_id' => $drug->id,
'dosage' => '1 '.$drug->unit,
'frequency' => 'BD',
'duration' => '5 days',
'quantity' => 10,
'sort_order' => 0,
],
);
}
for ($i = 0; $i < $volumes['bills']; $i++) {
$patient = $patients[$i % count($patients)];
$branch = $branches[$i % count($branches)];
$fee = 5000 + (($i % 5) * 1000);
$visit = Visit::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("bill-visit|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => Visit::STATUS_COMPLETED,
'checked_in_at' => now()->subDays($i % 12),
'completed_at' => now()->subDays($i % 12)->addHour(),
'checked_in_by' => $ownerRef,
'deleted_at' => null,
],
);
$bill = Bill::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("bill|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'visit_id' => $visit->id,
'patient_id' => $patient->id,
'invoice_number' => sprintf('DEMO-INV-%s-%04d', Str::upper(Str::substr(md5($ownerRef), 0, 4)), $i + 1),
'status' => $i % 3 === 0 ? Bill::STATUS_PAID : Bill::STATUS_OPEN,
'subtotal_minor' => $fee,
'discount_minor' => 0,
'tax_minor' => 0,
'total_minor' => $fee,
'amount_paid_minor' => $i % 3 === 0 ? $fee : 0,
'balance_minor' => $i % 3 === 0 ? 0 : $fee,
'created_by' => $ownerRef,
'finalized_at' => now()->subDays($i % 12),
'deleted_at' => null,
],
);
BillLineItem::query()->updateOrCreate(
[
'bill_id' => $bill->id,
'description' => 'Consultation fee',
],
[
'owner_ref' => $ownerRef,
'type' => 'consultation',
'quantity' => 1,
'unit_price_minor' => $fee,
'total_minor' => $fee,
],
);
if ($bill->status === Bill::STATUS_PAID) {
Payment::query()->updateOrCreate(
['uuid' => $this->demoUuid("pay|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'bill_id' => $bill->id,
'amount_minor' => $fee,
'method' => Payment::METHOD_CASH,
'status' => Payment::STATUS_PAID,
'reference' => 'DEMO-PAY-'.($i + 1),
'paid_at' => now()->subDays($i % 12),
'recorded_by' => $ownerRef,
],
);
}
}
}
/**
* @param list<Branch> $branches
* @param list<Practitioner> $practitioners
* @param list<Patient> $patients
*/
private function seedAssessments(
Organization $organization,
array $branches,
array $practitioners,
array $patients,
string $ownerRef,
int $count,
): void {
$template = AssessmentTemplate::withTrashed()->updateOrCreate(
[
'code' => self::DEMO_TEMPLATE_CODE,
'version' => 1,
],
[
'organization_id' => $organization->id,
'name' => 'Demo Triage Screen',
'category' => AssessmentTemplate::CATEGORY_SCREENING,
'description' => 'Lightweight demo assessment for enterprise analytics.',
'is_current' => true,
'is_active' => true,
'scoring_strategy' => 'sum_items',
'deleted_at' => null,
],
);
$question = AssessmentQuestion::query()->updateOrCreate(
[
'template_id' => $template->id,
'code' => 'severity',
],
[
'section' => 'Triage',
'label' => 'Severity (0-10)',
'answer_type' => AssessmentQuestion::TYPE_SCALE,
'is_required' => true,
'sort_order' => 1,
'score_key' => 'severity',
'options' => ['min' => 0, 'max' => 10],
],
);
for ($i = 0; $i < $count; $i++) {
$patient = $patients[$i % count($patients)];
$branch = $branches[$i % count($branches)];
$practitioner = $practitioners[$i % count($practitioners)];
$scoreValue = (float) ($i % 11);
$assessment = Assessment::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("assess|{$ownerRef}|{$i}")],
[
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'template_id' => $template->id,
'practitioner_id' => $practitioner->id,
'status' => Assessment::STATUS_COMPLETED,
'assessed_at' => now()->subDays($i % 30),
'completed_at' => now()->subDays($i % 30),
'completed_by' => $ownerRef,
'started_by' => $ownerRef,
'notes' => 'Demo assessment',
'deleted_at' => null,
],
);
AssessmentAnswer::query()->updateOrCreate(
[
'assessment_id' => $assessment->id,
'question_id' => $question->id,
],
[
'owner_ref' => $ownerRef,
'value_number' => $scoreValue,
],
);
AssessmentScore::query()->updateOrCreate(
['assessment_id' => $assessment->id],
[
'owner_ref' => $ownerRef,
'template_code' => self::DEMO_TEMPLATE_CODE,
'total_score' => $scoreValue,
'max_score' => 10,
'subscores' => ['severity' => $scoreValue],
'severity_label' => $scoreValue >= 7 ? 'high' : ($scoreValue >= 4 ? 'moderate' : 'low'),
'computed_at' => now()->subDays($i % 30),
],
);
}
}
private function demoUuid(string $key): string
{
$hex = md5('ladill-care-demo|'.$key);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12),
);
}
}