WIP
This commit is contained in:
+426
-24
@@ -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,
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
@@ -246,11 +260,31 @@ function setupEventListeners() {
|
|||||||
const batchReceiveForm = document.getElementById('batchReceiveForm');
|
const batchReceiveForm = document.getElementById('batchReceiveForm');
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,6 +306,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,8 +647,16 @@ 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+89
-19
@@ -203,11 +203,33 @@
|
|||||||
</select>
|
</select>
|
||||||
<small style="display: block; margin-top: 6px; color: #666;">Leave on automatic to use the earliest-expiry batch first. Choose a batch here to consume that batch first instead.</small>
|
<small style="display: block; margin-top: 6px; color: #666;">Leave on automatic to use the earliest-expiry batch first. Choose a batch here to consume that batch first instead.</small>
|
||||||
</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>
|
||||||
@@ -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>
|
||||||
<div class="form-group">
|
<option value="units">units</option>
|
||||||
<label for="variantUnit">Unit *</label>
|
<option value="vials">vials</option>
|
||||||
<select id="variantUnit">
|
</select>
|
||||||
<option value="tablets">Tablets</option>
|
</div>
|
||||||
<option value="bottles">Bottles</option>
|
|
||||||
<option value="boxes">boxes</option>
|
<div class="form-group">
|
||||||
<option value="vials">Vials</option>
|
<label>Pack Sizes *</label>
|
||||||
<option value="units">Units</option>
|
<div id="variantPackRows" class="delivery-lines"></div>
|
||||||
<option value="packets">Packets</option>
|
<button type="button" id="addVariantPackRowBtn" class="btn btn-secondary btn-small">+ Add Another Size</button>
|
||||||
</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">×</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">×</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user