diff --git a/app/Console/Commands/DemoSeedCommand.php b/app/Console/Commands/DemoSeedCommand.php new file mode 100644 index 0000000..741540b --- /dev/null +++ b/app/Console/Commands/DemoSeedCommand.php @@ -0,0 +1,134 @@ +option('plan')); + if (! in_array($plan, ['free', 'pro', 'enterprise'], true)) { + $this->error('Invalid --plan. Use free, pro, or enterprise.'); + + return self::FAILURE; + } + + try { + $user = $this->resolveUser(); + } catch (\InvalidArgumentException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + $reset = (bool) $this->option('reset'); + $this->info(sprintf( + 'Seeding Care demo for %s (%s) plan=%s%s', + $user->email ?: '(no email)', + $user->public_id, + $plan, + $reset ? ' [reset]' : '', + )); + + $result = $seeder->seed($user, $plan, $reset); + + $this->table( + ['Field', 'Value'], + [ + ['owner_ref', $user->public_id], + ['organization', $result['organization']->name], + ['plan', $result['plan']], + ['plan_expires_at', data_get($result['organization']->settings, 'plan_expires_at')], + ['billed_branches', (string) data_get($result['organization']->settings, 'billed_branches')], + ['branches', (string) $result['branches']], + ['patients', (string) $result['patients']], + ['appointments', (string) $result['appointments']], + ], + ); + + $this->info('Done.'); + + return self::SUCCESS; + } + + private function resolveUser(): User + { + $identity = trim((string) ($this->argument('identity') ?? '')); + $publicId = trim((string) ($this->option('public-id') ?? '')); + $email = trim((string) ($this->option('email') ?? '')); + $name = trim((string) ($this->option('name') ?? '')); + + if ($identity !== '') { + if (filter_var($identity, FILTER_VALIDATE_EMAIL)) { + $email = $email !== '' ? $email : $identity; + } else { + $publicId = $publicId !== '' ? $publicId : $identity; + } + } + + $user = null; + if ($publicId !== '') { + $user = User::query()->where('public_id', $publicId)->first(); + } + if (! $user && $email !== '') { + $user = User::query()->where('email', $email)->first(); + } + + if ($user) { + $updates = []; + if ($email !== '' && $user->email !== $email) { + $updates['email'] = $email; + } + if ($name !== '' && $user->name !== $name) { + $updates['name'] = $name; + } + if ($updates !== []) { + $user->forceFill($updates)->save(); + } + + return $user->fresh(); + } + + if ($publicId === '' || $email === '') { + throw new \InvalidArgumentException( + 'Local User mirror not found. Pass an existing email/public_id, or create one with --public-id and --email (optional --name).' + ); + } + + $user = new User([ + 'public_id' => $publicId, + 'email' => $email, + 'name' => $name !== '' ? $name : Str::before($email, '@'), + ]); + $user->email_verified_at = now(); + $user->save(); + + return $user; + } +} diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php new file mode 100644 index 0000000..374dfaa --- /dev/null +++ b/app/Services/Care/DemoTenantSeeder.php @@ -0,0 +1,1019 @@ + */ + private const FIRST_NAMES = [ + 'Ama', 'Kofi', 'Abena', 'Kwame', 'Akosua', 'Yaw', 'Efua', 'Kojo', + 'Adwoa', 'Kwesi', 'Afia', 'Kwadwo', 'Esi', 'Fiifi', 'Mansa', 'Nana', + ]; + + /** @var list */ + private const LAST_NAMES = [ + 'Mensah', 'Asante', 'Osei', 'Boateng', 'Owusu', 'Adjei', 'Appiah', 'Darko', + 'Agyeman', 'Sarpong', 'Nyarko', 'Ankrah', 'Tetteh', 'Quaye', 'Addo', 'Frimpong', + ]; + + /** @var list */ + 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 */ + private const CORE_DEPARTMENTS = [ + ['name' => 'Outpatient', 'type' => 'outpatient'], + ['name' => 'General Practice', 'type' => 'general'], + ['name' => 'Nursing', 'type' => 'nursing'], + ['name' => 'Specialist Clinic', 'type' => 'specialist'], + ]; + + /** @var list */ + private const PAID_DEPARTMENTS = [ + ['name' => 'Laboratory', 'type' => 'laboratory'], + ['name' => 'Pharmacy', 'type' => 'pharmacy'], + ]; + + /** @var list */ + 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); + + if ($reset) { + DB::transaction(fn () => $this->resetTenant($ownerRef)); + } + + return DB::transaction(function () use ($user, $ownerRef, $plan, $volumes) { + $faker = FakerFactory::create(); + $faker->seed(crc32($ownerRef.'|'.$plan)); + + $organization = $this->upsertOrganization($user, $ownerRef, $plan, $volumes['branches']); + $this->upsertOwnerMember($organization, $ownerRef); + + $branches = $this->seedBranches($organization, $ownerRef, $volumes, $faker); + $departments = $this->seedDepartments($branches, $ownerRef, $volumes); + $practitioners = $this->seedPractitioners($organization, $branches, $departments, $ownerRef, $volumes, $faker); + $patients = $this->seedPatients($organization, $branches, $ownerRef, $volumes, $faker); + $appointmentCount = $this->seedAppointmentsAndClinical( + $organization, + $branches, + $departments, + $practitioners, + $patients, + $ownerRef, + $volumes, + $faker, + ); + + if ($volumes['paid_modules']) { + $this->seedPaidModules( + $organization, + $branches, + $practitioners, + $patients, + $ownerRef, + $volumes, + $faker, + ); + } + + if ($volumes['assessments'] > 0) { + $this->seedAssessments( + $organization, + $branches, + $practitioners, + $patients, + $ownerRef, + $volumes['assessments'], + ); + } + + return [ + 'organization' => $organization->fresh(), + 'branches' => count($branches), + 'patients' => count($patients), + 'appointments' => $appointmentCount, + 'plan' => $plan, + ]; + }); + } + + public function resetTenant(string $ownerRef): void + { + $orgIds = DB::table('care_organizations')->where('owner_ref', $ownerRef)->pluck('id'); + + 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_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(); + + DB::table('care_members')->where('owner_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(); + DB::table('care_organizations')->where('owner_ref', $ownerRef)->delete(); + } + + /** + * @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 = match ($plan) { + 'enterprise' => 'Accra Healthcare Group (Care Demo)', + 'pro' => 'Ladill Care Demo (Pro)', + default => 'Ladill Care Demo (Free)', + }; + + $organization = Organization::withTrashed() + ->where('owner_ref', $ownerRef) + ->first(); + + $settings = array_merge($organization?->settings ?? [], [ + 'onboarded' => true, + 'facility_type' => $plan === 'enterprise' ? 'hospital' : 'clinic', + 'plan' => $plan, + 'plan_expires_at' => now()->addYear()->toIso8601String(), + 'billed_branches' => max(1, $billedBranches), + 'demo' => true, + ]); + + 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 $volumes + * @return list + */ + private function seedBranches(Organization $organization, string $ownerRef, array $volumes, Faker $faker): array + { + $branches = []; + for ($i = 0; $i < $volumes['branches']; $i++) { + $code = sprintf('DEMO-BR-%02d', $i + 1); + $branches[] = Branch::withTrashed()->updateOrCreate( + [ + 'organization_id' => $organization->id, + 'code' => $code, + ], + [ + 'owner_ref' => $ownerRef, + 'name' => self::BRANCH_NAMES[$i % count(self::BRANCH_NAMES)], + 'address' => $faker->streetAddress().', Accra', + 'phone' => '+23320'.str_pad((string) (1000000 + $i), 7, '0', STR_PAD_LEFT), + 'is_active' => true, + 'deleted_at' => null, + ], + ); + } + + return $branches; + } + + /** + * @param list $branches + * @param array $volumes + * @return array> 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; + } + + /** + * @param list $branches + * @param array> $departments + * @param array $volumes + * @return list + */ + private function seedPractitioners( + Organization $organization, + array $branches, + array $departments, + string $ownerRef, + array $volumes, + Faker $faker, + ): 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)], + 'is_active' => true, + 'deleted_at' => null, + ], + ); + } + + return $practitioners; + } + + /** + * @param list $branches + * @param array $volumes + * @return list + */ + private function seedPatients( + Organization $organization, + array $branches, + string $ownerRef, + array $volumes, + Faker $faker, + ): array { + $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); + + $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' => self::FIRST_NAMES[$i % count(self::FIRST_NAMES)], + 'last_name' => self::LAST_NAMES[($i * 3) % count(self::LAST_NAMES)], + 'gender' => $i % 2 === 0 ? 'female' : 'male', + 'date_of_birth' => now()->subYears(18 + ($i % 50))->subDays($i % 28)->toDateString(), + 'phone' => '+23324'.str_pad((string) (2000000 + $i), 7, '0', STR_PAD_LEFT), + 'email' => sprintf('demo.patient.%d@example.test', $i + 1), + 'city' => 'Accra', + 'region' => 'Greater Accra', + 'address' => $faker->streetAddress(), + 'deleted_at' => null, + ], + ); + } + + return $patients; + } + + /** + * @param list $branches + * @param array> $departments + * @param list $practitioners + * @param list $patients + * @param array $volumes + */ + private function seedAppointmentsAndClinical( + Organization $organization, + array $branches, + array $departments, + array $practitioners, + array $patients, + string $ownerRef, + array $volumes, + Faker $faker, + ): int { + $statuses = [ + Appointment::STATUS_SCHEDULED, + Appointment::STATUS_CHECKED_IN, + Appointment::STATUS_WAITING, + Appointment::STATUS_COMPLETED, + Appointment::STATUS_COMPLETED, + Appointment::STATUS_CANCELLED, + ]; + + $count = 0; + for ($i = 0; $i < $volumes['appointments']; $i++) { + $patient = $patients[$i % count($patients)]; + $practitioner = $practitioners[$i % count($practitioners)]; + $branch = $branches[$i % count($branches)]; + $branchDepts = $departments[$branch->id] ?? []; + $department = $branchDepts[$i % max(1, count($branchDepts))] ?? null; + $status = $statuses[$i % count($statuses)]; + $scheduledAt = now()->subDays(14)->addHours($i * 3); + + $visit = null; + if (in_array($status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_WAITING, Appointment::STATUS_COMPLETED], true)) { + $visit = Visit::withTrashed()->updateOrCreate( + ['uuid' => $this->demoUuid("visit|{$ownerRef}|{$i}")], + [ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'patient_id' => $patient->id, + 'status' => $status === Appointment::STATUS_COMPLETED + ? Visit::STATUS_COMPLETED + : Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => $scheduledAt->copy()->addMinutes(5), + 'completed_at' => $status === Appointment::STATUS_COMPLETED + ? $scheduledAt->copy()->addHour() + : null, + 'checked_in_by' => $ownerRef, + 'deleted_at' => null, + ], + ); + } + + Appointment::withTrashed()->updateOrCreate( + ['uuid' => $this->demoUuid("appt|{$ownerRef}|{$i}")], + [ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'patient_id' => $patient->id, + 'practitioner_id' => $practitioner->id, + 'department_id' => $department?->id, + 'visit_id' => $visit?->id, + 'type' => $i % 5 === 0 ? Appointment::TYPE_WALK_IN : Appointment::TYPE_SCHEDULED, + 'status' => $status, + 'scheduled_at' => $scheduledAt, + 'checked_in_at' => $visit?->checked_in_at, + 'completed_at' => $status === Appointment::STATUS_COMPLETED + ? $scheduledAt->copy()->addHour() + : null, + 'cancelled_at' => $status === Appointment::STATUS_CANCELLED + ? $scheduledAt->copy()->subHour() + : null, + 'reason' => $faker->randomElement(['Follow-up', 'New complaint', 'Review labs', 'Chronic care']), + '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' => $faker->randomElement(['Fever', 'Cough', 'Headache', 'Fatigue']), + '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 $branches + * @param list $practitioners + * @param list $patients + * @param array $volumes + */ + private function seedPaidModules( + Organization $organization, + array $branches, + array $practitioners, + array $patients, + string $ownerRef, + array $volumes, + Faker $faker, + ): 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 $branches + * @param list $practitioners + * @param list $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), + ); + } +} diff --git a/tests/Feature/DemoSeedCommandTest.php b/tests/Feature/DemoSeedCommandTest.php new file mode 100644 index 0000000..94f3fb7 --- /dev/null +++ b/tests/Feature/DemoSeedCommandTest.php @@ -0,0 +1,227 @@ +assertTrue(collect(Artisan::all())->has('demo:seed')); + } + + public function test_free_plan_respects_branch_cap_and_skips_paid_modules(): void + { + $user = User::create([ + 'public_id' => 'demo-free-public-id', + 'name' => 'Ladill Demo (Free)', + 'email' => 'demo-free@ladill.com', + ]); + + $this->artisan('demo:seed', [ + 'identity' => 'demo-free@ladill.com', + '--plan' => 'free', + ])->assertSuccessful(); + + $org = Organization::query()->where('owner_ref', $user->public_id)->first(); + $this->assertNotNull($org); + $this->assertSame('free', data_get($org->settings, 'plan')); + $this->assertTrue((bool) data_get($org->settings, 'onboarded')); + $this->assertSame(1, (int) data_get($org->settings, 'billed_branches')); + $this->assertNotEmpty(data_get($org->settings, 'plan_expires_at')); + $this->assertTrue(\Carbon\Carbon::parse(data_get($org->settings, 'plan_expires_at'))->isFuture()); + + $this->assertSame(1, Branch::query()->where('owner_ref', $user->public_id)->where('is_active', true)->count()); + $this->assertSame(1, app(PlanService::class)->maxBranches($org)); + $this->assertGreaterThanOrEqual(12, Patient::query()->where('owner_ref', $user->public_id)->count()); + $this->assertGreaterThanOrEqual(20, Appointment::query()->where('owner_ref', $user->public_id)->count()); + + $this->assertSame(0, InvestigationType::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame(0, InvestigationRequest::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame(0, Drug::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame(0, Prescription::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame(0, Bill::query()->where('owner_ref', $user->public_id)->count()); + } + + public function test_pro_plan_seeds_multi_branch_and_paid_modules(): void + { + $user = User::create([ + 'public_id' => 'demo-pro-public-id', + 'name' => 'Ladill Demo (Pro)', + 'email' => 'demo-pro@ladill.com', + ]); + + $this->artisan('demo:seed', [ + 'identity' => 'demo-pro@ladill.com', + '--plan' => 'pro', + ])->assertSuccessful(); + + $org = Organization::query()->where('owner_ref', $user->public_id)->firstOrFail(); + $this->assertSame('pro', app(PlanService::class)->planKey($org)); + $this->assertSame(3, Branch::query()->where('owner_ref', $user->public_id)->count()); + $this->assertTrue(app(PlanService::class)->canUseProModules($org)); + + $this->assertGreaterThan(0, InvestigationType::query()->where('owner_ref', $user->public_id)->count()); + $this->assertGreaterThan(0, InvestigationRequest::query()->where('owner_ref', $user->public_id)->count()); + $this->assertGreaterThan(0, Drug::query()->where('owner_ref', $user->public_id)->count()); + $this->assertGreaterThan(0, Prescription::query()->where('owner_ref', $user->public_id)->count()); + $this->assertGreaterThan(0, Bill::query()->where('owner_ref', $user->public_id)->count()); + } + + public function test_enterprise_seeds_assessments_and_more_branches(): void + { + $user = User::create([ + 'public_id' => 'demo-enterprise-public-id', + 'name' => 'Accra Healthcare Group', + 'email' => 'demo-enterprise@ladill.com', + ]); + + $this->artisan('demo:seed', [ + 'identity' => $user->public_id, + '--plan' => 'enterprise', + ])->assertSuccessful(); + + $org = Organization::query()->where('owner_ref', $user->public_id)->firstOrFail(); + $this->assertSame('enterprise', app(PlanService::class)->planKey($org)); + $this->assertSame(6, Branch::query()->where('owner_ref', $user->public_id)->count()); + $this->assertGreaterThan(0, Assessment::query()->where('owner_ref', $user->public_id)->count()); + $this->assertGreaterThan(0, Bill::query()->where('owner_ref', $user->public_id)->count()); + } + + public function test_idempotent_reseed_without_reset_does_not_duplicate(): void + { + $user = User::create([ + 'public_id' => 'demo-idempotent-id', + 'name' => 'Idempotent Demo', + 'email' => 'demo-idempotent@ladill.com', + ]); + + $this->artisan('demo:seed', [ + 'identity' => 'demo-idempotent@ladill.com', + '--plan' => 'free', + ])->assertSuccessful(); + + $patients = Patient::query()->where('owner_ref', $user->public_id)->count(); + $appointments = Appointment::query()->where('owner_ref', $user->public_id)->count(); + $orgs = Organization::query()->where('owner_ref', $user->public_id)->count(); + + $this->artisan('demo:seed', [ + 'identity' => 'demo-idempotent@ladill.com', + '--plan' => 'free', + ])->assertSuccessful(); + + $this->assertSame($patients, Patient::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame($appointments, Appointment::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame($orgs, Organization::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame(1, Branch::query()->where('owner_ref', $user->public_id)->count()); + } + + public function test_reset_wipes_only_tenant_scoped_data_then_reseeds(): void + { + $keep = User::create([ + 'public_id' => 'other-owner', + 'name' => 'Other', + 'email' => 'other@ladill.com', + ]); + Organization::create([ + 'owner_ref' => $keep->public_id, + 'name' => 'Keep Me', + 'slug' => 'keep-me', + 'settings' => ['onboarded' => true, 'plan' => 'free'], + ]); + Branch::create([ + 'owner_ref' => $keep->public_id, + 'organization_id' => Organization::query()->where('owner_ref', $keep->public_id)->value('id'), + 'name' => 'Keep Branch', + 'is_active' => true, + ]); + + $user = User::create([ + 'public_id' => 'demo-reset-id', + 'name' => 'Reset Demo', + 'email' => 'demo-reset@ladill.com', + ]); + + $this->artisan('demo:seed', [ + 'identity' => 'demo-reset@ladill.com', + '--plan' => 'pro', + ])->assertSuccessful(); + + $patientCount = Patient::query()->where('owner_ref', $user->public_id)->count(); + $this->assertGreaterThan(0, $patientCount); + $this->assertGreaterThan(0, Bill::query()->where('owner_ref', $user->public_id)->count()); + + $this->artisan('demo:seed', [ + 'identity' => 'demo-reset@ladill.com', + '--plan' => 'free', + '--reset' => true, + ])->assertSuccessful(); + + $this->assertSame(1, Branch::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame(0, Bill::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame(0, Drug::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame('free', data_get( + Organization::query()->where('owner_ref', $user->public_id)->value('settings'), + 'plan' + )); + + // Other tenant untouched. + $this->assertSame(1, Organization::query()->where('owner_ref', $keep->public_id)->count()); + $this->assertSame(1, Branch::query()->where('owner_ref', $keep->public_id)->count()); + } + + public function test_creates_local_mirror_when_public_id_and_email_provided(): void + { + $this->assertDatabaseMissing('users', ['email' => 'new-demo@ladill.com']); + + $this->artisan('demo:seed', [ + '--public-id' => 'brand-new-public-id', + '--email' => 'new-demo@ladill.com', + '--name' => 'New Demo', + '--plan' => 'free', + ])->assertSuccessful(); + + $user = User::query()->where('email', 'new-demo@ladill.com')->first(); + $this->assertNotNull($user); + $this->assertSame('brand-new-public-id', $user->public_id); + $this->assertSame(1, Organization::query()->where('owner_ref', $user->public_id)->count()); + } + + public function test_fails_when_mirror_missing_and_insufficient_identity(): void + { + $this->artisan('demo:seed', [ + 'identity' => 'missing@ladill.com', + '--plan' => 'free', + ])->assertFailed(); + } + + public function test_volumes_helper_marks_free_without_paid_modules(): void + { + $volumes = app(DemoTenantSeeder::class)->volumes('free'); + $this->assertFalse($volumes['paid_modules']); + $this->assertSame(1, $volumes['branches']); + $this->assertSame(0, $volumes['assessments']); + + $pro = app(DemoTenantSeeder::class)->volumes('pro'); + $this->assertTrue($pro['paid_modules']); + $this->assertSame(3, $pro['branches']); + } +}