Deploy Ladill Care / deploy (push) Successful in 38s
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>
376 lines
13 KiB
PHP
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,
|
|
]);
|
|
}
|
|
}
|
|
}
|