Better dispensing
This commit is contained in:
+187
-41
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user