Files
ladill-care/app/Services/Care/InvestigationService.php
T
isaaccladandCursor 015a4cc7fe
Deploy Ladill Care / deploy (push) Successful in 1m45s
Wire Care lists into Ladill Queue workflows instead of reception panels.
When Queue integration is on, role pages issue tickets into their own
department queues and drive Call next → Serve → Complete on the native
lists. Integration off leaves existing UI unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:27:48 +00:00

421 lines
16 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\Consultation;
use App\Models\InvestigationAttachment;
use App\Models\InvestigationRequest;
use App\Models\InvestigationResult;
use App\Models\InvestigationResultValue;
use App\Models\InvestigationType;
use App\Models\Organization;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
class InvestigationService
{
public function __construct(
protected CareQueueBridge $queueBridge,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return InvestigationRequest::owned($ownerRef)->where('organization_id', $organizationId);
}
/**
* @param array<string, mixed> $filters
*/
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
{
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['patient', 'investigationType', 'practitioner', 'branch', 'result'])
->orderByDesc('created_at');
if ($branchId !== null) {
$query->where('branch_id', $branchId);
}
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
if ($patientId = $filters['patient_id'] ?? null) {
$query->where('patient_id', $patientId);
}
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
}
/**
* @return Collection<int, InvestigationRequest>
*/
public function workQueue(string $ownerRef, int $branchId, ?string $status = null): Collection
{
$query = InvestigationRequest::owned($ownerRef)
->where('branch_id', $branchId)
->whereIn('status', InvestigationRequest::activeStatuses())
->with(['patient', 'investigationType', 'assignedMember', 'result'])
->orderByRaw("CASE priority WHEN 'urgent' THEN 0 ELSE 1 END")
->orderBy('created_at');
if ($status) {
$query->where('status', $status);
}
return $query->get();
}
/**
* @param array<int, int> $typeIds
* @return Collection<int, InvestigationRequest>
*/
public function requestFromConsultation(
Consultation $consultation,
string $ownerRef,
array $typeIds,
?string $clinicalNotes = null,
string $priority = 'routine',
?string $actorRef = null,
): Collection {
$consultation->loadMissing('visit');
$created = new Collection();
foreach ($typeIds as $typeId) {
$type = InvestigationType::owned($ownerRef)->findOrFail($typeId);
$request = InvestigationRequest::create([
'owner_ref' => $ownerRef,
'organization_id' => $consultation->visit->organization_id,
'branch_id' => $consultation->visit->branch_id,
'visit_id' => $consultation->visit_id,
'consultation_id' => $consultation->id,
'patient_id' => $consultation->patient_id,
'investigation_type_id' => $type->id,
'practitioner_id' => $consultation->practitioner_id,
'status' => InvestigationRequest::STATUS_PENDING,
'priority' => $priority,
'clinical_notes' => $clinicalNotes,
'requested_by' => $actorRef,
]);
AuditLogger::record(
$ownerRef,
'investigation.requested',
$consultation->visit->organization_id,
$actorRef,
InvestigationRequest::class,
$request->id,
);
$request = $request->load(['investigationType', 'patient']);
$organization = Organization::query()->find($request->organization_id);
if ($organization) {
$this->queueBridge->issueForInvestigation($organization, $request);
}
$created->push($request->fresh(['investigationType', 'patient']));
}
return $created;
}
public function collectSample(
InvestigationRequest $request,
string $ownerRef,
?string $barcode = null,
?string $actorRef = null,
): InvestigationRequest {
$this->assertStatus($request, InvestigationRequest::STATUS_PENDING);
$request->update([
'status' => InvestigationRequest::STATUS_SAMPLE_COLLECTED,
'sample_barcode' => $barcode ?? $this->generateBarcode($request),
'sample_collected_at' => now(),
'sample_collected_by' => $actorRef,
]);
AuditLogger::record($ownerRef, 'investigation.sample_collected', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
$request = $request->fresh(['patient', 'investigationType']);
$organization = Organization::query()->find($request->organization_id);
if ($organization) {
$this->queueBridge->serve($organization, $request);
}
return $request;
}
public function startProcessing(
InvestigationRequest $request,
string $ownerRef,
?int $assignedMemberId = null,
?string $actorRef = null,
): InvestigationRequest {
$this->assertStatus($request, InvestigationRequest::STATUS_SAMPLE_COLLECTED);
$request->update([
'status' => InvestigationRequest::STATUS_IN_PROGRESS,
'assigned_member_id' => $assignedMemberId,
]);
InvestigationResult::firstOrCreate(
['investigation_request_id' => $request->id],
['owner_ref' => $ownerRef, 'entered_by' => $actorRef, 'status' => InvestigationResult::STATUS_DRAFT],
);
AuditLogger::record($ownerRef, 'investigation.in_progress', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
return $request->fresh(['patient', 'investigationType', 'result']);
}
/**
* @param array<string, mixed> $data
*/
public function enterResults(
InvestigationRequest $request,
string $ownerRef,
array $data,
?string $actorRef = null,
): InvestigationResult {
if (! in_array($request->status, [InvestigationRequest::STATUS_IN_PROGRESS, InvestigationRequest::STATUS_AWAITING_REVIEW], true)) {
throw new InvalidArgumentException('Results can only be entered when processing.');
}
$result = InvestigationResult::firstOrCreate(
['investigation_request_id' => $request->id],
['owner_ref' => $ownerRef, 'entered_by' => $actorRef],
);
$type = $request->investigationType;
$isAbnormal = false;
if (! empty($data['values']) && is_array($data['values'])) {
$result->values()->delete();
foreach ($data['values'] as $row) {
if (empty($row['parameter'])) {
continue;
}
$abnormal = $this->isValueAbnormal(
$row['value'] ?? null,
$row['reference_low'] ?? $type->reference_low,
$row['reference_high'] ?? $type->reference_high,
);
$isAbnormal = $isAbnormal || $abnormal;
InvestigationResultValue::create([
'owner_ref' => $ownerRef,
'investigation_result_id' => $result->id,
'parameter' => $row['parameter'],
'value' => $row['value'] ?? null,
'unit' => $row['unit'] ?? $type->unit,
'reference_low' => $row['reference_low'] ?? $type->reference_low,
'reference_high' => $row['reference_high'] ?? $type->reference_high,
'reference_text' => $row['reference_text'] ?? $type->reference_text,
'is_abnormal' => $abnormal,
]);
}
} elseif (! empty($data['value'])) {
$result->values()->delete();
$abnormal = $this->isValueAbnormal($data['value'], $type->reference_low, $type->reference_high);
$isAbnormal = $abnormal;
InvestigationResultValue::create([
'owner_ref' => $ownerRef,
'investigation_result_id' => $result->id,
'parameter' => $type->name,
'value' => $data['value'],
'unit' => $type->unit,
'reference_low' => $type->reference_low,
'reference_high' => $type->reference_high,
'reference_text' => $type->reference_text,
'is_abnormal' => $abnormal,
]);
}
$result->update([
'result_summary' => $data['result_summary'] ?? $result->result_summary,
'interpretation' => $data['interpretation'] ?? $result->interpretation,
'is_abnormal' => $isAbnormal,
'entered_by' => $actorRef ?? $result->entered_by,
'status' => InvestigationResult::STATUS_DRAFT,
]);
if (! empty($data['attachments'])) {
$this->storeAttachments($result, $ownerRef, $data['attachments'], $actorRef);
}
$request->update(['status' => InvestigationRequest::STATUS_AWAITING_REVIEW]);
AuditLogger::record($ownerRef, 'investigation.results_entered', $request->organization_id, $actorRef, InvestigationResult::class, $result->id);
return $result->fresh(['values', 'attachments']);
}
public function approve(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest
{
$this->assertStatus($request, InvestigationRequest::STATUS_AWAITING_REVIEW);
$result = $request->result;
abort_unless($result, 422, 'No results to approve.');
$result->update([
'status' => InvestigationResult::STATUS_APPROVED,
'approved_by' => $actorRef,
'approved_at' => now(),
]);
$request->update([
'status' => InvestigationRequest::STATUS_COMPLETED,
'completed_at' => now(),
'approved_by' => $actorRef,
'approved_at' => now(),
]);
AuditLogger::record($ownerRef, 'investigation.approved', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
$request = $request->fresh(['patient', 'investigationType', 'result.values']);
$organization = Organization::query()->find($request->organization_id);
if ($organization) {
$this->queueBridge->complete($organization, $request);
}
return $request;
}
public function deliver(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest
{
$this->assertStatus($request, InvestigationRequest::STATUS_COMPLETED);
$request->update([
'status' => InvestigationRequest::STATUS_DELIVERED,
'delivered_at' => now(),
]);
AuditLogger::record($ownerRef, 'investigation.delivered', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
return $request->fresh(['patient', 'investigationType', 'result']);
}
public function cancel(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest
{
if (in_array($request->status, [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED], true)) {
throw new InvalidArgumentException('Cannot cancel a completed investigation.');
}
$request->update(['status' => InvestigationRequest::STATUS_CANCELLED]);
AuditLogger::record($ownerRef, 'investigation.cancelled', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
return $request->fresh();
}
/**
* @param array<string, mixed> $data
*/
public function createType(Organization $organization, string $ownerRef, array $data): InvestigationType
{
$type = InvestigationType::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'name' => $data['name'],
'code' => $data['code'] ?? null,
'category' => $data['category'],
'description' => $data['description'] ?? null,
'unit' => $data['unit'] ?? null,
'reference_low' => $data['reference_low'] ?? null,
'reference_high' => $data['reference_high'] ?? null,
'reference_text' => $data['reference_text'] ?? null,
'price_minor' => (int) ($data['price_minor'] ?? 0),
'is_active' => (bool) ($data['is_active'] ?? true),
]);
AuditLogger::record($ownerRef, 'investigation_type.created', $organization->id, null, InvestigationType::class, $type->id);
return $type;
}
/**
* @param array<string, mixed> $data
*/
public function updateType(InvestigationType $type, string $ownerRef, array $data): InvestigationType
{
$type->update([
'name' => $data['name'],
'code' => $data['code'] ?? null,
'category' => $data['category'],
'description' => $data['description'] ?? null,
'unit' => $data['unit'] ?? null,
'reference_low' => $data['reference_low'] ?? null,
'reference_high' => $data['reference_high'] ?? null,
'reference_text' => $data['reference_text'] ?? null,
'price_minor' => (int) ($data['price_minor'] ?? 0),
'is_active' => (bool) ($data['is_active'] ?? true),
]);
AuditLogger::record($ownerRef, 'investigation_type.updated', $type->organization_id, null, InvestigationType::class, $type->id);
return $type;
}
protected function assertStatus(InvestigationRequest $request, string ...$allowed): void
{
if (! in_array($request->status, $allowed, true)) {
throw new InvalidArgumentException("Cannot transition from {$request->status}.");
}
}
protected function isValueAbnormal(?string $value, mixed $low, mixed $high): bool
{
if ($value === null || $value === '') {
return false;
}
if (! is_numeric($value)) {
return false;
}
$numeric = (float) $value;
if ($low !== null && $numeric < (float) $low) {
return true;
}
if ($high !== null && $numeric > (float) $high) {
return true;
}
return false;
}
protected function generateBarcode(InvestigationRequest $request): string
{
return 'LAB-'.str_pad((string) $request->id, 6, '0', STR_PAD_LEFT);
}
/**
* @param array<int, UploadedFile> $files
*/
protected function storeAttachments(InvestigationResult $result, string $ownerRef, array $files, ?string $actorRef): void
{
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
$path = $file->store("care/investigations/{$result->id}/attachments", 'public');
InvestigationAttachment::create([
'owner_ref' => $ownerRef,
'investigation_result_id' => $result->id,
'file_path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'uploaded_by' => $actorRef,
]);
}
}
}