Files
isaaccladandCursor 45a1a95142
Deploy Ladill Care / deploy (push) Successful in 38s
Add visit placement on care units and beds.
Patients can be admitted, transferred, and discharged from unit/bed stays with occupancy sync, so MAR and ward boards have inpatient context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:10:37 +00:00

376 lines
13 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\EmergencyContact;
use App\Models\InsurancePolicy;
use App\Models\Consultation;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\PatientAllergy;
use App\Models\PatientAttachment;
use App\Models\PatientCondition;
use App\Models\PatientFamilyHistory;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
class PatientService
{
public function __construct(
protected PatientNumberGenerator $numbers,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return Patient::owned($ownerRef)->where('organization_id', $organizationId);
}
/**
* @param array<string, mixed> $filters
* @param list<int>|null $practitionerIds When set, only patients with appointments on these desks.
*/
public function search(
string $ownerRef,
int $organizationId,
array $filters = [],
?int $branchId = null,
?array $practitionerIds = null,
): LengthAwarePaginator {
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['branch'])
->orderByDesc('created_at');
if ($branchId !== null) {
$query->where('branch_id', $branchId);
}
if ($practitionerIds !== null) {
if ($practitionerIds === []) {
$query->whereRaw('1 = 0');
} else {
$query->whereHas(
'appointments',
fn (Builder $inner) => $inner->whereIn('practitioner_id', $practitionerIds),
);
}
}
if ($q = trim((string) ($filters['q'] ?? ''))) {
$query->where(function (Builder $inner) use ($q) {
$inner->where('patient_number', 'like', "%{$q}%")
->orWhere('phone', 'like', "%{$q}%")
->orWhere('national_id', 'like', "%{$q}%")
->orWhere('first_name', 'like', "%{$q}%")
->orWhere('last_name', 'like', "%{$q}%")
->orWhere('other_names', 'like', "%{$q}%")
->orWhereRaw("concat(first_name, ' ', coalesce(other_names, ''), ' ', last_name) like ?", ["%{$q}%"]);
});
}
if ($patientNumber = trim((string) ($filters['patient_number'] ?? ''))) {
$query->where('patient_number', $patientNumber);
}
if ($phone = trim((string) ($filters['phone'] ?? ''))) {
$query->where('phone', 'like', "%{$phone}%");
}
if ($nationalId = trim((string) ($filters['national_id'] ?? ''))) {
$query->where('national_id', $nationalId);
}
if ($dob = $filters['date_of_birth'] ?? null) {
$query->whereDate('date_of_birth', Carbon::parse($dob)->toDateString());
}
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
}
/**
* Exact patient_number match first, then fall back to broad search (first hit).
*/
public function findByScanCode(string $ownerRef, int $organizationId, string $code, ?int $branchId = null): ?Patient
{
$code = trim($code);
if ($code === '') {
return null;
}
$base = $this->queryForOrganization($ownerRef, $organizationId)
->when($branchId !== null, fn ($q) => $q->where('branch_id', $branchId));
$exact = (clone $base)->where('patient_number', $code)->first();
if ($exact) {
return $exact;
}
// Case-insensitive exact patient number.
$ci = (clone $base)->whereRaw('lower(patient_number) = ?', [strtolower($code)])->first();
if ($ci) {
return $ci;
}
return (clone $base)
->where(function (Builder $inner) use ($code) {
$inner->where('patient_number', 'like', "%{$code}%")
->orWhere('national_id', $code)
->orWhere('phone', 'like', "%{$code}%");
})
->orderByDesc('created_at')
->first();
}
/**
* @param array<string, mixed> $data
*/
public function create(Organization $organization, string $ownerRef, array $data, ?string $actorRef = null): Patient
{
$patient = Patient::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $data['branch_id'] ?? null,
'patient_number' => $this->numbers->generate($organization),
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'other_names' => $data['other_names'] ?? null,
'gender' => $data['gender'] ?? null,
'date_of_birth' => $data['date_of_birth'] ?? null,
'phone' => $data['phone'] ?? null,
'email' => $data['email'] ?? null,
'national_id' => $data['national_id'] ?? null,
'address' => $data['address'] ?? null,
'city' => $data['city'] ?? null,
'region' => $data['region'] ?? null,
'notes' => $data['notes'] ?? null,
]);
$this->syncRelated($patient, $ownerRef, $data);
$this->storeAttachments($patient, $ownerRef, $data['attachments'] ?? [], $actorRef);
AuditLogger::record($ownerRef, 'patient.created', $organization->id, $actorRef, Patient::class, $patient->id);
return $patient->fresh([
'allergies', 'conditions', 'familyHistory', 'emergencyContacts',
'insurancePolicies', 'attachments', 'branch',
]);
}
/**
* @param array<string, mixed> $data
*/
public function update(Patient $patient, string $ownerRef, array $data, ?string $actorRef = null): Patient
{
$patient->update([
'branch_id' => $data['branch_id'] ?? $patient->branch_id,
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'other_names' => $data['other_names'] ?? null,
'gender' => $data['gender'] ?? null,
'date_of_birth' => $data['date_of_birth'] ?? null,
'phone' => $data['phone'] ?? null,
'email' => $data['email'] ?? null,
'national_id' => $data['national_id'] ?? null,
'address' => $data['address'] ?? null,
'city' => $data['city'] ?? null,
'region' => $data['region'] ?? null,
'notes' => $data['notes'] ?? null,
]);
$this->syncRelated($patient, $ownerRef, $data);
$this->storeAttachments($patient, $ownerRef, $data['attachments'] ?? [], $actorRef);
AuditLogger::record($ownerRef, 'patient.updated', $patient->organization_id, $actorRef, Patient::class, $patient->id);
return $patient->fresh([
'allergies', 'conditions', 'familyHistory', 'emergencyContacts',
'insurancePolicies', 'attachments', 'branch',
]);
}
public function delete(Patient $patient, string $ownerRef, ?string $actorRef = null): void
{
$organizationId = $patient->organization_id;
$patientId = $patient->id;
foreach ($patient->attachments as $attachment) {
Storage::disk('public')->delete($attachment->file_path);
}
$patient->delete();
AuditLogger::record($ownerRef, 'patient.deleted', $organizationId, $actorRef, Patient::class, $patientId);
}
/**
* @return array<string, mixed>
*/
public function dashboard(Patient $patient): array
{
$patient->load([
'branch', 'allergies', 'conditions', 'familyHistory',
'emergencyContacts', 'insurancePolicies', 'attachments',
]);
$visits = $patient->visits()
->with(['branch', 'careUnit', 'bed'])
->orderByDesc('checked_in_at')
->limit(10)
->get();
$consultations = Consultation::owned($patient->owner_ref)
->where('patient_id', $patient->id)
->with(['practitioner', 'diagnoses'])
->orderByDesc('started_at')
->limit(10)
->get();
$laboratory = $patient->investigationRequests()
->with(['investigationType', 'result'])
->whereIn('status', [
\App\Models\InvestigationRequest::STATUS_COMPLETED,
\App\Models\InvestigationRequest::STATUS_DELIVERED,
])
->orderByDesc('completed_at')
->limit(10)
->get();
$prescriptions = $patient->prescriptions()
->with(['practitioner', 'items'])
->orderByDesc('created_at')
->limit(10)
->get();
$invoices = $patient->bills()
->with(['branch', 'payments'])
->orderByDesc('created_at')
->limit(10)
->get();
return [
'patient' => $patient,
'visits' => $visits,
'consultations' => $consultations,
'laboratory' => $laboratory,
'prescriptions' => $prescriptions,
'invoices' => $invoices,
];
}
/**
* @param array<string, mixed> $data
*/
protected function syncRelated(Patient $patient, string $ownerRef, array $data): void
{
if (array_key_exists('allergies', $data)) {
$patient->allergies()->delete();
foreach ($data['allergies'] as $row) {
if (empty($row['allergen'])) {
continue;
}
PatientAllergy::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'allergen' => $row['allergen'],
'severity' => $row['severity'] ?? 'unknown',
'notes' => $row['notes'] ?? null,
]);
}
}
if (array_key_exists('conditions', $data)) {
$patient->conditions()->delete();
foreach ($data['conditions'] as $row) {
if (empty($row['condition'])) {
continue;
}
PatientCondition::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'condition' => $row['condition'],
'onset_date' => $row['onset_date'] ?? null,
'is_chronic' => (bool) ($row['is_chronic'] ?? false),
'notes' => $row['notes'] ?? null,
]);
}
}
if (array_key_exists('family_history', $data)) {
$patient->familyHistory()->delete();
foreach ($data['family_history'] as $row) {
if (empty($row['condition'])) {
continue;
}
PatientFamilyHistory::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'relation' => $row['relation'] ?? 'other',
'condition' => $row['condition'],
'notes' => $row['notes'] ?? null,
]);
}
}
if (array_key_exists('emergency_contacts', $data)) {
$patient->emergencyContacts()->delete();
foreach ($data['emergency_contacts'] as $row) {
if (empty($row['name']) || empty($row['phone'])) {
continue;
}
EmergencyContact::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'name' => $row['name'],
'phone' => $row['phone'],
'relationship' => $row['relationship'] ?? null,
'is_primary' => (bool) ($row['is_primary'] ?? false),
]);
}
}
if (array_key_exists('insurance', $data)) {
$patient->insurancePolicies()->delete();
foreach ($data['insurance'] as $row) {
if (empty($row['provider_name'])) {
continue;
}
InsurancePolicy::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'provider_name' => $row['provider_name'],
'policy_number' => $row['policy_number'] ?? null,
'coverage_type' => $row['coverage_type'] ?? null,
'expiry_date' => $row['expiry_date'] ?? null,
'notes' => $row['notes'] ?? null,
]);
}
}
}
/**
* @param array<int, UploadedFile> $files
*/
protected function storeAttachments(Patient $patient, string $ownerRef, array $files, ?string $actorRef): void
{
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
$path = $file->store("care/patients/{$patient->id}/attachments", 'public');
PatientAttachment::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'file_path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'document_type' => 'other',
'uploaded_by' => $actorRef,
]);
}
}
}