Deploy Ladill Care / deploy (push) Successful in 53s
Remove the standalone Service Queues nav so call-next/now-serving lives on clinical queue, appointments, pharmacy, lab, and specialty surfaces; Pro orgs can activate dentistry and related modules with branch departments and queue stubs. Co-authored-by: Cursor <cursoragent@cursor.com>
1130 lines
44 KiB
PHP
1130 lines
44 KiB
PHP
<?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\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);
|
|
|
|
if ($reset) {
|
|
DB::transaction(fn () => $this->resetTenant($ownerRef));
|
|
}
|
|
|
|
return DB::transaction(function () use ($user, $ownerRef, $plan, $volumes) {
|
|
$organization = $this->upsertOrganization($user, $ownerRef, $plan, $volumes['branches']);
|
|
$this->upsertOwnerMember($organization, $ownerRef);
|
|
|
|
$branches = $this->seedBranches($organization, $ownerRef, $volumes);
|
|
$this->seedStaffMembers($organization, $ownerRef, $plan, $branches);
|
|
$departments = $this->seedDepartments($branches, $ownerRef, $volumes);
|
|
if (in_array($plan, ['pro', 'enterprise'], true)) {
|
|
$this->seedSpecialtyModules($organization, $ownerRef);
|
|
}
|
|
$practitioners = $this->seedPractitioners($organization, $branches, $departments, $ownerRef, $volumes);
|
|
$patients = $this->seedPatients($organization, $branches, $ownerRef, $volumes);
|
|
$appointmentCount = $this->seedAppointmentsAndClinical(
|
|
$organization,
|
|
$branches,
|
|
$departments,
|
|
$practitioners,
|
|
$patients,
|
|
$ownerRef,
|
|
$volumes,
|
|
);
|
|
|
|
if ($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'],
|
|
);
|
|
}
|
|
|
|
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_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();
|
|
|
|
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 = DemoWorld::business($plan)['name'];
|
|
|
|
$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,
|
|
// Pro/Enterprise demos: Queue on natural role pages + dentistry specialty module.
|
|
'queue_integration_enabled' => in_array($plan, ['pro', 'enterprise'], true),
|
|
'specialty_modules' => in_array($plan, ['pro', 'enterprise'], true)
|
|
? ['dentistry' => 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<string, mixed> $volumes
|
|
* @return list<Branch>
|
|
*/
|
|
private function seedBranches(Organization $organization, string $ownerRef, array $volumes): array
|
|
{
|
|
$catalog = DemoWorld::branches($this->normalizePlan(
|
|
(string) data_get($organization->settings, 'plan', 'free')
|
|
), $volumes['branches']);
|
|
$branches = [];
|
|
foreach ($catalog as $i => $spec) {
|
|
$code = $spec['code'] !== '' ? $spec['code'] : sprintf('DEMO-BR-%02d', $i + 1);
|
|
$branches[] = Branch::withTrashed()->updateOrCreate(
|
|
[
|
|
'organization_id' => $organization->id,
|
|
'code' => $code,
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'name' => $spec['name'],
|
|
'address' => $spec['address'],
|
|
'phone' => $spec['phone'],
|
|
'is_active' => true,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
}
|
|
|
|
return $branches;
|
|
}
|
|
|
|
/**
|
|
* Local Member rows for DemoWorld staff (user_ref = email until SSO remaps to public_id).
|
|
*
|
|
* @param list<Branch> $branches
|
|
*/
|
|
private function seedStaffMembers(Organization $organization, string $ownerRef, string $plan, array $branches): void
|
|
{
|
|
$hq = $branches[0] ?? null;
|
|
foreach (DemoWorld::staff($plan) as $staff) {
|
|
$role = $staff['roles']['care'] ?? null;
|
|
if (! is_string($role) || $role === '') {
|
|
continue;
|
|
}
|
|
|
|
Member::query()->updateOrCreate(
|
|
[
|
|
'organization_id' => $organization->id,
|
|
'user_ref' => strtolower($staff['email']),
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'role' => $role,
|
|
'branch_id' => in_array($role, ['hospital_admin'], true) ? null : $hq?->id,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<Branch> $branches
|
|
* @param array<string, mixed> $volumes
|
|
* @return array<int, list<Department>> keyed by branch id
|
|
*/
|
|
private function seedDepartments(array $branches, string $ownerRef, array $volumes): array
|
|
{
|
|
$catalog = self::CORE_DEPARTMENTS;
|
|
if ($volumes['paid_modules']) {
|
|
$catalog = array_merge($catalog, self::PAID_DEPARTMENTS);
|
|
}
|
|
|
|
$byBranch = [];
|
|
foreach ($branches as $branch) {
|
|
$byBranch[$branch->id] = [];
|
|
$count = min($volumes['departments_per_branch'], count($catalog));
|
|
for ($i = 0; $i < $count; $i++) {
|
|
$spec = $catalog[$i];
|
|
$byBranch[$branch->id][] = Department::withTrashed()->updateOrCreate(
|
|
[
|
|
'branch_id' => $branch->id,
|
|
'name' => $spec['name'],
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'type' => $spec['type'],
|
|
'is_active' => true,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
return $byBranch;
|
|
}
|
|
|
|
private function seedSpecialtyModules(Organization $organization, string $ownerRef): void
|
|
{
|
|
try {
|
|
app(SpecialtyModuleService::class)->activate($organization->fresh(), $ownerRef, 'dentistry');
|
|
} catch (\Throwable) {
|
|
// Demo seed should not fail if Queue API is unreachable; settings flag remains.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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)],
|
|
'is_active' => true,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
}
|
|
|
|
return $practitioners;
|
|
}
|
|
|
|
/**
|
|
* @param list<Branch> $branches
|
|
* @param array<string, mixed> $volumes
|
|
* @return list<Patient>
|
|
*/
|
|
private function seedPatients(
|
|
Organization $organization,
|
|
array $branches,
|
|
string $ownerRef,
|
|
array $volumes,
|
|
): array {
|
|
$worldPeople = DemoWorld::people();
|
|
$patients = [];
|
|
for ($i = 0; $i < $volumes['patients']; $i++) {
|
|
$branch = $branches[$i % count($branches)];
|
|
$number = sprintf('DEMO-%s-%05d', Str::upper(Str::substr(md5($ownerRef), 0, 4)), $i + 1);
|
|
$person = $worldPeople[$i] ?? null;
|
|
if ($person === null) {
|
|
$base = $worldPeople[$i % count($worldPeople)];
|
|
$seq = $i + 1;
|
|
$person = [
|
|
'first_name' => $base['first_name'],
|
|
'last_name' => $base['last_name'],
|
|
'gender' => $base['gender'],
|
|
'dob' => $base['dob'],
|
|
'phone' => '+23324'.str_pad((string) (1000000 + $seq), 7, '0', STR_PAD_LEFT),
|
|
'email' => sprintf('patient.%d@demo.ladill.com', $seq),
|
|
'national_id' => sprintf('GHA-729%06d', $seq),
|
|
'city' => $base['city'],
|
|
'address' => $base['address'],
|
|
];
|
|
}
|
|
|
|
$patients[] = Patient::withTrashed()->updateOrCreate(
|
|
[
|
|
'organization_id' => $organization->id,
|
|
'patient_number' => $number,
|
|
],
|
|
[
|
|
'uuid' => $this->demoUuid("patient|{$ownerRef}|{$i}"),
|
|
'owner_ref' => $ownerRef,
|
|
'branch_id' => $branch->id,
|
|
'first_name' => $person['first_name'],
|
|
'last_name' => $person['last_name'],
|
|
'gender' => $person['gender'],
|
|
'date_of_birth' => $person['dob'],
|
|
'phone' => $person['phone'],
|
|
'email' => $person['email'],
|
|
'national_id' => $person['national_id'],
|
|
'city' => $person['city'],
|
|
'region' => 'Greater Accra',
|
|
'address' => $person['address'],
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
}
|
|
|
|
return $patients;
|
|
}
|
|
|
|
/**
|
|
* @param list<Branch> $branches
|
|
* @param array<int, list<Department>> $departments
|
|
* @param list<Practitioner> $practitioners
|
|
* @param list<Patient> $patients
|
|
* @param array<string, mixed> $volumes
|
|
*/
|
|
private function seedAppointmentsAndClinical(
|
|
Organization $organization,
|
|
array $branches,
|
|
array $departments,
|
|
array $practitioners,
|
|
array $patients,
|
|
string $ownerRef,
|
|
array $volumes,
|
|
): int {
|
|
$statuses = [
|
|
Appointment::STATUS_SCHEDULED,
|
|
Appointment::STATUS_CHECKED_IN,
|
|
Appointment::STATUS_WAITING,
|
|
Appointment::STATUS_COMPLETED,
|
|
Appointment::STATUS_COMPLETED,
|
|
Appointment::STATUS_CANCELLED,
|
|
];
|
|
$reasons = ['Follow-up', 'New complaint', 'Review labs', 'Chronic care'];
|
|
$symptoms = ['Fever', 'Cough', 'Headache', 'Fatigue'];
|
|
|
|
$count = 0;
|
|
for ($i = 0; $i < $volumes['appointments']; $i++) {
|
|
$patient = $patients[$i % count($patients)];
|
|
$practitioner = $practitioners[$i % count($practitioners)];
|
|
$branch = $branches[$i % count($branches)];
|
|
$branchDepts = $departments[$branch->id] ?? [];
|
|
$department = $branchDepts[$i % max(1, count($branchDepts))] ?? null;
|
|
$status = $statuses[$i % count($statuses)];
|
|
$scheduledAt = now()->subDays(14)->addHours($i * 3);
|
|
|
|
$visit = null;
|
|
if (in_array($status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_WAITING, Appointment::STATUS_COMPLETED], true)) {
|
|
$visit = Visit::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("visit|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'patient_id' => $patient->id,
|
|
'status' => $status === Appointment::STATUS_COMPLETED
|
|
? Visit::STATUS_COMPLETED
|
|
: Visit::STATUS_IN_PROGRESS,
|
|
'checked_in_at' => $scheduledAt->copy()->addMinutes(5),
|
|
'completed_at' => $status === Appointment::STATUS_COMPLETED
|
|
? $scheduledAt->copy()->addHour()
|
|
: null,
|
|
'checked_in_by' => $ownerRef,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
}
|
|
|
|
Appointment::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("appt|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'patient_id' => $patient->id,
|
|
'practitioner_id' => $practitioner->id,
|
|
'department_id' => $department?->id,
|
|
'visit_id' => $visit?->id,
|
|
'type' => $i % 5 === 0 ? Appointment::TYPE_WALK_IN : Appointment::TYPE_SCHEDULED,
|
|
'status' => $status,
|
|
'scheduled_at' => $scheduledAt,
|
|
'checked_in_at' => $visit?->checked_in_at,
|
|
'completed_at' => $status === Appointment::STATUS_COMPLETED
|
|
? $scheduledAt->copy()->addHour()
|
|
: null,
|
|
'cancelled_at' => $status === Appointment::STATUS_CANCELLED
|
|
? $scheduledAt->copy()->subHour()
|
|
: null,
|
|
'reason' => $reasons[$i % count($reasons)],
|
|
'created_by' => $ownerRef,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
if ($visit && $status === Appointment::STATUS_COMPLETED) {
|
|
$consultation = Consultation::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("consult|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'visit_id' => $visit->id,
|
|
'appointment_id' => null,
|
|
'practitioner_id' => $practitioner->id,
|
|
'patient_id' => $patient->id,
|
|
'status' => Consultation::STATUS_COMPLETED,
|
|
'symptoms' => $symptoms[$i % count($symptoms)],
|
|
'clinical_notes' => 'Demo consultation notes.',
|
|
'started_at' => $scheduledAt->copy()->addMinutes(15),
|
|
'completed_at' => $scheduledAt->copy()->addHour(),
|
|
'completed_by' => $ownerRef,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
VitalSign::query()->updateOrCreate(
|
|
[
|
|
'consultation_id' => $consultation->id,
|
|
'recorded_at' => $scheduledAt->copy()->addMinutes(10),
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'bp_systolic' => 110 + ($i % 30),
|
|
'bp_diastolic' => 70 + ($i % 15),
|
|
'pulse' => 60 + ($i % 40),
|
|
'temperature' => 36.5 + (($i % 10) / 10),
|
|
'weight_kg' => 55 + ($i % 40),
|
|
'height_cm' => 150 + ($i % 40),
|
|
'spo2' => 95 + ($i % 5),
|
|
'respiratory_rate' => 14 + ($i % 6),
|
|
'recorded_by' => $ownerRef,
|
|
],
|
|
);
|
|
}
|
|
|
|
$count++;
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* @param list<Branch> $branches
|
|
* @param list<Practitioner> $practitioners
|
|
* @param list<Patient> $patients
|
|
* @param array<string, mixed> $volumes
|
|
*/
|
|
/**
|
|
* Demo hardware inventory: browser wedge scanner + agent thermometer per branch (Pro+).
|
|
*
|
|
* @param list<Branch> $branches
|
|
*/
|
|
private function seedDevices(Organization $organization, array $branches, string $ownerRef): void
|
|
{
|
|
$deviceService = app(DeviceService::class);
|
|
|
|
foreach ($branches as $i => $branch) {
|
|
Device::withTrashed()->updateOrCreate(
|
|
[
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'name' => 'Demo barcode scanner',
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'type' => 'barcode_scanner',
|
|
'connection_mode' => Device::MODE_BROWSER_WEDGE,
|
|
'status' => Device::STATUS_OFFLINE,
|
|
'device_token_hash' => null,
|
|
'metadata' => ['demo' => true],
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
$thermo = Device::withTrashed()->updateOrCreate(
|
|
[
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'name' => 'Demo thermometer',
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'type' => 'thermometer',
|
|
'connection_mode' => Device::MODE_AGENT,
|
|
'status' => Device::STATUS_OFFLINE,
|
|
'metadata' => ['demo' => true, 'note' => 'Token issued on first seed only if missing'],
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
if (! $thermo->device_token_hash) {
|
|
// Deterministic demo token so sales can reuse docs examples after reseed.
|
|
$plain = 'demo-care-device-'.$organization->id.'-'.$branch->id.'-thermo';
|
|
$thermo->update(['device_token_hash' => $deviceService->hashToken($plain)]);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function seedPaidModules(
|
|
Organization $organization,
|
|
array $branches,
|
|
array $practitioners,
|
|
array $patients,
|
|
string $ownerRef,
|
|
array $volumes,
|
|
): void {
|
|
$types = [];
|
|
foreach ([
|
|
['name' => 'Fasting Blood Glucose', 'code' => 'FBG', 'category' => 'blood', 'unit' => 'mmol/L', 'low' => 3.9, 'high' => 6.1, 'price' => 2500],
|
|
['name' => 'Full Blood Count', 'code' => 'FBC', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 4500],
|
|
['name' => 'Malaria Parasite', 'code' => 'MP', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 1500],
|
|
['name' => 'Urinalysis', 'code' => 'UA', 'category' => 'urine', 'unit' => null, 'low' => null, 'high' => null, 'price' => 2000],
|
|
] as $i => $spec) {
|
|
$types[] = InvestigationType::withTrashed()->updateOrCreate(
|
|
[
|
|
'organization_id' => $organization->id,
|
|
'code' => $spec['code'],
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'name' => $spec['name'],
|
|
'category' => $spec['category'],
|
|
'unit' => $spec['unit'],
|
|
'reference_low' => $spec['low'],
|
|
'reference_high' => $spec['high'],
|
|
'price_minor' => $spec['price'],
|
|
'is_active' => true,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
}
|
|
|
|
$drugs = [];
|
|
foreach ([
|
|
['name' => 'Amoxicillin 500mg', 'unit' => 'capsule', 'price' => 1500],
|
|
['name' => 'Paracetamol 500mg', 'unit' => 'tablet', 'price' => 50],
|
|
['name' => 'Metformin 500mg', 'unit' => 'tablet', 'price' => 80],
|
|
['name' => 'Amlodipine 5mg', 'unit' => 'tablet', 'price' => 120],
|
|
] as $i => $spec) {
|
|
$drug = Drug::withTrashed()->updateOrCreate(
|
|
[
|
|
'organization_id' => $organization->id,
|
|
'sku' => 'DEMO-DRUG-'.($i + 1),
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'branch_id' => $branches[0]->id,
|
|
'name' => $spec['name'],
|
|
'generic_name' => $spec['name'],
|
|
'unit' => $spec['unit'],
|
|
'unit_price_minor' => $spec['price'],
|
|
'reorder_level' => 20,
|
|
'is_active' => true,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
DrugBatch::query()->updateOrCreate(
|
|
[
|
|
'drug_id' => $drug->id,
|
|
'batch_number' => 'DEMO-BATCH-'.($i + 1),
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'expiry_date' => now()->addYear()->toDateString(),
|
|
'quantity_on_hand' => 200,
|
|
'cost_minor' => (int) ($spec['price'] * 0.6),
|
|
'received_at' => now()->subDays(10),
|
|
],
|
|
);
|
|
$drugs[] = $drug;
|
|
}
|
|
|
|
for ($i = 0; $i < $volumes['lab_requests']; $i++) {
|
|
$patient = $patients[$i % count($patients)];
|
|
$branch = $branches[$i % count($branches)];
|
|
$practitioner = $practitioners[$i % count($practitioners)];
|
|
$type = $types[$i % count($types)];
|
|
|
|
$visit = Visit::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("lab-visit|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'patient_id' => $patient->id,
|
|
'status' => Visit::STATUS_IN_PROGRESS,
|
|
'checked_in_at' => now()->subDays($i % 10),
|
|
'checked_in_by' => $ownerRef,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
$consultation = Consultation::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("lab-consult|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'visit_id' => $visit->id,
|
|
'practitioner_id' => $practitioner->id,
|
|
'patient_id' => $patient->id,
|
|
'status' => Consultation::STATUS_DRAFT,
|
|
'started_at' => now()->subDays($i % 10),
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
InvestigationRequest::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("lab-req|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'visit_id' => $visit->id,
|
|
'consultation_id' => $consultation->id,
|
|
'patient_id' => $patient->id,
|
|
'investigation_type_id' => $type->id,
|
|
'practitioner_id' => $practitioner->id,
|
|
'status' => InvestigationRequest::STATUS_PENDING,
|
|
'priority' => 'routine',
|
|
'clinical_notes' => 'Demo lab request',
|
|
'requested_by' => $ownerRef,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
}
|
|
|
|
for ($i = 0; $i < $volumes['prescriptions']; $i++) {
|
|
$patient = $patients[$i % count($patients)];
|
|
$branch = $branches[$i % count($branches)];
|
|
$practitioner = $practitioners[$i % count($practitioners)];
|
|
$drug = $drugs[$i % count($drugs)];
|
|
|
|
$visit = Visit::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("rx-visit|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'patient_id' => $patient->id,
|
|
'status' => Visit::STATUS_IN_PROGRESS,
|
|
'checked_in_at' => now()->subDays($i % 7),
|
|
'checked_in_by' => $ownerRef,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
$consultation = Consultation::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("rx-consult|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'visit_id' => $visit->id,
|
|
'practitioner_id' => $practitioner->id,
|
|
'patient_id' => $patient->id,
|
|
'status' => Consultation::STATUS_DRAFT,
|
|
'started_at' => now()->subDays($i % 7),
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
$prescription = Prescription::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("rx|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'visit_id' => $visit->id,
|
|
'consultation_id' => $consultation->id,
|
|
'patient_id' => $patient->id,
|
|
'practitioner_id' => $practitioner->id,
|
|
'status' => Prescription::STATUS_ACTIVE,
|
|
'prescribed_by' => $ownerRef,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
$prescription->items()->updateOrCreate(
|
|
[
|
|
'prescription_id' => $prescription->id,
|
|
'name' => $drug->name,
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'drug_id' => $drug->id,
|
|
'dosage' => '1 '.$drug->unit,
|
|
'frequency' => 'BD',
|
|
'duration' => '5 days',
|
|
'quantity' => 10,
|
|
'sort_order' => 0,
|
|
],
|
|
);
|
|
}
|
|
|
|
for ($i = 0; $i < $volumes['bills']; $i++) {
|
|
$patient = $patients[$i % count($patients)];
|
|
$branch = $branches[$i % count($branches)];
|
|
$fee = 5000 + (($i % 5) * 1000);
|
|
|
|
$visit = Visit::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("bill-visit|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'patient_id' => $patient->id,
|
|
'status' => Visit::STATUS_COMPLETED,
|
|
'checked_in_at' => now()->subDays($i % 12),
|
|
'completed_at' => now()->subDays($i % 12)->addHour(),
|
|
'checked_in_by' => $ownerRef,
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
$bill = Bill::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("bill|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'visit_id' => $visit->id,
|
|
'patient_id' => $patient->id,
|
|
'invoice_number' => sprintf('DEMO-INV-%s-%04d', Str::upper(Str::substr(md5($ownerRef), 0, 4)), $i + 1),
|
|
'status' => $i % 3 === 0 ? Bill::STATUS_PAID : Bill::STATUS_OPEN,
|
|
'subtotal_minor' => $fee,
|
|
'discount_minor' => 0,
|
|
'tax_minor' => 0,
|
|
'total_minor' => $fee,
|
|
'amount_paid_minor' => $i % 3 === 0 ? $fee : 0,
|
|
'balance_minor' => $i % 3 === 0 ? 0 : $fee,
|
|
'created_by' => $ownerRef,
|
|
'finalized_at' => now()->subDays($i % 12),
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
BillLineItem::query()->updateOrCreate(
|
|
[
|
|
'bill_id' => $bill->id,
|
|
'description' => 'Consultation fee',
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'type' => 'consultation',
|
|
'quantity' => 1,
|
|
'unit_price_minor' => $fee,
|
|
'total_minor' => $fee,
|
|
],
|
|
);
|
|
|
|
if ($bill->status === Bill::STATUS_PAID) {
|
|
Payment::query()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("pay|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'bill_id' => $bill->id,
|
|
'amount_minor' => $fee,
|
|
'method' => Payment::METHOD_CASH,
|
|
'status' => Payment::STATUS_PAID,
|
|
'reference' => 'DEMO-PAY-'.($i + 1),
|
|
'paid_at' => now()->subDays($i % 12),
|
|
'recorded_by' => $ownerRef,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<Branch> $branches
|
|
* @param list<Practitioner> $practitioners
|
|
* @param list<Patient> $patients
|
|
*/
|
|
private function seedAssessments(
|
|
Organization $organization,
|
|
array $branches,
|
|
array $practitioners,
|
|
array $patients,
|
|
string $ownerRef,
|
|
int $count,
|
|
): void {
|
|
$template = AssessmentTemplate::withTrashed()->updateOrCreate(
|
|
[
|
|
'code' => self::DEMO_TEMPLATE_CODE,
|
|
'version' => 1,
|
|
],
|
|
[
|
|
'organization_id' => $organization->id,
|
|
'name' => 'Demo Triage Screen',
|
|
'category' => AssessmentTemplate::CATEGORY_SCREENING,
|
|
'description' => 'Lightweight demo assessment for enterprise analytics.',
|
|
'is_current' => true,
|
|
'is_active' => true,
|
|
'scoring_strategy' => 'sum_items',
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
$question = AssessmentQuestion::query()->updateOrCreate(
|
|
[
|
|
'template_id' => $template->id,
|
|
'code' => 'severity',
|
|
],
|
|
[
|
|
'section' => 'Triage',
|
|
'label' => 'Severity (0-10)',
|
|
'answer_type' => AssessmentQuestion::TYPE_SCALE,
|
|
'is_required' => true,
|
|
'sort_order' => 1,
|
|
'score_key' => 'severity',
|
|
'options' => ['min' => 0, 'max' => 10],
|
|
],
|
|
);
|
|
|
|
for ($i = 0; $i < $count; $i++) {
|
|
$patient = $patients[$i % count($patients)];
|
|
$branch = $branches[$i % count($branches)];
|
|
$practitioner = $practitioners[$i % count($practitioners)];
|
|
$scoreValue = (float) ($i % 11);
|
|
|
|
$assessment = Assessment::withTrashed()->updateOrCreate(
|
|
['uuid' => $this->demoUuid("assess|{$ownerRef}|{$i}")],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $branch->id,
|
|
'patient_id' => $patient->id,
|
|
'template_id' => $template->id,
|
|
'practitioner_id' => $practitioner->id,
|
|
'status' => Assessment::STATUS_COMPLETED,
|
|
'assessed_at' => now()->subDays($i % 30),
|
|
'completed_at' => now()->subDays($i % 30),
|
|
'completed_by' => $ownerRef,
|
|
'started_by' => $ownerRef,
|
|
'notes' => 'Demo assessment',
|
|
'deleted_at' => null,
|
|
],
|
|
);
|
|
|
|
AssessmentAnswer::query()->updateOrCreate(
|
|
[
|
|
'assessment_id' => $assessment->id,
|
|
'question_id' => $question->id,
|
|
],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'value_number' => $scoreValue,
|
|
],
|
|
);
|
|
|
|
AssessmentScore::query()->updateOrCreate(
|
|
['assessment_id' => $assessment->id],
|
|
[
|
|
'owner_ref' => $ownerRef,
|
|
'template_code' => self::DEMO_TEMPLATE_CODE,
|
|
'total_score' => $scoreValue,
|
|
'max_score' => 10,
|
|
'subscores' => ['severity' => $scoreValue],
|
|
'severity_label' => $scoreValue >= 7 ? 'high' : ($scoreValue >= 4 ? 'moderate' : 'low'),
|
|
'computed_at' => now()->subDays($i % 30),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
private function demoUuid(string $key): string
|
|
{
|
|
$hex = md5('ladill-care-demo|'.$key);
|
|
|
|
return sprintf(
|
|
'%s-%s-%s-%s-%s',
|
|
substr($hex, 0, 8),
|
|
substr($hex, 8, 4),
|
|
substr($hex, 12, 4),
|
|
substr($hex, 16, 4),
|
|
substr($hex, 20, 12),
|
|
);
|
|
}
|
|
}
|