Batch disposal

This commit is contained in:
2026-04-06 10:41:33 -04:00
parent 5b5e17ec3e
commit b958ca493b
7 changed files with 620 additions and 65 deletions
+87 -17
View File
@@ -134,6 +134,10 @@ class BatchUpdate(BaseModel):
notes: Optional[str] = None
class BatchDisposeRequest(BaseModel):
notes: Optional[str] = None
class BatchResponse(BaseModel):
id: int
drug_variant_id: int
@@ -150,6 +154,10 @@ class BatchResponse(BaseModel):
location_name: Optional[str] = None
notes: Optional[str] = None
received_at: datetime
disposed_at: Optional[datetime] = None
disposed_by_user_id: Optional[int] = None
disposed_quantity: Optional[float] = None
disposal_notes: Optional[str] = None
class Config:
from_attributes = True
@@ -384,6 +392,10 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
"location_name": location.name if location else None,
"notes": batch.notes,
"received_at": batch.received_at,
"disposed_at": batch.disposed_at,
"disposed_by_user_id": batch.disposed_by_user_id,
"disposed_quantity": batch.disposed_quantity,
"disposal_notes": batch.disposal_notes,
}
@@ -480,6 +492,7 @@ def resolve_pack_quantity(
def resolve_requested_allocations(
db: Session,
variant_id: int,
variant_quantity: float,
requested_quantity: float,
requested_allocations: List[DispensingAllocationCreate],
dispense_mode: str,
@@ -487,6 +500,8 @@ def resolve_requested_allocations(
) -> List[Dict[str, Any]]:
"""Validate explicit batch allocations against in-date stock for the variant."""
today = date.today()
total_batched_quantity = sum(float(batch.quantity or 0) for batch in db.query(Batch).filter(Batch.drug_variant_id == variant_id).all())
legacy_unbatched_quantity = max(0.0, float(variant_quantity or 0) - total_batched_quantity)
eligible_batches = (
db.query(Batch)
.filter(
@@ -499,7 +514,18 @@ def resolve_requested_allocations(
)
if not eligible_batches:
raise HTTPException(status_code=400, detail="No in-date stock batches available for this variant")
if dispense_mode == "pack":
raise HTTPException(status_code=400, detail="Whole-pack dispensing requires batched stock with pack information")
if requested_allocations:
raise HTTPException(status_code=400, detail="Batch allocations cannot be supplied when dispensing legacy stock")
if legacy_unbatched_quantity <= 0:
raise HTTPException(status_code=400, detail="No in-date stock batches available for this variant")
if requested_quantity - legacy_unbatched_quantity > 1e-6:
raise HTTPException(
status_code=400,
detail=f"Insufficient unbatched stock. Available: {legacy_unbatched_quantity}, Requested: {requested_quantity}",
)
return []
if not requested_allocations:
raise HTTPException(status_code=400, detail="At least one batch allocation is required")
@@ -1329,6 +1355,7 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
allocations = resolve_requested_allocations(
db,
variant_id=variant.id,
variant_quantity=variant.quantity,
requested_quantity=dispense_qty,
requested_allocations=dispensing.allocations,
dispense_mode=dispense_mode,
@@ -1336,7 +1363,7 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
)
user_name = dispensing.user_name or current_user.username
primary_batch_id = allocations[0]["batch"].id
primary_batch_id = allocations[0]["batch"].id if allocations else None
db_dispensing = Dispensing(
drug_variant_id=dispensing.drug_variant_id,
@@ -1669,6 +1696,10 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
"expiry_date": batch.expiry_date,
"location_id": batch.location_id,
"notes": batch.notes,
"disposed_at": batch.disposed_at,
"disposed_by_user_id": batch.disposed_by_user_id,
"disposed_quantity": batch.disposed_quantity,
"disposal_notes": batch.disposal_notes,
}
if payload.batch_number is not None:
@@ -1751,6 +1782,58 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
return serialize_batch_response(db, batch)
@router.post("/batches/{batch_id}/dispose", response_model=BatchResponse)
def dispose_batch(batch_id: int, payload: BatchDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)):
batch = db.query(Batch).filter(Batch.id == batch_id).first()
if not batch:
raise HTTPException(status_code=404, detail="Batch not found")
if batch.disposed_at is not None:
raise HTTPException(status_code=400, detail="Batch has already been disposed")
if batch.quantity <= 0:
raise HTTPException(status_code=400, detail="Batch has no remaining stock to dispose")
if batch.expiry_date >= date.today():
raise HTTPException(status_code=400, detail="Only expired batches can be marked as disposed")
variant = db.query(DrugVariant).filter(DrugVariant.id == batch.drug_variant_id).first()
if not variant:
raise HTTPException(status_code=404, detail="Parent variant not found")
disposed_quantity = batch.quantity
if variant.quantity - disposed_quantity < -1e-6:
raise HTTPException(status_code=400, detail="Variant quantity cannot become negative during disposal")
batch.quantity = 0
batch.disposed_at = datetime.utcnow()
batch.disposed_by_user_id = current_user.id
batch.disposed_quantity = disposed_quantity
batch.disposal_notes = (payload.notes or '').strip() or None
recompute_batch_pack_state(batch)
variant.quantity = max(0, variant.quantity - disposed_quantity)
write_audit_log(
db,
action="batch.dispose",
entity_type="batch",
entity_id=batch.id,
actor=current_user,
details={
"batch_number": batch.batch_number,
"variant_id": batch.drug_variant_id,
"disposed_quantity": disposed_quantity,
"expiry_date": batch.expiry_date.isoformat() if batch.expiry_date else None,
"location_id": batch.location_id,
"disposal_notes": batch.disposal_notes,
},
)
db.commit()
db.refresh(batch)
return serialize_batch_response(db, batch)
@router.get("/audit", response_model=List[Dict[str, Any]])
def list_audit_events(skip: int = 0, limit: int = 200, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
events = db.query(AuditLog).order_by(AuditLog.created_at.desc()).offset(skip).limit(limit).all()
@@ -1984,26 +2067,13 @@ def report_batch_attention(
.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)
.filter(Batch.quantity > 0, Batch.expiry_date < today)
.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,
@@ -2014,7 +2084,7 @@ def report_batch_attention(
"unit": variant.unit,
"location": location.name,
"expiry_date": batch.expiry_date,
"status": status,
"status": "expired",
"received_pack_label": None,
"current_full_pack_count": batch.current_full_pack_count,
"current_loose_base_units": batch.current_loose_base_units,