Compare commits

..

2 Commits

Author SHA1 Message Date
jamesp 664a3189bd WIP gettnig there 2026-03-29 11:13:56 -04:00
jamesp ad1bb59f98 WIP 2026-03-29 10:39:03 -04:00
6 changed files with 1801 additions and 69 deletions
+513 -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,52 @@ 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
has_inventory_history: bool = False
packs: List[VariantPackResponse] = []
batches: List[BatchResponse] = [] batches: List[BatchResponse] = []
class Config: class Config:
@@ -187,8 +230,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 +254,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
@@ -270,14 +319,36 @@ def write_audit_log(
def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str, Any]: def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str, Any]:
"""Return variant data with active batch details for API responses.""" """Return variant data with active batch details for API responses."""
has_batch_history = (
db.query(Batch.id)
.filter(Batch.drug_variant_id == variant.id)
.first()
is not None
)
has_dispense_history = (
db.query(Dispensing.id)
.filter(Dispensing.drug_variant_id == variant.id)
.first()
is not None
)
variant_dict = { variant_dict = {
"id": variant.id, "id": variant.id,
"drug_id": variant.drug_id, "drug_id": variant.drug_id,
"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,
"has_inventory_history": has_batch_history or has_dispense_history,
} }
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 +361,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 +383,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,
@@ -706,12 +876,33 @@ def delete_drug(drug_id: int, db: Session = Depends(get_db), current_user: User
raise HTTPException(status_code=404, detail="Drug not found") raise HTTPException(status_code=404, detail="Drug not found")
variant_ids = [row[0] for row in db.query(DrugVariant.id).filter(DrugVariant.drug_id == drug_id).all()] variant_ids = [row[0] for row in db.query(DrugVariant.id).filter(DrugVariant.drug_id == drug_id).all()]
if variant_ids:
has_batch_history = (
db.query(Batch.id)
.filter(Batch.drug_variant_id.in_(variant_ids))
.first()
is not None
)
has_dispense_history = (
db.query(Dispensing.id)
.filter(Dispensing.drug_variant_id.in_(variant_ids))
.first()
is not None
)
if has_batch_history or has_dispense_history:
raise HTTPException(
status_code=400,
detail="Cannot delete drug with variants that have batch or dispensing history. Archive or manage records first.",
)
if variant_ids: if variant_ids:
batch_ids = [row[0] for row in db.query(Batch.id).filter(Batch.drug_variant_id.in_(variant_ids)).all()] batch_ids = [row[0] for row in db.query(Batch.id).filter(Batch.drug_variant_id.in_(variant_ids)).all()]
if batch_ids: if batch_ids:
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 +936,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 +971,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 +999,48 @@ 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)
has_batch_history = (
db.query(Batch.id)
.filter(Batch.drug_variant_id == variant_id)
.first()
is not None
)
has_dispense_history = (
db.query(Dispensing.id)
.filter(Dispensing.drug_variant_id == variant_id)
.first()
is not None
)
is_locked = has_batch_history or has_dispense_history
locked_field_changes = []
if is_locked:
if "strength" in payload and payload["strength"] != variant.strength:
locked_field_changes.append("strength")
if "unit" in payload and payload["unit"] != variant.unit:
locked_field_changes.append("base_unit")
if "quantity" in payload and payload["quantity"] != variant.quantity:
locked_field_changes.append("quantity")
if locked_field_changes:
raise HTTPException(
status_code=400,
detail=(
"Cannot change "
+ ", ".join(locked_field_changes)
+ " after batches or dispensing history exist for this variant"
),
)
for field, value in payload.items():
setattr(variant, field, value) setattr(variant, field, value)
write_audit_log( write_audit_log(
@@ -800,7 +1049,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()
@@ -814,12 +1063,32 @@ def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_
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")
has_batch_history = (
db.query(Batch.id)
.filter(Batch.drug_variant_id == variant_id)
.first()
is not None
)
has_dispense_history = (
db.query(Dispensing.id)
.filter(Dispensing.drug_variant_id == variant_id)
.first()
is not None
)
if has_batch_history or has_dispense_history:
raise HTTPException(
status_code=400,
detail="Cannot delete variant with batch or dispensing history. Archive or manage records first.",
)
batch_ids = [row[0] for row in db.query(Batch.id).filter(Batch.drug_variant_id == variant_id).all()] batch_ids = [row[0] for row in db.query(Batch.id).filter(Batch.drug_variant_id == variant_id).all()]
if batch_ids: if batch_ids:
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 == 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 +1102,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 +1287,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 +1303,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 +1318,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 +1336,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 +1360,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 +1394,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 +1425,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 +1529,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 +1558,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 +1580,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 +1606,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 +1645,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 +1762,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)
+934 -25
View File
File diff suppressed because it is too large Load Diff
+67 -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">
@@ -346,6 +366,9 @@
<h2>Edit Variant</h2> <h2>Edit Variant</h2>
<form id="editVariantForm"> <form id="editVariantForm">
<input type="hidden" id="editVariantId"> <input type="hidden" id="editVariantId">
<p id="editVariantLockNotice" style="display:none; margin: 0 0 12px; padding: 8px 10px; background: #fff8e1; border: 1px solid #f5c15d; border-radius: 6px; color: #7a4f01;">
Strength, quantity, and base unit are locked once this variant has stock/batch history.
</p>
<div class="form-group"> <div class="form-group">
<label for="editVariantStrength">Strength *</label> <label for="editVariantStrength">Strength *</label>
<input type="text" id="editVariantStrength" required> <input type="text" id="editVariantStrength" required>
@@ -358,7 +381,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>
@@ -370,6 +393,12 @@
</div> </div>
</div> </div>
<div class="form-group">
<label>Add Pack Sizes</label>
<div id="editVariantPackRows" class="delivery-lines"></div>
<button type="button" id="addEditVariantPackRowBtn" class="btn btn-secondary btn-small">+ Add Another Size</button>
</div>
<div class="form-group"> <div class="form-group">
<label for="editVariantThreshold">Low Stock Threshold *</label> <label for="editVariantThreshold">Low Stock Threshold *</label>
<input type="number" id="editVariantThreshold" step="0.1" required> <input type="number" id="editVariantThreshold" step="0.1" required>
@@ -575,6 +604,27 @@
</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>
</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;
} }