Files
ladill-care/app/Services/Care/PharmacyService.php
T
isaaccladandCursor 6c9c742ed8
Deploy Ladill Care / deploy (push) Failing after 13s
Initial Ladill Care release.
Healthcare management app: patients, appointments, consultations, lab,
pharmacy inventory, billing, and reports at care.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 11:36:22 +00:00

203 lines
7.0 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\Models\PrescriptionItem;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use InvalidArgumentException;
class PharmacyService
{
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);
return $prescription->fresh(['items', 'patient']);
}
}