WIP
This commit is contained in:
+426
-24
@@ -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,
|
||||
|
||||
@@ -56,6 +56,25 @@ def migrate_compliance_schema() -> None:
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
if _table_exists(cursor, "drug_variants") and not _table_exists(cursor, "variant_packs"):
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE variant_packs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
drug_variant_id INTEGER NOT NULL,
|
||||
label VARCHAR NOT NULL,
|
||||
pack_unit_name VARCHAR NOT NULL DEFAULT 'pack',
|
||||
pack_size_in_base_units FLOAT NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(drug_variant_id) REFERENCES drug_variants(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS ix_variant_packs_drug_variant_id ON variant_packs(drug_variant_id)")
|
||||
print("Created variant_packs table")
|
||||
|
||||
if _table_exists(cursor, "drugs") and not _column_exists(cursor, "drugs", "is_controlled"):
|
||||
cursor.execute("ALTER TABLE drugs ADD COLUMN is_controlled BOOLEAN NOT NULL DEFAULT 0")
|
||||
print("Added drugs.is_controlled")
|
||||
@@ -68,6 +87,133 @@ def migrate_compliance_schema() -> None:
|
||||
cursor.execute("ALTER TABLE dispensings ADD COLUMN actor_user_id INTEGER")
|
||||
print("Added dispensings.actor_user_id")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "received_pack_id"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN received_pack_id INTEGER")
|
||||
print("Added batches.received_pack_id")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "received_pack_count"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN received_pack_count FLOAT")
|
||||
print("Added batches.received_pack_count")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "received_pack_size_snapshot"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN received_pack_size_snapshot FLOAT")
|
||||
print("Added batches.received_pack_size_snapshot")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "current_full_pack_count"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN current_full_pack_count FLOAT")
|
||||
print("Added batches.current_full_pack_count")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "current_loose_base_units"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN current_loose_base_units FLOAT")
|
||||
print("Added batches.current_loose_base_units")
|
||||
|
||||
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "requested_pack_id"):
|
||||
cursor.execute("ALTER TABLE dispensings ADD COLUMN requested_pack_id INTEGER")
|
||||
print("Added dispensings.requested_pack_id")
|
||||
|
||||
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "requested_pack_count"):
|
||||
cursor.execute("ALTER TABLE dispensings ADD COLUMN requested_pack_count FLOAT")
|
||||
print("Added dispensings.requested_pack_count")
|
||||
|
||||
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "dispense_mode"):
|
||||
cursor.execute("ALTER TABLE dispensings ADD COLUMN dispense_mode VARCHAR NOT NULL DEFAULT 'subunit'")
|
||||
print("Added dispensings.dispense_mode")
|
||||
|
||||
if _table_exists(cursor, "variant_packs") and _table_exists(cursor, "drug_variants"):
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO variant_packs (drug_variant_id, label, pack_unit_name, pack_size_in_base_units, is_active)
|
||||
SELECT v.id,
|
||||
'1 ' || COALESCE(NULLIF(v.unit, ''), 'unit'),
|
||||
COALESCE(NULLIF(v.unit, ''), 'unit'),
|
||||
1,
|
||||
1
|
||||
FROM drug_variants v
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM variant_packs p
|
||||
WHERE p.drug_variant_id = v.id
|
||||
)
|
||||
"""
|
||||
)
|
||||
print("Ensured default pack rows for variants")
|
||||
|
||||
if _table_exists(cursor, "batches") and _table_exists(cursor, "variant_packs"):
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE batches
|
||||
SET received_pack_id = (
|
||||
SELECT p.id
|
||||
FROM variant_packs p
|
||||
WHERE p.drug_variant_id = batches.drug_variant_id
|
||||
ORDER BY p.id ASC
|
||||
LIMIT 1
|
||||
),
|
||||
received_pack_count = quantity
|
||||
WHERE received_pack_id IS NULL
|
||||
"""
|
||||
)
|
||||
print("Backfilled batches pack context where missing")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE batches
|
||||
SET received_pack_size_snapshot = (
|
||||
SELECT p.pack_size_in_base_units
|
||||
FROM variant_packs p
|
||||
WHERE p.id = batches.received_pack_id
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE received_pack_id IS NOT NULL
|
||||
AND (received_pack_size_snapshot IS NULL OR received_pack_size_snapshot <= 0)
|
||||
"""
|
||||
)
|
||||
print("Backfilled batches pack size snapshot where missing")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE batches
|
||||
SET current_full_pack_count = CASE
|
||||
WHEN COALESCE(received_pack_size_snapshot, 0) > 0 THEN CAST(quantity / received_pack_size_snapshot AS INTEGER)
|
||||
ELSE NULL
|
||||
END,
|
||||
current_loose_base_units = CASE
|
||||
WHEN COALESCE(received_pack_size_snapshot, 0) > 0 THEN quantity - (CAST(quantity / received_pack_size_snapshot AS INTEGER) * received_pack_size_snapshot)
|
||||
ELSE NULL
|
||||
END
|
||||
"""
|
||||
)
|
||||
print("Backfilled batches live pack state")
|
||||
|
||||
if _table_exists(cursor, "dispensings") and _table_exists(cursor, "variant_packs"):
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE dispensings
|
||||
SET requested_pack_id = (
|
||||
SELECT p.id
|
||||
FROM variant_packs p
|
||||
WHERE p.drug_variant_id = dispensings.drug_variant_id
|
||||
ORDER BY p.id ASC
|
||||
LIMIT 1
|
||||
),
|
||||
requested_pack_count = quantity
|
||||
WHERE requested_pack_id IS NULL
|
||||
"""
|
||||
)
|
||||
print("Backfilled dispensing pack context where missing")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE dispensings
|
||||
SET dispense_mode = CASE
|
||||
WHEN requested_pack_id IS NOT NULL AND requested_pack_count IS NOT NULL THEN 'pack'
|
||||
ELSE 'subunit'
|
||||
END
|
||||
WHERE dispense_mode IS NULL OR TRIM(dispense_mode) = ''
|
||||
"""
|
||||
)
|
||||
print("Backfilled dispensing mode where missing")
|
||||
|
||||
# Seed default locations once table exists (created via SQLAlchemy create_all).
|
||||
if _table_exists(cursor, "locations"):
|
||||
cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Cupboard', 1)")
|
||||
|
||||
@@ -36,6 +36,19 @@ class DrugVariant(Base):
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
|
||||
class VariantPack(Base):
|
||||
__tablename__ = "variant_packs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
|
||||
label = Column(String, nullable=False)
|
||||
pack_unit_name = Column(String, nullable=False, default="pack")
|
||||
pack_size_in_base_units = Column(Float, nullable=False, default=1)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
|
||||
class Dispensing(Base):
|
||||
__tablename__ = "dispensings"
|
||||
|
||||
@@ -44,6 +57,9 @@ class Dispensing(Base):
|
||||
batch_id = Column(Integer, ForeignKey("batches.id"), nullable=True)
|
||||
actor_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
quantity = Column(Float, nullable=False)
|
||||
dispense_mode = Column(String, nullable=False, default="subunit")
|
||||
requested_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=True)
|
||||
requested_pack_count = Column(Float, nullable=True)
|
||||
animal_name = Column(String, nullable=True) # Name/ID of the animal (optional)
|
||||
user_name = Column(String, nullable=False) # User who dispensed
|
||||
dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
@@ -67,6 +83,11 @@ class Batch(Base):
|
||||
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
|
||||
batch_number = Column(String, nullable=False, index=True)
|
||||
quantity = Column(Float, nullable=False, default=0)
|
||||
received_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=True)
|
||||
received_pack_count = Column(Float, nullable=True)
|
||||
received_pack_size_snapshot = Column(Float, nullable=True)
|
||||
current_full_pack_count = Column(Float, nullable=True)
|
||||
current_loose_base_units = Column(Float, nullable=True)
|
||||
expiry_date = Column(Date, nullable=False, index=True)
|
||||
location_id = Column(Integer, ForeignKey("locations.id"), nullable=False, index=True)
|
||||
received_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
||||
|
||||
Reference in New Issue
Block a user