Better dispensing

This commit is contained in:
2026-04-06 09:15:38 -04:00
parent 664a3189bd
commit 5b5e17ec3e
5 changed files with 773 additions and 269 deletions
+187 -41
View File
@@ -228,17 +228,21 @@ class DrugWithVariantsResponse(BaseModel):
class Config:
from_attributes = True
class DispensingAllocationCreate(BaseModel):
batch_id: int
quantity: float
class DispensingCreate(BaseModel):
drug_variant_id: int
quantity: Optional[float] = None
dispense_mode: str = "subunit"
batch_id: Optional[int] = None
requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None
allow_split: bool = True
animal_name: Optional[str] = None
user_name: Optional[str] = None
notes: Optional[str] = None
allocations: List[DispensingAllocationCreate] = []
class DispensingAllocationResponse(BaseModel):
@@ -473,14 +477,15 @@ def resolve_pack_quantity(
}
def select_batches_for_dispense(
def resolve_requested_allocations(
db: Session,
variant_id: int,
requested_quantity: float,
preferred_batch_id: Optional[int],
allow_split: bool,
requested_allocations: List[DispensingAllocationCreate],
dispense_mode: str,
requested_pack_id: Optional[int],
) -> List[Dict[str, Any]]:
"""Select one or more batch allocations using FEFO with optional preferred batch override."""
"""Validate explicit batch allocations against in-date stock for the variant."""
today = date.today()
eligible_batches = (
db.query(Batch)
@@ -496,47 +501,96 @@ def select_batches_for_dispense(
if not eligible_batches:
raise HTTPException(status_code=400, detail="No in-date stock batches available for this variant")
remaining = requested_quantity
if not requested_allocations:
raise HTTPException(status_code=400, detail="At least one batch allocation is required")
eligible_by_id = {batch.id: batch for batch in eligible_batches}
seen_batch_ids = set()
allocations: List[Dict[str, Any]] = []
total_allocated = 0.0
selected_pack = None
selected_pack_size = None
# If a preferred batch is supplied, consume from that batch first.
if preferred_batch_id is not None:
preferred = next((b for b in eligible_batches if b.id == preferred_batch_id), None)
if preferred is None:
raise HTTPException(status_code=400, detail="Preferred batch is unavailable or expired")
if dispense_mode == "pack":
if requested_pack_id is None:
raise HTTPException(status_code=400, detail="Pack dispense requires a requested pack")
used = min(preferred.quantity, remaining)
if used > 0:
allocations.append({"batch": preferred, "quantity": used})
remaining -= used
eligible_batches = [b for b in eligible_batches if b.id != preferred_batch_id]
selected_pack = (
db.query(VariantPack)
.filter(
VariantPack.id == requested_pack_id,
VariantPack.drug_variant_id == variant_id,
VariantPack.is_active.is_(True),
)
.first()
)
if selected_pack is None:
raise HTTPException(status_code=400, detail="Selected pack is unavailable for this variant")
if remaining > 0 and not allow_split:
selected_pack_size = selected_pack.pack_size_in_base_units
total_full_packs_available = sum(
int(batch.current_full_pack_count or 0)
for batch in eligible_batches
if batch.received_pack_id == requested_pack_id
)
if total_full_packs_available <= 0:
raise HTTPException(status_code=400, detail="No full packs are available for the selected pack")
if requested_quantity - (total_full_packs_available * selected_pack_size) > 1e-6:
raise HTTPException(
status_code=400,
detail=f"Preferred batch cannot fully satisfy request. Remaining required: {remaining}",
detail=f"Only {total_full_packs_available} full packs are available for the selected pack",
)
if remaining > 0 and not allow_split:
# In non-split mode, only a single batch may satisfy the request.
first = eligible_batches[0]
if first.quantity >= remaining:
allocations.append({"batch": first, "quantity": remaining})
remaining = 0
else:
raise HTTPException(status_code=400, detail="Single-batch fulfillment is not possible for requested quantity")
for entry in requested_allocations:
batch = eligible_by_id.get(entry.batch_id)
if batch is None:
raise HTTPException(status_code=400, detail=f"Batch {entry.batch_id} is unavailable, expired, or not valid for this variant")
if entry.batch_id in seen_batch_ids:
raise HTTPException(status_code=400, detail="Each batch may only be allocated once")
if entry.quantity < 0:
raise HTTPException(status_code=400, detail="Batch allocation quantity cannot be negative")
if entry.quantity == 0:
continue
if entry.quantity - batch.quantity > 1e-6:
raise HTTPException(status_code=400, detail=f"Batch {batch.batch_number} does not have enough stock for requested allocation")
if remaining > 0:
for batch in eligible_batches:
if remaining <= 0:
break
used = min(batch.quantity, remaining)
if used > 0:
allocations.append({"batch": batch, "quantity": used})
remaining -= used
if dispense_mode == "pack":
if batch.received_pack_id != requested_pack_id:
raise HTTPException(
status_code=400,
detail=f"Batch {batch.batch_number} does not contain full packs of the selected pack type",
)
if remaining > 0:
raise HTTPException(status_code=400, detail="Insufficient in-date stock across available batches")
available_full_packs = int(batch.current_full_pack_count or 0)
available_batch_quantity = available_full_packs * selected_pack_size
if available_full_packs <= 0 or available_batch_quantity <= 0:
raise HTTPException(
status_code=400,
detail=f"Batch {batch.batch_number} has no full packs available",
)
if abs((entry.quantity / selected_pack_size) - round(entry.quantity / selected_pack_size)) > 1e-6:
raise HTTPException(
status_code=400,
detail=f"Batch {batch.batch_number} allocation must be a whole number of packs",
)
if entry.quantity - available_batch_quantity > 1e-6:
raise HTTPException(
status_code=400,
detail=f"Batch {batch.batch_number} only has {available_full_packs} full packs available",
)
seen_batch_ids.add(entry.batch_id)
allocations.append({"batch": batch, "quantity": entry.quantity})
total_allocated += entry.quantity
if not allocations:
raise HTTPException(status_code=400, detail="At least one batch allocation must be greater than zero")
if abs(total_allocated - requested_quantity) > 1e-6:
raise HTTPException(
status_code=400,
detail=f"Allocated quantity ({total_allocated}) must match requested quantity ({requested_quantity})",
)
return allocations
@@ -1272,16 +1326,17 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispense_qty}",
)
allocations = select_batches_for_dispense(
allocations = resolve_requested_allocations(
db,
variant_id=variant.id,
requested_quantity=dispense_qty,
preferred_batch_id=dispensing.batch_id,
allow_split=dispensing.allow_split,
requested_allocations=dispensing.allocations,
dispense_mode=dispense_mode,
requested_pack_id=resolved["pack_id"],
)
user_name = dispensing.user_name or current_user.username
primary_batch_id = dispensing.batch_id if dispensing.batch_id is not None else allocations[0]["batch"].id
primary_batch_id = allocations[0]["batch"].id
db_dispensing = Dispensing(
drug_variant_id=dispensing.drug_variant_id,
@@ -1916,6 +1971,97 @@ def report_stock_by_location(
return result
@router.get("/reports/batch-attention")
def report_batch_attention(
format: str = "json",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
today = date.today()
rows = (
db.query(Batch, DrugVariant, Drug, Location)
.join(DrugVariant, Batch.drug_variant_id == DrugVariant.id)
.join(Drug, DrugVariant.drug_id == Drug.id)
.join(Location, Batch.location_id == Location.id)
.filter(Batch.quantity > 0)
.order_by(Batch.expiry_date.asc(), Drug.name.asc(), DrugVariant.strength.asc(), Batch.batch_number.asc())
.all()
)
result = []
for batch, variant, drug, location in rows:
is_expired = batch.expiry_date < today
is_partial = bool((batch.current_loose_base_units or 0) > 1e-6)
if not is_expired and not is_partial:
continue
if is_expired and is_partial:
status = "expired_partial"
elif is_expired:
status = "expired"
else:
status = "partial"
result.append(
{
"batch_id": batch.id,
"batch_number": batch.batch_number,
"drug_name": drug.name,
"strength": variant.strength,
"quantity": batch.quantity,
"unit": variant.unit,
"location": location.name,
"expiry_date": batch.expiry_date,
"status": status,
"received_pack_label": None,
"current_full_pack_count": batch.current_full_pack_count,
"current_loose_base_units": batch.current_loose_base_units,
"is_controlled": bool(drug.is_controlled),
}
)
if format.lower() == "csv":
csv_rows = [
[
item["batch_id"],
item["batch_number"],
item["drug_name"],
item["strength"],
item["quantity"],
item["unit"],
item["location"],
item["expiry_date"],
item["status"],
item["current_full_pack_count"],
item["current_loose_base_units"],
item["is_controlled"],
]
for item in result
]
return _csv_response(
"batch_attention.csv",
[
"batch_id",
"batch_number",
"drug_name",
"strength",
"quantity",
"unit",
"location",
"expiry_date",
"status",
"current_full_pack_count",
"current_loose_base_units",
"is_controlled",
],
csv_rows,
)
return result
@router.get("/reports/audit-trail")
def report_audit_trail(
from_date: Optional[date] = None,