Deploy Ladill Care / deploy (push) Failing after 1m9s
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>
431 lines
16 KiB
PHP
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,
|
|
]);
|
|
}
|
|
}
|
|
}
|