Compare commits
2 Commits
e00669ae2c
...
664a3189bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 664a3189bd | |||
| ad1bb59f98 |
+513
-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,52 @@ class DrugVariantCreate(BaseModel):
|
|||||||
strength: str
|
strength: str
|
||||||
quantity: float
|
quantity: float
|
||||||
unit: str = "units"
|
unit: str = "units"
|
||||||
|
base_unit: Optional[str] = None
|
||||||
low_stock_threshold: float = 10
|
low_stock_threshold: float = 10
|
||||||
|
|
||||||
class DrugVariantUpdate(BaseModel):
|
class DrugVariantUpdate(BaseModel):
|
||||||
strength: str = None
|
strength: str = None
|
||||||
quantity: float = None
|
quantity: float = None
|
||||||
unit: str = None
|
unit: str = None
|
||||||
|
base_unit: str = None
|
||||||
low_stock_threshold: float = None
|
low_stock_threshold: float = None
|
||||||
|
|
||||||
|
|
||||||
|
class VariantPackCreate(BaseModel):
|
||||||
|
label: str
|
||||||
|
pack_unit_name: str
|
||||||
|
pack_size_in_base_units: float
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class VariantPackUpdate(BaseModel):
|
||||||
|
label: Optional[str] = None
|
||||||
|
pack_unit_name: Optional[str] = None
|
||||||
|
pack_size_in_base_units: Optional[float] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VariantPackResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
drug_variant_id: int
|
||||||
|
label: str
|
||||||
|
pack_unit_name: str
|
||||||
|
pack_size_in_base_units: float
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
class DrugVariantResponse(BaseModel):
|
class DrugVariantResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
drug_id: int
|
drug_id: int
|
||||||
strength: str
|
strength: str
|
||||||
quantity: float
|
quantity: float
|
||||||
unit: str
|
unit: str
|
||||||
|
base_unit: str
|
||||||
low_stock_threshold: float
|
low_stock_threshold: float
|
||||||
|
has_inventory_history: bool = False
|
||||||
|
packs: List[VariantPackResponse] = []
|
||||||
batches: List[BatchResponse] = []
|
batches: List[BatchResponse] = []
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -187,8 +230,11 @@ class DrugWithVariantsResponse(BaseModel):
|
|||||||
|
|
||||||
class DispensingCreate(BaseModel):
|
class DispensingCreate(BaseModel):
|
||||||
drug_variant_id: int
|
drug_variant_id: int
|
||||||
quantity: float
|
quantity: Optional[float] = None
|
||||||
|
dispense_mode: str = "subunit"
|
||||||
batch_id: Optional[int] = None
|
batch_id: Optional[int] = None
|
||||||
|
requested_pack_id: Optional[int] = None
|
||||||
|
requested_pack_count: Optional[float] = None
|
||||||
allow_split: bool = True
|
allow_split: bool = True
|
||||||
animal_name: Optional[str] = None
|
animal_name: Optional[str] = None
|
||||||
user_name: Optional[str] = None
|
user_name: Optional[str] = None
|
||||||
@@ -208,6 +254,9 @@ class DispensingResponse(BaseModel):
|
|||||||
batch_id: Optional[int] = None
|
batch_id: Optional[int] = None
|
||||||
actor_user_id: Optional[int] = None
|
actor_user_id: Optional[int] = None
|
||||||
quantity: float
|
quantity: float
|
||||||
|
dispense_mode: str = "subunit"
|
||||||
|
requested_pack_id: Optional[int] = None
|
||||||
|
requested_pack_count: Optional[float] = None
|
||||||
animal_name: Optional[str] = None
|
animal_name: Optional[str] = None
|
||||||
user_name: str
|
user_name: str
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
@@ -270,14 +319,36 @@ def write_audit_log(
|
|||||||
|
|
||||||
def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str, Any]:
|
def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str, Any]:
|
||||||
"""Return variant data with active batch details for API responses."""
|
"""Return variant data with active batch details for API responses."""
|
||||||
|
has_batch_history = (
|
||||||
|
db.query(Batch.id)
|
||||||
|
.filter(Batch.drug_variant_id == variant.id)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
has_dispense_history = (
|
||||||
|
db.query(Dispensing.id)
|
||||||
|
.filter(Dispensing.drug_variant_id == variant.id)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
variant_dict = {
|
variant_dict = {
|
||||||
"id": variant.id,
|
"id": variant.id,
|
||||||
"drug_id": variant.drug_id,
|
"drug_id": variant.drug_id,
|
||||||
"strength": variant.strength,
|
"strength": variant.strength,
|
||||||
"quantity": variant.quantity,
|
"quantity": variant.quantity,
|
||||||
"unit": variant.unit,
|
"unit": variant.unit,
|
||||||
|
"base_unit": variant.unit,
|
||||||
"low_stock_threshold": variant.low_stock_threshold,
|
"low_stock_threshold": variant.low_stock_threshold,
|
||||||
|
"has_inventory_history": has_batch_history or has_dispense_history,
|
||||||
}
|
}
|
||||||
|
packs = (
|
||||||
|
db.query(VariantPack)
|
||||||
|
.filter(VariantPack.drug_variant_id == variant.id)
|
||||||
|
.order_by(VariantPack.is_active.desc(), VariantPack.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
variant_dict["packs"] = [serialize_variant_pack(pack) for pack in packs]
|
||||||
batches = (
|
batches = (
|
||||||
db.query(Batch)
|
db.query(Batch)
|
||||||
.filter(Batch.drug_variant_id == variant.id, Batch.quantity > 0)
|
.filter(Batch.drug_variant_id == variant.id, Batch.quantity > 0)
|
||||||
@@ -290,11 +361,20 @@ def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str,
|
|||||||
|
|
||||||
def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
|
def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
|
||||||
location = db.query(Location).filter(Location.id == batch.location_id).first()
|
location = db.query(Location).filter(Location.id == batch.location_id).first()
|
||||||
|
pack = None
|
||||||
|
if batch.received_pack_id is not None:
|
||||||
|
pack = db.query(VariantPack).filter(VariantPack.id == batch.received_pack_id).first()
|
||||||
return {
|
return {
|
||||||
"id": batch.id,
|
"id": batch.id,
|
||||||
"drug_variant_id": batch.drug_variant_id,
|
"drug_variant_id": batch.drug_variant_id,
|
||||||
"batch_number": batch.batch_number,
|
"batch_number": batch.batch_number,
|
||||||
"quantity": batch.quantity,
|
"quantity": batch.quantity,
|
||||||
|
"received_pack_id": batch.received_pack_id,
|
||||||
|
"received_pack_label": pack.label if pack else None,
|
||||||
|
"received_pack_count": batch.received_pack_count,
|
||||||
|
"received_pack_size_snapshot": batch.received_pack_size_snapshot,
|
||||||
|
"current_full_pack_count": batch.current_full_pack_count,
|
||||||
|
"current_loose_base_units": batch.current_loose_base_units,
|
||||||
"expiry_date": batch.expiry_date,
|
"expiry_date": batch.expiry_date,
|
||||||
"location_id": batch.location_id,
|
"location_id": batch.location_id,
|
||||||
"location_name": location.name if location else None,
|
"location_name": location.name if location else None,
|
||||||
@@ -303,6 +383,96 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_pack_size_snapshot(db: Session, pack_id: Optional[int]) -> Optional[float]:
|
||||||
|
if pack_id is None:
|
||||||
|
return None
|
||||||
|
pack = db.query(VariantPack).filter(VariantPack.id == pack_id).first()
|
||||||
|
if not pack:
|
||||||
|
return None
|
||||||
|
return pack.pack_size_in_base_units
|
||||||
|
|
||||||
|
|
||||||
|
def recompute_batch_pack_state(batch: Batch) -> None:
|
||||||
|
pack_size = batch.received_pack_size_snapshot
|
||||||
|
if pack_size is None or pack_size <= 0 or batch.quantity < 0:
|
||||||
|
batch.current_full_pack_count = None
|
||||||
|
batch.current_loose_base_units = None
|
||||||
|
return
|
||||||
|
|
||||||
|
full_packs = math.floor((batch.quantity + 1e-9) / pack_size)
|
||||||
|
loose_units = batch.quantity - (full_packs * pack_size)
|
||||||
|
if loose_units < 1e-9:
|
||||||
|
loose_units = 0.0
|
||||||
|
|
||||||
|
batch.current_full_pack_count = float(full_packs)
|
||||||
|
batch.current_loose_base_units = loose_units
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_variant_pack(pack: VariantPack) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": pack.id,
|
||||||
|
"drug_variant_id": pack.drug_variant_id,
|
||||||
|
"label": pack.label,
|
||||||
|
"pack_unit_name": pack.pack_unit_name,
|
||||||
|
"pack_size_in_base_units": pack.pack_size_in_base_units,
|
||||||
|
"is_active": pack.is_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_pack_quantity(
|
||||||
|
db: Session,
|
||||||
|
variant_id: int,
|
||||||
|
quantity: Optional[float],
|
||||||
|
pack_id: Optional[int],
|
||||||
|
pack_count: Optional[float],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Resolve canonical base-unit quantity from either direct quantity or pack input."""
|
||||||
|
if quantity is None and pack_id is None and pack_count is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Either quantity or pack fields must be provided")
|
||||||
|
|
||||||
|
resolved_quantity = quantity
|
||||||
|
resolved_pack: Optional[VariantPack] = None
|
||||||
|
|
||||||
|
if pack_id is not None or pack_count is not None:
|
||||||
|
if pack_id is None or pack_count is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Both pack_id and pack_count are required when using pack input")
|
||||||
|
if pack_count <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Pack count must be greater than zero")
|
||||||
|
|
||||||
|
resolved_pack = (
|
||||||
|
db.query(VariantPack)
|
||||||
|
.filter(
|
||||||
|
VariantPack.id == pack_id,
|
||||||
|
VariantPack.drug_variant_id == variant_id,
|
||||||
|
VariantPack.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if resolved_pack is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Pack not found for variant or is inactive")
|
||||||
|
|
||||||
|
derived_quantity = pack_count * resolved_pack.pack_size_in_base_units
|
||||||
|
if derived_quantity <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Derived quantity from pack must be greater than zero")
|
||||||
|
|
||||||
|
if resolved_quantity is None:
|
||||||
|
resolved_quantity = derived_quantity
|
||||||
|
elif abs(resolved_quantity - derived_quantity) > 1e-6:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Quantity does not match pack conversion for selected pack",
|
||||||
|
)
|
||||||
|
|
||||||
|
if resolved_quantity is None or resolved_quantity <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Quantity must be greater than zero")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"quantity": resolved_quantity,
|
||||||
|
"pack_id": resolved_pack.id if resolved_pack else None,
|
||||||
|
"pack_count": pack_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def select_batches_for_dispense(
|
def select_batches_for_dispense(
|
||||||
db: Session,
|
db: Session,
|
||||||
variant_id: int,
|
variant_id: int,
|
||||||
@@ -706,12 +876,33 @@ def delete_drug(drug_id: int, db: Session = Depends(get_db), current_user: User
|
|||||||
raise HTTPException(status_code=404, detail="Drug not found")
|
raise HTTPException(status_code=404, detail="Drug not found")
|
||||||
|
|
||||||
variant_ids = [row[0] for row in db.query(DrugVariant.id).filter(DrugVariant.drug_id == drug_id).all()]
|
variant_ids = [row[0] for row in db.query(DrugVariant.id).filter(DrugVariant.drug_id == drug_id).all()]
|
||||||
|
|
||||||
|
if variant_ids:
|
||||||
|
has_batch_history = (
|
||||||
|
db.query(Batch.id)
|
||||||
|
.filter(Batch.drug_variant_id.in_(variant_ids))
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
has_dispense_history = (
|
||||||
|
db.query(Dispensing.id)
|
||||||
|
.filter(Dispensing.drug_variant_id.in_(variant_ids))
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_batch_history or has_dispense_history:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot delete drug with variants that have batch or dispensing history. Archive or manage records first.",
|
||||||
|
)
|
||||||
if variant_ids:
|
if variant_ids:
|
||||||
batch_ids = [row[0] for row in db.query(Batch.id).filter(Batch.drug_variant_id.in_(variant_ids)).all()]
|
batch_ids = [row[0] for row in db.query(Batch.id).filter(Batch.drug_variant_id.in_(variant_ids)).all()]
|
||||||
if batch_ids:
|
if batch_ids:
|
||||||
db.query(DispensingAllocation).filter(DispensingAllocation.batch_id.in_(batch_ids)).delete(synchronize_session=False)
|
db.query(DispensingAllocation).filter(DispensingAllocation.batch_id.in_(batch_ids)).delete(synchronize_session=False)
|
||||||
db.query(Batch).filter(Batch.id.in_(batch_ids)).delete(synchronize_session=False)
|
db.query(Batch).filter(Batch.id.in_(batch_ids)).delete(synchronize_session=False)
|
||||||
db.query(Dispensing).filter(Dispensing.drug_variant_id.in_(variant_ids)).delete(synchronize_session=False)
|
db.query(Dispensing).filter(Dispensing.drug_variant_id.in_(variant_ids)).delete(synchronize_session=False)
|
||||||
|
db.query(VariantPack).filter(VariantPack.drug_variant_id.in_(variant_ids)).delete(synchronize_session=False)
|
||||||
db.query(DrugVariant).filter(DrugVariant.id.in_(variant_ids)).delete(synchronize_session=False)
|
db.query(DrugVariant).filter(DrugVariant.id.in_(variant_ids)).delete(synchronize_session=False)
|
||||||
|
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
@@ -745,14 +936,31 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
|
|||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Variant with this strength already exists for this drug")
|
raise HTTPException(status_code=400, detail="Variant with this strength already exists for this drug")
|
||||||
|
|
||||||
|
base_unit = (variant.base_unit or variant.unit).strip()
|
||||||
|
if not base_unit:
|
||||||
|
raise HTTPException(status_code=400, detail="Variant unit/base_unit cannot be empty")
|
||||||
|
|
||||||
db_variant = DrugVariant(
|
db_variant = DrugVariant(
|
||||||
drug_id=drug_id,
|
drug_id=drug_id,
|
||||||
strength=variant.strength,
|
strength=variant.strength,
|
||||||
quantity=variant.quantity,
|
quantity=variant.quantity,
|
||||||
unit=variant.unit,
|
unit=base_unit,
|
||||||
low_stock_threshold=variant.low_stock_threshold
|
low_stock_threshold=variant.low_stock_threshold
|
||||||
)
|
)
|
||||||
db.add(db_variant)
|
db.add(db_variant)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Ensure each variant has at least one active default 1:1 pack representation.
|
||||||
|
db.add(
|
||||||
|
VariantPack(
|
||||||
|
drug_variant_id=db_variant.id,
|
||||||
|
label=f"1 {base_unit}",
|
||||||
|
pack_unit_name=base_unit,
|
||||||
|
pack_size_in_base_units=1,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
db,
|
db,
|
||||||
action="variant.create",
|
action="variant.create",
|
||||||
@@ -763,7 +971,7 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
|
|||||||
"drug_id": drug_id,
|
"drug_id": drug_id,
|
||||||
"strength": variant.strength,
|
"strength": variant.strength,
|
||||||
"quantity": variant.quantity,
|
"quantity": variant.quantity,
|
||||||
"unit": variant.unit,
|
"unit": base_unit,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -791,7 +999,48 @@ def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db:
|
|||||||
"unit": variant.unit,
|
"unit": variant.unit,
|
||||||
"low_stock_threshold": variant.low_stock_threshold,
|
"low_stock_threshold": variant.low_stock_threshold,
|
||||||
}
|
}
|
||||||
for field, value in variant_update.dict(exclude_unset=True).items():
|
payload = variant_update.dict(exclude_unset=True)
|
||||||
|
if "base_unit" in payload and payload["base_unit"] is not None:
|
||||||
|
cleaned_base_unit = payload["base_unit"].strip()
|
||||||
|
if not cleaned_base_unit:
|
||||||
|
raise HTTPException(status_code=400, detail="base_unit cannot be empty")
|
||||||
|
payload["unit"] = cleaned_base_unit
|
||||||
|
payload.pop("base_unit", None)
|
||||||
|
|
||||||
|
has_batch_history = (
|
||||||
|
db.query(Batch.id)
|
||||||
|
.filter(Batch.drug_variant_id == variant_id)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
has_dispense_history = (
|
||||||
|
db.query(Dispensing.id)
|
||||||
|
.filter(Dispensing.drug_variant_id == variant_id)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
is_locked = has_batch_history or has_dispense_history
|
||||||
|
|
||||||
|
locked_field_changes = []
|
||||||
|
if is_locked:
|
||||||
|
if "strength" in payload and payload["strength"] != variant.strength:
|
||||||
|
locked_field_changes.append("strength")
|
||||||
|
if "unit" in payload and payload["unit"] != variant.unit:
|
||||||
|
locked_field_changes.append("base_unit")
|
||||||
|
if "quantity" in payload and payload["quantity"] != variant.quantity:
|
||||||
|
locked_field_changes.append("quantity")
|
||||||
|
|
||||||
|
if locked_field_changes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
"Cannot change "
|
||||||
|
+ ", ".join(locked_field_changes)
|
||||||
|
+ " after batches or dispensing history exist for this variant"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in payload.items():
|
||||||
setattr(variant, field, value)
|
setattr(variant, field, value)
|
||||||
|
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
@@ -800,7 +1049,7 @@ def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db:
|
|||||||
entity_type="drug_variant",
|
entity_type="drug_variant",
|
||||||
entity_id=variant.id,
|
entity_id=variant.id,
|
||||||
actor=current_user,
|
actor=current_user,
|
||||||
details={"before": before, "after": variant_update.dict(exclude_unset=True)},
|
details={"before": before, "after": payload},
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -814,12 +1063,32 @@ def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_
|
|||||||
if not variant:
|
if not variant:
|
||||||
raise HTTPException(status_code=404, detail="Drug variant not found")
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
|
||||||
|
has_batch_history = (
|
||||||
|
db.query(Batch.id)
|
||||||
|
.filter(Batch.drug_variant_id == variant_id)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
has_dispense_history = (
|
||||||
|
db.query(Dispensing.id)
|
||||||
|
.filter(Dispensing.drug_variant_id == variant_id)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_batch_history or has_dispense_history:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot delete variant with batch or dispensing history. Archive or manage records first.",
|
||||||
|
)
|
||||||
|
|
||||||
batch_ids = [row[0] for row in db.query(Batch.id).filter(Batch.drug_variant_id == variant_id).all()]
|
batch_ids = [row[0] for row in db.query(Batch.id).filter(Batch.drug_variant_id == variant_id).all()]
|
||||||
if batch_ids:
|
if batch_ids:
|
||||||
db.query(DispensingAllocation).filter(DispensingAllocation.batch_id.in_(batch_ids)).delete(synchronize_session=False)
|
db.query(DispensingAllocation).filter(DispensingAllocation.batch_id.in_(batch_ids)).delete(synchronize_session=False)
|
||||||
db.query(Batch).filter(Batch.id.in_(batch_ids)).delete(synchronize_session=False)
|
db.query(Batch).filter(Batch.id.in_(batch_ids)).delete(synchronize_session=False)
|
||||||
|
|
||||||
db.query(Dispensing).filter(Dispensing.drug_variant_id == variant_id).delete(synchronize_session=False)
|
db.query(Dispensing).filter(Dispensing.drug_variant_id == variant_id).delete(synchronize_session=False)
|
||||||
|
db.query(VariantPack).filter(VariantPack.drug_variant_id == variant_id).delete(synchronize_session=False)
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
db,
|
db,
|
||||||
action="variant.delete",
|
action="variant.delete",
|
||||||
@@ -833,29 +1102,180 @@ def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_
|
|||||||
|
|
||||||
return {"message": "Drug variant deleted successfully"}
|
return {"message": "Drug variant deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/variants/{variant_id}/packs", response_model=List[VariantPackResponse])
|
||||||
|
def list_variant_packs(
|
||||||
|
variant_id: int,
|
||||||
|
active_only: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||||
|
if not variant:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
|
||||||
|
query = db.query(VariantPack).filter(VariantPack.drug_variant_id == variant_id)
|
||||||
|
if active_only:
|
||||||
|
query = query.filter(VariantPack.is_active.is_(True))
|
||||||
|
|
||||||
|
packs = query.order_by(VariantPack.is_active.desc(), VariantPack.id.asc()).all()
|
||||||
|
return [serialize_variant_pack(pack) for pack in packs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/variants/{variant_id}/packs", response_model=VariantPackResponse)
|
||||||
|
def create_variant_pack(
|
||||||
|
variant_id: int,
|
||||||
|
payload: VariantPackCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_non_readonly_user),
|
||||||
|
):
|
||||||
|
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||||
|
if not variant:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
|
||||||
|
label = payload.label.strip()
|
||||||
|
pack_unit_name = payload.pack_unit_name.strip()
|
||||||
|
if not label:
|
||||||
|
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
|
||||||
|
if not pack_unit_name:
|
||||||
|
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
|
||||||
|
if payload.pack_size_in_base_units <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Pack size in base units must be greater than zero")
|
||||||
|
|
||||||
|
row = VariantPack(
|
||||||
|
drug_variant_id=variant_id,
|
||||||
|
label=label,
|
||||||
|
pack_unit_name=pack_unit_name,
|
||||||
|
pack_size_in_base_units=payload.pack_size_in_base_units,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="variant_pack.create",
|
||||||
|
entity_type="variant_pack",
|
||||||
|
entity_id=None,
|
||||||
|
actor=current_user,
|
||||||
|
details={
|
||||||
|
"variant_id": variant_id,
|
||||||
|
"label": label,
|
||||||
|
"pack_unit_name": pack_unit_name,
|
||||||
|
"pack_size_in_base_units": payload.pack_size_in_base_units,
|
||||||
|
"is_active": payload.is_active,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return serialize_variant_pack(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/variant-packs/{pack_id}", response_model=VariantPackResponse)
|
||||||
|
def update_variant_pack(
|
||||||
|
pack_id: int,
|
||||||
|
payload: VariantPackUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_non_readonly_user),
|
||||||
|
):
|
||||||
|
row = db.query(VariantPack).filter(VariantPack.id == pack_id).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Variant pack not found")
|
||||||
|
|
||||||
|
before = serialize_variant_pack(row)
|
||||||
|
|
||||||
|
if payload.label is not None:
|
||||||
|
cleaned = payload.label.strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
|
||||||
|
row.label = cleaned
|
||||||
|
|
||||||
|
if payload.pack_unit_name is not None:
|
||||||
|
cleaned = payload.pack_unit_name.strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
|
||||||
|
row.pack_unit_name = cleaned
|
||||||
|
|
||||||
|
if payload.pack_size_in_base_units is not None:
|
||||||
|
if payload.pack_size_in_base_units <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Pack size in base units must be greater than zero")
|
||||||
|
row.pack_size_in_base_units = payload.pack_size_in_base_units
|
||||||
|
|
||||||
|
if payload.is_active is not None and payload.is_active is False:
|
||||||
|
# Keep at least one active pack per variant to preserve usable receive/dispense UX.
|
||||||
|
active_count = db.query(VariantPack).filter(
|
||||||
|
VariantPack.drug_variant_id == row.drug_variant_id,
|
||||||
|
VariantPack.is_active.is_(True),
|
||||||
|
VariantPack.id != row.id,
|
||||||
|
).count()
|
||||||
|
if active_count == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="At least one active pack must remain for this variant")
|
||||||
|
row.is_active = False
|
||||||
|
elif payload.is_active is not None and payload.is_active is True:
|
||||||
|
row.is_active = True
|
||||||
|
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="variant_pack.update",
|
||||||
|
entity_type="variant_pack",
|
||||||
|
entity_id=pack_id,
|
||||||
|
actor=current_user,
|
||||||
|
details={"before": before, "after": payload.dict(exclude_unset=True)},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return serialize_variant_pack(row)
|
||||||
|
|
||||||
# Dispensing endpoints
|
# Dispensing endpoints
|
||||||
@router.post("/dispense", response_model=DispensingResponse)
|
@router.post("/dispense", response_model=DispensingResponse)
|
||||||
def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)):
|
def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)):
|
||||||
"""Record a drug dispensing and reduce inventory"""
|
"""Record a drug dispensing and reduce inventory"""
|
||||||
if dispensing.quantity <= 0:
|
|
||||||
raise HTTPException(status_code=400, detail="Quantity must be greater than zero")
|
|
||||||
|
|
||||||
# Check if drug variant exists
|
# Check if drug variant exists
|
||||||
variant = db.query(DrugVariant).filter(DrugVariant.id == dispensing.drug_variant_id).first()
|
variant = db.query(DrugVariant).filter(DrugVariant.id == dispensing.drug_variant_id).first()
|
||||||
if not variant:
|
if not variant:
|
||||||
raise HTTPException(status_code=404, detail="Drug variant not found")
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
|
||||||
|
dispense_mode = (dispensing.dispense_mode or "subunit").strip().lower()
|
||||||
|
if dispense_mode not in {"subunit", "pack"}:
|
||||||
|
raise HTTPException(status_code=400, detail="dispense_mode must be either 'subunit' or 'pack'")
|
||||||
|
|
||||||
|
if dispense_mode == "pack":
|
||||||
|
if dispensing.requested_pack_id is None or dispensing.requested_pack_count is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Pack dispense requires requested_pack_id and requested_pack_count")
|
||||||
|
if dispensing.requested_pack_count <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Pack count must be greater than zero")
|
||||||
|
if abs(dispensing.requested_pack_count - round(dispensing.requested_pack_count)) > 1e-6:
|
||||||
|
raise HTTPException(status_code=400, detail="Whole-pack dispense requires an integer pack count")
|
||||||
|
|
||||||
|
resolved = resolve_pack_quantity(
|
||||||
|
db,
|
||||||
|
variant_id=variant.id,
|
||||||
|
quantity=None,
|
||||||
|
pack_id=dispensing.requested_pack_id,
|
||||||
|
pack_count=dispensing.requested_pack_count,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if dispensing.quantity is None or dispensing.quantity <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Subunit dispense requires quantity > 0")
|
||||||
|
resolved = resolve_pack_quantity(
|
||||||
|
db,
|
||||||
|
variant_id=variant.id,
|
||||||
|
quantity=dispensing.quantity,
|
||||||
|
pack_id=None,
|
||||||
|
pack_count=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
dispense_qty = resolved["quantity"]
|
||||||
|
|
||||||
# Check if enough total quantity available from active stock (legacy and batch-based remain in sync).
|
# Check if enough total quantity available from active stock (legacy and batch-based remain in sync).
|
||||||
if variant.quantity < dispensing.quantity:
|
if variant.quantity < dispense_qty:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispensing.quantity}",
|
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispense_qty}",
|
||||||
)
|
)
|
||||||
|
|
||||||
allocations = select_batches_for_dispense(
|
allocations = select_batches_for_dispense(
|
||||||
db,
|
db,
|
||||||
variant_id=variant.id,
|
variant_id=variant.id,
|
||||||
requested_quantity=dispensing.quantity,
|
requested_quantity=dispense_qty,
|
||||||
preferred_batch_id=dispensing.batch_id,
|
preferred_batch_id=dispensing.batch_id,
|
||||||
allow_split=dispensing.allow_split,
|
allow_split=dispensing.allow_split,
|
||||||
)
|
)
|
||||||
@@ -867,7 +1287,10 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
|||||||
drug_variant_id=dispensing.drug_variant_id,
|
drug_variant_id=dispensing.drug_variant_id,
|
||||||
batch_id=primary_batch_id,
|
batch_id=primary_batch_id,
|
||||||
actor_user_id=current_user.id,
|
actor_user_id=current_user.id,
|
||||||
quantity=dispensing.quantity,
|
quantity=dispense_qty,
|
||||||
|
dispense_mode=dispense_mode,
|
||||||
|
requested_pack_id=resolved["pack_id"],
|
||||||
|
requested_pack_count=resolved["pack_count"],
|
||||||
animal_name=dispensing.animal_name,
|
animal_name=dispensing.animal_name,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
notes=dispensing.notes,
|
notes=dispensing.notes,
|
||||||
@@ -880,11 +1303,12 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
|||||||
batch = allocation["batch"]
|
batch = allocation["batch"]
|
||||||
qty = allocation["quantity"]
|
qty = allocation["quantity"]
|
||||||
batch.quantity -= qty
|
batch.quantity -= qty
|
||||||
|
recompute_batch_pack_state(batch)
|
||||||
allocation_payload.append({"batch_id": batch.id, "quantity": qty})
|
allocation_payload.append({"batch_id": batch.id, "quantity": qty})
|
||||||
db.add(DispensingAllocation(dispensing_id=db_dispensing.id, batch_id=batch.id, quantity=qty))
|
db.add(DispensingAllocation(dispensing_id=db_dispensing.id, batch_id=batch.id, quantity=qty))
|
||||||
|
|
||||||
# Keep legacy variant quantity field in sync for existing frontend flows.
|
# Keep legacy variant quantity field in sync for existing frontend flows.
|
||||||
variant.quantity -= dispensing.quantity
|
variant.quantity -= dispense_qty
|
||||||
|
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
db,
|
db,
|
||||||
@@ -894,7 +1318,10 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
|||||||
actor=current_user,
|
actor=current_user,
|
||||||
details={
|
details={
|
||||||
"drug_variant_id": dispensing.drug_variant_id,
|
"drug_variant_id": dispensing.drug_variant_id,
|
||||||
"requested_quantity": dispensing.quantity,
|
"requested_quantity": dispense_qty,
|
||||||
|
"dispense_mode": dispense_mode,
|
||||||
|
"requested_pack_id": resolved["pack_id"],
|
||||||
|
"requested_pack_count": resolved["pack_count"],
|
||||||
"allocations": allocation_payload,
|
"allocations": allocation_payload,
|
||||||
"animal_name": dispensing.animal_name,
|
"animal_name": dispensing.animal_name,
|
||||||
"notes": dispensing.notes,
|
"notes": dispensing.notes,
|
||||||
@@ -909,6 +1336,9 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
|||||||
"batch_id": db_dispensing.batch_id,
|
"batch_id": db_dispensing.batch_id,
|
||||||
"actor_user_id": db_dispensing.actor_user_id,
|
"actor_user_id": db_dispensing.actor_user_id,
|
||||||
"quantity": db_dispensing.quantity,
|
"quantity": db_dispensing.quantity,
|
||||||
|
"dispense_mode": db_dispensing.dispense_mode,
|
||||||
|
"requested_pack_id": db_dispensing.requested_pack_id,
|
||||||
|
"requested_pack_count": db_dispensing.requested_pack_count,
|
||||||
"animal_name": db_dispensing.animal_name,
|
"animal_name": db_dispensing.animal_name,
|
||||||
"user_name": db_dispensing.user_name,
|
"user_name": db_dispensing.user_name,
|
||||||
"notes": db_dispensing.notes,
|
"notes": db_dispensing.notes,
|
||||||
@@ -930,6 +1360,9 @@ def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_
|
|||||||
"batch_id": item.batch_id,
|
"batch_id": item.batch_id,
|
||||||
"actor_user_id": item.actor_user_id,
|
"actor_user_id": item.actor_user_id,
|
||||||
"quantity": item.quantity,
|
"quantity": item.quantity,
|
||||||
|
"dispense_mode": item.dispense_mode,
|
||||||
|
"requested_pack_id": item.requested_pack_id,
|
||||||
|
"requested_pack_count": item.requested_pack_count,
|
||||||
"animal_name": item.animal_name,
|
"animal_name": item.animal_name,
|
||||||
"user_name": item.user_name,
|
"user_name": item.user_name,
|
||||||
"notes": item.notes,
|
"notes": item.notes,
|
||||||
@@ -961,6 +1394,9 @@ def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db), current_us
|
|||||||
"batch_id": item.batch_id,
|
"batch_id": item.batch_id,
|
||||||
"actor_user_id": item.actor_user_id,
|
"actor_user_id": item.actor_user_id,
|
||||||
"quantity": item.quantity,
|
"quantity": item.quantity,
|
||||||
|
"dispense_mode": item.dispense_mode,
|
||||||
|
"requested_pack_id": item.requested_pack_id,
|
||||||
|
"requested_pack_count": item.requested_pack_count,
|
||||||
"animal_name": item.animal_name,
|
"animal_name": item.animal_name,
|
||||||
"user_name": item.user_name,
|
"user_name": item.user_name,
|
||||||
"notes": item.notes,
|
"notes": item.notes,
|
||||||
@@ -989,6 +1425,9 @@ def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db), curr
|
|||||||
"batch_id": item.batch_id,
|
"batch_id": item.batch_id,
|
||||||
"actor_user_id": item.actor_user_id,
|
"actor_user_id": item.actor_user_id,
|
||||||
"quantity": item.quantity,
|
"quantity": item.quantity,
|
||||||
|
"dispense_mode": item.dispense_mode,
|
||||||
|
"requested_pack_id": item.requested_pack_id,
|
||||||
|
"requested_pack_count": item.requested_pack_count,
|
||||||
"animal_name": item.animal_name,
|
"animal_name": item.animal_name,
|
||||||
"user_name": item.user_name,
|
"user_name": item.user_name,
|
||||||
"notes": item.notes,
|
"notes": item.notes,
|
||||||
@@ -1090,8 +1529,15 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
|
|||||||
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||||
if not variant:
|
if not variant:
|
||||||
raise HTTPException(status_code=404, detail="Drug variant not found")
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
if payload.quantity <= 0:
|
|
||||||
raise HTTPException(status_code=400, detail="Batch quantity must be greater than zero")
|
resolved = resolve_pack_quantity(
|
||||||
|
db,
|
||||||
|
variant_id=variant_id,
|
||||||
|
quantity=payload.quantity,
|
||||||
|
pack_id=payload.received_pack_id,
|
||||||
|
pack_count=payload.received_pack_count,
|
||||||
|
)
|
||||||
|
batch_quantity = resolved["quantity"]
|
||||||
|
|
||||||
location = db.query(Location).filter(Location.id == payload.location_id, Location.is_active.is_(True)).first()
|
location = db.query(Location).filter(Location.id == payload.location_id, Location.is_active.is_(True)).first()
|
||||||
if not location:
|
if not location:
|
||||||
@@ -1112,14 +1558,18 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
|
|||||||
row = Batch(
|
row = Batch(
|
||||||
drug_variant_id=variant_id,
|
drug_variant_id=variant_id,
|
||||||
batch_number=batch_number,
|
batch_number=batch_number,
|
||||||
quantity=payload.quantity,
|
quantity=batch_quantity,
|
||||||
|
received_pack_id=resolved["pack_id"],
|
||||||
|
received_pack_count=resolved["pack_count"],
|
||||||
|
received_pack_size_snapshot=resolve_pack_size_snapshot(db, resolved["pack_id"]),
|
||||||
expiry_date=payload.expiry_date,
|
expiry_date=payload.expiry_date,
|
||||||
location_id=payload.location_id,
|
location_id=payload.location_id,
|
||||||
notes=payload.notes,
|
notes=payload.notes,
|
||||||
)
|
)
|
||||||
|
recompute_batch_pack_state(row)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
|
|
||||||
variant.quantity += payload.quantity
|
variant.quantity += batch_quantity
|
||||||
|
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
db,
|
db,
|
||||||
@@ -1130,7 +1580,10 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
|
|||||||
details={
|
details={
|
||||||
"variant_id": variant_id,
|
"variant_id": variant_id,
|
||||||
"batch_number": batch_number,
|
"batch_number": batch_number,
|
||||||
"quantity": payload.quantity,
|
"quantity": batch_quantity,
|
||||||
|
"received_pack_id": resolved["pack_id"],
|
||||||
|
"received_pack_count": resolved["pack_count"],
|
||||||
|
"received_pack_size_snapshot": row.received_pack_size_snapshot,
|
||||||
"expiry_date": payload.expiry_date,
|
"expiry_date": payload.expiry_date,
|
||||||
"location_id": payload.location_id,
|
"location_id": payload.location_id,
|
||||||
},
|
},
|
||||||
@@ -1153,6 +1606,11 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
|
|||||||
before = {
|
before = {
|
||||||
"batch_number": batch.batch_number,
|
"batch_number": batch.batch_number,
|
||||||
"quantity": batch.quantity,
|
"quantity": batch.quantity,
|
||||||
|
"received_pack_id": batch.received_pack_id,
|
||||||
|
"received_pack_count": batch.received_pack_count,
|
||||||
|
"received_pack_size_snapshot": batch.received_pack_size_snapshot,
|
||||||
|
"current_full_pack_count": batch.current_full_pack_count,
|
||||||
|
"current_loose_base_units": batch.current_loose_base_units,
|
||||||
"expiry_date": batch.expiry_date,
|
"expiry_date": batch.expiry_date,
|
||||||
"location_id": batch.location_id,
|
"location_id": batch.location_id,
|
||||||
"notes": batch.notes,
|
"notes": batch.notes,
|
||||||
@@ -1187,14 +1645,42 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
|
|||||||
if payload.notes is not None:
|
if payload.notes is not None:
|
||||||
batch.notes = payload.notes
|
batch.notes = payload.notes
|
||||||
|
|
||||||
if payload.quantity is not None:
|
if payload.received_pack_id is not None or payload.received_pack_count is not None or payload.quantity is not None:
|
||||||
if payload.quantity < 0:
|
if payload.quantity is not None and payload.quantity < 0:
|
||||||
raise HTTPException(status_code=400, detail="Batch quantity cannot be negative")
|
raise HTTPException(status_code=400, detail="Batch quantity cannot be negative")
|
||||||
delta = payload.quantity - batch.quantity
|
|
||||||
|
if payload.received_pack_id is None and payload.received_pack_count is None:
|
||||||
|
if payload.quantity is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Batch quantity cannot be empty")
|
||||||
|
resolved_quantity = payload.quantity
|
||||||
|
resolved_pack_id = batch.received_pack_id
|
||||||
|
resolved_pack_count = batch.received_pack_count
|
||||||
|
else:
|
||||||
|
target_pack_id = payload.received_pack_id if payload.received_pack_id is not None else batch.received_pack_id
|
||||||
|
target_pack_count = payload.received_pack_count if payload.received_pack_count is not None else batch.received_pack_count
|
||||||
|
if target_pack_id is None or target_pack_count is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Both pack_id and pack_count are required for pack-based updates")
|
||||||
|
|
||||||
|
resolved = resolve_pack_quantity(
|
||||||
|
db,
|
||||||
|
variant_id=batch.drug_variant_id,
|
||||||
|
quantity=payload.quantity,
|
||||||
|
pack_id=target_pack_id,
|
||||||
|
pack_count=target_pack_count,
|
||||||
|
)
|
||||||
|
resolved_quantity = resolved["quantity"]
|
||||||
|
resolved_pack_id = resolved["pack_id"]
|
||||||
|
resolved_pack_count = resolved["pack_count"]
|
||||||
|
|
||||||
|
delta = resolved_quantity - batch.quantity
|
||||||
projected_variant_qty = variant.quantity + delta
|
projected_variant_qty = variant.quantity + delta
|
||||||
if projected_variant_qty < 0:
|
if projected_variant_qty < 0:
|
||||||
raise HTTPException(status_code=400, detail="Variant quantity cannot become negative")
|
raise HTTPException(status_code=400, detail="Variant quantity cannot become negative")
|
||||||
batch.quantity = payload.quantity
|
batch.quantity = resolved_quantity
|
||||||
|
batch.received_pack_id = resolved_pack_id
|
||||||
|
batch.received_pack_count = resolved_pack_count
|
||||||
|
batch.received_pack_size_snapshot = resolve_pack_size_snapshot(db, resolved_pack_id)
|
||||||
|
recompute_batch_pack_state(batch)
|
||||||
variant.quantity = projected_variant_qty
|
variant.quantity = projected_variant_qty
|
||||||
|
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
@@ -1276,6 +1762,9 @@ def report_controlled_movement(
|
|||||||
"drug_name": drug.name,
|
"drug_name": drug.name,
|
||||||
"strength": variant.strength,
|
"strength": variant.strength,
|
||||||
"quantity": d.quantity,
|
"quantity": d.quantity,
|
||||||
|
"dispense_mode": d.dispense_mode,
|
||||||
|
"requested_pack_id": d.requested_pack_id,
|
||||||
|
"requested_pack_count": d.requested_pack_count,
|
||||||
"user_name": d.user_name,
|
"user_name": d.user_name,
|
||||||
"animal_name": d.animal_name,
|
"animal_name": d.animal_name,
|
||||||
"batch_id": d.batch_id,
|
"batch_id": d.batch_id,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+934
-25
File diff suppressed because it is too large
Load Diff
+64
-14
@@ -205,10 +205,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
<label for="dispenseMode">Dispense Mode *</label>
|
||||||
|
<select id="dispenseMode" onchange="updateDispenseModeUi()">
|
||||||
|
<option value="subunit">Subunit Quantity</option>
|
||||||
|
<option value="pack">Whole Packs</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="dispenseQuantityGroup">
|
||||||
<label for="dispenseQuantity">Quantity *</label>
|
<label for="dispenseQuantity">Quantity *</label>
|
||||||
<input type="number" id="dispenseQuantity" step="0.1" onchange="updateAllocationPreview()">
|
<input type="number" id="dispenseQuantity" step="0.1" onchange="updateAllocationPreview()">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row" id="dispensePackRow" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dispensePackSelect">Pack Type *</label>
|
||||||
|
<select id="dispensePackSelect" onchange="updateDispenseQuantityFromPack()">
|
||||||
|
<option value="">-- Select pack --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dispensePackCount">Pack Count *</label>
|
||||||
|
<input type="number" id="dispensePackCount" min="0.0001" step="0.0001" onchange="updateDispenseQuantityFromPack()">
|
||||||
|
<small id="dispensePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" id="dispenseAllowSplit" onchange="updateAllocationPreview()">
|
<input type="checkbox" id="dispenseAllowSplit" onchange="updateAllocationPreview()">
|
||||||
@@ -307,23 +329,21 @@
|
|||||||
<input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 10.8mg, 100ml" required>
|
<input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 10.8mg, 100ml" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="variantQuantity">Quantity *</label>
|
<label for="variantUnit">Base Unit *</label>
|
||||||
<input type="number" id="variantQuantity" step="0.1" required>
|
<select id="variantUnit">
|
||||||
|
<option value="ml">ml</option>
|
||||||
|
<option value="tablets">tablets</option>
|
||||||
|
<option value="capsules">capsules</option>
|
||||||
|
<option value="units">units</option>
|
||||||
|
<option value="vials">vials</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="variantUnit">Unit *</label>
|
<label>Pack Sizes *</label>
|
||||||
<select id="variantUnit">
|
<div id="variantPackRows" class="delivery-lines"></div>
|
||||||
<option value="tablets">Tablets</option>
|
<button type="button" id="addVariantPackRowBtn" class="btn btn-secondary btn-small">+ Add Another Size</button>
|
||||||
<option value="bottles">Bottles</option>
|
|
||||||
<option value="boxes">boxes</option>
|
|
||||||
<option value="vials">Vials</option>
|
|
||||||
<option value="units">Units</option>
|
|
||||||
<option value="packets">Packets</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -346,6 +366,9 @@
|
|||||||
<h2>Edit Variant</h2>
|
<h2>Edit Variant</h2>
|
||||||
<form id="editVariantForm">
|
<form id="editVariantForm">
|
||||||
<input type="hidden" id="editVariantId">
|
<input type="hidden" id="editVariantId">
|
||||||
|
<p id="editVariantLockNotice" style="display:none; margin: 0 0 12px; padding: 8px 10px; background: #fff8e1; border: 1px solid #f5c15d; border-radius: 6px; color: #7a4f01;">
|
||||||
|
Strength, quantity, and base unit are locked once this variant has stock/batch history.
|
||||||
|
</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editVariantStrength">Strength *</label>
|
<label for="editVariantStrength">Strength *</label>
|
||||||
<input type="text" id="editVariantStrength" required>
|
<input type="text" id="editVariantStrength" required>
|
||||||
@@ -358,7 +381,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editVariantUnit">Unit *</label>
|
<label for="editVariantUnit">Base Unit *</label>
|
||||||
<select id="editVariantUnit">
|
<select id="editVariantUnit">
|
||||||
<option value="tablets">Tablets</option>
|
<option value="tablets">Tablets</option>
|
||||||
<option value="bottles">Bottles</option>
|
<option value="bottles">Bottles</option>
|
||||||
@@ -370,6 +393,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Add Pack Sizes</label>
|
||||||
|
<div id="editVariantPackRows" class="delivery-lines"></div>
|
||||||
|
<button type="button" id="addEditVariantPackRowBtn" class="btn btn-secondary btn-small">+ Add Another Size</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editVariantThreshold">Low Stock Threshold *</label>
|
<label for="editVariantThreshold">Low Stock Threshold *</label>
|
||||||
<input type="number" id="editVariantThreshold" step="0.1" required>
|
<input type="number" id="editVariantThreshold" step="0.1" required>
|
||||||
@@ -575,6 +604,27 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Receive Delivery Modal -->
|
||||||
|
<div id="receiveDeliveryModal" class="modal">
|
||||||
|
<div class="modal-content modal-large receive-delivery-modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Receive Delivery</h2>
|
||||||
|
<p id="receiveDeliveryDrugLabel" style="margin: 6px 0 16px; color: #666; font-weight: 600;"></p>
|
||||||
|
<form id="receiveDeliveryForm" novalidate>
|
||||||
|
<div id="deliveryLinesContainer" class="delivery-lines"></div>
|
||||||
|
<div class="delivery-toolbar">
|
||||||
|
<button type="button" id="addDeliveryLineBtn" class="btn btn-secondary">+ Add Delivery Line</button>
|
||||||
|
<button type="button" id="addVariantFromDeliveryBtn" class="btn btn-info">+ Add Variant</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Receive Delivery</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelReceiveDeliveryBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|||||||
@@ -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