This commit is contained in:
2026-03-29 10:39:03 -04:00
parent e00669ae2c
commit ad1bb59f98
6 changed files with 1615 additions and 51 deletions
+426 -24
View File
@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta, date
import math
import json
import csv
import io
@@ -10,6 +11,7 @@ from .database import engine, get_db, Base
from .models import (
Drug,
DrugVariant,
VariantPack,
Dispensing,
DispensingAllocation,
Location,
@@ -114,7 +116,9 @@ class LocationResponse(BaseModel):
class BatchCreate(BaseModel):
batch_number: str
quantity: float
quantity: Optional[float] = None
received_pack_id: Optional[int] = None
received_pack_count: Optional[float] = None
expiry_date: date
location_id: int
notes: Optional[str] = None
@@ -123,6 +127,8 @@ class BatchCreate(BaseModel):
class BatchUpdate(BaseModel):
batch_number: Optional[str] = None
quantity: Optional[float] = None
received_pack_id: Optional[int] = None
received_pack_count: Optional[float] = None
expiry_date: Optional[date] = None
location_id: Optional[int] = None
notes: Optional[str] = None
@@ -133,6 +139,12 @@ class BatchResponse(BaseModel):
drug_variant_id: int
batch_number: str
quantity: float
received_pack_id: Optional[int] = None
received_pack_label: Optional[str] = None
received_pack_count: Optional[float] = None
received_pack_size_snapshot: Optional[float] = None
current_full_pack_count: Optional[float] = None
current_loose_base_units: Optional[float] = None
expiry_date: date
location_id: int
location_name: Optional[str] = None
@@ -155,21 +167,51 @@ class DrugVariantCreate(BaseModel):
strength: str
quantity: float
unit: str = "units"
base_unit: Optional[str] = None
low_stock_threshold: float = 10
class DrugVariantUpdate(BaseModel):
strength: str = None
quantity: float = None
unit: str = None
base_unit: str = None
low_stock_threshold: float = None
class VariantPackCreate(BaseModel):
label: str
pack_unit_name: str
pack_size_in_base_units: float
is_active: bool = True
class VariantPackUpdate(BaseModel):
label: Optional[str] = None
pack_unit_name: Optional[str] = None
pack_size_in_base_units: Optional[float] = None
is_active: Optional[bool] = None
class VariantPackResponse(BaseModel):
id: int
drug_variant_id: int
label: str
pack_unit_name: str
pack_size_in_base_units: float
is_active: bool
class Config:
from_attributes = True
class DrugVariantResponse(BaseModel):
id: int
drug_id: int
strength: str
quantity: float
unit: str
base_unit: str
low_stock_threshold: float
packs: List[VariantPackResponse] = []
batches: List[BatchResponse] = []
class Config:
@@ -187,8 +229,11 @@ class DrugWithVariantsResponse(BaseModel):
class DispensingCreate(BaseModel):
drug_variant_id: int
quantity: float
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
@@ -208,6 +253,9 @@ class DispensingResponse(BaseModel):
batch_id: Optional[int] = None
actor_user_id: Optional[int] = None
quantity: float
dispense_mode: str = "subunit"
requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None
animal_name: Optional[str] = None
user_name: str
notes: Optional[str] = None
@@ -276,8 +324,16 @@ def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str,
"strength": variant.strength,
"quantity": variant.quantity,
"unit": variant.unit,
"base_unit": variant.unit,
"low_stock_threshold": variant.low_stock_threshold,
}
packs = (
db.query(VariantPack)
.filter(VariantPack.drug_variant_id == variant.id)
.order_by(VariantPack.is_active.desc(), VariantPack.id.asc())
.all()
)
variant_dict["packs"] = [serialize_variant_pack(pack) for pack in packs]
batches = (
db.query(Batch)
.filter(Batch.drug_variant_id == variant.id, Batch.quantity > 0)
@@ -290,11 +346,20 @@ def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str,
def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
location = db.query(Location).filter(Location.id == batch.location_id).first()
pack = None
if batch.received_pack_id is not None:
pack = db.query(VariantPack).filter(VariantPack.id == batch.received_pack_id).first()
return {
"id": batch.id,
"drug_variant_id": batch.drug_variant_id,
"batch_number": batch.batch_number,
"quantity": batch.quantity,
"received_pack_id": batch.received_pack_id,
"received_pack_label": pack.label if pack else None,
"received_pack_count": batch.received_pack_count,
"received_pack_size_snapshot": batch.received_pack_size_snapshot,
"current_full_pack_count": batch.current_full_pack_count,
"current_loose_base_units": batch.current_loose_base_units,
"expiry_date": batch.expiry_date,
"location_id": batch.location_id,
"location_name": location.name if location else None,
@@ -303,6 +368,96 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
}
def resolve_pack_size_snapshot(db: Session, pack_id: Optional[int]) -> Optional[float]:
if pack_id is None:
return None
pack = db.query(VariantPack).filter(VariantPack.id == pack_id).first()
if not pack:
return None
return pack.pack_size_in_base_units
def recompute_batch_pack_state(batch: Batch) -> None:
pack_size = batch.received_pack_size_snapshot
if pack_size is None or pack_size <= 0 or batch.quantity < 0:
batch.current_full_pack_count = None
batch.current_loose_base_units = None
return
full_packs = math.floor((batch.quantity + 1e-9) / pack_size)
loose_units = batch.quantity - (full_packs * pack_size)
if loose_units < 1e-9:
loose_units = 0.0
batch.current_full_pack_count = float(full_packs)
batch.current_loose_base_units = loose_units
def serialize_variant_pack(pack: VariantPack) -> Dict[str, Any]:
return {
"id": pack.id,
"drug_variant_id": pack.drug_variant_id,
"label": pack.label,
"pack_unit_name": pack.pack_unit_name,
"pack_size_in_base_units": pack.pack_size_in_base_units,
"is_active": pack.is_active,
}
def resolve_pack_quantity(
db: Session,
variant_id: int,
quantity: Optional[float],
pack_id: Optional[int],
pack_count: Optional[float],
) -> Dict[str, Any]:
"""Resolve canonical base-unit quantity from either direct quantity or pack input."""
if quantity is None and pack_id is None and pack_count is None:
raise HTTPException(status_code=400, detail="Either quantity or pack fields must be provided")
resolved_quantity = quantity
resolved_pack: Optional[VariantPack] = None
if pack_id is not None or pack_count is not None:
if pack_id is None or pack_count is None:
raise HTTPException(status_code=400, detail="Both pack_id and pack_count are required when using pack input")
if pack_count <= 0:
raise HTTPException(status_code=400, detail="Pack count must be greater than zero")
resolved_pack = (
db.query(VariantPack)
.filter(
VariantPack.id == pack_id,
VariantPack.drug_variant_id == variant_id,
VariantPack.is_active.is_(True),
)
.first()
)
if resolved_pack is None:
raise HTTPException(status_code=400, detail="Pack not found for variant or is inactive")
derived_quantity = pack_count * resolved_pack.pack_size_in_base_units
if derived_quantity <= 0:
raise HTTPException(status_code=400, detail="Derived quantity from pack must be greater than zero")
if resolved_quantity is None:
resolved_quantity = derived_quantity
elif abs(resolved_quantity - derived_quantity) > 1e-6:
raise HTTPException(
status_code=400,
detail="Quantity does not match pack conversion for selected pack",
)
if resolved_quantity is None or resolved_quantity <= 0:
raise HTTPException(status_code=400, detail="Quantity must be greater than zero")
return {
"quantity": resolved_quantity,
"pack_id": resolved_pack.id if resolved_pack else None,
"pack_count": pack_count,
}
def select_batches_for_dispense(
db: Session,
variant_id: int,
@@ -712,6 +867,7 @@ def delete_drug(drug_id: int, db: Session = Depends(get_db), current_user: User
db.query(DispensingAllocation).filter(DispensingAllocation.batch_id.in_(batch_ids)).delete(synchronize_session=False)
db.query(Batch).filter(Batch.id.in_(batch_ids)).delete(synchronize_session=False)
db.query(Dispensing).filter(Dispensing.drug_variant_id.in_(variant_ids)).delete(synchronize_session=False)
db.query(VariantPack).filter(VariantPack.drug_variant_id.in_(variant_ids)).delete(synchronize_session=False)
db.query(DrugVariant).filter(DrugVariant.id.in_(variant_ids)).delete(synchronize_session=False)
write_audit_log(
@@ -745,14 +901,31 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
if existing:
raise HTTPException(status_code=400, detail="Variant with this strength already exists for this drug")
base_unit = (variant.base_unit or variant.unit).strip()
if not base_unit:
raise HTTPException(status_code=400, detail="Variant unit/base_unit cannot be empty")
db_variant = DrugVariant(
drug_id=drug_id,
strength=variant.strength,
quantity=variant.quantity,
unit=variant.unit,
unit=base_unit,
low_stock_threshold=variant.low_stock_threshold
)
db.add(db_variant)
db.flush()
# Ensure each variant has at least one active default 1:1 pack representation.
db.add(
VariantPack(
drug_variant_id=db_variant.id,
label=f"1 {base_unit}",
pack_unit_name=base_unit,
pack_size_in_base_units=1,
is_active=True,
)
)
write_audit_log(
db,
action="variant.create",
@@ -763,7 +936,7 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
"drug_id": drug_id,
"strength": variant.strength,
"quantity": variant.quantity,
"unit": variant.unit,
"unit": base_unit,
},
)
db.commit()
@@ -791,7 +964,15 @@ def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db:
"unit": variant.unit,
"low_stock_threshold": variant.low_stock_threshold,
}
for field, value in variant_update.dict(exclude_unset=True).items():
payload = variant_update.dict(exclude_unset=True)
if "base_unit" in payload and payload["base_unit"] is not None:
cleaned_base_unit = payload["base_unit"].strip()
if not cleaned_base_unit:
raise HTTPException(status_code=400, detail="base_unit cannot be empty")
payload["unit"] = cleaned_base_unit
payload.pop("base_unit", None)
for field, value in payload.items():
setattr(variant, field, value)
write_audit_log(
@@ -800,7 +981,7 @@ def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db:
entity_type="drug_variant",
entity_id=variant.id,
actor=current_user,
details={"before": before, "after": variant_update.dict(exclude_unset=True)},
details={"before": before, "after": payload},
)
db.commit()
@@ -820,6 +1001,7 @@ def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_
db.query(Batch).filter(Batch.id.in_(batch_ids)).delete(synchronize_session=False)
db.query(Dispensing).filter(Dispensing.drug_variant_id == variant_id).delete(synchronize_session=False)
db.query(VariantPack).filter(VariantPack.drug_variant_id == variant_id).delete(synchronize_session=False)
write_audit_log(
db,
action="variant.delete",
@@ -833,29 +1015,180 @@ def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_
return {"message": "Drug variant deleted successfully"}
@router.get("/variants/{variant_id}/packs", response_model=List[VariantPackResponse])
def list_variant_packs(
variant_id: int,
active_only: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found")
query = db.query(VariantPack).filter(VariantPack.drug_variant_id == variant_id)
if active_only:
query = query.filter(VariantPack.is_active.is_(True))
packs = query.order_by(VariantPack.is_active.desc(), VariantPack.id.asc()).all()
return [serialize_variant_pack(pack) for pack in packs]
@router.post("/variants/{variant_id}/packs", response_model=VariantPackResponse)
def create_variant_pack(
variant_id: int,
payload: VariantPackCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_non_readonly_user),
):
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found")
label = payload.label.strip()
pack_unit_name = payload.pack_unit_name.strip()
if not label:
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
if not pack_unit_name:
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
if payload.pack_size_in_base_units <= 0:
raise HTTPException(status_code=400, detail="Pack size in base units must be greater than zero")
row = VariantPack(
drug_variant_id=variant_id,
label=label,
pack_unit_name=pack_unit_name,
pack_size_in_base_units=payload.pack_size_in_base_units,
is_active=payload.is_active,
)
db.add(row)
write_audit_log(
db,
action="variant_pack.create",
entity_type="variant_pack",
entity_id=None,
actor=current_user,
details={
"variant_id": variant_id,
"label": label,
"pack_unit_name": pack_unit_name,
"pack_size_in_base_units": payload.pack_size_in_base_units,
"is_active": payload.is_active,
},
)
db.commit()
db.refresh(row)
return serialize_variant_pack(row)
@router.put("/variant-packs/{pack_id}", response_model=VariantPackResponse)
def update_variant_pack(
pack_id: int,
payload: VariantPackUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_non_readonly_user),
):
row = db.query(VariantPack).filter(VariantPack.id == pack_id).first()
if not row:
raise HTTPException(status_code=404, detail="Variant pack not found")
before = serialize_variant_pack(row)
if payload.label is not None:
cleaned = payload.label.strip()
if not cleaned:
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
row.label = cleaned
if payload.pack_unit_name is not None:
cleaned = payload.pack_unit_name.strip()
if not cleaned:
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
row.pack_unit_name = cleaned
if payload.pack_size_in_base_units is not None:
if payload.pack_size_in_base_units <= 0:
raise HTTPException(status_code=400, detail="Pack size in base units must be greater than zero")
row.pack_size_in_base_units = payload.pack_size_in_base_units
if payload.is_active is not None and payload.is_active is False:
# Keep at least one active pack per variant to preserve usable receive/dispense UX.
active_count = db.query(VariantPack).filter(
VariantPack.drug_variant_id == row.drug_variant_id,
VariantPack.is_active.is_(True),
VariantPack.id != row.id,
).count()
if active_count == 0:
raise HTTPException(status_code=400, detail="At least one active pack must remain for this variant")
row.is_active = False
elif payload.is_active is not None and payload.is_active is True:
row.is_active = True
write_audit_log(
db,
action="variant_pack.update",
entity_type="variant_pack",
entity_id=pack_id,
actor=current_user,
details={"before": before, "after": payload.dict(exclude_unset=True)},
)
db.commit()
db.refresh(row)
return serialize_variant_pack(row)
# Dispensing endpoints
@router.post("/dispense", response_model=DispensingResponse)
def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)):
"""Record a drug dispensing and reduce inventory"""
if dispensing.quantity <= 0:
raise HTTPException(status_code=400, detail="Quantity must be greater than zero")
# Check if drug variant exists
variant = db.query(DrugVariant).filter(DrugVariant.id == dispensing.drug_variant_id).first()
if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found")
dispense_mode = (dispensing.dispense_mode or "subunit").strip().lower()
if dispense_mode not in {"subunit", "pack"}:
raise HTTPException(status_code=400, detail="dispense_mode must be either 'subunit' or 'pack'")
if dispense_mode == "pack":
if dispensing.requested_pack_id is None or dispensing.requested_pack_count is None:
raise HTTPException(status_code=400, detail="Pack dispense requires requested_pack_id and requested_pack_count")
if dispensing.requested_pack_count <= 0:
raise HTTPException(status_code=400, detail="Pack count must be greater than zero")
if abs(dispensing.requested_pack_count - round(dispensing.requested_pack_count)) > 1e-6:
raise HTTPException(status_code=400, detail="Whole-pack dispense requires an integer pack count")
resolved = resolve_pack_quantity(
db,
variant_id=variant.id,
quantity=None,
pack_id=dispensing.requested_pack_id,
pack_count=dispensing.requested_pack_count,
)
else:
if dispensing.quantity is None or dispensing.quantity <= 0:
raise HTTPException(status_code=400, detail="Subunit dispense requires quantity > 0")
resolved = resolve_pack_quantity(
db,
variant_id=variant.id,
quantity=dispensing.quantity,
pack_id=None,
pack_count=None,
)
dispense_qty = resolved["quantity"]
# Check if enough total quantity available from active stock (legacy and batch-based remain in sync).
if variant.quantity < dispensing.quantity:
if variant.quantity < dispense_qty:
raise HTTPException(
status_code=400,
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispensing.quantity}",
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispense_qty}",
)
allocations = select_batches_for_dispense(
db,
variant_id=variant.id,
requested_quantity=dispensing.quantity,
requested_quantity=dispense_qty,
preferred_batch_id=dispensing.batch_id,
allow_split=dispensing.allow_split,
)
@@ -867,7 +1200,10 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
drug_variant_id=dispensing.drug_variant_id,
batch_id=primary_batch_id,
actor_user_id=current_user.id,
quantity=dispensing.quantity,
quantity=dispense_qty,
dispense_mode=dispense_mode,
requested_pack_id=resolved["pack_id"],
requested_pack_count=resolved["pack_count"],
animal_name=dispensing.animal_name,
user_name=user_name,
notes=dispensing.notes,
@@ -880,11 +1216,12 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
batch = allocation["batch"]
qty = allocation["quantity"]
batch.quantity -= qty
recompute_batch_pack_state(batch)
allocation_payload.append({"batch_id": batch.id, "quantity": qty})
db.add(DispensingAllocation(dispensing_id=db_dispensing.id, batch_id=batch.id, quantity=qty))
# Keep legacy variant quantity field in sync for existing frontend flows.
variant.quantity -= dispensing.quantity
variant.quantity -= dispense_qty
write_audit_log(
db,
@@ -894,7 +1231,10 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
actor=current_user,
details={
"drug_variant_id": dispensing.drug_variant_id,
"requested_quantity": dispensing.quantity,
"requested_quantity": dispense_qty,
"dispense_mode": dispense_mode,
"requested_pack_id": resolved["pack_id"],
"requested_pack_count": resolved["pack_count"],
"allocations": allocation_payload,
"animal_name": dispensing.animal_name,
"notes": dispensing.notes,
@@ -909,6 +1249,9 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
"batch_id": db_dispensing.batch_id,
"actor_user_id": db_dispensing.actor_user_id,
"quantity": db_dispensing.quantity,
"dispense_mode": db_dispensing.dispense_mode,
"requested_pack_id": db_dispensing.requested_pack_id,
"requested_pack_count": db_dispensing.requested_pack_count,
"animal_name": db_dispensing.animal_name,
"user_name": db_dispensing.user_name,
"notes": db_dispensing.notes,
@@ -930,6 +1273,9 @@ def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_
"batch_id": item.batch_id,
"actor_user_id": item.actor_user_id,
"quantity": item.quantity,
"dispense_mode": item.dispense_mode,
"requested_pack_id": item.requested_pack_id,
"requested_pack_count": item.requested_pack_count,
"animal_name": item.animal_name,
"user_name": item.user_name,
"notes": item.notes,
@@ -961,6 +1307,9 @@ def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db), current_us
"batch_id": item.batch_id,
"actor_user_id": item.actor_user_id,
"quantity": item.quantity,
"dispense_mode": item.dispense_mode,
"requested_pack_id": item.requested_pack_id,
"requested_pack_count": item.requested_pack_count,
"animal_name": item.animal_name,
"user_name": item.user_name,
"notes": item.notes,
@@ -989,6 +1338,9 @@ def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db), curr
"batch_id": item.batch_id,
"actor_user_id": item.actor_user_id,
"quantity": item.quantity,
"dispense_mode": item.dispense_mode,
"requested_pack_id": item.requested_pack_id,
"requested_pack_count": item.requested_pack_count,
"animal_name": item.animal_name,
"user_name": item.user_name,
"notes": item.notes,
@@ -1090,8 +1442,15 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found")
if payload.quantity <= 0:
raise HTTPException(status_code=400, detail="Batch quantity must be greater than zero")
resolved = resolve_pack_quantity(
db,
variant_id=variant_id,
quantity=payload.quantity,
pack_id=payload.received_pack_id,
pack_count=payload.received_pack_count,
)
batch_quantity = resolved["quantity"]
location = db.query(Location).filter(Location.id == payload.location_id, Location.is_active.is_(True)).first()
if not location:
@@ -1112,14 +1471,18 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
row = Batch(
drug_variant_id=variant_id,
batch_number=batch_number,
quantity=payload.quantity,
quantity=batch_quantity,
received_pack_id=resolved["pack_id"],
received_pack_count=resolved["pack_count"],
received_pack_size_snapshot=resolve_pack_size_snapshot(db, resolved["pack_id"]),
expiry_date=payload.expiry_date,
location_id=payload.location_id,
notes=payload.notes,
)
recompute_batch_pack_state(row)
db.add(row)
variant.quantity += payload.quantity
variant.quantity += batch_quantity
write_audit_log(
db,
@@ -1130,7 +1493,10 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
details={
"variant_id": variant_id,
"batch_number": batch_number,
"quantity": payload.quantity,
"quantity": batch_quantity,
"received_pack_id": resolved["pack_id"],
"received_pack_count": resolved["pack_count"],
"received_pack_size_snapshot": row.received_pack_size_snapshot,
"expiry_date": payload.expiry_date,
"location_id": payload.location_id,
},
@@ -1153,6 +1519,11 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
before = {
"batch_number": batch.batch_number,
"quantity": batch.quantity,
"received_pack_id": batch.received_pack_id,
"received_pack_count": batch.received_pack_count,
"received_pack_size_snapshot": batch.received_pack_size_snapshot,
"current_full_pack_count": batch.current_full_pack_count,
"current_loose_base_units": batch.current_loose_base_units,
"expiry_date": batch.expiry_date,
"location_id": batch.location_id,
"notes": batch.notes,
@@ -1187,14 +1558,42 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
if payload.notes is not None:
batch.notes = payload.notes
if payload.quantity is not None:
if payload.quantity < 0:
if payload.received_pack_id is not None or payload.received_pack_count is not None or payload.quantity is not None:
if payload.quantity is not None and payload.quantity < 0:
raise HTTPException(status_code=400, detail="Batch quantity cannot be negative")
delta = payload.quantity - batch.quantity
if payload.received_pack_id is None and payload.received_pack_count is None:
if payload.quantity is None:
raise HTTPException(status_code=400, detail="Batch quantity cannot be empty")
resolved_quantity = payload.quantity
resolved_pack_id = batch.received_pack_id
resolved_pack_count = batch.received_pack_count
else:
target_pack_id = payload.received_pack_id if payload.received_pack_id is not None else batch.received_pack_id
target_pack_count = payload.received_pack_count if payload.received_pack_count is not None else batch.received_pack_count
if target_pack_id is None or target_pack_count is None:
raise HTTPException(status_code=400, detail="Both pack_id and pack_count are required for pack-based updates")
resolved = resolve_pack_quantity(
db,
variant_id=batch.drug_variant_id,
quantity=payload.quantity,
pack_id=target_pack_id,
pack_count=target_pack_count,
)
resolved_quantity = resolved["quantity"]
resolved_pack_id = resolved["pack_id"]
resolved_pack_count = resolved["pack_count"]
delta = resolved_quantity - batch.quantity
projected_variant_qty = variant.quantity + delta
if projected_variant_qty < 0:
raise HTTPException(status_code=400, detail="Variant quantity cannot become negative")
batch.quantity = payload.quantity
batch.quantity = resolved_quantity
batch.received_pack_id = resolved_pack_id
batch.received_pack_count = resolved_pack_count
batch.received_pack_size_snapshot = resolve_pack_size_snapshot(db, resolved_pack_id)
recompute_batch_pack_state(batch)
variant.quantity = projected_variant_qty
write_audit_log(
@@ -1276,6 +1675,9 @@ def report_controlled_movement(
"drug_name": drug.name,
"strength": variant.strength,
"quantity": d.quantity,
"dispense_mode": d.dispense_mode,
"requested_pack_id": d.requested_pack_id,
"requested_pack_count": d.requested_pack_count,
"user_name": d.user_name,
"animal_name": d.animal_name,
"batch_id": d.batch_id,