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>
223 lines
7.7 KiB
PHP
223 lines
7.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Care;
|
|
|
|
use App\Models\DispensingRecord;
|
|
use App\Models\Drug;
|
|
use App\Models\DrugBatch;
|
|
use App\Models\Organization;
|
|
use App\Models\Prescription;
|
|
use App\Services\Care\Workflow\WorkflowStageCompletionService;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use InvalidArgumentException;
|
|
|
|
class PharmacyService
|
|
{
|
|
public function __construct(
|
|
protected CareQueueBridge $queueBridge,
|
|
protected WorkflowStageCompletionService $workflowCompletion,
|
|
) {}
|
|
|
|
public function queryDrugs(string $ownerRef, int $organizationId): Builder
|
|
{
|
|
return Drug::owned($ownerRef)->where('organization_id', $organizationId);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
public function listDrugs(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
|
|
{
|
|
$query = $this->queryDrugs($ownerRef, $organizationId)
|
|
->with(['batches'])
|
|
->orderBy('name');
|
|
|
|
if ($branchId !== null) {
|
|
$query->where(function (Builder $q) use ($branchId) {
|
|
$q->where('branch_id', $branchId)->orWhereNull('branch_id');
|
|
});
|
|
}
|
|
|
|
if ($search = trim((string) ($filters['q'] ?? ''))) {
|
|
$query->where(function (Builder $inner) use ($search) {
|
|
$inner->where('name', 'like', "%{$search}%")
|
|
->orWhere('generic_name', 'like', "%{$search}%")
|
|
->orWhere('sku', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
return $query->paginate((int) ($filters['per_page'] ?? 30))->withQueryString();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function createDrug(Organization $organization, string $ownerRef, array $data): Drug
|
|
{
|
|
$drug = Drug::create([
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $organization->id,
|
|
'branch_id' => $data['branch_id'] ?? null,
|
|
'name' => $data['name'],
|
|
'generic_name' => $data['generic_name'] ?? null,
|
|
'sku' => $data['sku'] ?? null,
|
|
'unit' => $data['unit'] ?? 'unit',
|
|
'unit_price_minor' => (int) ($data['unit_price_minor'] ?? 0),
|
|
'reorder_level' => (int) ($data['reorder_level'] ?? 10),
|
|
'is_active' => (bool) ($data['is_active'] ?? true),
|
|
]);
|
|
|
|
AuditLogger::record($ownerRef, 'drug.created', $organization->id, null, Drug::class, $drug->id);
|
|
|
|
return $drug;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function updateDrug(Drug $drug, string $ownerRef, array $data): Drug
|
|
{
|
|
$drug->update([
|
|
'branch_id' => $data['branch_id'] ?? $drug->branch_id,
|
|
'name' => $data['name'],
|
|
'generic_name' => $data['generic_name'] ?? null,
|
|
'sku' => $data['sku'] ?? null,
|
|
'unit' => $data['unit'] ?? $drug->unit,
|
|
'unit_price_minor' => (int) ($data['unit_price_minor'] ?? 0),
|
|
'reorder_level' => (int) ($data['reorder_level'] ?? 10),
|
|
'is_active' => (bool) ($data['is_active'] ?? true),
|
|
]);
|
|
|
|
AuditLogger::record($ownerRef, 'drug.updated', $drug->organization_id, null, Drug::class, $drug->id);
|
|
|
|
return $drug->fresh(['batches']);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function receiveBatch(Drug $drug, string $ownerRef, array $data, ?string $actorRef = null): DrugBatch
|
|
{
|
|
$batch = DrugBatch::create([
|
|
'owner_ref' => $ownerRef,
|
|
'drug_id' => $drug->id,
|
|
'batch_number' => $data['batch_number'],
|
|
'expiry_date' => $data['expiry_date'] ?? null,
|
|
'quantity_on_hand' => (int) ($data['quantity_on_hand'] ?? 0),
|
|
'cost_minor' => (int) ($data['cost_minor'] ?? 0),
|
|
'received_at' => now(),
|
|
]);
|
|
|
|
AuditLogger::record($ownerRef, 'drug.batch_received', $drug->organization_id, $actorRef, DrugBatch::class, $batch->id);
|
|
|
|
return $batch->load('drug');
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, Drug>
|
|
*/
|
|
public function lowStock(string $ownerRef, int $organizationId): Collection
|
|
{
|
|
return $this->queryDrugs($ownerRef, $organizationId)
|
|
->where('is_active', true)
|
|
->with('batches')
|
|
->get()
|
|
->filter(fn (Drug $drug) => $drug->stockOnHand() <= $drug->reorder_level);
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, DrugBatch>
|
|
*/
|
|
public function expiredBatches(string $ownerRef, int $organizationId): Collection
|
|
{
|
|
return DrugBatch::owned($ownerRef)
|
|
->whereHas('drug', fn (Builder $q) => $q->where('organization_id', $organizationId))
|
|
->whereNotNull('expiry_date')
|
|
->where('expiry_date', '<', now()->toDateString())
|
|
->where('quantity_on_hand', '>', 0)
|
|
->with('drug')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $allocations
|
|
*/
|
|
public function dispensePrescription(
|
|
Prescription $prescription,
|
|
string $ownerRef,
|
|
array $allocations,
|
|
?string $actorRef = null,
|
|
): Prescription {
|
|
if ($prescription->status !== Prescription::STATUS_ACTIVE) {
|
|
throw new InvalidArgumentException('Only active prescriptions can be dispensed.');
|
|
}
|
|
|
|
$prescription->load('items');
|
|
|
|
foreach ($allocations as $row) {
|
|
if (empty($row['prescription_item_id']) || empty($row['drug_batch_id'])) {
|
|
continue;
|
|
}
|
|
|
|
$item = $prescription->items->firstWhere('id', (int) $row['prescription_item_id']);
|
|
if (! $item || $item->is_procedure) {
|
|
continue;
|
|
}
|
|
|
|
$qty = max(1, (int) ($row['quantity'] ?? 1));
|
|
$batch = DrugBatch::owned($ownerRef)->findOrFail((int) $row['drug_batch_id']);
|
|
|
|
if ($batch->isExpired()) {
|
|
throw new InvalidArgumentException("Batch {$batch->batch_number} is expired.");
|
|
}
|
|
|
|
if ($batch->quantity_on_hand < $qty) {
|
|
throw new InvalidArgumentException("Insufficient stock for {$batch->drug->name}.");
|
|
}
|
|
|
|
$batch->decrement('quantity_on_hand', $qty);
|
|
|
|
DispensingRecord::create([
|
|
'owner_ref' => $ownerRef,
|
|
'prescription_id' => $prescription->id,
|
|
'prescription_item_id' => $item->id,
|
|
'drug_batch_id' => $batch->id,
|
|
'quantity' => $qty,
|
|
'dispensed_by' => $actorRef,
|
|
'dispensed_at' => now(),
|
|
]);
|
|
|
|
if (! $item->drug_id) {
|
|
$item->update(['drug_id' => $batch->drug_id]);
|
|
}
|
|
}
|
|
|
|
$prescription->update([
|
|
'status' => Prescription::STATUS_DISPENSED,
|
|
'dispensed_at' => now(),
|
|
'dispensed_by' => $actorRef,
|
|
]);
|
|
|
|
AuditLogger::record($ownerRef, 'prescription.dispensed', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
|
|
|
|
$prescription = $prescription->fresh(['items', 'patient', 'visit']);
|
|
$organization = Organization::query()->find($prescription->organization_id);
|
|
if ($organization) {
|
|
$this->queueBridge->complete($organization, $prescription);
|
|
if ($prescription->visit) {
|
|
$this->workflowCompletion->complete(
|
|
$organization,
|
|
$prescription->visit,
|
|
CareQueueContexts::PHARMACY,
|
|
$ownerRef,
|
|
$actorRef,
|
|
);
|
|
}
|
|
}
|
|
|
|
return $prescription;
|
|
}
|
|
}
|