diff --git a/backend/app/main.py b/backend/app/main.py index 574329a..9cef6a9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, diff --git a/backend/app/migrate_compliance.py b/backend/app/migrate_compliance.py index 4c85467..4eae2b3 100644 --- a/backend/app/migrate_compliance.py +++ b/backend/app/migrate_compliance.py @@ -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)") diff --git a/backend/app/models.py b/backend/app/models.py index 5c37fb5..441495f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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) diff --git a/frontend/app.js b/frontend/app.js index 9c86a0c..6f458df 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -7,6 +7,10 @@ let searchTerm = ''; let expandedDrugs = new Set(); let currentUser = null; let accessToken = null; +let deliveryDrugId = null; +let deliveryLineCounter = 0; +let deliveryLocations = []; +let activeVariantPacksVariantId = null; // Toast notification system function showToast(message, type = 'info', duration = 3000) { @@ -212,6 +216,7 @@ function setupEventListeners() { const editModal = document.getElementById('editModal'); const printNotesModal = document.getElementById('printNotesModal'); const batchReceiveModal = document.getElementById('batchReceiveModal'); + const receiveDeliveryModal = document.getElementById('receiveDeliveryModal'); const addDrugBtn = document.getElementById('addDrugBtn'); const dispenseBtn = document.getElementById('dispenseBtn'); const printNotesBtn = document.getElementById('printNotesBtn'); @@ -222,6 +227,15 @@ function setupEventListeners() { const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn'); const cancelEditBtn = document.getElementById('cancelEditBtn'); const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn'); + const cancelReceiveDeliveryBtn = document.getElementById('cancelReceiveDeliveryBtn'); + const addDeliveryLineBtn = document.getElementById('addDeliveryLineBtn'); + const addVariantFromDeliveryBtn = document.getElementById('addVariantFromDeliveryBtn'); + const addVariantPackRowBtn = document.getElementById('addVariantPackRowBtn'); + const variantUnitSelect = document.getElementById('variantUnit'); + const variantStrengthInput = document.getElementById('variantStrength'); + const dispenseModeSelect = document.getElementById('dispenseMode'); + const variantPacksForm = document.getElementById('variantPacksForm'); + const closeVariantPacksBtn = document.getElementById('closeVariantPacksBtn'); const showAllBtn = document.getElementById('showAllBtn'); const showLowStockBtn = document.getElementById('showLowStockBtn'); const locationFilterSelect = document.getElementById('locationFilterSelect'); @@ -246,11 +260,31 @@ function setupEventListeners() { const batchReceiveForm = document.getElementById('batchReceiveForm'); if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive); if (cancelBatchReceiveBtn) cancelBatchReceiveBtn.addEventListener('click', () => closeModal(batchReceiveModal)); + + const receiveDeliveryForm = document.getElementById('receiveDeliveryForm'); + if (receiveDeliveryForm) receiveDeliveryForm.addEventListener('submit', handleReceiveDelivery); + if (cancelReceiveDeliveryBtn) cancelReceiveDeliveryBtn.addEventListener('click', () => closeModal(receiveDeliveryModal)); + if (addDeliveryLineBtn) addDeliveryLineBtn.addEventListener('click', () => appendDeliveryLine()); + if (addVariantFromDeliveryBtn) addVariantFromDeliveryBtn.addEventListener('click', handleAddVariantFromDelivery); + if (addVariantPackRowBtn) addVariantPackRowBtn.addEventListener('click', () => appendVariantPackRow()); + if (variantUnitSelect) { + variantUnitSelect.addEventListener('change', () => { + refreshVariantPackRowLabels(); + }); + } + if (variantStrengthInput && variantUnitSelect) { + variantStrengthInput.addEventListener('blur', () => { + variantUnitSelect.value = inferBaseUnitFromStrength(variantStrengthInput.value); + refreshVariantPackRowLabels(); + }); + } + if (dispenseModeSelect) dispenseModeSelect.addEventListener('change', updateDispenseModeUi); if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal)); if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal)); if (dispenseBtn) dispenseBtn.addEventListener('click', () => { updateDispenseDrugSelect(); + updateDispenseModeUi(); openModal(dispenseModal); }); @@ -272,6 +306,9 @@ function setupEventListeners() { const closeLocationManagementBtn = document.getElementById('closeLocationManagementBtn'); if (closeLocationManagementBtn) closeLocationManagementBtn.addEventListener('click', () => closeModal(document.getElementById('locationManagementModal'))); + + if (variantPacksForm) variantPacksForm.addEventListener('submit', handleCreateVariantPack); + if (closeVariantPacksBtn) closeVariantPacksBtn.addEventListener('click', () => closeModal(document.getElementById('variantPacksModal'))); const createLocationForm = document.getElementById('createLocationForm'); if (createLocationForm) createLocationForm.addEventListener('submit', createLocation); @@ -333,6 +370,28 @@ function setupEventListeners() { }); } + const dispenseQuantityInput = document.getElementById('dispenseQuantity'); + if (dispenseQuantityInput) { + dispenseQuantityInput.addEventListener('input', () => { + const mode = document.getElementById('dispenseMode')?.value || 'subunit'; + if (mode !== 'subunit') { + return; + } + + const packSelect = document.getElementById('dispensePackSelect'); + const packCount = document.getElementById('dispensePackCount'); + const packPreview = document.getElementById('dispensePackPreview'); + const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10); + const variant = getVariantById(variantId); + + if (packSelect) packSelect.value = ''; + if (packCount) packCount.value = ''; + if (packPreview && variant) { + packPreview.textContent = `Enter direct quantity in ${variant.unit}.`; + } + }); + } + // Close modal when clicking outside window.addEventListener('click', (e) => { if (e.target.classList.contains('modal')) { @@ -398,6 +457,108 @@ function updateDispenseDrugSelect() { select.appendChild(option); }); }); + + const packSelect = document.getElementById('dispensePackSelect'); + const packCount = document.getElementById('dispensePackCount'); + const packPreview = document.getElementById('dispensePackPreview'); + const modeSelect = document.getElementById('dispenseMode'); + if (packSelect) { + packSelect.innerHTML = ''; + } + if (packCount) { + packCount.value = ''; + } + if (modeSelect) { + modeSelect.value = 'subunit'; + } + if (packPreview) { + packPreview.textContent = 'Select a pack and whole-number count.'; + } + + updateDispenseModeUi(); +} + +function populateDispensePackSelect(variant) { + const packSelect = document.getElementById('dispensePackSelect'); + const packCount = document.getElementById('dispensePackCount'); + const packPreview = document.getElementById('dispensePackPreview'); + if (!packSelect) return; + + const activePacks = getActivePacksForVariant(variant); + packSelect.innerHTML = ''; + + activePacks.forEach(pack => { + const option = document.createElement('option'); + option.value = String(pack.id); + option.textContent = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`; + packSelect.appendChild(option); + }); + + if (packCount) packCount.value = ''; + if (packPreview) { + packPreview.textContent = activePacks.length > 0 + ? `Select a pack and whole-number count (${variant.unit} base unit).` + : `No active packs for this variant.`; + } +} + +function updateDispenseModeUi() { + const mode = document.getElementById('dispenseMode')?.value || 'subunit'; + const quantityGroup = document.getElementById('dispenseQuantityGroup'); + const packRow = document.getElementById('dispensePackRow'); + const quantityInput = document.getElementById('dispenseQuantity'); + const packSelect = document.getElementById('dispensePackSelect'); + const packCount = document.getElementById('dispensePackCount'); + + if (quantityGroup) { + quantityGroup.style.display = mode === 'subunit' ? '' : 'none'; + } + if (packRow) { + packRow.style.display = mode === 'pack' ? '' : 'none'; + } + + if (quantityInput) { + quantityInput.required = mode === 'subunit'; + } + if (packSelect) { + packSelect.required = mode === 'pack'; + } + if (packCount) { + packCount.required = mode === 'pack'; + } + + updateAllocationPreview(); +} + +function updateDispenseQuantityFromPack() { + const mode = document.getElementById('dispenseMode')?.value || 'subunit'; + if (mode !== 'pack') return; + + const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10); + const packId = parseInt(document.getElementById('dispensePackSelect')?.value || '', 10); + const packCount = parseFloat(document.getElementById('dispensePackCount')?.value || ''); + const quantityInput = document.getElementById('dispenseQuantity'); + const preview = document.getElementById('dispensePackPreview'); + + const variant = getVariantById(variantId); + if (!quantityInput || !preview || !variant) return; + + const selectedPack = getActivePacksForVariant(variant).find(pack => pack.id === packId); + if (selectedPack && !Number.isNaN(packCount) && packCount > 0) { + if (Math.abs(packCount - Math.round(packCount)) > 1e-6) { + preview.textContent = 'Whole-pack mode requires a whole-number pack count.'; + return; + } + const quantity = packCount * selectedPack.pack_size_in_base_units; + quantityInput.value = String(quantity); + preview.textContent = `${packCount} × ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit}`; + updateAllocationPreview(); + return; + } + + preview.textContent = selectedPack + ? `1 ${selectedPack.pack_unit_name} = ${selectedPack.pack_size_in_base_units} ${variant.unit}` + : `Select a pack to calculate quantity.`; } function formatDisplayDate(value) { @@ -455,6 +616,8 @@ function updateLocationFilterOptions() { function populateDispenseBatchSelect(activeBatches) { const batchSelect = document.getElementById('dispenseBatchSelect'); + const selectedVariantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10); + const unitLabel = getVariantById(selectedVariantId)?.unit || 'units'; const previousValue = batchSelect.value; batchSelect.innerHTML = ''; @@ -465,7 +628,7 @@ function populateDispenseBatchSelect(activeBatches) { const locationLabel = getBatchLocationLabel(batch); const fefoLabel = index === 0 ? ' [FEFO default]' : ''; option.value = batch.id; - option.textContent = `${batch.batch_number} | ${batch.quantity} units | ${locationLabel} | Expires ${expiryLabel}${fefoLabel}`; + option.textContent = `${batch.batch_number} | ${batch.quantity} ${unitLabel} | ${locationLabel} | Expires ${expiryLabel}${fefoLabel}`; batchSelect.appendChild(option); }); @@ -484,8 +647,16 @@ async function updateBatchInfo() { if (!variantId) { batchInfoSection.style.display = 'none'; batchSelect.innerHTML = ''; + const packSelect = document.getElementById('dispensePackSelect'); + if (packSelect) packSelect.innerHTML = ''; return; } + + const variant = getVariantById(variantId); + if (variant) { + populateDispensePackSelect(variant); + } + updateDispenseModeUi(); batchInfoSection.style.display = 'block'; batchInfoContent.innerHTML = '
Loading batches...
'; @@ -558,6 +729,7 @@ async function updateBatchInfo() { // Update allocation preview based on quantity and allow_split flag async function updateAllocationPreview() { const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); + const unitLabel = getVariantById(variantId)?.unit || 'units'; const quantity = parseFloat(document.getElementById('dispenseQuantity').value); const allowSplit = document.getElementById('dispenseAllowSplit').checked; const preferredBatchId = parseInt(document.getElementById('dispenseBatchSelect').value); @@ -626,10 +798,10 @@ async function updateAllocationPreview() { if (remainingQty > 0 && allowSplit) { allocationPreviewContent.innerHTML = ` -✕ Warning: Only ${quantity - remainingQty} units available across all batches (${remainingQty} short)
+✕ Warning: Only ${quantity - remainingQty} ${escapeHtml(unitLabel)} available across all batches (${remainingQty} short)
Loading packs...
'; + + try { + const response = await apiCall(`/variants/${variantId}/packs`); + if (!response.ok) throw new Error('Failed to load pack presentations'); + const packs = await response.json(); + + if (!Array.isArray(packs) || packs.length === 0) { + list.innerHTML = 'No pack presentations defined.
'; + return; + } + + list.innerHTML = ` +Failed to load pack presentations
'; + } +} + +async function handleCreateVariantPack(e) { + e.preventDefault(); + + const variantId = parseInt(document.getElementById('variantPacksVariantId').value, 10); + const label = document.getElementById('variantPacksNewLabel').value.trim(); + const packUnitName = document.getElementById('variantPacksNewUnit').value.trim(); + const size = parseFloat(document.getElementById('variantPacksNewSize').value); + + if (!variantId || !label || !packUnitName || Number.isNaN(size) || size <= 0) { + showToast('Please complete all pack fields', 'warning'); + return; + } + + try { + const response = await apiCall(`/variants/${variantId}/packs`, { + method: 'POST', + body: JSON.stringify({ + label, + pack_unit_name: packUnitName, + pack_size_in_base_units: size, + is_active: true + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to add pack presentation'); + } + + document.getElementById('variantPacksForm').reset(); + const variant = getVariantById(variantId); + document.getElementById('variantPacksNewUnit').value = inferPackUnitName(variant?.unit || 'pack'); + await loadDrugs(); + await refreshVariantPacksList(); + if (document.getElementById('receiveDeliveryModal')?.classList.contains('show')) { + refreshDeliveryVariantSelects(); + } + showToast('Pack presentation added', 'success'); + } catch (error) { + console.error('Error creating pack presentation:', error); + showToast('Failed to add pack presentation: ' + error.message, 'error'); + } +} + +async function toggleVariantPackActive(packId, nextActiveState) { + try { + const response = await apiCall(`/variant-packs/${packId}`, { + method: 'PUT', + body: JSON.stringify({ is_active: Boolean(nextActiveState) }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to update pack state'); + } + + await loadDrugs(); + await refreshVariantPacksList(); + if (document.getElementById('receiveDeliveryModal')?.classList.contains('show')) { + refreshDeliveryVariantSelects(); + } + showToast('Pack updated', 'success'); + } catch (error) { + console.error('Error updating pack state:', error); + showToast('Failed to update pack: ' + error.message, 'error'); + } +} + // Dispense from variant function dispenseVariant(variantId) { // Update the dropdown display with all variants @@ -1751,3 +2256,306 @@ async function handleBatchReceive(e) { showToast('Failed to receive batch: ' + error.message, 'error'); } } + +function getActiveDeliveryDrug() { + return allDrugs.find(d => d.id === deliveryDrugId); +} + +function getVariantById(variantId) { + for (const drug of allDrugs) { + const found = (drug.variants || []).find(v => v.id === variantId); + if (found) return found; + } + return null; +} + +function buildDeliveryVariantOptions(drug, selectedVariantId = '') { + if (!drug || !drug.variants || drug.variants.length === 0) { + return ''; + } + + return [``, ...drug.variants.map(v => { + const selected = String(v.id) === String(selectedVariantId) ? ' selected' : ''; + return ``; + })].join(''); +} + +function getActivePacksForVariant(variant) { + if (!variant || !Array.isArray(variant.packs)) return []; + return variant.packs.filter(pack => pack.is_active); +} + +function buildDeliveryPackOptions(variant, selectedPackId = '') { + const packs = getActivePacksForVariant(variant); + if (packs.length === 0) { + return ''; + } + + return [``, ...packs.map(pack => { + const selected = String(pack.id) === String(selectedPackId) ? ' selected' : ''; + const label = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`; + return ``; + })].join(''); +} + +function buildDeliveryLocationOptions(selectedLocationId = '') { + return [``, ...deliveryLocations.map(location => { + const selected = String(location.id) === String(selectedLocationId) ? ' selected' : ''; + return ``; + })].join(''); +} + +function updateDeliveryLineQuantityDisplay(line) { + const variantId = parseInt(line.querySelector('.delivery-variant-select')?.value || '', 10); + const packSelect = line.querySelector('.delivery-pack-select'); + + const variant = getVariantById(variantId); + if (!variant || !packSelect) { + return; + } + + const currentPackId = packSelect.value; + packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId); +} + +function wireDeliveryLineEvents(line) { + const variantSelect = line.querySelector('.delivery-variant-select'); + const packSelect = line.querySelector('.delivery-pack-select'); + const packCountInput = line.querySelector('.delivery-pack-count'); + + if (variantSelect && packSelect) { + variantSelect.addEventListener('change', () => { + const variantId = parseInt(variantSelect.value || '', 10); + const variant = getVariantById(variantId); + packSelect.innerHTML = buildDeliveryPackOptions(variant, ''); + if (packCountInput) packCountInput.value = ''; + updateDeliveryLineQuantityDisplay(line); + }); + } + + if (packSelect) { + packSelect.addEventListener('change', () => { + updateDeliveryLineQuantityDisplay(line); + }); + } + + if (packCountInput) { + packCountInput.addEventListener('input', () => { + updateDeliveryLineQuantityDisplay(line); + }); + } +} + +function appendDeliveryLine(prefill = {}) { + const container = document.getElementById('deliveryLinesContainer'); + const drug = getActiveDeliveryDrug(); + if (!container || !drug) return; + + deliveryLineCounter += 1; + const lineId = `delivery-line-${deliveryLineCounter}`; + + const line = document.createElement('div'); + line.className = 'delivery-line'; + line.dataset.lineId = lineId; + + const initialVariant = drug.variants.find(v => String(v.id) === String(prefill.variantId)) || drug.variants[0] || null; + const initialVariantId = prefill.variantId || (initialVariant ? initialVariant.id : ''); + const initialPackId = prefill.packId || ''; + const initialPackCount = prefill.packCount || ''; + + line.innerHTML = ` +