This commit is contained in:
2026-03-29 10:39:03 -04:00
parent e00669ae2c
commit ad1bb59f98
6 changed files with 1615 additions and 51 deletions
+426 -24
View File
@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
import math
import json import json
import csv import csv
import io import io
@@ -10,6 +11,7 @@ from .database import engine, get_db, Base
from .models import ( from .models import (
Drug, Drug,
DrugVariant, DrugVariant,
VariantPack,
Dispensing, Dispensing,
DispensingAllocation, DispensingAllocation,
Location, Location,
@@ -114,7 +116,9 @@ class LocationResponse(BaseModel):
class BatchCreate(BaseModel): class BatchCreate(BaseModel):
batch_number: str batch_number: str
quantity: float quantity: Optional[float] = None
received_pack_id: Optional[int] = None
received_pack_count: Optional[float] = None
expiry_date: date expiry_date: date
location_id: int location_id: int
notes: Optional[str] = None notes: Optional[str] = None
@@ -123,6 +127,8 @@ class BatchCreate(BaseModel):
class BatchUpdate(BaseModel): class BatchUpdate(BaseModel):
batch_number: Optional[str] = None batch_number: Optional[str] = None
quantity: Optional[float] = None quantity: Optional[float] = None
received_pack_id: Optional[int] = None
received_pack_count: Optional[float] = None
expiry_date: Optional[date] = None expiry_date: Optional[date] = None
location_id: Optional[int] = None location_id: Optional[int] = None
notes: Optional[str] = None notes: Optional[str] = None
@@ -133,6 +139,12 @@ class BatchResponse(BaseModel):
drug_variant_id: int drug_variant_id: int
batch_number: str batch_number: str
quantity: float 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 expiry_date: date
location_id: int location_id: int
location_name: Optional[str] = None location_name: Optional[str] = None
@@ -155,21 +167,51 @@ class DrugVariantCreate(BaseModel):
strength: str strength: str
quantity: float quantity: float
unit: str = "units" unit: str = "units"
base_unit: Optional[str] = None
low_stock_threshold: float = 10 low_stock_threshold: float = 10
class DrugVariantUpdate(BaseModel): class DrugVariantUpdate(BaseModel):
strength: str = None strength: str = None
quantity: float = None quantity: float = None
unit: str = None unit: str = None
base_unit: str = None
low_stock_threshold: float = 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): class DrugVariantResponse(BaseModel):
id: int id: int
drug_id: int drug_id: int
strength: str strength: str
quantity: float quantity: float
unit: str unit: str
base_unit: str
low_stock_threshold: float low_stock_threshold: float
packs: List[VariantPackResponse] = []
batches: List[BatchResponse] = [] batches: List[BatchResponse] = []
class Config: class Config:
@@ -187,8 +229,11 @@ class DrugWithVariantsResponse(BaseModel):
class DispensingCreate(BaseModel): class DispensingCreate(BaseModel):
drug_variant_id: int drug_variant_id: int
quantity: float quantity: Optional[float] = None
dispense_mode: str = "subunit"
batch_id: Optional[int] = None batch_id: Optional[int] = None
requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None
allow_split: bool = True allow_split: bool = True
animal_name: Optional[str] = None animal_name: Optional[str] = None
user_name: Optional[str] = None user_name: Optional[str] = None
@@ -208,6 +253,9 @@ class DispensingResponse(BaseModel):
batch_id: Optional[int] = None batch_id: Optional[int] = None
actor_user_id: Optional[int] = None actor_user_id: Optional[int] = None
quantity: float quantity: float
dispense_mode: str = "subunit"
requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None
animal_name: Optional[str] = None animal_name: Optional[str] = None
user_name: str user_name: str
notes: Optional[str] = None notes: Optional[str] = None
@@ -276,8 +324,16 @@ def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str,
"strength": variant.strength, "strength": variant.strength,
"quantity": variant.quantity, "quantity": variant.quantity,
"unit": variant.unit, "unit": variant.unit,
"base_unit": variant.unit,
"low_stock_threshold": variant.low_stock_threshold, "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 = ( batches = (
db.query(Batch) db.query(Batch)
.filter(Batch.drug_variant_id == variant.id, Batch.quantity > 0) .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]: def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
location = db.query(Location).filter(Location.id == batch.location_id).first() 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 { return {
"id": batch.id, "id": batch.id,
"drug_variant_id": batch.drug_variant_id, "drug_variant_id": batch.drug_variant_id,
"batch_number": batch.batch_number, "batch_number": batch.batch_number,
"quantity": batch.quantity, "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, "expiry_date": batch.expiry_date,
"location_id": batch.location_id, "location_id": batch.location_id,
"location_name": location.name if location else None, "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( def select_batches_for_dispense(
db: Session, db: Session,
variant_id: int, 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(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(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(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) db.query(DrugVariant).filter(DrugVariant.id.in_(variant_ids)).delete(synchronize_session=False)
write_audit_log( write_audit_log(
@@ -745,14 +901,31 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
if existing: if existing:
raise HTTPException(status_code=400, detail="Variant with this strength already exists for this drug") 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( db_variant = DrugVariant(
drug_id=drug_id, drug_id=drug_id,
strength=variant.strength, strength=variant.strength,
quantity=variant.quantity, quantity=variant.quantity,
unit=variant.unit, unit=base_unit,
low_stock_threshold=variant.low_stock_threshold low_stock_threshold=variant.low_stock_threshold
) )
db.add(db_variant) 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( write_audit_log(
db, db,
action="variant.create", action="variant.create",
@@ -763,7 +936,7 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
"drug_id": drug_id, "drug_id": drug_id,
"strength": variant.strength, "strength": variant.strength,
"quantity": variant.quantity, "quantity": variant.quantity,
"unit": variant.unit, "unit": base_unit,
}, },
) )
db.commit() db.commit()
@@ -791,7 +964,15 @@ def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db:
"unit": variant.unit, "unit": variant.unit,
"low_stock_threshold": variant.low_stock_threshold, "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) setattr(variant, field, value)
write_audit_log( write_audit_log(
@@ -800,7 +981,7 @@ def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db:
entity_type="drug_variant", entity_type="drug_variant",
entity_id=variant.id, entity_id=variant.id,
actor=current_user, actor=current_user,
details={"before": before, "after": variant_update.dict(exclude_unset=True)}, details={"before": before, "after": payload},
) )
db.commit() 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(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(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( write_audit_log(
db, db,
action="variant.delete", 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"} 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 # Dispensing endpoints
@router.post("/dispense", response_model=DispensingResponse) @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)): 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""" """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 # Check if drug variant exists
variant = db.query(DrugVariant).filter(DrugVariant.id == dispensing.drug_variant_id).first() variant = db.query(DrugVariant).filter(DrugVariant.id == dispensing.drug_variant_id).first()
if not variant: if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found") 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). # 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( raise HTTPException(
status_code=400, 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( allocations = select_batches_for_dispense(
db, db,
variant_id=variant.id, variant_id=variant.id,
requested_quantity=dispensing.quantity, requested_quantity=dispense_qty,
preferred_batch_id=dispensing.batch_id, preferred_batch_id=dispensing.batch_id,
allow_split=dispensing.allow_split, 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, drug_variant_id=dispensing.drug_variant_id,
batch_id=primary_batch_id, batch_id=primary_batch_id,
actor_user_id=current_user.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, animal_name=dispensing.animal_name,
user_name=user_name, user_name=user_name,
notes=dispensing.notes, notes=dispensing.notes,
@@ -880,11 +1216,12 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
batch = allocation["batch"] batch = allocation["batch"]
qty = allocation["quantity"] qty = allocation["quantity"]
batch.quantity -= qty batch.quantity -= qty
recompute_batch_pack_state(batch)
allocation_payload.append({"batch_id": batch.id, "quantity": qty}) allocation_payload.append({"batch_id": batch.id, "quantity": qty})
db.add(DispensingAllocation(dispensing_id=db_dispensing.id, 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. # Keep legacy variant quantity field in sync for existing frontend flows.
variant.quantity -= dispensing.quantity variant.quantity -= dispense_qty
write_audit_log( write_audit_log(
db, db,
@@ -894,7 +1231,10 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
actor=current_user, actor=current_user,
details={ details={
"drug_variant_id": dispensing.drug_variant_id, "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, "allocations": allocation_payload,
"animal_name": dispensing.animal_name, "animal_name": dispensing.animal_name,
"notes": dispensing.notes, "notes": dispensing.notes,
@@ -909,6 +1249,9 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
"batch_id": db_dispensing.batch_id, "batch_id": db_dispensing.batch_id,
"actor_user_id": db_dispensing.actor_user_id, "actor_user_id": db_dispensing.actor_user_id,
"quantity": db_dispensing.quantity, "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, "animal_name": db_dispensing.animal_name,
"user_name": db_dispensing.user_name, "user_name": db_dispensing.user_name,
"notes": db_dispensing.notes, "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, "batch_id": item.batch_id,
"actor_user_id": item.actor_user_id, "actor_user_id": item.actor_user_id,
"quantity": item.quantity, "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, "animal_name": item.animal_name,
"user_name": item.user_name, "user_name": item.user_name,
"notes": item.notes, "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, "batch_id": item.batch_id,
"actor_user_id": item.actor_user_id, "actor_user_id": item.actor_user_id,
"quantity": item.quantity, "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, "animal_name": item.animal_name,
"user_name": item.user_name, "user_name": item.user_name,
"notes": item.notes, "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, "batch_id": item.batch_id,
"actor_user_id": item.actor_user_id, "actor_user_id": item.actor_user_id,
"quantity": item.quantity, "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, "animal_name": item.animal_name,
"user_name": item.user_name, "user_name": item.user_name,
"notes": item.notes, "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() variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
if not variant: if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found") 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() location = db.query(Location).filter(Location.id == payload.location_id, Location.is_active.is_(True)).first()
if not location: if not location:
@@ -1112,14 +1471,18 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
row = Batch( row = Batch(
drug_variant_id=variant_id, drug_variant_id=variant_id,
batch_number=batch_number, 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, expiry_date=payload.expiry_date,
location_id=payload.location_id, location_id=payload.location_id,
notes=payload.notes, notes=payload.notes,
) )
recompute_batch_pack_state(row)
db.add(row) db.add(row)
variant.quantity += payload.quantity variant.quantity += batch_quantity
write_audit_log( write_audit_log(
db, db,
@@ -1130,7 +1493,10 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
details={ details={
"variant_id": variant_id, "variant_id": variant_id,
"batch_number": batch_number, "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, "expiry_date": payload.expiry_date,
"location_id": payload.location_id, "location_id": payload.location_id,
}, },
@@ -1153,6 +1519,11 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
before = { before = {
"batch_number": batch.batch_number, "batch_number": batch.batch_number,
"quantity": batch.quantity, "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, "expiry_date": batch.expiry_date,
"location_id": batch.location_id, "location_id": batch.location_id,
"notes": batch.notes, "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: if payload.notes is not None:
batch.notes = payload.notes batch.notes = payload.notes
if payload.quantity is not None: 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 < 0: if payload.quantity is not None and payload.quantity < 0:
raise HTTPException(status_code=400, detail="Batch quantity cannot be negative") 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 projected_variant_qty = variant.quantity + delta
if projected_variant_qty < 0: if projected_variant_qty < 0:
raise HTTPException(status_code=400, detail="Variant quantity cannot become negative") 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 variant.quantity = projected_variant_qty
write_audit_log( write_audit_log(
@@ -1276,6 +1675,9 @@ def report_controlled_movement(
"drug_name": drug.name, "drug_name": drug.name,
"strength": variant.strength, "strength": variant.strength,
"quantity": d.quantity, "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, "user_name": d.user_name,
"animal_name": d.animal_name, "animal_name": d.animal_name,
"batch_id": d.batch_id, "batch_id": d.batch_id,
+146
View File
@@ -56,6 +56,25 @@ def migrate_compliance_schema() -> None:
cursor = conn.cursor() cursor = conn.cursor()
try: 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"): 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") cursor.execute("ALTER TABLE drugs ADD COLUMN is_controlled BOOLEAN NOT NULL DEFAULT 0")
print("Added drugs.is_controlled") 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") cursor.execute("ALTER TABLE dispensings ADD COLUMN actor_user_id INTEGER")
print("Added dispensings.actor_user_id") 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). # Seed default locations once table exists (created via SQLAlchemy create_all).
if _table_exists(cursor, "locations"): if _table_exists(cursor, "locations"):
cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Cupboard', 1)") cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Cupboard', 1)")
+21
View File
@@ -36,6 +36,19 @@ class DrugVariant(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now()) 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): class Dispensing(Base):
__tablename__ = "dispensings" __tablename__ = "dispensings"
@@ -44,6 +57,9 @@ class Dispensing(Base):
batch_id = Column(Integer, ForeignKey("batches.id"), nullable=True) batch_id = Column(Integer, ForeignKey("batches.id"), nullable=True)
actor_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) actor_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
quantity = Column(Float, nullable=False) 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) animal_name = Column(String, nullable=True) # Name/ID of the animal (optional)
user_name = Column(String, nullable=False) # User who dispensed user_name = Column(String, nullable=False) # User who dispensed
dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) 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) drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
batch_number = Column(String, nullable=False, index=True) batch_number = Column(String, nullable=False, index=True)
quantity = Column(Float, nullable=False, default=0) 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) expiry_date = Column(Date, nullable=False, index=True)
location_id = Column(Integer, ForeignKey("locations.id"), 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) received_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
+816 -8
View File
@@ -7,6 +7,10 @@ let searchTerm = '';
let expandedDrugs = new Set(); let expandedDrugs = new Set();
let currentUser = null; let currentUser = null;
let accessToken = null; let accessToken = null;
let deliveryDrugId = null;
let deliveryLineCounter = 0;
let deliveryLocations = [];
let activeVariantPacksVariantId = null;
// Toast notification system // Toast notification system
function showToast(message, type = 'info', duration = 3000) { function showToast(message, type = 'info', duration = 3000) {
@@ -212,6 +216,7 @@ function setupEventListeners() {
const editModal = document.getElementById('editModal'); const editModal = document.getElementById('editModal');
const printNotesModal = document.getElementById('printNotesModal'); const printNotesModal = document.getElementById('printNotesModal');
const batchReceiveModal = document.getElementById('batchReceiveModal'); const batchReceiveModal = document.getElementById('batchReceiveModal');
const receiveDeliveryModal = document.getElementById('receiveDeliveryModal');
const addDrugBtn = document.getElementById('addDrugBtn'); const addDrugBtn = document.getElementById('addDrugBtn');
const dispenseBtn = document.getElementById('dispenseBtn'); const dispenseBtn = document.getElementById('dispenseBtn');
const printNotesBtn = document.getElementById('printNotesBtn'); const printNotesBtn = document.getElementById('printNotesBtn');
@@ -222,6 +227,15 @@ function setupEventListeners() {
const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn'); const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn');
const cancelEditBtn = document.getElementById('cancelEditBtn'); const cancelEditBtn = document.getElementById('cancelEditBtn');
const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn'); 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 showAllBtn = document.getElementById('showAllBtn');
const showLowStockBtn = document.getElementById('showLowStockBtn'); const showLowStockBtn = document.getElementById('showLowStockBtn');
const locationFilterSelect = document.getElementById('locationFilterSelect'); const locationFilterSelect = document.getElementById('locationFilterSelect');
@@ -247,10 +261,30 @@ function setupEventListeners() {
if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive); if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive);
if (cancelBatchReceiveBtn) cancelBatchReceiveBtn.addEventListener('click', () => closeModal(batchReceiveModal)); 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 (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal)); if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
if (dispenseBtn) dispenseBtn.addEventListener('click', () => { if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
updateDispenseDrugSelect(); updateDispenseDrugSelect();
updateDispenseModeUi();
openModal(dispenseModal); openModal(dispenseModal);
}); });
@@ -273,6 +307,9 @@ function setupEventListeners() {
const closeLocationManagementBtn = document.getElementById('closeLocationManagementBtn'); const closeLocationManagementBtn = document.getElementById('closeLocationManagementBtn');
if (closeLocationManagementBtn) closeLocationManagementBtn.addEventListener('click', () => closeModal(document.getElementById('locationManagementModal'))); 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'); const createLocationForm = document.getElementById('createLocationForm');
if (createLocationForm) createLocationForm.addEventListener('submit', createLocation); 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 // Close modal when clicking outside
window.addEventListener('click', (e) => { window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) { if (e.target.classList.contains('modal')) {
@@ -398,6 +457,108 @@ function updateDispenseDrugSelect() {
select.appendChild(option); 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 = '<option value="">-- Select pack --</option>';
}
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 = '<option value="">-- Select pack --</option>';
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) { function formatDisplayDate(value) {
@@ -455,6 +616,8 @@ function updateLocationFilterOptions() {
function populateDispenseBatchSelect(activeBatches) { function populateDispenseBatchSelect(activeBatches) {
const batchSelect = document.getElementById('dispenseBatchSelect'); const batchSelect = document.getElementById('dispenseBatchSelect');
const selectedVariantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10);
const unitLabel = getVariantById(selectedVariantId)?.unit || 'units';
const previousValue = batchSelect.value; const previousValue = batchSelect.value;
batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>'; batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>';
@@ -465,7 +628,7 @@ function populateDispenseBatchSelect(activeBatches) {
const locationLabel = getBatchLocationLabel(batch); const locationLabel = getBatchLocationLabel(batch);
const fefoLabel = index === 0 ? ' [FEFO default]' : ''; const fefoLabel = index === 0 ? ' [FEFO default]' : '';
option.value = batch.id; 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); batchSelect.appendChild(option);
}); });
@@ -484,9 +647,17 @@ async function updateBatchInfo() {
if (!variantId) { if (!variantId) {
batchInfoSection.style.display = 'none'; batchInfoSection.style.display = 'none';
batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>'; batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>';
const packSelect = document.getElementById('dispensePackSelect');
if (packSelect) packSelect.innerHTML = '<option value="">-- Select pack --</option>';
return; return;
} }
const variant = getVariantById(variantId);
if (variant) {
populateDispensePackSelect(variant);
}
updateDispenseModeUi();
batchInfoSection.style.display = 'block'; batchInfoSection.style.display = 'block';
batchInfoContent.innerHTML = '<p class="loading">Loading batches...</p>'; batchInfoContent.innerHTML = '<p class="loading">Loading batches...</p>';
@@ -558,6 +729,7 @@ async function updateBatchInfo() {
// Update allocation preview based on quantity and allow_split flag // Update allocation preview based on quantity and allow_split flag
async function updateAllocationPreview() { async function updateAllocationPreview() {
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
const unitLabel = getVariantById(variantId)?.unit || 'units';
const quantity = parseFloat(document.getElementById('dispenseQuantity').value); const quantity = parseFloat(document.getElementById('dispenseQuantity').value);
const allowSplit = document.getElementById('dispenseAllowSplit').checked; const allowSplit = document.getElementById('dispenseAllowSplit').checked;
const preferredBatchId = parseInt(document.getElementById('dispenseBatchSelect').value); const preferredBatchId = parseInt(document.getElementById('dispenseBatchSelect').value);
@@ -626,10 +798,10 @@ async function updateAllocationPreview() {
if (remainingQty > 0 && allowSplit) { if (remainingQty > 0 && allowSplit) {
allocationPreviewContent.innerHTML = ` allocationPreviewContent.innerHTML = `
<p style="color: #d32f2f; margin: 0 0 10px 0;">✕ Warning: Only ${quantity - remainingQty} units available across all batches (${remainingQty} short)</p> <p style="color: #d32f2f; margin: 0 0 10px 0;">✕ Warning: Only ${quantity - remainingQty} ${escapeHtml(unitLabel)} available across all batches (${remainingQty} short)</p>
<div>${allocations.map(a => ` <div>${allocations.map(a => `
<div style="padding: 5px; margin: 3px 0; background: white; border-radius: 2px; font-size: 0.9em;"> <div style="padding: 5px; margin: 3px 0; background: white; border-radius: 2px; font-size: 0.9em;">
<strong>${a.batchNumber}</strong>${a.preferred ? ' <span style="color: #1565c0;">(preferred)</span>' : ''}: ${a.quantity} units (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)} <strong>${a.batchNumber}</strong>${a.preferred ? ' <span style="color: #1565c0;">(preferred)</span>' : ''}: ${a.quantity} ${escapeHtml(unitLabel)} (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)}
</div> </div>
`).join('')}</div> `).join('')}</div>
`; `;
@@ -638,7 +810,7 @@ async function updateAllocationPreview() {
const allocationHtml = allocations.map(a => ` const allocationHtml = allocations.map(a => `
<div style="padding: 5px; margin: 3px 0; background: white; border-radius: 2px; font-size: 0.9em;"> <div style="padding: 5px; margin: 3px 0; background: white; border-radius: 2px; font-size: 0.9em;">
<strong>${a.batchNumber}</strong>${a.preferred ? ' <span style="color: #1565c0;">(preferred)</span>' : ''}: ${a.quantity} units (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)} <strong>${a.batchNumber}</strong>${a.preferred ? ' <span style="color: #1565c0;">(preferred)</span>' : ''}: ${a.quantity} ${escapeHtml(unitLabel)} (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)}
</div> </div>
`).join(''); `).join('');
@@ -725,7 +897,7 @@ function renderDrugs() {
</div> </div>
<div class="variant-actions"> <div class="variant-actions">
${!isReadOnly ? ` ${!isReadOnly ? `
<button class="btn btn-success btn-small" onclick="openBatchReceiveModal(${variant.id})">📦 Receive Batch</button> <button class="btn btn-info btn-small" onclick="event.stopPropagation(); openVariantPacksModal(${variant.id})">📦 Packs</button>
<button class="btn btn-primary btn-small" onclick="prescribeVariant(${variant.id}, '${drug.name.replace(/'/g, "\\'")}', '${variant.strength.replace(/'/g, "\\'")}', '${variant.unit.replace(/'/g, "\\'")}')">🏷️ Prescribe & Print</button> <button class="btn btn-primary btn-small" onclick="prescribeVariant(${variant.id}, '${drug.name.replace(/'/g, "\\'")}', '${variant.strength.replace(/'/g, "\\'")}', '${variant.unit.replace(/'/g, "\\'")}')">🏷️ Prescribe & Print</button>
<button class="btn btn-success btn-small" onclick="dispenseVariant(${variant.id})">💊 Dispense</button> <button class="btn btn-success btn-small" onclick="dispenseVariant(${variant.id})">💊 Dispense</button>
<button class="btn btn-warning btn-small" onclick="openEditVariantModal(${variant.id})">Edit</button> <button class="btn btn-warning btn-small" onclick="openEditVariantModal(${variant.id})">Edit</button>
@@ -753,6 +925,7 @@ function renderDrugs() {
</div> </div>
<div class="drug-actions"> <div class="drug-actions">
${!isReadOnly ? ` ${!isReadOnly ? `
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); openReceiveDeliveryModal(${drug.id})">📦 Receive Delivery</button>
<button class="btn btn-primary btn-small" onclick="event.stopPropagation(); openAddVariantModal(${drug.id})"> Add</button> <button class="btn btn-primary btn-small" onclick="event.stopPropagation(); openAddVariantModal(${drug.id})"> Add</button>
` : ''} ` : ''}
<button class="btn btn-info btn-small" onclick="event.stopPropagation(); showDrugHistory(${drug.id})">📋 History</button> <button class="btn btn-info btn-small" onclick="event.stopPropagation(); showDrugHistory(${drug.id})">📋 History</button>
@@ -825,13 +998,45 @@ async function handleDispenseDrug(e) {
e.preventDefault(); e.preventDefault();
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
const quantity = parseFloat(document.getElementById('dispenseQuantity').value); let quantity = parseFloat(document.getElementById('dispenseQuantity').value);
const dispenseMode = (document.getElementById('dispenseMode').value || 'subunit').toLowerCase();
const preferredBatchIdValue = document.getElementById('dispenseBatchSelect').value; const preferredBatchIdValue = document.getElementById('dispenseBatchSelect').value;
const requestedPackIdValue = document.getElementById('dispensePackSelect').value;
const requestedPackCountValue = document.getElementById('dispensePackCount').value;
const animalName = document.getElementById('dispenseAnimal').value; const animalName = document.getElementById('dispenseAnimal').value;
const userName = document.getElementById('dispenseUser').value; const userName = document.getElementById('dispenseUser').value;
const notes = document.getElementById('dispenseNotes').value; const notes = document.getElementById('dispenseNotes').value;
const allowSplit = document.getElementById('dispenseAllowSplit').checked; const allowSplit = document.getElementById('dispenseAllowSplit').checked;
const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null;
const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null;
const variant = getVariantById(variantId);
const selectedPack = variant && selectedPackId
? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId)
: null;
if (!['subunit', 'pack'].includes(dispenseMode)) {
showToast('Please select a valid dispense mode.', 'warning');
return;
}
if (dispenseMode === 'pack') {
if (!selectedPack) {
showToast('Please select a pack type for whole-pack dispensing.', 'warning');
return;
}
if (selectedPackCount == null || Number.isNaN(selectedPackCount) || selectedPackCount <= 0) {
showToast('Please enter a valid pack count greater than zero.', 'warning');
return;
}
if (Math.abs(selectedPackCount - Math.round(selectedPackCount)) > 1e-6) {
showToast('Whole-pack dispensing requires a whole-number pack count.', 'warning');
return;
}
quantity = selectedPackCount * selectedPack.pack_size_in_base_units;
}
if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) { if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) {
showToast('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)', 'warning'); showToast('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)', 'warning');
return; return;
@@ -840,7 +1045,10 @@ async function handleDispenseDrug(e) {
const dispensingData = { const dispensingData = {
drug_variant_id: variantId, drug_variant_id: variantId,
quantity: quantity, quantity: quantity,
dispense_mode: dispenseMode,
batch_id: preferredBatchIdValue ? parseInt(preferredBatchIdValue) : null, batch_id: preferredBatchIdValue ? parseInt(preferredBatchIdValue) : null,
requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null,
requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null,
animal_name: animalName || null, animal_name: animalName || null,
user_name: userName, user_name: userName,
notes: notes || null, notes: notes || null,
@@ -903,19 +1111,143 @@ function openAddVariantModal(drugId) {
if (!drug) return; if (!drug) return;
currentDrug = drug; currentDrug = drug;
const form = document.getElementById('variantForm');
if (form) form.reset();
document.getElementById('variantDrugId').value = drug.id; document.getElementById('variantDrugId').value = drug.id;
initializeVariantPackRows();
document.getElementById('addVariantModal').classList.add('show'); document.getElementById('addVariantModal').classList.add('show');
} }
function inferBaseUnitFromStrength(strength) {
const value = String(strength || '').toLowerCase();
if (value.includes('/ml') || value.includes('ml')) return 'ml';
if (value.includes('tablet')) return 'tablets';
if (value.includes('capsule')) return 'capsules';
return 'units';
}
function getVariantPackRowsContainer() {
return document.getElementById('variantPackRows');
}
function refreshVariantPackRowLabels() {
const container = getVariantPackRowsContainer();
const baseUnit = document.getElementById('variantUnit')?.value || 'units';
if (!container) return;
container.querySelectorAll('.variant-pack-row').forEach(row => {
const packUnit = row.querySelector('.variant-pack-unit')?.value || 'pack';
const label = row.querySelector('.variant-pack-size-label');
if (!label) return;
const titleCasePack = packUnit.charAt(0).toUpperCase() + packUnit.slice(1);
label.textContent = `${titleCasePack} Size (${baseUnit}) *`;
});
}
function appendVariantPackRow(prefill = {}) {
const container = getVariantPackRowsContainer();
if (!container) return;
const row = document.createElement('div');
row.className = 'delivery-line variant-pack-row';
const selectedPackUnit = prefill.packUnit || 'bottle';
const selectedSize = prefill.packSize || '';
const baseUnit = document.getElementById('variantUnit')?.value || 'units';
row.innerHTML = `
<div class="delivery-line-grid" style="grid-template-columns: 1.2fr 1.2fr auto;">
<div class="form-group">
<label>Pack Type *</label>
<select class="variant-pack-unit" required>
<option value="bottle" ${selectedPackUnit === 'bottle' ? 'selected' : ''}>Bottle</option>
<option value="box" ${selectedPackUnit === 'box' ? 'selected' : ''}>Box</option>
<option value="vial" ${selectedPackUnit === 'vial' ? 'selected' : ''}>Vial</option>
<option value="packet" ${selectedPackUnit === 'packet' ? 'selected' : ''}>Packet</option>
</select>
</div>
<div class="form-group">
<label class="variant-pack-size-label">Bottle Size (${baseUnit}) *</label>
<input type="number" class="variant-pack-size" min="0.0001" step="0.0001" value="${selectedSize}" required>
</div>
<button type="button" class="btn btn-danger btn-small variant-pack-remove-btn">Remove</button>
</div>
`;
const removeBtn = row.querySelector('.variant-pack-remove-btn');
const unitSelect = row.querySelector('.variant-pack-unit');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
if (container.querySelectorAll('.variant-pack-row').length <= 1) {
showToast('At least one pack size is required', 'warning');
return;
}
row.remove();
});
}
if (unitSelect) {
unitSelect.addEventListener('change', refreshVariantPackRowLabels);
}
container.appendChild(row);
refreshVariantPackRowLabels();
}
function initializeVariantPackRows() {
const container = getVariantPackRowsContainer();
if (!container) return;
container.innerHTML = '';
const strengthValue = document.getElementById('variantStrength')?.value || '';
const inferredBaseUnit = inferBaseUnitFromStrength(strengthValue);
const variantUnitSelect = document.getElementById('variantUnit');
if (variantUnitSelect) {
variantUnitSelect.value = inferredBaseUnit;
}
appendVariantPackRow({ packUnit: 'bottle' });
}
// Handle add variant form // Handle add variant form
async function handleAddVariant(e) { async function handleAddVariant(e) {
e.preventDefault(); e.preventDefault();
const drugId = parseInt(document.getElementById('variantDrugId').value); const drugId = parseInt(document.getElementById('variantDrugId').value);
const baseUnit = document.getElementById('variantUnit').value;
const rows = Array.from(document.querySelectorAll('#variantPackRows .variant-pack-row'));
if (rows.length === 0) {
showToast('Please add at least one pack size', 'warning');
return;
}
const packPayloads = [];
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i];
const packUnit = row.querySelector('.variant-pack-unit')?.value;
const packSize = parseFloat(row.querySelector('.variant-pack-size')?.value || '');
if (!packUnit || Number.isNaN(packSize) || packSize <= 0) {
showToast(`Pack row ${i + 1} is incomplete`, 'warning');
return;
}
const normalizedPackUnit = packUnit.trim().toLowerCase();
const titleCasePack = normalizedPackUnit.charAt(0).toUpperCase() + normalizedPackUnit.slice(1);
packPayloads.push({
label: `${titleCasePack} ${packSize} ${baseUnit}`,
pack_unit_name: normalizedPackUnit,
pack_size_in_base_units: packSize,
is_active: true
});
}
const variantData = { const variantData = {
strength: document.getElementById('variantStrength').value, strength: document.getElementById('variantStrength').value,
quantity: parseFloat(document.getElementById('variantQuantity').value), quantity: 0,
unit: document.getElementById('variantUnit').value, unit: baseUnit,
base_unit: baseUnit,
low_stock_threshold: parseFloat(document.getElementById('variantThreshold').value) low_stock_threshold: parseFloat(document.getElementById('variantThreshold').value)
}; };
@@ -927,9 +1259,41 @@ async function handleAddVariant(e) {
if (!response.ok) throw new Error('Failed to add variant'); if (!response.ok) throw new Error('Failed to add variant');
const createdVariant = await response.json();
for (const packPayload of packPayloads) {
const packResponse = await apiCall(`/variants/${createdVariant.id}/packs`, {
method: 'POST',
body: JSON.stringify(packPayload)
});
if (!packResponse.ok) {
const packError = await packResponse.json();
throw new Error(packError.detail || 'Variant created but pack size creation failed');
}
}
// Archive the auto-created default 1:1 pack when custom pack sizes are configured.
const packsResponse = await apiCall(`/variants/${createdVariant.id}/packs`);
if (packsResponse.ok) {
const packs = await packsResponse.json();
const defaultPack = packs.find(
p => p.is_active && Number(p.pack_size_in_base_units) === 1 && (p.pack_unit_name || '').toLowerCase() === baseUnit.toLowerCase()
);
if (defaultPack && packs.filter(p => p.is_active).length > 1) {
await apiCall(`/variant-packs/${defaultPack.id}`, {
method: 'PUT',
body: JSON.stringify({ is_active: false })
});
}
}
document.getElementById('variantForm').reset(); document.getElementById('variantForm').reset();
closeModal(document.getElementById('addVariantModal')); closeModal(document.getElementById('addVariantModal'));
await loadDrugs(); await loadDrugs();
if (document.getElementById('receiveDeliveryModal')?.classList.contains('show')) {
refreshDeliveryVariantSelects();
}
renderDrugs(); renderDrugs();
showToast('Variant added successfully!', 'success'); showToast('Variant added successfully!', 'success');
} catch (error) { } catch (error) {
@@ -958,6 +1322,13 @@ function openEditVariantModal(variantId) {
document.getElementById('editVariantModal').classList.add('show'); document.getElementById('editVariantModal').classList.add('show');
} }
function inferPackUnitName(baseUnit) {
const value = String(baseUnit || 'pack').trim().toLowerCase();
if (!value) return 'pack';
if (value.endsWith('s') && value.length > 1) return value.slice(0, -1);
return value;
}
// Handle edit variant form // Handle edit variant form
async function handleEditVariant(e) { async function handleEditVariant(e) {
e.preventDefault(); e.preventDefault();
@@ -988,6 +1359,140 @@ async function handleEditVariant(e) {
} }
} }
async function openVariantPacksModal(variantId) {
const variant = getVariantById(variantId);
if (!variant) {
showToast('Variant not found', 'error');
return;
}
const parentDrug = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId));
activeVariantPacksVariantId = variantId;
const label = document.getElementById('variantPacksLabel');
if (label) {
const drugName = parentDrug ? parentDrug.name : 'Drug';
label.textContent = `${drugName} ${variant.strength} | Base Unit: ${variant.unit}`;
}
const form = document.getElementById('variantPacksForm');
if (form) form.reset();
document.getElementById('variantPacksVariantId').value = String(variantId);
document.getElementById('variantPacksNewUnit').value = inferPackUnitName(variant.unit);
await refreshVariantPacksList();
openModal(document.getElementById('variantPacksModal'));
}
async function refreshVariantPacksList() {
const variantId = parseInt(document.getElementById('variantPacksVariantId')?.value || '', 10);
const list = document.getElementById('variantPacksList');
if (!variantId || !list) return;
list.innerHTML = '<p class="loading">Loading packs...</p>';
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 = '<p class="empty">No pack presentations defined.</p>';
return;
}
list.innerHTML = `
<div class="locations-table">
${packs.map(pack => `
<div class="location-item" style="${pack.is_active ? '' : 'opacity: 0.6;'}">
<div style="flex: 1;">
<strong>${escapeHtml(pack.label)}</strong>
<div style="font-size: 0.88em; color: #666;">
${escapeHtml(pack.pack_unit_name)} | ${pack.pack_size_in_base_units} base units
${pack.is_active ? '' : ' | archived'}
</div>
</div>
<button class="btn ${pack.is_active ? 'btn-danger' : 'btn-secondary'} btn-small" onclick="toggleVariantPackActive(${pack.id}, ${pack.is_active ? 'false' : 'true'})">
${pack.is_active ? 'Archive' : 'Restore'}
</button>
</div>
`).join('')}
</div>
`;
} catch (error) {
console.error('Error loading variant packs:', error);
list.innerHTML = '<p class="empty">Failed to load pack presentations</p>';
}
}
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 // Dispense from variant
function dispenseVariant(variantId) { function dispenseVariant(variantId) {
// Update the dropdown display with all variants // Update the dropdown display with all variants
@@ -1751,3 +2256,306 @@ async function handleBatchReceive(e) {
showToast('Failed to receive batch: ' + error.message, 'error'); 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 '<option value="">-- No variants available --</option>';
}
return [`<option value="">-- Select variant --</option>`, ...drug.variants.map(v => {
const selected = String(v.id) === String(selectedVariantId) ? ' selected' : '';
return `<option value="${v.id}"${selected}>${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`;
})].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 '<option value="">-- No active packs --</option>';
}
return [`<option value="">-- Select pack --</option>`, ...packs.map(pack => {
const selected = String(pack.id) === String(selectedPackId) ? ' selected' : '';
const label = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`;
return `<option value="${pack.id}"${selected}>${escapeHtml(label)}</option>`;
})].join('');
}
function buildDeliveryLocationOptions(selectedLocationId = '') {
return [`<option value="">-- Select location --</option>`, ...deliveryLocations.map(location => {
const selected = String(location.id) === String(selectedLocationId) ? ' selected' : '';
return `<option value="${location.id}"${selected}>${escapeHtml(location.name)}</option>`;
})].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 = `
<div class="delivery-line-grid">
<div class="form-group">
<label>Variant</label>
<select class="delivery-variant-select" required>
${buildDeliveryVariantOptions(drug, initialVariantId)}
</select>
</div>
<div class="form-group">
<label>Pack Type</label>
<select class="delivery-pack-select" required>
${buildDeliveryPackOptions(initialVariant, initialPackId)}
</select>
</div>
<div class="form-group">
<label>Pack Count</label>
<input type="number" class="delivery-pack-count" min="0.0001" step="0.0001" value="${initialPackCount}" required>
</div>
<div class="form-group">
<label>Batch Number</label>
<input type="text" class="delivery-batch-number" value="${prefill.batchNumber || ''}" placeholder="e.g. ABC123" required>
</div>
<div class="form-group">
<label>Expiry</label>
<input type="date" class="delivery-expiry-date" value="${prefill.expiryDate || ''}" required>
</div>
<div class="form-group">
<label>Location</label>
<select class="delivery-location-select" required>
${buildDeliveryLocationOptions(prefill.locationId || '')}
</select>
</div>
<button type="button" class="btn btn-danger btn-small delivery-remove-btn">Remove</button>
</div>
`;
const removeBtn = line.querySelector('.delivery-remove-btn');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
if (container.children.length <= 1) {
showToast('At least one delivery line is required', 'warning');
return;
}
line.remove();
});
}
wireDeliveryLineEvents(line);
updateDeliveryLineQuantityDisplay(line);
container.appendChild(line);
}
function refreshDeliveryVariantSelects() {
const drug = getActiveDeliveryDrug();
const container = document.getElementById('deliveryLinesContainer');
if (!drug || !container) return;
container.querySelectorAll('.delivery-line').forEach(line => {
const select = line.querySelector('.delivery-variant-select');
const packSelect = line.querySelector('.delivery-pack-select');
if (!select) return;
const currentVariantId = select.value;
select.innerHTML = buildDeliveryVariantOptions(drug, currentVariantId);
const variant = getVariantById(parseInt(select.value || '', 10));
if (packSelect) {
const currentPackId = packSelect.value;
packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId);
}
updateDeliveryLineQuantityDisplay(line);
});
}
async function initializeDeliveryLocations() {
try {
const response = await apiCall('/locations');
if (!response.ok) throw new Error('Failed to load locations');
const locations = await response.json();
deliveryLocations = locations.filter(location => location.is_active);
} catch (error) {
console.error('Error loading delivery locations:', error);
showToast('Failed to load storage locations', 'error');
deliveryLocations = [];
}
}
async function openReceiveDeliveryModal(drugId) {
deliveryDrugId = drugId;
const drug = getActiveDeliveryDrug();
if (!drug) {
showToast('Drug not found', 'error');
return;
}
const form = document.getElementById('receiveDeliveryForm');
const container = document.getElementById('deliveryLinesContainer');
const label = document.getElementById('receiveDeliveryDrugLabel');
if (form) form.reset();
if (container) container.innerHTML = '';
if (label) label.textContent = `Drug: ${drug.name}`;
await initializeDeliveryLocations();
appendDeliveryLine();
openModal(document.getElementById('receiveDeliveryModal'));
}
function handleAddVariantFromDelivery() {
if (!deliveryDrugId) {
showToast('Select a drug first', 'warning');
return;
}
openAddVariantModal(deliveryDrugId);
}
async function handleReceiveDelivery(e) {
e.preventDefault();
const drug = getActiveDeliveryDrug();
const container = document.getElementById('deliveryLinesContainer');
if (!drug || !container) {
showToast('Delivery context unavailable', 'error');
return;
}
const lines = Array.from(container.querySelectorAll('.delivery-line'));
if (lines.length === 0) {
showToast('Add at least one delivery line', 'warning');
return;
}
const payloads = [];
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const variantId = parseInt(line.querySelector('.delivery-variant-select')?.value || '', 10);
const packIdRaw = line.querySelector('.delivery-pack-select')?.value || '';
const packId = packIdRaw ? parseInt(packIdRaw, 10) : null;
const packCountRaw = line.querySelector('.delivery-pack-count')?.value || '';
const packCount = packCountRaw ? parseFloat(packCountRaw) : null;
const batchNumber = (line.querySelector('.delivery-batch-number')?.value || '').trim();
const expiryDate = line.querySelector('.delivery-expiry-date')?.value || '';
const locationId = parseInt(line.querySelector('.delivery-location-select')?.value || '', 10);
if (!variantId || !packId || packCount === null || Number.isNaN(packCount) || packCount <= 0 || !batchNumber || !expiryDate || !locationId) {
showToast(`Delivery line ${i + 1} is incomplete`, 'warning');
return;
}
const variant = drug.variants.find(v => v.id === variantId);
const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null;
if (!selectedPack) {
showToast(`Delivery line ${i + 1} has an invalid pack selection`, 'warning');
return;
}
const computedQuantity = packCount * selectedPack.pack_size_in_base_units;
payloads.push({
variantId,
payload: {
batch_number: batchNumber,
received_pack_id: packId,
received_pack_count: packCount,
expiry_date: expiryDate,
location_id: locationId,
notes: `Received ${packCount} ${selectedPack.pack_unit_name}(s), total ${computedQuantity} ${variant ? variant.unit : 'units'}`
}
});
}
try {
for (let i = 0; i < payloads.length; i += 1) {
const entry = payloads[i];
const response = await apiCall(`/variants/${entry.variantId}/batches`, {
method: 'POST',
body: JSON.stringify(entry.payload)
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Line ${i + 1}: ${error.detail || 'Failed to receive delivery line'}`);
}
}
closeModal(document.getElementById('receiveDeliveryModal'));
await loadDrugs();
showToast(`Delivery received successfully (${payloads.length} line${payloads.length === 1 ? '' : 's'})`, 'success');
} catch (error) {
console.error('Error receiving delivery:', error);
showToast('Failed to receive delivery: ' + error.message, 'error');
}
}
+87 -17
View File
@@ -205,10 +205,32 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="dispenseMode">Dispense Mode *</label>
<select id="dispenseMode" onchange="updateDispenseModeUi()">
<option value="subunit">Subunit Quantity</option>
<option value="pack">Whole Packs</option>
</select>
</div>
<div class="form-group" id="dispenseQuantityGroup">
<label for="dispenseQuantity">Quantity *</label> <label for="dispenseQuantity">Quantity *</label>
<input type="number" id="dispenseQuantity" step="0.1" onchange="updateAllocationPreview()"> <input type="number" id="dispenseQuantity" step="0.1" onchange="updateAllocationPreview()">
</div> </div>
<div class="form-row" id="dispensePackRow" style="display: none;">
<div class="form-group">
<label for="dispensePackSelect">Pack Type *</label>
<select id="dispensePackSelect" onchange="updateDispenseQuantityFromPack()">
<option value="">-- Select pack --</option>
</select>
</div>
<div class="form-group">
<label for="dispensePackCount">Pack Count *</label>
<input type="number" id="dispensePackCount" min="0.0001" step="0.0001" onchange="updateDispenseQuantityFromPack()">
<small id="dispensePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" id="dispenseAllowSplit" onchange="updateAllocationPreview()"> <input type="checkbox" id="dispenseAllowSplit" onchange="updateAllocationPreview()">
@@ -307,23 +329,21 @@
<input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 10.8mg, 100ml" required> <input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 10.8mg, 100ml" required>
</div> </div>
<div class="form-row"> <div class="form-group">
<div class="form-group"> <label for="variantUnit">Base Unit *</label>
<label for="variantQuantity">Quantity *</label> <select id="variantUnit">
<input type="number" id="variantQuantity" step="0.1" required> <option value="ml">ml</option>
</div> <option value="tablets">tablets</option>
<option value="capsules">capsules</option>
<option value="units">units</option>
<option value="vials">vials</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="variantUnit">Unit *</label> <label>Pack Sizes *</label>
<select id="variantUnit"> <div id="variantPackRows" class="delivery-lines"></div>
<option value="tablets">Tablets</option> <button type="button" id="addVariantPackRowBtn" class="btn btn-secondary btn-small">+ Add Another Size</button>
<option value="bottles">Bottles</option>
<option value="boxes">boxes</option>
<option value="vials">Vials</option>
<option value="units">Units</option>
<option value="packets">Packets</option>
</select>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -358,7 +378,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="editVariantUnit">Unit *</label> <label for="editVariantUnit">Base Unit *</label>
<select id="editVariantUnit"> <select id="editVariantUnit">
<option value="tablets">Tablets</option> <option value="tablets">Tablets</option>
<option value="bottles">Bottles</option> <option value="bottles">Bottles</option>
@@ -575,6 +595,56 @@
</form> </form>
</div> </div>
</div> </div>
<!-- Receive Delivery Modal -->
<div id="receiveDeliveryModal" class="modal">
<div class="modal-content modal-large receive-delivery-modal-content">
<span class="close">&times;</span>
<h2>Receive Delivery</h2>
<p id="receiveDeliveryDrugLabel" style="margin: 6px 0 16px; color: #666; font-weight: 600;"></p>
<form id="receiveDeliveryForm" novalidate>
<div id="deliveryLinesContainer" class="delivery-lines"></div>
<div class="delivery-toolbar">
<button type="button" id="addDeliveryLineBtn" class="btn btn-secondary">+ Add Delivery Line</button>
<button type="button" id="addVariantFromDeliveryBtn" class="btn btn-info">+ Add Variant</button>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Receive Delivery</button>
<button type="button" class="btn btn-secondary" id="cancelReceiveDeliveryBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Variant Pack Management Modal -->
<div id="variantPacksModal" class="modal">
<div class="modal-content modal-large">
<span class="close">&times;</span>
<h2>Pack Presentations</h2>
<p id="variantPacksLabel" style="margin: 6px 0 16px; color: #666; font-weight: 600;"></p>
<div class="form-group">
<h3 style="margin-bottom: 8px;">Add Pack Presentation</h3>
<form id="variantPacksForm">
<input type="hidden" id="variantPacksVariantId">
<div class="form-row">
<input type="text" id="variantPacksNewLabel" placeholder="Label (e.g., Bottle 300 ml)" required>
<input type="text" id="variantPacksNewUnit" placeholder="Pack Unit Name (e.g., bottle, box)" required>
<input type="number" id="variantPacksNewSize" min="0.0001" step="0.0001" placeholder="Size in base units" required>
<button type="submit" class="btn btn-primary btn-small">Add Pack</button>
</div>
</form>
</div>
<div id="variantPacksList" class="locations-list">
<p class="loading">Loading packs...</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="closeVariantPacksBtn">Close</button>
</div>
</div>
</div>
</div> </div>
<script src="app.js"></script> <script src="app.js"></script>
+117
View File
@@ -673,6 +673,18 @@ footer {
overflow-y: auto; overflow-y: auto;
} }
#receiveDeliveryModal.show {
align-items: flex-start;
overflow-y: auto;
padding: 24px 0;
}
#receiveDeliveryModal .modal-content {
width: min(1280px, 96vw) !important;
max-width: min(1280px, 96vw) !important;
max-height: calc(100vh - 48px) !important;
}
#dispenseModal.show { #dispenseModal.show {
align-items: flex-start; align-items: flex-start;
overflow-y: auto; overflow-y: auto;
@@ -877,6 +889,103 @@ footer {
color: #1f2937; color: #1f2937;
} }
.delivery-lines {
display: flex;
flex-direction: column;
gap: 12px;
margin: 8px 0 16px;
}
.delivery-line {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 14px;
background: #f9fbfd;
overflow-x: hidden;
}
.receive-delivery-modal-content {
width: min(1320px, 98vw);
max-width: 1320px;
max-height: 88vh;
overflow-y: auto;
}
.delivery-line-grid {
display: grid;
grid-template-columns: 1.9fr 1.8fr 0.9fr 1.4fr 1.2fr 1.3fr auto;
gap: 12px;
align-items: end;
}
.delivery-line-grid > * {
min-width: 0;
}
.delivery-line-grid .form-group {
margin-bottom: 0;
}
.delivery-line-grid label {
display: block;
margin-bottom: 6px;
font-weight: 600;
font-size: 0.88em;
}
.delivery-line-grid input,
.delivery-line-grid select {
width: 100%;
min-height: 40px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
}
.delivery-toolbar {
display: flex;
gap: 10px;
margin: 6px 0 10px;
flex-wrap: wrap;
}
.delivery-remove-btn {
align-self: end;
white-space: nowrap;
}
@media (max-width: 1200px) {
.receive-delivery-modal-content {
width: min(1120px, 97vw);
max-width: 1120px;
}
.delivery-line-grid {
grid-template-columns: 1.7fr 1.4fr 0.95fr 1.2fr 1.1fr 1.15fr;
}
.delivery-remove-btn {
grid-column: 1 / -1;
justify-self: end;
}
}
@media (max-width: 980px) {
#receiveDeliveryModal .modal-content {
width: 94vw !important;
max-width: 94vw !important;
}
.delivery-line-grid {
grid-template-columns: 1fr 1fr;
}
.delivery-remove-btn {
grid-column: 1 / -1;
justify-self: end;
}
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
main { main {
@@ -935,6 +1044,14 @@ footer {
min-width: 0; min-width: 0;
} }
.delivery-line-grid {
grid-template-columns: 1fr;
}
.delivery-toolbar {
flex-direction: column;
}
.drug-details { .drug-details {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }