Files
ladill-care/app/Services/Care/InvestigationService.php
isaaccladandCursor 3ee59a0956
Deploy Ladill Care / deploy (push) Failing after 1m9s
Activate Care workflows across check-in and financial clearance.
Enforce service-queue gates, cashier settlements, and clinical stage progression so patient journeys cannot bypass configured payment or authorization rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 21:13:06 +00:00

431 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 App\Services\Care\Workflow\WorkflowStageCompletionService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use InvalidArgumentException;
class InvestigationService
{
public function __construct(
protected CareQueueBridge $queueBridge,
protected WorkflowStageCompletionService $workflowCompletion,
) {}
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', 'visit']);
$organization = Organization::query()->find($request->organization_id);
if ($organization) {
$this->queueBridge->complete($organization, $request);
if ($request->visit) {
$this->workflowCompletion->complete(
$organization,
$request->visit,
$this->queueBridge->contextForInvestigation($request),
$ownerRef,
$actorRef,
);
}
}
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,
]);
}
}
}