Batch disposal
This commit is contained in:
+87
-17
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user