Deploy Ladill Care / deploy (push) Successful in 52s
Mirror Eye Care depth with stages, workspace tabs, clinical JSON, stage Move→tab routing, reports/print, demo seeds, and suite feature tests. Co-authored-by: Cursor <cursoragent@cursor.com>
2355 lines
94 KiB
PHP
2355 lines
94 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\Facades\Schema;
|
||
use Illuminate\Support\Str;
|
||
|
||
/**
|
||
* Idempotent, plan-aware demo data for a Care tenant (keyed by owner_ref / public_id).
|
||
*/
|
||
class DemoTenantSeeder
|
||
{
|
||
public const DEMO_TEMPLATE_CODE = 'demo_triage';
|
||
|
||
/** @var list<string> */
|
||
private const FIRST_NAMES = [
|
||
'Ama', 'Kofi', 'Abena', 'Kwame', 'Akosua', 'Yaw', 'Efua', 'Kojo',
|
||
'Adwoa', 'Kwesi', 'Afia', 'Kwadwo', 'Esi', 'Fiifi', 'Mansa', 'Nana',
|
||
];
|
||
|
||
/** @var list<string> */
|
||
private const LAST_NAMES = [
|
||
'Mensah', 'Asante', 'Osei', 'Boateng', 'Owusu', 'Adjei', 'Appiah', 'Darko',
|
||
'Agyeman', 'Sarpong', 'Nyarko', 'Ankrah', 'Tetteh', 'Quaye', 'Addo', 'Frimpong',
|
||
];
|
||
|
||
/** @var list<string> */
|
||
private const BRANCH_NAMES = [
|
||
'Ridge Clinic', 'Osu Medical Centre', 'East Legon Care', 'Tema Harbour Clinic',
|
||
'Kaneshie Health Hub', 'Spintex Wellness', 'Madina Family Clinic', 'Airport Residential Care',
|
||
];
|
||
|
||
/** @var list<array{name: string, type: string}> */
|
||
private const CORE_DEPARTMENTS = [
|
||
['name' => 'Outpatient', 'type' => 'outpatient'],
|
||
['name' => 'General Practice', 'type' => 'general'],
|
||
['name' => 'Nursing', 'type' => 'nursing'],
|
||
['name' => 'Specialist Clinic', 'type' => 'specialist'],
|
||
];
|
||
|
||
/** @var list<array{name: string, type: string}> */
|
||
private const PAID_DEPARTMENTS = [
|
||
['name' => 'Laboratory', 'type' => 'laboratory'],
|
||
['name' => 'Pharmacy', 'type' => 'pharmacy'],
|
||
];
|
||
|
||
/** @var list<string> */
|
||
private const SPECIALTIES = [
|
||
'General Practice', 'Internal Medicine', 'Paediatrics', 'Obstetrics',
|
||
'Emergency', 'Nursing', 'Laboratory', 'Pharmacy',
|
||
];
|
||
|
||
/**
|
||
* @return array{
|
||
* organization: Organization,
|
||
* branches: int,
|
||
* patients: int,
|
||
* appointments: int,
|
||
* plan: string
|
||
* }
|
||
*/
|
||
public function seed(User $user, string $plan, bool $reset = false): array
|
||
{
|
||
$plan = $this->normalizePlan($plan);
|
||
$ownerRef = $user->ownerRef();
|
||
$volumes = $this->volumes($plan);
|
||
|
||
// Always materialize the onboarded org shell before any wipe so concurrent
|
||
// SSO redirects never land on the blank onboarding form.
|
||
$this->ensureOnboardedShell($user, $plan);
|
||
|
||
if ($reset) {
|
||
$this->resetTenant($ownerRef);
|
||
$this->ensureOnboardedShell($user, $plan);
|
||
}
|
||
|
||
// No outer transaction — Enterprise volumes can hold row locks for minutes
|
||
// and 500 concurrent settings/login requests (messaging credentials, org updates).
|
||
$organization = $this->upsertOrganization($user, $ownerRef, $plan, $volumes['branches']);
|
||
$this->upsertOwnerMember($organization, $ownerRef);
|
||
|
||
$branches = $this->seedBranches($organization, $ownerRef, $volumes);
|
||
// Lab catalog early — survives if later clinical seeding fails mid-run.
|
||
$investigationTypes = $volumes['paid_modules']
|
||
? $this->seedInvestigationCatalog($organization, $ownerRef)
|
||
: [];
|
||
$this->seedStaffMembers($organization, $ownerRef, $plan, $branches);
|
||
$departments = $this->seedDepartments($branches, $ownerRef, $volumes);
|
||
if (in_array($plan, ['pro', 'enterprise'], true)) {
|
||
$this->seedSpecialtyModules($organization, $ownerRef);
|
||
// Keep $departments as core/paid only. Specialty departments must not feed
|
||
// general GP desks or appointments — those waiters live in seedSpecialtyDemoData.
|
||
}
|
||
$practitioners = $this->seedPractitioners($organization, $branches, $departments, $ownerRef, $volumes);
|
||
$this->linkStaffDoctorsToPractitioners($organization, $ownerRef, $plan, $branches, $practitioners);
|
||
$patients = $this->seedPatients($organization, $branches, $ownerRef, $volumes);
|
||
$appointmentCount = $this->seedAppointmentsAndClinical(
|
||
$organization,
|
||
$branches,
|
||
$departments,
|
||
$practitioners,
|
||
$patients,
|
||
$ownerRef,
|
||
$volumes,
|
||
);
|
||
if (in_array($plan, ['pro', 'enterprise'], true)) {
|
||
$appointmentCount += $this->seedSpecialtyDemoData(
|
||
$organization->fresh(),
|
||
$branches,
|
||
$practitioners,
|
||
$patients,
|
||
$ownerRef,
|
||
$plan,
|
||
);
|
||
}
|
||
$this->assignAllDemoWaiters($organization, $ownerRef, $branches, $practitioners);
|
||
$this->renumberWaitingQueuePositions($organization, $ownerRef, $branches);
|
||
|
||
// Catalog first so a later lab-request failure never leaves Lab → Catalog empty.
|
||
if ($volumes['paid_modules']) {
|
||
if ($investigationTypes === []) {
|
||
$investigationTypes = $this->seedInvestigationCatalog($organization, $ownerRef);
|
||
}
|
||
$this->seedPaidModules(
|
||
$organization,
|
||
$branches,
|
||
$practitioners,
|
||
$patients,
|
||
$ownerRef,
|
||
$volumes,
|
||
$investigationTypes,
|
||
);
|
||
$this->seedDevices($organization, $branches, $ownerRef);
|
||
}
|
||
|
||
if ($volumes['assessments'] > 0) {
|
||
$this->seedAssessments(
|
||
$organization,
|
||
$branches,
|
||
$practitioners,
|
||
$patients,
|
||
$ownerRef,
|
||
$volumes['assessments'],
|
||
);
|
||
}
|
||
|
||
$this->syncQueueTicketsForDemo($organization->fresh(), $branches);
|
||
|
||
return [
|
||
'organization' => $organization->fresh(),
|
||
'branches' => count($branches),
|
||
'patients' => count($patients),
|
||
'appointments' => $appointmentCount,
|
||
'plan' => $plan,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param list<Branch> $branches
|
||
*/
|
||
private function syncQueueTicketsForDemo(Organization $organization, array $branches): void
|
||
{
|
||
if (! data_get($organization->settings, 'queue_integration_enabled')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$bridge = app(CareQueueBridge::class);
|
||
if (! $bridge->isEnabled($organization)) {
|
||
return;
|
||
}
|
||
|
||
// Scoped per branch (not full-org) so demos finish quickly while
|
||
// still leaving waiters with real tickets instead of local positions only.
|
||
foreach ($branches as $branch) {
|
||
try {
|
||
$bridge->syncMissingAppointmentTickets($organization, (int) $branch->id, 100);
|
||
} catch (\Throwable) {
|
||
// Continue — page load will retry.
|
||
}
|
||
|
||
foreach ([
|
||
CareQueueContexts::PHARMACY,
|
||
CareQueueContexts::LABORATORY,
|
||
CareQueueContexts::BILLING,
|
||
] as $context) {
|
||
try {
|
||
$bridge->syncMissingTickets($organization, $context, (int) $branch->id, 50);
|
||
} catch (\Throwable) {
|
||
// Continue — page load will retry for that context.
|
||
}
|
||
}
|
||
}
|
||
} catch (\Throwable) {
|
||
// Demo seed should not fail if Queue API is unreachable.
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Point DemoWorld doctor staff at a real practitioner so Call next / rooms work.
|
||
*
|
||
* @param list<Branch> $branches
|
||
* @param list<Practitioner> $practitioners
|
||
*/
|
||
private function linkStaffDoctorsToPractitioners(
|
||
Organization $organization,
|
||
string $ownerRef,
|
||
string $plan,
|
||
array $branches,
|
||
array $practitioners,
|
||
): void {
|
||
$doctorEmail = null;
|
||
$doctorName = null;
|
||
foreach (DemoWorld::staff($plan) as $staff) {
|
||
if (($staff['roles']['care'] ?? null) === 'doctor') {
|
||
$doctorEmail = strtolower((string) $staff['email']);
|
||
$doctorName = (string) $staff['name'];
|
||
break;
|
||
}
|
||
}
|
||
if ($doctorEmail === null || $practitioners === []) {
|
||
return;
|
||
}
|
||
|
||
$member = $this->findStaffMemberByEmail($organization, $doctorEmail);
|
||
|
||
// Link the demo doctor to a single desk on their home branch (not every site).
|
||
$homeBranchId = (int) ($member?->branch_id ?: ($branches[0]->id ?? 0));
|
||
$practitioner = collect($practitioners)->first(
|
||
fn (Practitioner $p) => (int) $p->branch_id === $homeBranchId,
|
||
) ?? ($practitioners[0] ?? null);
|
||
|
||
if (! $practitioner) {
|
||
return;
|
||
}
|
||
|
||
$practitioner->forceFill([
|
||
'user_ref' => $member?->user_ref ?: $doctorEmail,
|
||
'member_id' => $member?->id,
|
||
'name' => $doctorName !== '' ? $doctorName : $practitioner->name,
|
||
])->save();
|
||
|
||
if ($homeBranchId > 0) {
|
||
$practitioner->syncAssignedBranches([$homeBranchId]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Every waiting / checked-in demo appointment gets a doctor and a clean routing
|
||
* state so Queue sync can issue tickets (no amber Unassigned leftovers).
|
||
*
|
||
* @param list<Branch> $branches
|
||
* @param list<Practitioner> $practitioners
|
||
*/
|
||
private function assignAllDemoWaiters(
|
||
Organization $organization,
|
||
string $ownerRef,
|
||
array $branches,
|
||
array $practitioners,
|
||
): void {
|
||
if ($practitioners === []) {
|
||
return;
|
||
}
|
||
|
||
$byBranch = [];
|
||
foreach ($practitioners as $practitioner) {
|
||
$byBranch[(int) $practitioner->branch_id][] = $practitioner;
|
||
}
|
||
|
||
$waiters = Appointment::withTrashed()
|
||
->owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||
->where(function ($q) {
|
||
$q->whereNull('practitioner_id')->orWhere('practitioner_id', 0);
|
||
})
|
||
->get();
|
||
|
||
foreach ($waiters as $index => $appointment) {
|
||
$branchPracs = $byBranch[(int) $appointment->branch_id] ?? $practitioners;
|
||
$practitioner = $branchPracs[$index % max(1, count($branchPracs))] ?? $practitioners[0];
|
||
|
||
$appointment->forceFill([
|
||
'practitioner_id' => $practitioner->id,
|
||
'queue_routing_status' => null,
|
||
'deleted_at' => null,
|
||
])->save();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Give each waiting/checked-in appointment a unique 1..n position per branch.
|
||
*
|
||
* @param list<Branch> $branches
|
||
*/
|
||
private function renumberWaitingQueuePositions(
|
||
Organization $organization,
|
||
string $ownerRef,
|
||
array $branches,
|
||
): void {
|
||
foreach ($branches as $branch) {
|
||
$waiters = Appointment::withTrashed()
|
||
->owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->where('branch_id', $branch->id)
|
||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||
->orderByRaw('COALESCE(waiting_at, checked_in_at, scheduled_at) asc')
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
foreach ($waiters as $index => $appointment) {
|
||
$position = $index + 1;
|
||
if ((int) $appointment->queue_position === $position) {
|
||
continue;
|
||
}
|
||
$appointment->forceFill([
|
||
'queue_position' => $position,
|
||
'deleted_at' => null,
|
||
])->save();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ensure the demo tenant has an onboarded organization + owner member.
|
||
* Safe to call on the SSO request path before redirecting.
|
||
*/
|
||
public function ensureOnboardedShell(User $user, string $plan): Organization
|
||
{
|
||
$plan = $this->normalizePlan($plan);
|
||
$ownerRef = $user->ownerRef();
|
||
$volumes = $this->volumes($plan);
|
||
|
||
return DB::transaction(function () use ($user, $ownerRef, $plan, $volumes) {
|
||
$organization = $this->upsertOrganization($user, $ownerRef, $plan, $volumes['branches']);
|
||
$this->upsertOwnerMember($organization, $ownerRef);
|
||
|
||
return $organization;
|
||
});
|
||
}
|
||
|
||
public function resetTenant(string $ownerRef): void
|
||
{
|
||
$orgIds = DB::table('care_organizations')->where('owner_ref', $ownerRef)->pluck('id');
|
||
|
||
if (Schema::hasTable('care_financial_obligations')) {
|
||
DB::table('care_financial_obligations')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_visit_stage_advances')->where('owner_ref', $ownerRef)->delete();
|
||
$workflowIds = DB::table('care_facility_workflows')->where('owner_ref', $ownerRef)->pluck('id');
|
||
if ($workflowIds->isNotEmpty()) {
|
||
DB::table('care_workflow_stages')->whereIn('workflow_id', $workflowIds)->delete();
|
||
DB::table('care_facility_workflows')->whereIn('id', $workflowIds)->delete();
|
||
}
|
||
}
|
||
|
||
DB::table('care_assessment_answers')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_assessment_scores')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_assessments')->where('owner_ref', $ownerRef)->update(['patient_pathway_id' => null]);
|
||
DB::table('care_patient_pathways')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_assessments')->where('owner_ref', $ownerRef)->delete();
|
||
|
||
if ($orgIds->isNotEmpty()) {
|
||
$templateIds = DB::table('care_assessment_templates')
|
||
->whereIn('organization_id', $orgIds)
|
||
->pluck('id');
|
||
if ($templateIds->isNotEmpty()) {
|
||
DB::table('care_assessment_questions')->whereIn('template_id', $templateIds)->delete();
|
||
DB::table('care_assessment_templates')->whereIn('id', $templateIds)->delete();
|
||
}
|
||
}
|
||
|
||
// Orphaned demo templates left behind by nullOnDelete on prior resets.
|
||
$orphanIds = DB::table('care_assessment_templates')
|
||
->where('code', self::DEMO_TEMPLATE_CODE)
|
||
->whereNull('organization_id')
|
||
->pluck('id');
|
||
if ($orphanIds->isNotEmpty()) {
|
||
DB::table('care_assessment_questions')->whereIn('template_id', $orphanIds)->delete();
|
||
DB::table('care_assessment_templates')->whereIn('id', $orphanIds)->delete();
|
||
}
|
||
|
||
DB::table('care_dispensing_records')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_payments')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_bill_line_items')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_bills')->where('owner_ref', $ownerRef)->delete();
|
||
|
||
DB::table('care_investigation_result_values')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_investigation_attachments')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_investigation_results')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_investigation_requests')->where('owner_ref', $ownerRef)->delete();
|
||
|
||
DB::table('care_prescription_items')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_prescriptions')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_drug_batches')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_drugs')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_investigation_types')->where('owner_ref', $ownerRef)->delete();
|
||
|
||
DB::table('care_consultation_documents')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_diagnoses')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_vital_signs')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_consultations')->where('owner_ref', $ownerRef)->delete();
|
||
|
||
DB::table('care_devices')->where('owner_ref', $ownerRef)->delete();
|
||
|
||
DB::table('care_appointments')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_visits')->where('owner_ref', $ownerRef)->delete();
|
||
|
||
DB::table('care_patient_allergies')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_patient_conditions')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_patient_family_history')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_emergency_contacts')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_insurance_policies')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_patient_attachments')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_patients')->where('owner_ref', $ownerRef)->delete();
|
||
|
||
DB::table('care_practitioner_schedules')->where('owner_ref', $ownerRef)->delete();
|
||
if (Schema::hasTable('care_practitioner_branch')) {
|
||
$practitionerIds = DB::table('care_practitioners')->where('owner_ref', $ownerRef)->pluck('id');
|
||
if ($practitionerIds->isNotEmpty()) {
|
||
DB::table('care_practitioner_branch')->whereIn('practitioner_id', $practitionerIds)->delete();
|
||
}
|
||
}
|
||
DB::table('care_practitioners')->where('owner_ref', $ownerRef)->delete();
|
||
|
||
// Keep the owner member + organization so SSO redirects never see
|
||
// "not onboarded" while afterResponse() reseeding is still running.
|
||
DB::table('care_members')
|
||
->where('owner_ref', $ownerRef)
|
||
->where('user_ref', '!=', $ownerRef)
|
||
->delete();
|
||
DB::table('care_departments')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_branches')->where('owner_ref', $ownerRef)->delete();
|
||
|
||
DB::table('care_messaging_credentials')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_payment_gateway_settings')->where('owner_ref', $ownerRef)->delete();
|
||
DB::table('care_audit_logs')->where('owner_ref', $ownerRef)->delete();
|
||
|
||
$organization = Organization::withTrashed()->where('owner_ref', $ownerRef)->first();
|
||
if ($organization) {
|
||
if ($organization->trashed()) {
|
||
$organization->restore();
|
||
}
|
||
$settings = $organization->settings ?? [];
|
||
$settings['onboarded'] = true;
|
||
$organization->forceFill(['settings' => $settings])->save();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return array{
|
||
* branches: int,
|
||
* departments_per_branch: int,
|
||
* practitioners: int,
|
||
* patients: int,
|
||
* appointments: int,
|
||
* paid_modules: bool,
|
||
* assessments: int,
|
||
* lab_requests: int,
|
||
* prescriptions: int,
|
||
* bills: int
|
||
* }
|
||
*/
|
||
public function volumes(string $plan): array
|
||
{
|
||
$testing = app()->runningUnitTests();
|
||
|
||
return match ($this->normalizePlan($plan)) {
|
||
'free' => [
|
||
'branches' => 1,
|
||
'departments_per_branch' => 2,
|
||
'practitioners' => 3,
|
||
'patients' => $testing ? 12 : 30,
|
||
'appointments' => $testing ? 20 : 50,
|
||
'paid_modules' => false,
|
||
'assessments' => 0,
|
||
'lab_requests' => 0,
|
||
'prescriptions' => 0,
|
||
'bills' => 0,
|
||
],
|
||
'pro' => [
|
||
'branches' => 3,
|
||
'departments_per_branch' => 3,
|
||
'practitioners' => 8,
|
||
'patients' => $testing ? 24 : 200,
|
||
'appointments' => $testing ? 40 : 300,
|
||
'paid_modules' => true,
|
||
'assessments' => 0,
|
||
'lab_requests' => $testing ? 8 : 40,
|
||
'prescriptions' => $testing ? 8 : 40,
|
||
'bills' => $testing ? 10 : 60,
|
||
],
|
||
'enterprise' => [
|
||
'branches' => 6,
|
||
'departments_per_branch' => 4,
|
||
'practitioners' => 16,
|
||
'patients' => $testing ? 36 : 500,
|
||
'appointments' => $testing ? 60 : 800,
|
||
'paid_modules' => true,
|
||
'assessments' => $testing ? 12 : 80,
|
||
'lab_requests' => $testing ? 12 : 80,
|
||
'prescriptions' => $testing ? 12 : 80,
|
||
'bills' => $testing ? 15 : 120,
|
||
],
|
||
default => throw new \InvalidArgumentException("Unsupported plan [{$plan}]"),
|
||
};
|
||
}
|
||
|
||
private function normalizePlan(string $plan): string
|
||
{
|
||
$plan = strtolower(trim($plan));
|
||
|
||
return in_array($plan, ['free', 'pro', 'enterprise'], true) ? $plan : 'free';
|
||
}
|
||
|
||
private function upsertOrganization(User $user, string $ownerRef, string $plan, int $billedBranches): Organization
|
||
{
|
||
$slug = 'demo-'.Str::slug(Str::limit($user->email ?: $ownerRef, 40, ''));
|
||
if ($slug === 'demo-' || $slug === 'demo') {
|
||
$slug = 'demo-'.Str::lower(Str::substr($ownerRef, 0, 8));
|
||
}
|
||
|
||
$name = DemoWorld::business($plan)['name'];
|
||
|
||
$organization = Organization::withTrashed()
|
||
->where('owner_ref', $ownerRef)
|
||
->first();
|
||
|
||
$facilityType = $plan === 'enterprise' ? 'hospital' : 'clinic';
|
||
$settings = array_merge($organization?->settings ?? [], [
|
||
'onboarded' => true,
|
||
'facility_type' => $facilityType,
|
||
'facility_category' => $facilityType,
|
||
'plan' => $plan,
|
||
'plan_expires_at' => now()->addYear()->toIso8601String(),
|
||
'billed_branches' => max(1, $billedBranches),
|
||
'demo' => true,
|
||
// Pro/Enterprise demos: Queue on natural role pages + every specialty module.
|
||
// Free / non-demo tenants leave specialty_modules empty (opt-in via Settings).
|
||
'queue_integration_enabled' => in_array($plan, ['pro', 'enterprise'], true),
|
||
'specialty_modules' => in_array($plan, ['pro', 'enterprise'], true)
|
||
? $this->allSpecialtyFlagsEnabled()
|
||
: [],
|
||
// Demo seed uses the legacy clinical path. Explicitly clear leftover
|
||
// onboarding rollout so a failed race that created a real workflow
|
||
// shell cannot leave an empty gated tenant.
|
||
'rollout' => [
|
||
CareFeatures::WORKFLOW_ENGINE => false,
|
||
CareFeatures::FINANCIAL_GATES => false,
|
||
],
|
||
]);
|
||
unset($settings['workflow_template']);
|
||
|
||
if ($organization) {
|
||
if ($organization->trashed()) {
|
||
$organization->restore();
|
||
}
|
||
$organization->update([
|
||
'name' => $name,
|
||
'slug' => $slug,
|
||
'timezone' => 'Africa/Accra',
|
||
'settings' => $settings,
|
||
]);
|
||
|
||
return $organization->fresh();
|
||
}
|
||
|
||
return Organization::create([
|
||
'owner_ref' => $ownerRef,
|
||
'name' => $name,
|
||
'slug' => $slug,
|
||
'timezone' => 'Africa/Accra',
|
||
'settings' => $settings,
|
||
]);
|
||
}
|
||
|
||
private function upsertOwnerMember(Organization $organization, string $ownerRef): Member
|
||
{
|
||
return Member::query()->updateOrCreate(
|
||
[
|
||
'organization_id' => $organization->id,
|
||
'user_ref' => $ownerRef,
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'role' => 'hospital_admin',
|
||
'branch_id' => null,
|
||
],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $volumes
|
||
* @return list<Branch>
|
||
*/
|
||
private function seedBranches(Organization $organization, string $ownerRef, array $volumes): array
|
||
{
|
||
$catalog = DemoWorld::branches($this->normalizePlan(
|
||
(string) data_get($organization->settings, 'plan', 'free')
|
||
), $volumes['branches']);
|
||
$branches = [];
|
||
foreach ($catalog as $i => $spec) {
|
||
$code = $spec['code'] !== '' ? $spec['code'] : sprintf('DEMO-BR-%02d', $i + 1);
|
||
$branches[] = Branch::withTrashed()->updateOrCreate(
|
||
[
|
||
'organization_id' => $organization->id,
|
||
'code' => $code,
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'name' => $spec['name'],
|
||
'address' => $spec['address'],
|
||
'phone' => $spec['phone'],
|
||
'is_active' => true,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
}
|
||
|
||
return $branches;
|
||
}
|
||
|
||
/**
|
||
* Find a demo staff Member by login email, including SSO-remapped public_id user_ref.
|
||
*/
|
||
private function findStaffMemberByEmail(Organization $organization, string $email): ?Member
|
||
{
|
||
$email = strtolower(trim($email));
|
||
if ($email === '') {
|
||
return null;
|
||
}
|
||
|
||
$publicId = User::query()
|
||
->whereRaw('LOWER(email) = ?', [$email])
|
||
->value('public_id');
|
||
|
||
return Member::query()
|
||
->where('organization_id', $organization->id)
|
||
->where(function ($query) use ($email, $publicId) {
|
||
$query->where('user_ref', $email);
|
||
if (is_string($publicId) && $publicId !== '') {
|
||
$query->orWhere('user_ref', $publicId);
|
||
}
|
||
})
|
||
->orderByRaw('CASE WHEN user_ref = ? THEN 0 ELSE 1 END', [
|
||
is_string($publicId) && $publicId !== '' ? $publicId : $email,
|
||
])
|
||
->first();
|
||
}
|
||
|
||
/**
|
||
* Local Member rows for DemoWorld staff (user_ref = email until SSO remaps to public_id).
|
||
* Multi-branch demos spread staff across branches so each site has a role roster.
|
||
*
|
||
* @param list<Branch> $branches
|
||
*/
|
||
private function seedStaffMembers(Organization $organization, string $ownerRef, string $plan, array $branches): void
|
||
{
|
||
$hq = $branches[0] ?? null;
|
||
$staffList = DemoWorld::staff($plan);
|
||
foreach ($staffList as $index => $staff) {
|
||
$role = $staff['roles']['care'] ?? null;
|
||
if (! is_string($role) || $role === '') {
|
||
continue;
|
||
}
|
||
|
||
$branch = $hq;
|
||
if ($role !== 'hospital_admin' && $branches !== []) {
|
||
// Cashiers always staff HQ so demo unpaid invoices on branch[0] are collectible.
|
||
$branch = $role === 'cashier'
|
||
? $hq
|
||
: $branches[$index % count($branches)];
|
||
}
|
||
|
||
$email = strtolower((string) $staff['email']);
|
||
// Prefer remapped public_id when the staff user already exists locally,
|
||
// so reseeds do not orphan SSO sessions keyed by public_id.
|
||
$publicId = User::query()
|
||
->whereRaw('LOWER(email) = ?', [$email])
|
||
->value('public_id');
|
||
$userRef = is_string($publicId) && $publicId !== '' ? $publicId : $email;
|
||
|
||
$existing = Member::query()
|
||
->where('organization_id', $organization->id)
|
||
->where(function ($query) use ($email, $publicId) {
|
||
$query->where('user_ref', $email);
|
||
if (is_string($publicId) && $publicId !== '') {
|
||
$query->orWhere('user_ref', $publicId);
|
||
}
|
||
})
|
||
->orderByRaw('CASE WHEN user_ref = ? THEN 0 ELSE 1 END', [$userRef])
|
||
->first();
|
||
|
||
if ($existing) {
|
||
$existing->forceFill([
|
||
'user_ref' => $userRef,
|
||
'owner_ref' => $ownerRef,
|
||
'role' => $role,
|
||
'branch_id' => $role === 'hospital_admin' ? null : $branch?->id,
|
||
])->save();
|
||
|
||
Member::query()
|
||
->where('organization_id', $organization->id)
|
||
->where('id', '!=', $existing->id)
|
||
->where(function ($query) use ($email, $publicId) {
|
||
$query->where('user_ref', $email);
|
||
if (is_string($publicId) && $publicId !== '') {
|
||
$query->orWhere('user_ref', $publicId);
|
||
}
|
||
})
|
||
->delete();
|
||
|
||
continue;
|
||
}
|
||
|
||
Member::query()->create([
|
||
'organization_id' => $organization->id,
|
||
'user_ref' => $userRef,
|
||
'owner_ref' => $ownerRef,
|
||
'role' => $role,
|
||
'branch_id' => $role === 'hospital_admin' ? null : $branch?->id,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param list<Branch> $branches
|
||
* @param array<string, mixed> $volumes
|
||
* @return array<int, list<Department>> keyed by branch id
|
||
*/
|
||
private function seedDepartments(array $branches, string $ownerRef, array $volumes): array
|
||
{
|
||
$catalog = self::CORE_DEPARTMENTS;
|
||
if ($volumes['paid_modules']) {
|
||
$catalog = array_merge($catalog, self::PAID_DEPARTMENTS);
|
||
}
|
||
|
||
$byBranch = [];
|
||
foreach ($branches as $branch) {
|
||
$byBranch[$branch->id] = [];
|
||
$count = min($volumes['departments_per_branch'], count($catalog));
|
||
for ($i = 0; $i < $count; $i++) {
|
||
$spec = $catalog[$i];
|
||
$byBranch[$branch->id][] = Department::withTrashed()->updateOrCreate(
|
||
[
|
||
'branch_id' => $branch->id,
|
||
'name' => $spec['name'],
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'type' => $spec['type'],
|
||
'is_active' => true,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
return $byBranch;
|
||
}
|
||
|
||
/**
|
||
* @return array<string, bool>
|
||
*/
|
||
private function allSpecialtyFlagsEnabled(): array
|
||
{
|
||
$flags = [];
|
||
foreach (array_keys(config('care.specialty_modules', [])) as $key) {
|
||
$flags[$key] = true;
|
||
}
|
||
|
||
return $flags;
|
||
}
|
||
|
||
private function seedSpecialtyModules(Organization $organization, string $ownerRef): void
|
||
{
|
||
$organization = $organization->fresh();
|
||
$queueEnabled = (bool) data_get($organization->settings, 'queue_integration_enabled');
|
||
|
||
$service = app(SpecialtyModuleService::class);
|
||
foreach (array_keys($service->catalog()) as $key) {
|
||
try {
|
||
$service->activate($organization->fresh(), $ownerRef, $key);
|
||
} catch (\Throwable) {
|
||
// Demo seed should not fail if activation hits an unexpected error.
|
||
}
|
||
}
|
||
|
||
if ($queueEnabled) {
|
||
try {
|
||
app(CareQueueProvisioner::class)->provisionOrganization($organization->fresh());
|
||
} catch (\Throwable) {
|
||
// Demo seed continues; pages lazy-provision on use.
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param list<Branch> $branches
|
||
* @return array<int, list<Department>> keyed by branch id
|
||
*/
|
||
private function departmentsByBranch(array $branches, string $ownerRef): array
|
||
{
|
||
$byBranch = [];
|
||
foreach ($branches as $branch) {
|
||
$byBranch[$branch->id] = Department::withTrashed()
|
||
->owned($ownerRef)
|
||
->where('branch_id', $branch->id)
|
||
->where('is_active', true)
|
||
->orderBy('id')
|
||
->get()
|
||
->all();
|
||
}
|
||
|
||
return $byBranch;
|
||
}
|
||
|
||
/**
|
||
* Waiting / completed specialty appointments so each module page has demo traffic.
|
||
*
|
||
* @param list<Branch> $branches
|
||
* @param list<Practitioner> $practitioners
|
||
* @param list<Patient> $patients
|
||
*/
|
||
private function seedSpecialtyDemoData(
|
||
Organization $organization,
|
||
array $branches,
|
||
array $practitioners,
|
||
array $patients,
|
||
string $ownerRef,
|
||
string $plan = 'pro',
|
||
): int {
|
||
if ($branches === [] || $patients === [] || $practitioners === []) {
|
||
return 0;
|
||
}
|
||
|
||
$reasons = [
|
||
'emergency' => ['Chest pain', 'Trauma review', 'Acute fever'],
|
||
'blood_bank' => ['Cross-match request', 'Transfusion review', 'Blood product issue'],
|
||
'dentistry' => ['Toothache', 'Dental cleaning', 'Extraction review'],
|
||
'ophthalmology' => ['Blurry vision', 'Eye exam', 'Contact lens fitting'],
|
||
'physiotherapy' => ['Back pain rehab', 'Post-injury physio', 'Mobility session'],
|
||
'maternity' => ['Antenatal visit', 'Pregnancy check', 'Postnatal review'],
|
||
'radiology' => ['X-ray referral', 'Ultrasound', 'Imaging review'],
|
||
'cardiology' => ['Chest pain review', 'ECG follow-up', 'Hypertension clinic'],
|
||
'psychiatry' => ['Mental health review', 'Counselling session', 'Medication review'],
|
||
'pediatrics' => ['Well-child visit', 'Fever in child', 'Growth check'],
|
||
'orthopedics' => ['Fracture follow-up', 'Joint pain', 'Cast review'],
|
||
'ent' => ['Ear infection', 'Sore throat', 'Sinus review'],
|
||
'oncology' => ['Chemo day-care', 'Oncology review', 'Symptom management'],
|
||
'renal' => ['Dialysis session', 'Nephrology review', 'Fluid assessment'],
|
||
'surgery' => ['Pre-op assessment', 'Surgical review', 'Wound check'],
|
||
'vaccination' => ['Routine immunization', 'Catch-up vaccine', 'Travel vaccine'],
|
||
'pathology' => ['Biopsy review', 'Histology result', 'Cytology sample'],
|
||
'infusion' => ['IV infusion', 'Iron infusion', 'Day-care therapy'],
|
||
'dermatology' => ['Skin rash', 'Mole review', 'Dermatology procedure'],
|
||
'podiatry' => ['Diabetic foot care', 'Nail procedure', 'Foot pain'],
|
||
'fertility' => ['Fertility consult', 'Cycle review', 'IVF follow-up'],
|
||
'child_welfare' => ['Growth monitoring', 'Immunization CWC', 'Well-baby visit'],
|
||
];
|
||
|
||
$count = 0;
|
||
$catalog = config('care.specialty_modules', []);
|
||
/** @var array<int, int> $nextPositionByBranch */
|
||
$nextPositionByBranch = [];
|
||
/** @var array<string, Member|null> $moduleMembers */
|
||
$moduleMembers = [];
|
||
|
||
foreach ($catalog as $key => $definition) {
|
||
$type = (string) ($definition['department_type'] ?? '');
|
||
if ($type === '') {
|
||
continue;
|
||
}
|
||
|
||
$moduleReasons = $reasons[$key] ?? ['Specialty visit'];
|
||
$staffEmail = sprintf('demo-%s-%s@ladill.com', $plan, $key);
|
||
// Resolve by email or SSO-remapped public_id (seedStaffMembers prefers public_id).
|
||
$moduleMembers[$key] = $this->findStaffMemberByEmail($organization, $staffEmail);
|
||
|
||
foreach ($branches as $branchIndex => $branch) {
|
||
$department = Department::owned($ownerRef)
|
||
->where('branch_id', $branch->id)
|
||
->where('type', $type)
|
||
->where('is_active', true)
|
||
->first();
|
||
if (! $department) {
|
||
continue;
|
||
}
|
||
|
||
$member = $moduleMembers[$key];
|
||
$staff = $member ? \App\Support\DemoWorld::staffByEmail($staffEmail) : null;
|
||
$homeBranchId = (int) ($member?->branch_id ?? 0);
|
||
// One doctor = one branch. Only link the specialty desk on their home site.
|
||
$assignMember = $member && $homeBranchId > 0 && (int) $branch->id === $homeBranchId;
|
||
$stableRef = sprintf('demo-specialty-%s-%s', $key, $branch->id);
|
||
$doctorName = (string) (\App\Support\DemoWorld::SPECIALTY_DOCTORS[$key]
|
||
?? ('Dr. '.($definition['label'] ?? ucfirst($key))));
|
||
if ($assignMember && is_array($staff) && filled($staff['name'] ?? null)) {
|
||
$practitionerName = (string) $staff['name'];
|
||
} elseif ($assignMember) {
|
||
$practitionerName = $doctorName;
|
||
} else {
|
||
// Cover desk for demo traffic at other sites — still a clinician, never a "Clinic" entity.
|
||
$practitionerName = $doctorName.' · '.$branch->name;
|
||
}
|
||
$practitioner = Practitioner::withTrashed()->updateOrCreate(
|
||
[
|
||
'organization_id' => $organization->id,
|
||
'user_ref' => $stableRef,
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'branch_id' => $branch->id,
|
||
'department_id' => $department->id,
|
||
'member_id' => $assignMember ? $member->id : null,
|
||
'name' => $practitionerName,
|
||
'specialty' => (string) ($definition['label'] ?? $key),
|
||
'room' => 'Bay '.(($branchIndex % 4) + 1),
|
||
'is_active' => true,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
$practitioner->syncAssignedBranches([(int) $branch->id]);
|
||
|
||
foreach ([Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN, Appointment::STATUS_COMPLETED] as $slot => $status) {
|
||
$patient = $patients[($branchIndex + $slot + $count) % count($patients)];
|
||
$scheduledAt = now()->subHours(($branchIndex * 3) + $slot + 1);
|
||
$token = "specialty|{$key}|{$branch->id}|{$slot}";
|
||
|
||
$visit = null;
|
||
if (in_array($status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_WAITING, Appointment::STATUS_COMPLETED], true)) {
|
||
$visit = Visit::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("visit|{$ownerRef}|{$token}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'patient_id' => $patient->id,
|
||
'status' => $status === Appointment::STATUS_COMPLETED
|
||
? Visit::STATUS_COMPLETED
|
||
: Visit::STATUS_IN_PROGRESS,
|
||
'checked_in_at' => $scheduledAt->copy()->addMinutes(5),
|
||
'completed_at' => $status === Appointment::STATUS_COMPLETED
|
||
? $scheduledAt->copy()->addHour()
|
||
: null,
|
||
'checked_in_by' => $ownerRef,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
}
|
||
|
||
$inQueue = in_array($status, [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN], true);
|
||
$queuePosition = null;
|
||
if ($inQueue) {
|
||
$nextPositionByBranch[$branch->id] = ($nextPositionByBranch[$branch->id] ?? 0) + 1;
|
||
$queuePosition = $nextPositionByBranch[$branch->id];
|
||
}
|
||
|
||
Appointment::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("appt|{$ownerRef}|{$token}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'patient_id' => $patient->id,
|
||
'practitioner_id' => $practitioner->id,
|
||
'department_id' => $department->id,
|
||
'visit_id' => $visit?->id,
|
||
'type' => Appointment::TYPE_SCHEDULED,
|
||
'status' => $status,
|
||
'scheduled_at' => $scheduledAt,
|
||
'checked_in_at' => $visit?->checked_in_at,
|
||
'waiting_at' => $inQueue ? $scheduledAt->copy()->addMinutes(5) : null,
|
||
'completed_at' => $status === Appointment::STATUS_COMPLETED
|
||
? $scheduledAt->copy()->addHour()
|
||
: null,
|
||
'cancelled_at' => null,
|
||
'queue_position' => $queuePosition,
|
||
'reason' => $moduleReasons[$slot % count($moduleReasons)],
|
||
'created_by' => $ownerRef,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
$count++;
|
||
}
|
||
}
|
||
}
|
||
|
||
$this->seedDentistryClinicalDemo($organization, $ownerRef);
|
||
$this->seedBloodBankInventoryDemo($organization, $ownerRef);
|
||
$this->seedOphthalmologyClinicalDemo($organization, $ownerRef);
|
||
$this->seedPhysiotherapyClinicalDemo($organization, $ownerRef);
|
||
$this->seedMaternityClinicalDemo($organization, $ownerRef);
|
||
|
||
return $count;
|
||
}
|
||
|
||
/**
|
||
* Visit-scoped facility stock for Blood Bank Inventory tab / reports.
|
||
* Seeds inventory_note on each open BB visit so findForVisit shows stock without manual entry.
|
||
*/
|
||
private function seedBloodBankInventoryDemo(Organization $organization, string $ownerRef): void
|
||
{
|
||
// Specialty departments are provisioned without organization_id; scope via branches.
|
||
$branchIds = Branch::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->pluck('id');
|
||
|
||
if ($branchIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$departmentIds = Department::owned($ownerRef)
|
||
->whereIn('branch_id', $branchIds)
|
||
->where('type', 'blood_bank')
|
||
->where('is_active', true)
|
||
->pluck('id');
|
||
|
||
if ($departmentIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$appointments = Appointment::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->whereIn('department_id', $departmentIds)
|
||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||
->whereNotNull('visit_id')
|
||
->with('visit')
|
||
->orderBy('branch_id')
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
if ($appointments->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$clinical = app(SpecialtyClinicalRecordService::class);
|
||
/** @var array<int, int> $branchOrder */
|
||
$branchOrder = [];
|
||
|
||
foreach ($appointments as $appointment) {
|
||
$visit = $appointment->visit;
|
||
if (! $visit) {
|
||
continue;
|
||
}
|
||
|
||
$branchId = (int) $appointment->branch_id;
|
||
if (! array_key_exists($branchId, $branchOrder)) {
|
||
$branchOrder[$branchId] = count($branchOrder);
|
||
}
|
||
|
||
$clinical->upsert(
|
||
$organization,
|
||
$visit,
|
||
'blood_bank',
|
||
'inventory_note',
|
||
$this->demoBloodBankInventoryPayload($branchOrder[$branchId]),
|
||
$ownerRef,
|
||
$ownerRef,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function demoBloodBankInventoryPayload(int $branchIndex): array
|
||
{
|
||
// First branch: low whole-blood + alert flag so Inventory / reports demo alerts.
|
||
if ($branchIndex === 0) {
|
||
return [
|
||
'whole_blood_units' => 1,
|
||
'packed_rbc_units' => 14,
|
||
'platelet_units' => 7,
|
||
'ffp_units' => 9,
|
||
'cryo_units' => 4,
|
||
'low_stock_alert' => true,
|
||
'notes' => 'Demo morning stock count — whole blood critically low; replenishment requested.',
|
||
];
|
||
}
|
||
|
||
return [
|
||
'whole_blood_units' => 8 + ($branchIndex * 2),
|
||
'packed_rbc_units' => 18 + $branchIndex,
|
||
'platelet_units' => 10 + $branchIndex,
|
||
'ffp_units' => 12 + $branchIndex,
|
||
'cryo_units' => 5,
|
||
'low_stock_alert' => false,
|
||
'notes' => 'Demo facility stock count for blood bank.',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Seed refraction + eye exam (+ optional plan) on open ophthalmology visits.
|
||
*/
|
||
private function seedOphthalmologyClinicalDemo(Organization $organization, string $ownerRef): void
|
||
{
|
||
$branchIds = Branch::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->pluck('id');
|
||
|
||
if ($branchIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$departmentIds = Department::owned($ownerRef)
|
||
->whereIn('branch_id', $branchIds)
|
||
->where('type', 'ophthalmology')
|
||
->where('is_active', true)
|
||
->pluck('id');
|
||
|
||
if ($departmentIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$appointments = Appointment::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->whereIn('department_id', $departmentIds)
|
||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||
->whereNotNull('visit_id')
|
||
->with('visit')
|
||
->orderBy('branch_id')
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
if ($appointments->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$clinical = app(SpecialtyClinicalRecordService::class);
|
||
$index = 0;
|
||
|
||
foreach ($appointments as $appointment) {
|
||
$visit = $appointment->visit;
|
||
if (! $visit) {
|
||
continue;
|
||
}
|
||
|
||
$elevated = $index === 0;
|
||
$clinical->upsert(
|
||
$organization,
|
||
$visit,
|
||
'ophthalmology',
|
||
'refraction',
|
||
[
|
||
'va_od' => $elevated ? '6/60' : '6/9',
|
||
'va_os' => $elevated ? '6/36' : '6/6',
|
||
'va_od_near' => 'N8',
|
||
'va_os_near' => 'N6',
|
||
'sphere_od' => $elevated ? '-2.50' : '-0.75',
|
||
'cylinder_od' => '-0.50',
|
||
'axis_od' => '90',
|
||
'sphere_os' => $elevated ? '-1.75' : '-0.50',
|
||
'cylinder_os' => '-0.25',
|
||
'axis_os' => '85',
|
||
'add' => '+1.50',
|
||
'pd' => 62,
|
||
'notes' => 'Demo refraction for eye care suite.',
|
||
],
|
||
$ownerRef,
|
||
$ownerRef,
|
||
'refraction',
|
||
);
|
||
|
||
$clinical->upsert(
|
||
$organization,
|
||
$visit,
|
||
'ophthalmology',
|
||
'eye_exam',
|
||
[
|
||
'chief_complaint' => $elevated ? 'Blurry vision and headache' : 'Routine eye exam',
|
||
'history' => $elevated
|
||
? 'Gradual reduction in vision over 2 weeks; intermittent haloes.'
|
||
: 'Annual review; no acute symptoms.',
|
||
'va_od' => $elevated ? '6/60' : '6/9',
|
||
'va_os' => $elevated ? '6/36' : '6/6',
|
||
'iop_od' => $elevated ? 32 : 16,
|
||
'iop_os' => $elevated ? 28 : 15,
|
||
'pupils' => 'Equal, reactive',
|
||
'extraocular' => 'Full',
|
||
'anterior_segment' => $elevated ? 'Quiet; possible early cataract OD' : 'Quiet',
|
||
'fundus' => $elevated ? 'Cup:disc elevated OD; review for glaucoma' : 'Healthy discs',
|
||
'findings' => $elevated
|
||
? 'Elevated IOP with reduced acuity — urgent glaucoma work-up.'
|
||
: 'Normal examination; refraction updated.',
|
||
],
|
||
$ownerRef,
|
||
$ownerRef,
|
||
'exam',
|
||
);
|
||
|
||
if ($elevated) {
|
||
$clinical->upsert(
|
||
$organization,
|
||
$visit,
|
||
'ophthalmology',
|
||
'eye_plan',
|
||
[
|
||
'diagnosis' => 'Suspected primary open-angle glaucoma',
|
||
'laterality' => 'OU',
|
||
'plan' => 'Start topical IOP-lowering drops; arrange OCT and visual fields.',
|
||
'medications' => 'Latanoprost 0.005% OU nocte',
|
||
'procedure_planned' => false,
|
||
'follow_up' => '2 weeks',
|
||
'advice' => 'Return sooner if vision worsens or severe eye pain.',
|
||
],
|
||
$ownerRef,
|
||
$ownerRef,
|
||
'plan',
|
||
);
|
||
}
|
||
|
||
if (! $visit->specialty_stage) {
|
||
$visit->update(['specialty_stage' => $elevated ? 'plan' : 'exam']);
|
||
}
|
||
|
||
$index++;
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* Seed assessment + plan (+ optional session) on open physiotherapy visits.
|
||
*/
|
||
private function seedPhysiotherapyClinicalDemo(Organization $organization, string $ownerRef): void
|
||
{
|
||
$branchIds = Branch::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->pluck('id');
|
||
|
||
if ($branchIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$departmentIds = Department::owned($ownerRef)
|
||
->whereIn('branch_id', $branchIds)
|
||
->where('type', 'physiotherapy')
|
||
->where('is_active', true)
|
||
->pluck('id');
|
||
|
||
if ($departmentIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$appointments = Appointment::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->whereIn('department_id', $departmentIds)
|
||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||
->whereNotNull('visit_id')
|
||
->with('visit')
|
||
->orderBy('branch_id')
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
if ($appointments->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$clinical = app(SpecialtyClinicalRecordService::class);
|
||
$index = 0;
|
||
|
||
foreach ($appointments as $appointment) {
|
||
$visit = $appointment->visit;
|
||
if (! $visit) {
|
||
continue;
|
||
}
|
||
|
||
$highPain = $index === 0;
|
||
$clinical->upsert(
|
||
$organization,
|
||
$visit,
|
||
'physiotherapy',
|
||
'pt_assessment',
|
||
[
|
||
'chief_complaint' => $highPain ? 'Severe low back pain after lifting' : 'Knee stiffness post-injury',
|
||
'onset' => $highPain ? 'Acute — 3 days ago' : 'Subacute — 3 weeks',
|
||
'pain_score' => $highPain ? 9 : 4,
|
||
'body_region' => $highPain ? 'Back / lumbar' : 'Knee',
|
||
'rom' => $highPain ? 'Lumbar flexion limited, guarded' : 'Knee flexion 90°, extension -5°',
|
||
'strength' => $highPain ? 'Core activation poor' : 'Quadriceps 4/5',
|
||
'special_tests' => $highPain ? 'SLR positive right' : 'McMurray negative',
|
||
'red_flags' => $highPain ? 'Night pain; no saddle anaesthesia' : '',
|
||
'goals' => $highPain ? 'Reduce pain and restore safe lifting' : 'Restore full ROM and return to sport',
|
||
],
|
||
$ownerRef,
|
||
$ownerRef,
|
||
'assessment',
|
||
);
|
||
|
||
$clinical->upsert(
|
||
$organization,
|
||
$visit,
|
||
'physiotherapy',
|
||
'pt_plan',
|
||
[
|
||
'diagnosis' => $highPain ? 'Acute lumbar strain' : 'Post-traumatic knee stiffness',
|
||
'frequency' => '2–3× / week',
|
||
'duration_weeks' => $highPain ? 4 : 6,
|
||
'interventions' => $highPain
|
||
? 'Manual therapy, core activation, graded activity'
|
||
: 'ROM drills, strengthening, proprioception',
|
||
'precautions' => $highPain ? 'Avoid heavy flexion under load' : 'No pivoting until week 4',
|
||
'goals' => 'Pain <3/10 and functional mobility',
|
||
'outcome_measures' => 'VAS, Oswestry / LEFS',
|
||
],
|
||
$ownerRef,
|
||
$ownerRef,
|
||
'treatment_plan',
|
||
);
|
||
|
||
if ($highPain) {
|
||
$clinical->upsert(
|
||
$organization,
|
||
$visit,
|
||
'physiotherapy',
|
||
'pt_session',
|
||
[
|
||
'session_number' => 1,
|
||
'modalities' => 'Heat, soft-tissue mobilisation',
|
||
'exercises' => 'Pelvic tilts, bird-dog, walking',
|
||
'pain_before' => 9,
|
||
'pain_after' => 6,
|
||
'response' => 'Tolerated well; pain eased post-session',
|
||
'outcome' => 'Completed — continue plan',
|
||
'next_review' => '2 days',
|
||
'notes' => 'Demo physio session.',
|
||
],
|
||
$ownerRef,
|
||
$ownerRef,
|
||
'session',
|
||
);
|
||
}
|
||
|
||
if (! $visit->specialty_stage) {
|
||
$visit->update(['specialty_stage' => $highPain ? 'session' : 'treatment_plan']);
|
||
}
|
||
|
||
$index++;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Seed ANC history + exam (+ optional plan) on open maternity visits.
|
||
*/
|
||
private function seedMaternityClinicalDemo(Organization $organization, string $ownerRef): void
|
||
{
|
||
$branchIds = Branch::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->pluck('id');
|
||
|
||
if ($branchIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$departmentIds = Department::owned($ownerRef)
|
||
->whereIn('branch_id', $branchIds)
|
||
->where('type', 'maternity')
|
||
->where('is_active', true)
|
||
->pluck('id');
|
||
|
||
if ($departmentIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$appointments = Appointment::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->whereIn('department_id', $departmentIds)
|
||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||
->whereNotNull('visit_id')
|
||
->with('visit')
|
||
->orderBy('branch_id')
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
if ($appointments->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$clinical = app(SpecialtyClinicalRecordService::class);
|
||
$index = 0;
|
||
|
||
foreach ($appointments as $appointment) {
|
||
$visit = $appointment->visit;
|
||
if (! $visit) {
|
||
continue;
|
||
}
|
||
|
||
$highRisk = $index === 0;
|
||
$clinical->upsert(
|
||
$organization,
|
||
$visit,
|
||
'maternity',
|
||
'anc_history',
|
||
[
|
||
'gravida' => $highRisk ? 3 : 1,
|
||
'para' => $highRisk ? 1 : 0,
|
||
'lmp' => '12 Jan 2026',
|
||
'edd' => '19 Oct 2026',
|
||
'gestational_age_weeks' => $highRisk ? 34 : 22,
|
||
'anc_visit_number' => $highRisk ? 6 : 3,
|
||
'obstetric_history' => $highRisk ? 'Previous CS; one miscarriage' : 'Primigravida',
|
||
'medical_history' => $highRisk ? 'Hypertension in pregnancy' : 'None significant',
|
||
'allergies' => 'None known',
|
||
'medications' => 'IFAS, calcium',
|
||
'social_history' => 'Lives with partner; support available',
|
||
],
|
||
$ownerRef,
|
||
$ownerRef,
|
||
'history',
|
||
);
|
||
|
||
$clinical->upsert(
|
||
$organization,
|
||
$visit,
|
||
'maternity',
|
||
'obstetric_exam',
|
||
[
|
||
'bp' => $highRisk ? '158/102' : '118/74',
|
||
'pulse' => $highRisk ? 92 : 78,
|
||
'weight_kg' => 72,
|
||
'fundal_height' => $highRisk ? 33 : 22,
|
||
'presentation' => 'Cephalic',
|
||
'lie' => 'Longitudinal',
|
||
'engagement' => $highRisk ? '3/5' : 'Free',
|
||
'oedema' => $highRisk ? 'Moderate' : 'None',
|
||
'urine' => $highRisk ? 'Protein +1' : 'NAD',
|
||
'danger_signs' => $highRisk ? 'Headache; visual spots' : '',
|
||
'findings' => $highRisk
|
||
? 'Hypertension with oedema — escalate high-risk ANC'
|
||
: 'Normal ANC examination',
|
||
],
|
||
$ownerRef,
|
||
$ownerRef,
|
||
'exam',
|
||
);
|
||
|
||
$clinical->upsert(
|
||
$organization,
|
||
$visit,
|
||
'maternity',
|
||
'fetal_notes',
|
||
[
|
||
'fetal_heart_rate' => $highRisk ? 168 : 142,
|
||
'fetal_movements' => $highRisk ? 'Reduced' : 'Normal',
|
||
'presentation' => 'Cephalic',
|
||
'liquor' => 'Adequate clinically',
|
||
'ctg_summary' => $highRisk ? 'Baseline raised; observe' : 'Not indicated',
|
||
'notes' => 'Demo fetal notes for maternity suite.',
|
||
],
|
||
$ownerRef,
|
||
$ownerRef,
|
||
'exam',
|
||
);
|
||
|
||
if ($highRisk) {
|
||
$clinical->upsert(
|
||
$organization,
|
||
$visit,
|
||
'maternity',
|
||
'mat_plan',
|
||
[
|
||
'risk_category' => 'High risk',
|
||
'plan' => 'Urgent senior review; BP control; fetal monitoring; consider admission.',
|
||
'medications' => 'Continue antihypertensives as prescribed',
|
||
'education' => 'Return immediately if headache, bleeding, or reduced movements',
|
||
'postnatal_needed' => false,
|
||
'follow_up' => 'Same day review',
|
||
'referral' => 'Obstetric high-risk clinic',
|
||
],
|
||
$ownerRef,
|
||
$ownerRef,
|
||
'plan',
|
||
);
|
||
}
|
||
|
||
if (! $visit->specialty_stage) {
|
||
$visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']);
|
||
}
|
||
|
||
$index++;
|
||
}
|
||
}
|
||
|
||
private function seedDentistryClinicalDemo(Organization $organization, string $ownerRef): void
|
||
{
|
||
// Specialty departments are provisioned without organization_id; scope via branches.
|
||
$branchIds = Branch::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->pluck('id');
|
||
|
||
if ($branchIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$departmentIds = Department::owned($ownerRef)
|
||
->whereIn('branch_id', $branchIds)
|
||
->where('type', 'dental')
|
||
->where('is_active', true)
|
||
->pluck('id');
|
||
|
||
if ($departmentIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$appointment = Appointment::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->whereIn('department_id', $departmentIds)
|
||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN, Appointment::STATUS_IN_CONSULTATION])
|
||
->whereNotNull('visit_id')
|
||
->with(['patient', 'visit'])
|
||
->latest('id')
|
||
->first();
|
||
|
||
if (! $appointment?->patient || ! $appointment->visit) {
|
||
return;
|
||
}
|
||
|
||
$charts = app(\App\Services\Care\Dentistry\DentalChartService::class);
|
||
$plans = app(\App\Services\Care\Dentistry\DentalTreatmentPlanService::class);
|
||
$procedures = app(\App\Services\Care\Dentistry\DentalProcedureService::class);
|
||
|
||
$chart = $charts->chartForPatient($organization, $appointment->patient, $ownerRef);
|
||
$charts->updateTooth($chart, '16', [
|
||
'status' => 'present',
|
||
'surfaces' => ['O'],
|
||
'conditions' => ['caries'],
|
||
'notes' => 'Demo occlusal caries',
|
||
], $ownerRef, $ownerRef, $appointment->visit);
|
||
|
||
$plan = $plans->ensurePlan($organization, $appointment->patient, $ownerRef, $appointment->visit, $ownerRef);
|
||
if ($plan->items()->count() === 0) {
|
||
$plans->addItem($plan, $organization, [
|
||
'procedure_code' => 'den.fill',
|
||
'tooth_code' => '16',
|
||
'surfaces' => ['O'],
|
||
'priority' => 10,
|
||
'notes' => 'Demo filling',
|
||
], $ownerRef, $ownerRef);
|
||
$plans->accept($plan->fresh('items'), $ownerRef, $ownerRef);
|
||
}
|
||
|
||
$completedPatient = Appointment::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->whereIn('department_id', $departmentIds)
|
||
->where('status', Appointment::STATUS_COMPLETED)
|
||
->whereNotNull('visit_id')
|
||
->with('visit')
|
||
->latest('id')
|
||
->first();
|
||
|
||
if ($completedPatient?->visit && ! \App\Models\DentalProcedure::owned($ownerRef)->where('visit_id', $completedPatient->visit_id)->exists()) {
|
||
try {
|
||
$procedures->complete($organization, $completedPatient->visit, [
|
||
'procedure_code' => 'den.fill',
|
||
'tooth_code' => '26',
|
||
'surfaces' => ['O'],
|
||
'anesthesia' => 'Local',
|
||
'bill' => true,
|
||
'notes' => 'Demo completed fill',
|
||
], $ownerRef, $ownerRef);
|
||
} catch (\Throwable) {
|
||
// Billing may be constrained in partial seeds — chart/plan still valuable.
|
||
}
|
||
}
|
||
|
||
$perio = app(\App\Services\Care\Dentistry\DentalPerioService::class);
|
||
if (! \App\Models\DentalPerioExam::owned($ownerRef)->where('patient_id', $appointment->patient_id)->exists()) {
|
||
$perio->saveExam($organization, $appointment->patient, $ownerRef, [
|
||
['tooth_code' => '16', 'surface' => 'B', 'probing_mm' => 3, 'bleeding' => true, 'plaque' => false],
|
||
['tooth_code' => '16', 'surface' => 'L', 'probing_mm' => 2, 'bleeding' => false, 'plaque' => true],
|
||
], $appointment->visit, 'Demo perio chart', $ownerRef);
|
||
}
|
||
|
||
$lab = app(\App\Services\Care\Dentistry\DentalLabCaseService::class);
|
||
if (! \App\Models\DentalLabCase::owned($ownerRef)->where('patient_id', $appointment->patient_id)->exists()) {
|
||
$lab->create($organization, $appointment->patient, $ownerRef, [
|
||
'case_type' => 'crown',
|
||
'lab_name' => 'Demo Dental Lab',
|
||
'tooth_codes' => ['16'],
|
||
'status' => 'ordered',
|
||
'due_at' => now()->addDays(14)->toDateString(),
|
||
'notes' => 'Demo crown case',
|
||
], $appointment->visit, $ownerRef);
|
||
}
|
||
|
||
$recalls = app(\App\Services\Care\Dentistry\DentalRecallService::class);
|
||
if (! \App\Models\DentalRecall::owned($ownerRef)->where('patient_id', $appointment->patient_id)->exists()) {
|
||
$recalls->schedule($organization, $appointment->patient, $ownerRef, [
|
||
'due_on' => now()->addMonths(6)->toDateString(),
|
||
'reason' => '6-month check',
|
||
'notes' => 'Demo recall',
|
||
], $ownerRef);
|
||
}
|
||
|
||
if (! $appointment->visit->specialty_stage) {
|
||
$appointment->visit->update(['specialty_stage' => 'chair']);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param list<Branch> $branches
|
||
* @param array<int, list<Department>> $departments
|
||
* @param array<string, mixed> $volumes
|
||
* @return list<Practitioner>
|
||
*/
|
||
private function seedPractitioners(
|
||
Organization $organization,
|
||
array $branches,
|
||
array $departments,
|
||
string $ownerRef,
|
||
array $volumes,
|
||
): array {
|
||
$practitioners = [];
|
||
for ($i = 0; $i < $volumes['practitioners']; $i++) {
|
||
$branch = $branches[$i % count($branches)];
|
||
$branchDepts = $departments[$branch->id] ?? [];
|
||
$department = $branchDepts[$i % max(1, count($branchDepts))] ?? null;
|
||
$key = sprintf('demo-prac-%s-%02d', $ownerRef, $i + 1);
|
||
|
||
$practitioners[] = Practitioner::withTrashed()->updateOrCreate(
|
||
[
|
||
'organization_id' => $organization->id,
|
||
'user_ref' => $key,
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'branch_id' => $branch->id,
|
||
'department_id' => $department?->id,
|
||
'member_id' => null,
|
||
'name' => 'Dr. '.self::FIRST_NAMES[$i % count(self::FIRST_NAMES)].' '.self::LAST_NAMES[$i % count(self::LAST_NAMES)],
|
||
'specialty' => self::SPECIALTIES[$i % count(self::SPECIALTIES)],
|
||
'room' => 'Consultation Room '.($i + 1),
|
||
'is_active' => true,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
$practitioners[array_key_last($practitioners)]->syncAssignedBranches([(int) $branch->id]);
|
||
}
|
||
|
||
return $practitioners;
|
||
}
|
||
|
||
/**
|
||
* @param list<Branch> $branches
|
||
* @param array<string, mixed> $volumes
|
||
* @return list<Patient>
|
||
*/
|
||
private function seedPatients(
|
||
Organization $organization,
|
||
array $branches,
|
||
string $ownerRef,
|
||
array $volumes,
|
||
): array {
|
||
$worldPeople = DemoWorld::people();
|
||
$patients = [];
|
||
for ($i = 0; $i < $volumes['patients']; $i++) {
|
||
$branch = $branches[$i % count($branches)];
|
||
$number = sprintf('DEMO-%s-%05d', Str::upper(Str::substr(md5($ownerRef), 0, 4)), $i + 1);
|
||
$person = $worldPeople[$i] ?? null;
|
||
if ($person === null) {
|
||
$base = $worldPeople[$i % count($worldPeople)];
|
||
$seq = $i + 1;
|
||
$person = [
|
||
'first_name' => $base['first_name'],
|
||
'last_name' => $base['last_name'],
|
||
'gender' => $base['gender'],
|
||
'dob' => $base['dob'],
|
||
'phone' => '+23324'.str_pad((string) (1000000 + $seq), 7, '0', STR_PAD_LEFT),
|
||
'email' => sprintf('patient.%d@demo.ladill.com', $seq),
|
||
'national_id' => sprintf('GHA-729%06d', $seq),
|
||
'city' => $base['city'],
|
||
'address' => $base['address'],
|
||
];
|
||
}
|
||
|
||
$patients[] = Patient::withTrashed()->updateOrCreate(
|
||
[
|
||
'organization_id' => $organization->id,
|
||
'patient_number' => $number,
|
||
],
|
||
[
|
||
'uuid' => $this->demoUuid("patient|{$ownerRef}|{$i}"),
|
||
'owner_ref' => $ownerRef,
|
||
'branch_id' => $branch->id,
|
||
'first_name' => $person['first_name'],
|
||
'last_name' => $person['last_name'],
|
||
'gender' => $person['gender'],
|
||
'date_of_birth' => $person['dob'],
|
||
'phone' => $person['phone'],
|
||
'email' => $person['email'],
|
||
'national_id' => $person['national_id'],
|
||
'city' => $person['city'],
|
||
'region' => 'Greater Accra',
|
||
'address' => $person['address'],
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
}
|
||
|
||
return $patients;
|
||
}
|
||
|
||
/**
|
||
* @param list<Branch> $branches
|
||
* @param array<int, list<Department>> $departments
|
||
* @param list<Practitioner> $practitioners
|
||
* @param list<Patient> $patients
|
||
* @param array<string, mixed> $volumes
|
||
*/
|
||
private function seedAppointmentsAndClinical(
|
||
Organization $organization,
|
||
array $branches,
|
||
array $departments,
|
||
array $practitioners,
|
||
array $patients,
|
||
string $ownerRef,
|
||
array $volumes,
|
||
): int {
|
||
$statuses = [
|
||
Appointment::STATUS_SCHEDULED,
|
||
Appointment::STATUS_CHECKED_IN,
|
||
Appointment::STATUS_WAITING,
|
||
Appointment::STATUS_COMPLETED,
|
||
Appointment::STATUS_COMPLETED,
|
||
Appointment::STATUS_CANCELLED,
|
||
];
|
||
$reasons = ['Follow-up', 'New complaint', 'Review labs', 'Chronic care'];
|
||
$symptoms = ['Fever', 'Cough', 'Headache', 'Fatigue'];
|
||
|
||
$count = 0;
|
||
for ($i = 0; $i < $volumes['appointments']; $i++) {
|
||
$patient = $patients[$i % count($patients)];
|
||
$practitioner = $practitioners[$i % count($practitioners)];
|
||
$branch = collect($branches)->first(
|
||
fn (Branch $b) => (int) $b->id === (int) $practitioner->branch_id,
|
||
) ?? $branches[$i % count($branches)];
|
||
$branchDepts = $departments[(int) $practitioner->branch_id] ?? ($departments[$branch->id] ?? []);
|
||
// Align department to the practitioner desk so Queue tickets stay consultation-context.
|
||
$department = collect($branchDepts)->first(
|
||
fn (Department $dept) => (int) $dept->id === (int) ($practitioner->department_id ?? 0),
|
||
) ?? ($branchDepts[$i % max(1, count($branchDepts))] ?? null);
|
||
$status = $statuses[$i % count($statuses)];
|
||
$scheduledAt = now()->subDays(14)->addHours($i * 3);
|
||
|
||
$visit = null;
|
||
if (in_array($status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_WAITING, Appointment::STATUS_COMPLETED], true)) {
|
||
$visit = Visit::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("visit|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'patient_id' => $patient->id,
|
||
'status' => $status === Appointment::STATUS_COMPLETED
|
||
? Visit::STATUS_COMPLETED
|
||
: Visit::STATUS_IN_PROGRESS,
|
||
'checked_in_at' => $scheduledAt->copy()->addMinutes(5),
|
||
'completed_at' => $status === Appointment::STATUS_COMPLETED
|
||
? $scheduledAt->copy()->addHour()
|
||
: null,
|
||
'checked_in_by' => $ownerRef,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
}
|
||
|
||
Appointment::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("appt|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'patient_id' => $patient->id,
|
||
'practitioner_id' => $practitioner->id,
|
||
'department_id' => $department?->id,
|
||
'visit_id' => $visit?->id,
|
||
'type' => $i % 5 === 0 ? Appointment::TYPE_WALK_IN : Appointment::TYPE_SCHEDULED,
|
||
'status' => $status,
|
||
'scheduled_at' => $scheduledAt,
|
||
'checked_in_at' => $visit?->checked_in_at,
|
||
'waiting_at' => in_array($status, [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN], true)
|
||
? $scheduledAt->copy()->addMinutes(5)
|
||
: null,
|
||
'queue_position' => in_array($status, [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN], true)
|
||
? ($i + 1)
|
||
: null,
|
||
'completed_at' => $status === Appointment::STATUS_COMPLETED
|
||
? $scheduledAt->copy()->addHour()
|
||
: null,
|
||
'cancelled_at' => $status === Appointment::STATUS_CANCELLED
|
||
? $scheduledAt->copy()->subHour()
|
||
: null,
|
||
'reason' => $reasons[$i % count($reasons)],
|
||
'created_by' => $ownerRef,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
if ($visit && $status === Appointment::STATUS_COMPLETED) {
|
||
$consultation = Consultation::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("consult|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'visit_id' => $visit->id,
|
||
'appointment_id' => null,
|
||
'practitioner_id' => $practitioner->id,
|
||
'patient_id' => $patient->id,
|
||
'status' => Consultation::STATUS_COMPLETED,
|
||
'symptoms' => $symptoms[$i % count($symptoms)],
|
||
'clinical_notes' => 'Demo consultation notes.',
|
||
'started_at' => $scheduledAt->copy()->addMinutes(15),
|
||
'completed_at' => $scheduledAt->copy()->addHour(),
|
||
'completed_by' => $ownerRef,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
VitalSign::query()->updateOrCreate(
|
||
[
|
||
'consultation_id' => $consultation->id,
|
||
'recorded_at' => $scheduledAt->copy()->addMinutes(10),
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'bp_systolic' => 110 + ($i % 30),
|
||
'bp_diastolic' => 70 + ($i % 15),
|
||
'pulse' => 60 + ($i % 40),
|
||
'temperature' => 36.5 + (($i % 10) / 10),
|
||
'weight_kg' => 55 + ($i % 40),
|
||
'height_cm' => 150 + ($i % 40),
|
||
'spo2' => 95 + ($i % 5),
|
||
'respiratory_rate' => 14 + ($i % 6),
|
||
'recorded_by' => $ownerRef,
|
||
],
|
||
);
|
||
}
|
||
|
||
$count++;
|
||
}
|
||
|
||
return $count;
|
||
}
|
||
|
||
/**
|
||
* @param list<Branch> $branches
|
||
* @param list<Practitioner> $practitioners
|
||
* @param list<Patient> $patients
|
||
* @param array<string, mixed> $volumes
|
||
*/
|
||
/**
|
||
* Demo hardware inventory: browser wedge scanner + agent thermometer per branch (Pro+).
|
||
*
|
||
* @param list<Branch> $branches
|
||
*/
|
||
private function seedDevices(Organization $organization, array $branches, string $ownerRef): void
|
||
{
|
||
$deviceService = app(DeviceService::class);
|
||
|
||
foreach ($branches as $i => $branch) {
|
||
Device::withTrashed()->updateOrCreate(
|
||
[
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'name' => 'Demo barcode scanner',
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'type' => 'barcode_scanner',
|
||
'connection_mode' => Device::MODE_BROWSER_WEDGE,
|
||
'status' => Device::STATUS_OFFLINE,
|
||
'device_token_hash' => null,
|
||
'metadata' => ['demo' => true],
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
$thermo = Device::withTrashed()->updateOrCreate(
|
||
[
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'name' => 'Demo thermometer',
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'type' => 'thermometer',
|
||
'connection_mode' => Device::MODE_AGENT,
|
||
'status' => Device::STATUS_OFFLINE,
|
||
'metadata' => ['demo' => true, 'note' => 'Token issued on first seed only if missing'],
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
if (! $thermo->device_token_hash) {
|
||
// Deterministic demo token so sales can reuse docs examples after reseed.
|
||
$plain = 'demo-care-device-'.$organization->id.'-'.$branch->id.'-thermo';
|
||
$thermo->update(['device_token_hash' => $deviceService->hashToken($plain)]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return list<InvestigationType>
|
||
*/
|
||
private function seedInvestigationCatalog(Organization $organization, string $ownerRef): array
|
||
{
|
||
$types = [];
|
||
foreach ([
|
||
['name' => 'Fasting Blood Glucose', 'code' => 'FBG', 'category' => 'blood', 'unit' => 'mmol/L', 'low' => 3.9, 'high' => 6.1, 'price' => 2500],
|
||
['name' => 'Full Blood Count', 'code' => 'FBC', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 4500],
|
||
['name' => 'Malaria Parasite', 'code' => 'MP', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 1500],
|
||
['name' => 'Urinalysis', 'code' => 'UA', 'category' => 'urine', 'unit' => null, 'low' => null, 'high' => null, 'price' => 2000],
|
||
['name' => 'HbA1c', 'code' => 'HBA1C', 'category' => 'blood', 'unit' => '%', 'low' => null, 'high' => 5.7, 'price' => 6000],
|
||
['name' => 'Lipid Profile', 'code' => 'LIPID', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 5500],
|
||
['name' => 'Liver Function Test', 'code' => 'LFT', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 5000],
|
||
['name' => 'Kidney Function Test', 'code' => 'KFT', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 5000],
|
||
['name' => 'Blood Group & Rh', 'code' => 'BGRH', 'category' => 'blood', 'unit' => null, 'low' => null, 'high' => null, 'price' => 1800],
|
||
['name' => 'Pregnancy Test (Urine)', 'code' => 'HCG', 'category' => 'urine', 'unit' => null, 'low' => null, 'high' => null, 'price' => 1200],
|
||
['name' => 'Stool Routine Examination', 'code' => 'SRE', 'category' => 'stool', 'unit' => null, 'low' => null, 'high' => null, 'price' => 2200],
|
||
['name' => 'Chest X-Ray', 'code' => 'CXR', 'category' => 'xray', 'unit' => null, 'low' => null, 'high' => null, 'price' => 8000],
|
||
] as $spec) {
|
||
$types[] = InvestigationType::withTrashed()->updateOrCreate(
|
||
[
|
||
'organization_id' => $organization->id,
|
||
'code' => $spec['code'],
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'name' => $spec['name'],
|
||
'category' => $spec['category'],
|
||
'unit' => $spec['unit'],
|
||
'reference_low' => $spec['low'],
|
||
'reference_high' => $spec['high'],
|
||
'price_minor' => $spec['price'],
|
||
'is_active' => true,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
}
|
||
|
||
return $types;
|
||
}
|
||
|
||
/**
|
||
* @param list<Branch> $branches
|
||
* @param list<Practitioner> $practitioners
|
||
* @param list<Patient> $patients
|
||
* @param list<InvestigationType> $types
|
||
*/
|
||
private function seedPaidModules(
|
||
Organization $organization,
|
||
array $branches,
|
||
array $practitioners,
|
||
array $patients,
|
||
string $ownerRef,
|
||
array $volumes,
|
||
array $types = [],
|
||
): void {
|
||
if ($types === []) {
|
||
$types = $this->seedInvestigationCatalog($organization, $ownerRef);
|
||
}
|
||
|
||
$drugs = [];
|
||
foreach ([
|
||
['name' => 'Amoxicillin 500mg', 'unit' => 'capsule', 'price' => 1500],
|
||
['name' => 'Paracetamol 500mg', 'unit' => 'tablet', 'price' => 50],
|
||
['name' => 'Metformin 500mg', 'unit' => 'tablet', 'price' => 80],
|
||
['name' => 'Amlodipine 5mg', 'unit' => 'tablet', 'price' => 120],
|
||
] as $i => $spec) {
|
||
$drug = Drug::withTrashed()->updateOrCreate(
|
||
[
|
||
'organization_id' => $organization->id,
|
||
'sku' => 'DEMO-DRUG-'.($i + 1),
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'branch_id' => $branches[0]->id,
|
||
'name' => $spec['name'],
|
||
'generic_name' => $spec['name'],
|
||
'unit' => $spec['unit'],
|
||
'unit_price_minor' => $spec['price'],
|
||
'reorder_level' => 20,
|
||
'is_active' => true,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
DrugBatch::query()->updateOrCreate(
|
||
[
|
||
'drug_id' => $drug->id,
|
||
'batch_number' => 'DEMO-BATCH-'.($i + 1),
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'expiry_date' => now()->addYear()->toDateString(),
|
||
'quantity_on_hand' => 200,
|
||
'cost_minor' => (int) ($spec['price'] * 0.6),
|
||
'received_at' => now()->subDays(10),
|
||
],
|
||
);
|
||
$drugs[] = $drug;
|
||
}
|
||
|
||
if ($patients === [] || $practitioners === [] || $types === [] || $branches === []) {
|
||
return;
|
||
}
|
||
|
||
for ($i = 0; $i < $volumes['lab_requests']; $i++) {
|
||
$patient = $patients[$i % count($patients)];
|
||
$branch = $branches[$i % count($branches)];
|
||
$practitioner = $practitioners[$i % count($practitioners)];
|
||
$type = $types[$i % count($types)];
|
||
|
||
$visit = Visit::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("lab-visit|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'patient_id' => $patient->id,
|
||
'status' => Visit::STATUS_IN_PROGRESS,
|
||
'checked_in_at' => now()->subDays($i % 10),
|
||
'checked_in_by' => $ownerRef,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
$consultation = Consultation::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("lab-consult|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'visit_id' => $visit->id,
|
||
'practitioner_id' => $practitioner->id,
|
||
'patient_id' => $patient->id,
|
||
'status' => Consultation::STATUS_DRAFT,
|
||
'started_at' => now()->subDays($i % 10),
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
InvestigationRequest::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("lab-req|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'visit_id' => $visit->id,
|
||
'consultation_id' => $consultation->id,
|
||
'patient_id' => $patient->id,
|
||
'investigation_type_id' => $type->id,
|
||
'practitioner_id' => $practitioner->id,
|
||
'status' => InvestigationRequest::STATUS_PENDING,
|
||
'priority' => 'routine',
|
||
'clinical_notes' => 'Demo lab request',
|
||
'requested_by' => $ownerRef,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
}
|
||
|
||
for ($i = 0; $i < $volumes['prescriptions']; $i++) {
|
||
$patient = $patients[$i % count($patients)];
|
||
$branch = $branches[$i % count($branches)];
|
||
$practitioner = $practitioners[$i % count($practitioners)];
|
||
$drug = $drugs[$i % count($drugs)];
|
||
|
||
$visit = Visit::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("rx-visit|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'patient_id' => $patient->id,
|
||
'status' => Visit::STATUS_IN_PROGRESS,
|
||
'checked_in_at' => now()->subDays($i % 7),
|
||
'checked_in_by' => $ownerRef,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
$consultation = Consultation::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("rx-consult|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'visit_id' => $visit->id,
|
||
'practitioner_id' => $practitioner->id,
|
||
'patient_id' => $patient->id,
|
||
'status' => Consultation::STATUS_DRAFT,
|
||
'started_at' => now()->subDays($i % 7),
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
$prescription = Prescription::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("rx|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $organization->id,
|
||
'visit_id' => $visit->id,
|
||
'consultation_id' => $consultation->id,
|
||
'patient_id' => $patient->id,
|
||
'practitioner_id' => $practitioner->id,
|
||
'status' => Prescription::STATUS_ACTIVE,
|
||
'prescribed_by' => $ownerRef,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
$prescription->items()->updateOrCreate(
|
||
[
|
||
'prescription_id' => $prescription->id,
|
||
'name' => $drug->name,
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'drug_id' => $drug->id,
|
||
'dosage' => '1 '.$drug->unit,
|
||
'frequency' => 'BD',
|
||
'duration' => '5 days',
|
||
'quantity' => 10,
|
||
'sort_order' => 0,
|
||
],
|
||
);
|
||
}
|
||
|
||
$branchCount = max(1, count($branches));
|
||
for ($i = 0; $i < $volumes['bills']; $i++) {
|
||
$patient = $patients[$i % count($patients)];
|
||
$branch = $branches[$i % $branchCount];
|
||
$fee = 5000 + (($i % 5) * 1000);
|
||
// Payment mix is per-branch (not i%3 with branchCount=3), otherwise HQ
|
||
// only ever gets paid invoices and the Ridge cashier has nothing to collect.
|
||
$slot = intdiv($i, $branchCount) % 3;
|
||
if ($slot === 0) {
|
||
$status = Bill::STATUS_PAID;
|
||
$amountPaid = $fee;
|
||
$balance = 0;
|
||
} elseif ($slot === 1) {
|
||
$status = Bill::STATUS_PARTIAL;
|
||
$amountPaid = (int) floor($fee / 2);
|
||
$balance = $fee - $amountPaid;
|
||
} else {
|
||
$status = Bill::STATUS_OPEN;
|
||
$amountPaid = 0;
|
||
$balance = $fee;
|
||
}
|
||
|
||
$visit = Visit::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("bill-visit|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'patient_id' => $patient->id,
|
||
'status' => Visit::STATUS_COMPLETED,
|
||
'checked_in_at' => now()->subDays($i % 12),
|
||
'completed_at' => now()->subDays($i % 12)->addHour(),
|
||
'checked_in_by' => $ownerRef,
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
$bill = Bill::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("bill|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'visit_id' => $visit->id,
|
||
'patient_id' => $patient->id,
|
||
'invoice_number' => sprintf('DEMO-INV-%s-%04d', Str::upper(Str::substr(md5($ownerRef), 0, 4)), $i + 1),
|
||
'status' => $status,
|
||
'subtotal_minor' => $fee,
|
||
'discount_minor' => 0,
|
||
'tax_minor' => 0,
|
||
'total_minor' => $fee,
|
||
'amount_paid_minor' => $amountPaid,
|
||
'balance_minor' => $balance,
|
||
'created_by' => $ownerRef,
|
||
'finalized_at' => now()->subDays($i % 12),
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
BillLineItem::query()->updateOrCreate(
|
||
[
|
||
'bill_id' => $bill->id,
|
||
'description' => 'Consultation fee',
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'type' => 'consultation',
|
||
'quantity' => 1,
|
||
'unit_price_minor' => $fee,
|
||
'total_minor' => $fee,
|
||
],
|
||
);
|
||
|
||
$paymentUuid = $this->demoUuid("pay|{$ownerRef}|{$i}");
|
||
if ($amountPaid > 0) {
|
||
Payment::query()->updateOrCreate(
|
||
['uuid' => $paymentUuid],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'bill_id' => $bill->id,
|
||
'amount_minor' => $amountPaid,
|
||
'method' => Payment::METHOD_CASH,
|
||
'status' => Payment::STATUS_PAID,
|
||
'reference' => 'DEMO-PAY-'.($i + 1),
|
||
'paid_at' => now()->subDays($i % 12),
|
||
'recorded_by' => $ownerRef,
|
||
],
|
||
);
|
||
} else {
|
||
Payment::query()->where('uuid', $paymentUuid)->delete();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param list<Branch> $branches
|
||
* @param list<Practitioner> $practitioners
|
||
* @param list<Patient> $patients
|
||
*/
|
||
private function seedAssessments(
|
||
Organization $organization,
|
||
array $branches,
|
||
array $practitioners,
|
||
array $patients,
|
||
string $ownerRef,
|
||
int $count,
|
||
): void {
|
||
$template = AssessmentTemplate::withTrashed()->updateOrCreate(
|
||
[
|
||
'code' => self::DEMO_TEMPLATE_CODE,
|
||
'version' => 1,
|
||
],
|
||
[
|
||
'organization_id' => $organization->id,
|
||
'name' => 'Demo Triage Screen',
|
||
'category' => AssessmentTemplate::CATEGORY_SCREENING,
|
||
'description' => 'Lightweight demo assessment for enterprise analytics.',
|
||
'is_current' => true,
|
||
'is_active' => true,
|
||
'scoring_strategy' => 'sum_items',
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
$question = AssessmentQuestion::query()->updateOrCreate(
|
||
[
|
||
'template_id' => $template->id,
|
||
'code' => 'severity',
|
||
],
|
||
[
|
||
'section' => 'Triage',
|
||
'label' => 'Severity (0-10)',
|
||
'answer_type' => AssessmentQuestion::TYPE_SCALE,
|
||
'is_required' => true,
|
||
'sort_order' => 1,
|
||
'score_key' => 'severity',
|
||
'options' => ['min' => 0, 'max' => 10],
|
||
],
|
||
);
|
||
|
||
for ($i = 0; $i < $count; $i++) {
|
||
$patient = $patients[$i % count($patients)];
|
||
$branch = $branches[$i % count($branches)];
|
||
$practitioner = $practitioners[$i % count($practitioners)];
|
||
$scoreValue = (float) ($i % 11);
|
||
|
||
$assessment = Assessment::withTrashed()->updateOrCreate(
|
||
['uuid' => $this->demoUuid("assess|{$ownerRef}|{$i}")],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $organization->id,
|
||
'branch_id' => $branch->id,
|
||
'patient_id' => $patient->id,
|
||
'template_id' => $template->id,
|
||
'practitioner_id' => $practitioner->id,
|
||
'status' => Assessment::STATUS_COMPLETED,
|
||
'assessed_at' => now()->subDays($i % 30),
|
||
'completed_at' => now()->subDays($i % 30),
|
||
'completed_by' => $ownerRef,
|
||
'started_by' => $ownerRef,
|
||
'notes' => 'Demo assessment',
|
||
'deleted_at' => null,
|
||
],
|
||
);
|
||
|
||
AssessmentAnswer::query()->updateOrCreate(
|
||
[
|
||
'assessment_id' => $assessment->id,
|
||
'question_id' => $question->id,
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'value_number' => $scoreValue,
|
||
],
|
||
);
|
||
|
||
AssessmentScore::query()->updateOrCreate(
|
||
['assessment_id' => $assessment->id],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'template_code' => self::DEMO_TEMPLATE_CODE,
|
||
'total_score' => $scoreValue,
|
||
'max_score' => 10,
|
||
'subscores' => ['severity' => $scoreValue],
|
||
'severity_label' => $scoreValue >= 7 ? 'high' : ($scoreValue >= 4 ? 'moderate' : 'low'),
|
||
'computed_at' => now()->subDays($i % 30),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
private function demoUuid(string $key): string
|
||
{
|
||
$hex = md5('ladill-care-demo|'.$key);
|
||
|
||
return sprintf(
|
||
'%s-%s-%s-%s-%s',
|
||
substr($hex, 0, 8),
|
||
substr($hex, 8, 4),
|
||
substr($hex, 12, 4),
|
||
substr($hex, 16, 4),
|
||
substr($hex, 20, 12),
|
||
);
|
||
}
|
||
}
|