Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b958ca493b | |||
| 5b5e17ec3e |
+262
-46
@@ -134,6 +134,10 @@ class BatchUpdate(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class BatchDisposeRequest(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class BatchResponse(BaseModel):
|
||||
id: int
|
||||
drug_variant_id: int
|
||||
@@ -150,6 +154,10 @@ class BatchResponse(BaseModel):
|
||||
location_name: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
received_at: datetime
|
||||
disposed_at: Optional[datetime] = None
|
||||
disposed_by_user_id: Optional[int] = None
|
||||
disposed_quantity: Optional[float] = None
|
||||
disposal_notes: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -228,17 +236,21 @@ class DrugWithVariantsResponse(BaseModel):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class DispensingAllocationCreate(BaseModel):
|
||||
batch_id: int
|
||||
quantity: float
|
||||
|
||||
|
||||
class DispensingCreate(BaseModel):
|
||||
drug_variant_id: int
|
||||
quantity: Optional[float] = None
|
||||
dispense_mode: str = "subunit"
|
||||
batch_id: Optional[int] = None
|
||||
requested_pack_id: Optional[int] = None
|
||||
requested_pack_count: Optional[float] = None
|
||||
allow_split: bool = True
|
||||
animal_name: Optional[str] = None
|
||||
user_name: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
allocations: List[DispensingAllocationCreate] = []
|
||||
|
||||
|
||||
class DispensingAllocationResponse(BaseModel):
|
||||
@@ -380,6 +392,10 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
|
||||
"location_name": location.name if location else None,
|
||||
"notes": batch.notes,
|
||||
"received_at": batch.received_at,
|
||||
"disposed_at": batch.disposed_at,
|
||||
"disposed_by_user_id": batch.disposed_by_user_id,
|
||||
"disposed_quantity": batch.disposed_quantity,
|
||||
"disposal_notes": batch.disposal_notes,
|
||||
}
|
||||
|
||||
|
||||
@@ -473,15 +489,19 @@ def resolve_pack_quantity(
|
||||
}
|
||||
|
||||
|
||||
def select_batches_for_dispense(
|
||||
def resolve_requested_allocations(
|
||||
db: Session,
|
||||
variant_id: int,
|
||||
variant_quantity: float,
|
||||
requested_quantity: float,
|
||||
preferred_batch_id: Optional[int],
|
||||
allow_split: bool,
|
||||
requested_allocations: List[DispensingAllocationCreate],
|
||||
dispense_mode: str,
|
||||
requested_pack_id: Optional[int],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Select one or more batch allocations using FEFO with optional preferred batch override."""
|
||||
"""Validate explicit batch allocations against in-date stock for the variant."""
|
||||
today = date.today()
|
||||
total_batched_quantity = sum(float(batch.quantity or 0) for batch in db.query(Batch).filter(Batch.drug_variant_id == variant_id).all())
|
||||
legacy_unbatched_quantity = max(0.0, float(variant_quantity or 0) - total_batched_quantity)
|
||||
eligible_batches = (
|
||||
db.query(Batch)
|
||||
.filter(
|
||||
@@ -494,49 +514,109 @@ def select_batches_for_dispense(
|
||||
)
|
||||
|
||||
if not eligible_batches:
|
||||
if dispense_mode == "pack":
|
||||
raise HTTPException(status_code=400, detail="Whole-pack dispensing requires batched stock with pack information")
|
||||
if requested_allocations:
|
||||
raise HTTPException(status_code=400, detail="Batch allocations cannot be supplied when dispensing legacy stock")
|
||||
if legacy_unbatched_quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="No in-date stock batches available for this variant")
|
||||
|
||||
remaining = requested_quantity
|
||||
allocations: List[Dict[str, Any]] = []
|
||||
|
||||
# If a preferred batch is supplied, consume from that batch first.
|
||||
if preferred_batch_id is not None:
|
||||
preferred = next((b for b in eligible_batches if b.id == preferred_batch_id), None)
|
||||
if preferred is None:
|
||||
raise HTTPException(status_code=400, detail="Preferred batch is unavailable or expired")
|
||||
|
||||
used = min(preferred.quantity, remaining)
|
||||
if used > 0:
|
||||
allocations.append({"batch": preferred, "quantity": used})
|
||||
remaining -= used
|
||||
eligible_batches = [b for b in eligible_batches if b.id != preferred_batch_id]
|
||||
|
||||
if remaining > 0 and not allow_split:
|
||||
if requested_quantity - legacy_unbatched_quantity > 1e-6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Preferred batch cannot fully satisfy request. Remaining required: {remaining}",
|
||||
detail=f"Insufficient unbatched stock. Available: {legacy_unbatched_quantity}, Requested: {requested_quantity}",
|
||||
)
|
||||
return []
|
||||
|
||||
if not requested_allocations:
|
||||
raise HTTPException(status_code=400, detail="At least one batch allocation is required")
|
||||
|
||||
eligible_by_id = {batch.id: batch for batch in eligible_batches}
|
||||
seen_batch_ids = set()
|
||||
allocations: List[Dict[str, Any]] = []
|
||||
total_allocated = 0.0
|
||||
selected_pack = None
|
||||
selected_pack_size = None
|
||||
|
||||
if dispense_mode == "pack":
|
||||
if requested_pack_id is None:
|
||||
raise HTTPException(status_code=400, detail="Pack dispense requires a requested pack")
|
||||
|
||||
selected_pack = (
|
||||
db.query(VariantPack)
|
||||
.filter(
|
||||
VariantPack.id == requested_pack_id,
|
||||
VariantPack.drug_variant_id == variant_id,
|
||||
VariantPack.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if selected_pack is None:
|
||||
raise HTTPException(status_code=400, detail="Selected pack is unavailable for this variant")
|
||||
|
||||
selected_pack_size = selected_pack.pack_size_in_base_units
|
||||
total_full_packs_available = sum(
|
||||
int(batch.current_full_pack_count or 0)
|
||||
for batch in eligible_batches
|
||||
if batch.received_pack_id == requested_pack_id
|
||||
)
|
||||
if total_full_packs_available <= 0:
|
||||
raise HTTPException(status_code=400, detail="No full packs are available for the selected pack")
|
||||
if requested_quantity - (total_full_packs_available * selected_pack_size) > 1e-6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Only {total_full_packs_available} full packs are available for the selected pack",
|
||||
)
|
||||
|
||||
if remaining > 0 and not allow_split:
|
||||
# In non-split mode, only a single batch may satisfy the request.
|
||||
first = eligible_batches[0]
|
||||
if first.quantity >= remaining:
|
||||
allocations.append({"batch": first, "quantity": remaining})
|
||||
remaining = 0
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Single-batch fulfillment is not possible for requested quantity")
|
||||
for entry in requested_allocations:
|
||||
batch = eligible_by_id.get(entry.batch_id)
|
||||
if batch is None:
|
||||
raise HTTPException(status_code=400, detail=f"Batch {entry.batch_id} is unavailable, expired, or not valid for this variant")
|
||||
if entry.batch_id in seen_batch_ids:
|
||||
raise HTTPException(status_code=400, detail="Each batch may only be allocated once")
|
||||
if entry.quantity < 0:
|
||||
raise HTTPException(status_code=400, detail="Batch allocation quantity cannot be negative")
|
||||
if entry.quantity == 0:
|
||||
continue
|
||||
if entry.quantity - batch.quantity > 1e-6:
|
||||
raise HTTPException(status_code=400, detail=f"Batch {batch.batch_number} does not have enough stock for requested allocation")
|
||||
|
||||
if remaining > 0:
|
||||
for batch in eligible_batches:
|
||||
if remaining <= 0:
|
||||
break
|
||||
used = min(batch.quantity, remaining)
|
||||
if used > 0:
|
||||
allocations.append({"batch": batch, "quantity": used})
|
||||
remaining -= used
|
||||
if dispense_mode == "pack":
|
||||
if batch.received_pack_id != requested_pack_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Batch {batch.batch_number} does not contain full packs of the selected pack type",
|
||||
)
|
||||
|
||||
if remaining > 0:
|
||||
raise HTTPException(status_code=400, detail="Insufficient in-date stock across available batches")
|
||||
available_full_packs = int(batch.current_full_pack_count or 0)
|
||||
available_batch_quantity = available_full_packs * selected_pack_size
|
||||
if available_full_packs <= 0 or available_batch_quantity <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Batch {batch.batch_number} has no full packs available",
|
||||
)
|
||||
if abs((entry.quantity / selected_pack_size) - round(entry.quantity / selected_pack_size)) > 1e-6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Batch {batch.batch_number} allocation must be a whole number of packs",
|
||||
)
|
||||
if entry.quantity - available_batch_quantity > 1e-6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Batch {batch.batch_number} only has {available_full_packs} full packs available",
|
||||
)
|
||||
|
||||
seen_batch_ids.add(entry.batch_id)
|
||||
allocations.append({"batch": batch, "quantity": entry.quantity})
|
||||
total_allocated += entry.quantity
|
||||
|
||||
if not allocations:
|
||||
raise HTTPException(status_code=400, detail="At least one batch allocation must be greater than zero")
|
||||
|
||||
if abs(total_allocated - requested_quantity) > 1e-6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Allocated quantity ({total_allocated}) must match requested quantity ({requested_quantity})",
|
||||
)
|
||||
|
||||
return allocations
|
||||
|
||||
@@ -1272,16 +1352,18 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
||||
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispense_qty}",
|
||||
)
|
||||
|
||||
allocations = select_batches_for_dispense(
|
||||
allocations = resolve_requested_allocations(
|
||||
db,
|
||||
variant_id=variant.id,
|
||||
variant_quantity=variant.quantity,
|
||||
requested_quantity=dispense_qty,
|
||||
preferred_batch_id=dispensing.batch_id,
|
||||
allow_split=dispensing.allow_split,
|
||||
requested_allocations=dispensing.allocations,
|
||||
dispense_mode=dispense_mode,
|
||||
requested_pack_id=resolved["pack_id"],
|
||||
)
|
||||
|
||||
user_name = dispensing.user_name or current_user.username
|
||||
primary_batch_id = dispensing.batch_id if dispensing.batch_id is not None else allocations[0]["batch"].id
|
||||
primary_batch_id = allocations[0]["batch"].id if allocations else None
|
||||
|
||||
db_dispensing = Dispensing(
|
||||
drug_variant_id=dispensing.drug_variant_id,
|
||||
@@ -1614,6 +1696,10 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
|
||||
"expiry_date": batch.expiry_date,
|
||||
"location_id": batch.location_id,
|
||||
"notes": batch.notes,
|
||||
"disposed_at": batch.disposed_at,
|
||||
"disposed_by_user_id": batch.disposed_by_user_id,
|
||||
"disposed_quantity": batch.disposed_quantity,
|
||||
"disposal_notes": batch.disposal_notes,
|
||||
}
|
||||
|
||||
if payload.batch_number is not None:
|
||||
@@ -1696,6 +1782,58 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
|
||||
return serialize_batch_response(db, batch)
|
||||
|
||||
|
||||
@router.post("/batches/{batch_id}/dispose", response_model=BatchResponse)
|
||||
def dispose_batch(batch_id: int, payload: BatchDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)):
|
||||
batch = db.query(Batch).filter(Batch.id == batch_id).first()
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="Batch not found")
|
||||
|
||||
if batch.disposed_at is not None:
|
||||
raise HTTPException(status_code=400, detail="Batch has already been disposed")
|
||||
|
||||
if batch.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="Batch has no remaining stock to dispose")
|
||||
|
||||
if batch.expiry_date >= date.today():
|
||||
raise HTTPException(status_code=400, detail="Only expired batches can be marked as disposed")
|
||||
|
||||
variant = db.query(DrugVariant).filter(DrugVariant.id == batch.drug_variant_id).first()
|
||||
if not variant:
|
||||
raise HTTPException(status_code=404, detail="Parent variant not found")
|
||||
|
||||
disposed_quantity = batch.quantity
|
||||
if variant.quantity - disposed_quantity < -1e-6:
|
||||
raise HTTPException(status_code=400, detail="Variant quantity cannot become negative during disposal")
|
||||
|
||||
batch.quantity = 0
|
||||
batch.disposed_at = datetime.utcnow()
|
||||
batch.disposed_by_user_id = current_user.id
|
||||
batch.disposed_quantity = disposed_quantity
|
||||
batch.disposal_notes = (payload.notes or '').strip() or None
|
||||
recompute_batch_pack_state(batch)
|
||||
variant.quantity = max(0, variant.quantity - disposed_quantity)
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="batch.dispose",
|
||||
entity_type="batch",
|
||||
entity_id=batch.id,
|
||||
actor=current_user,
|
||||
details={
|
||||
"batch_number": batch.batch_number,
|
||||
"variant_id": batch.drug_variant_id,
|
||||
"disposed_quantity": disposed_quantity,
|
||||
"expiry_date": batch.expiry_date.isoformat() if batch.expiry_date else None,
|
||||
"location_id": batch.location_id,
|
||||
"disposal_notes": batch.disposal_notes,
|
||||
},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(batch)
|
||||
return serialize_batch_response(db, batch)
|
||||
|
||||
|
||||
@router.get("/audit", response_model=List[Dict[str, Any]])
|
||||
def list_audit_events(skip: int = 0, limit: int = 200, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
|
||||
events = db.query(AuditLog).order_by(AuditLog.created_at.desc()).offset(skip).limit(limit).all()
|
||||
@@ -1916,6 +2054,84 @@ def report_stock_by_location(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/reports/batch-attention")
|
||||
def report_batch_attention(
|
||||
format: str = "json",
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
today = date.today()
|
||||
|
||||
rows = (
|
||||
db.query(Batch, DrugVariant, Drug, Location)
|
||||
.join(DrugVariant, Batch.drug_variant_id == DrugVariant.id)
|
||||
.join(Drug, DrugVariant.drug_id == Drug.id)
|
||||
.join(Location, Batch.location_id == Location.id)
|
||||
.filter(Batch.quantity > 0, Batch.expiry_date < today)
|
||||
.order_by(Batch.expiry_date.asc(), Drug.name.asc(), DrugVariant.strength.asc(), Batch.batch_number.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for batch, variant, drug, location in rows:
|
||||
result.append(
|
||||
{
|
||||
"batch_id": batch.id,
|
||||
"batch_number": batch.batch_number,
|
||||
"drug_name": drug.name,
|
||||
"strength": variant.strength,
|
||||
"quantity": batch.quantity,
|
||||
"unit": variant.unit,
|
||||
"location": location.name,
|
||||
"expiry_date": batch.expiry_date,
|
||||
"status": "expired",
|
||||
"received_pack_label": None,
|
||||
"current_full_pack_count": batch.current_full_pack_count,
|
||||
"current_loose_base_units": batch.current_loose_base_units,
|
||||
"is_controlled": bool(drug.is_controlled),
|
||||
}
|
||||
)
|
||||
|
||||
if format.lower() == "csv":
|
||||
csv_rows = [
|
||||
[
|
||||
item["batch_id"],
|
||||
item["batch_number"],
|
||||
item["drug_name"],
|
||||
item["strength"],
|
||||
item["quantity"],
|
||||
item["unit"],
|
||||
item["location"],
|
||||
item["expiry_date"],
|
||||
item["status"],
|
||||
item["current_full_pack_count"],
|
||||
item["current_loose_base_units"],
|
||||
item["is_controlled"],
|
||||
]
|
||||
for item in result
|
||||
]
|
||||
return _csv_response(
|
||||
"batch_attention.csv",
|
||||
[
|
||||
"batch_id",
|
||||
"batch_number",
|
||||
"drug_name",
|
||||
"strength",
|
||||
"quantity",
|
||||
"unit",
|
||||
"location",
|
||||
"expiry_date",
|
||||
"status",
|
||||
"current_full_pack_count",
|
||||
"current_loose_base_units",
|
||||
"is_controlled",
|
||||
],
|
||||
csv_rows,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/reports/audit-trail")
|
||||
def report_audit_trail(
|
||||
from_date: Optional[date] = None,
|
||||
|
||||
@@ -107,6 +107,22 @@ def migrate_compliance_schema() -> None:
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN current_loose_base_units FLOAT")
|
||||
print("Added batches.current_loose_base_units")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposed_at"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN disposed_at DATETIME")
|
||||
print("Added batches.disposed_at")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposed_by_user_id"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN disposed_by_user_id INTEGER")
|
||||
print("Added batches.disposed_by_user_id")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposed_quantity"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN disposed_quantity FLOAT")
|
||||
print("Added batches.disposed_quantity")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposal_notes"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN disposal_notes VARCHAR")
|
||||
print("Added batches.disposal_notes")
|
||||
|
||||
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")
|
||||
|
||||
@@ -92,6 +92,10 @@ class Batch(Base):
|
||||
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)
|
||||
notes = Column(String, nullable=True)
|
||||
disposed_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
disposed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
disposed_quantity = Column(Float, nullable=True)
|
||||
disposal_notes = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
|
||||
+761
-202
File diff suppressed because it is too large
Load Diff
+68
-33
@@ -189,32 +189,23 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="batchInfoSection" style="display: none; margin: 15px 0; padding: 10px; background: #f5f5f5; border-radius: 4px;">
|
||||
<h4 style="margin-top: 0;">Available Batches (FEFO Order)</h4>
|
||||
<div id="batchInfoContent">
|
||||
<p class="loading">Loading batches...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dispenseBatchSelect">Preferred Batch Override</label>
|
||||
<select id="dispenseBatchSelect" onchange="updateAllocationPreview()">
|
||||
<option value="">Automatic FEFO Selection</option>
|
||||
</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>
|
||||
<label>Dispense Mode *</label>
|
||||
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
|
||||
<input type="radio" name="dispenseMode" id="dispenseModeQuantity" value="subunit" checked>
|
||||
Quantity
|
||||
</label>
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
|
||||
<input type="radio" name="dispenseMode" id="dispenseModePack" value="pack">
|
||||
Whole Pack
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input type="number" id="dispenseQuantity" step="0.1" onchange="updateAllocationPreview()">
|
||||
<input type="number" id="dispenseQuantity" step="0.1">
|
||||
</div>
|
||||
|
||||
<div class="form-row" id="dispensePackRow" style="display: none;">
|
||||
@@ -226,22 +217,23 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dispensePackCount">Pack Count *</label>
|
||||
<input type="number" id="dispensePackCount" min="0.0001" step="0.0001" onchange="updateDispenseQuantityFromPack()">
|
||||
<input type="number" id="dispensePackCount" min="1" step="1" 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">
|
||||
<label>
|
||||
<input type="checkbox" id="dispenseAllowSplit" onchange="updateAllocationPreview()">
|
||||
Allow Split Across Multiple Batches
|
||||
</label>
|
||||
<div id="batchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;">
|
||||
<h4 style="margin-top: 0; margin-bottom: 4px;">Batch Allocation</h4>
|
||||
<p style="margin: 0 0 10px; color: #666;">Batches are shown in FEFO order. Adjust the allocation against each batch so the total matches the requested dispense amount.</p>
|
||||
<details id="expiredBatchDetails" style="display: none; margin-bottom: 10px; background: #fffaf0; border: 1px solid #f5d08a; border-radius: 4px; padding: 8px 10px;">
|
||||
<summary style="cursor: pointer; font-weight: 600; color: #7a4f01;">Show expired batches</summary>
|
||||
<div id="expiredBatchContent" style="margin-top: 10px;"></div>
|
||||
</details>
|
||||
<div id="batchAllocationSummary" style="display: none; margin-bottom: 10px; padding: 8px 10px; background: #f0f8ff; border-left: 3px solid #2196F3; border-radius: 4px;">
|
||||
<div id="batchAllocationSummaryContent"></div>
|
||||
</div>
|
||||
|
||||
<div id="allocationPreviewSection" style="display: none; margin: 15px 0; padding: 10px; background: #f0f8ff; border-radius: 4px; border-left: 3px solid #2196F3;">
|
||||
<h4 style="margin-top: 0;">Allocation Preview</h4>
|
||||
<div id="allocationPreviewContent">
|
||||
<p class="loading">Loading allocation...</p>
|
||||
<div id="batchInfoContent">
|
||||
<p class="loading">Loading batches...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -250,9 +242,22 @@
|
||||
<input type="text" id="dispenseAnimal">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 18px; padding: 12px; background: #f9fafb; border: 1px solid #d9e2ec; border-radius: 6px;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; margin-bottom: 0; font-weight: 600;">
|
||||
<input type="checkbox" id="dispensePrintEnabled">
|
||||
Print label after dispensing
|
||||
</label>
|
||||
<div id="dispensePrintFields" style="display: none; margin-top: 12px;">
|
||||
<p id="dispensePrintHelpText" style="margin: 0 0 12px; color: #666;">Uses the dispensed quantity, the animal name/ID entered above, the logged-in user, and the latest expiry date from the allocated batches.</p>
|
||||
<div class="form-group">
|
||||
<label for="dispenseUser">Dispensed by *</label>
|
||||
<input type="text" id="dispenseUser">
|
||||
<label for="dispenseDosage">Dosage Instructions *</label>
|
||||
<input type="text" id="dispenseDosage" placeholder="e.g., 1 tablet twice daily with food">
|
||||
</div>
|
||||
<div class="form-group" id="dispenseLegacyExpiryGroup" style="display: none;">
|
||||
<label for="dispenseLegacyExpiry">Expiry Date *</label>
|
||||
<input type="date" id="dispenseLegacyExpiry">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -562,6 +567,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dispose Batch Modal -->
|
||||
<div id="disposeBatchModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h2>Dispose Expired Batch</h2>
|
||||
<form id="disposeBatchForm" novalidate>
|
||||
<input type="hidden" id="disposeBatchId">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="disposeBatchName">Batch</label>
|
||||
<input type="text" id="disposeBatchName" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<p style="margin: 0; color: #666;">This will mark the expired batch as disposed and remove its remaining stock from inventory.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="disposeBatchNotes">Disposal Note</label>
|
||||
<textarea id="disposeBatchNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Confirm Disposal</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancelDisposeBatchBtn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Receive Modal -->
|
||||
<div id="batchReceiveModal" class="modal">
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<label for="reportTypeSelect">Report</label>
|
||||
<select id="reportTypeSelect">
|
||||
<option value="dispensing" selected>Dispensing History</option>
|
||||
<option value="batch_attention">Expired Batches</option>
|
||||
<option value="audit">Audit Trail (Raw)</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -87,6 +88,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="disposeBatchModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h2>Dispose Expired Batch</h2>
|
||||
<form id="disposeBatchForm" novalidate>
|
||||
<input type="hidden" id="disposeBatchId">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="disposeBatchName">Batch</label>
|
||||
<input type="text" id="disposeBatchName" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<p style="margin: 0; color: #666;">This will mark the expired batch as disposed and remove its remaining stock from inventory.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="disposeBatchNotes">Disposal Note</label>
|
||||
<textarea id="disposeBatchNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Confirm Disposal</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancelDisposeBatchBtn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="reports.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+206
-7
@@ -5,10 +5,39 @@ let currentUser = null;
|
||||
let allDrugs = [];
|
||||
let auditTrailRows = [];
|
||||
let dispensingRows = [];
|
||||
let batchAttentionRows = [];
|
||||
let activeReportType = 'dispensing';
|
||||
const batchLookupById = new Map();
|
||||
const loadedBatchVariants = new Set();
|
||||
|
||||
function openModal(modal) {
|
||||
if (!modal) return;
|
||||
modal.classList.add('show');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal(modal) {
|
||||
if (!modal) return;
|
||||
modal.classList.remove('show');
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
function resetDisposeBatchModal() {
|
||||
const form = document.getElementById('disposeBatchForm');
|
||||
if (form) {
|
||||
form.reset();
|
||||
}
|
||||
const batchIdInput = document.getElementById('disposeBatchId');
|
||||
const batchNameInput = document.getElementById('disposeBatchName');
|
||||
if (batchIdInput) batchIdInput.value = '';
|
||||
if (batchNameInput) batchNameInput.value = '';
|
||||
}
|
||||
|
||||
function closeDisposeBatchModal() {
|
||||
resetDisposeBatchModal();
|
||||
closeModal(document.getElementById('disposeBatchModal'));
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
if (!container) return;
|
||||
@@ -170,19 +199,27 @@ function detailsContainsText(details, searchText) {
|
||||
}
|
||||
|
||||
function getActiveRows() {
|
||||
return activeReportType === 'dispensing' ? dispensingRows : auditTrailRows;
|
||||
if (activeReportType === 'dispensing') return dispensingRows;
|
||||
if (activeReportType === 'batch_attention') return batchAttentionRows;
|
||||
return auditTrailRows;
|
||||
}
|
||||
|
||||
function getRowUser(row) {
|
||||
return activeReportType === 'dispensing' ? (row.user_name || 'unknown') : (row.actor_username || 'system');
|
||||
if (activeReportType === 'dispensing') return row.user_name || 'unknown';
|
||||
if (activeReportType === 'batch_attention') return '';
|
||||
return row.actor_username || 'system';
|
||||
}
|
||||
|
||||
function getRowDrug(row) {
|
||||
return activeReportType === 'dispensing' ? extractDrugLabelFromDispenseRow(row) : extractDrugLabelFromAuditRow(row);
|
||||
if (activeReportType === 'dispensing') return extractDrugLabelFromDispenseRow(row);
|
||||
if (activeReportType === 'batch_attention') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`;
|
||||
return extractDrugLabelFromAuditRow(row);
|
||||
}
|
||||
|
||||
function getRowDate(row) {
|
||||
return new Date(activeReportType === 'dispensing' ? row.dispensed_at : row.created_at);
|
||||
if (activeReportType === 'dispensing') return new Date(row.dispensed_at);
|
||||
if (activeReportType === 'batch_attention') return new Date(row.expiry_date);
|
||||
return new Date(row.created_at);
|
||||
}
|
||||
|
||||
function populateCommonFilters(rows) {
|
||||
@@ -193,7 +230,7 @@ function populateCommonFilters(rows) {
|
||||
const previousUser = userFilter.value;
|
||||
const previousDrug = drugFilter.value;
|
||||
|
||||
const users = Array.from(new Set(rows.map(getRowUser))).sort((a, b) => a.localeCompare(b));
|
||||
const users = Array.from(new Set(rows.map(getRowUser).filter(Boolean))).sort((a, b) => a.localeCompare(b));
|
||||
const drugs = Array.from(new Set(rows.map(getRowDrug).filter(label => label && label !== 'N/A'))).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
userFilter.innerHTML = '<option value="">All Users</option>';
|
||||
@@ -311,6 +348,108 @@ function renderDispensingTable(rows) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderBatchAttentionTable(rows) {
|
||||
const container = document.getElementById('reportsTableContainer');
|
||||
if (!container) return;
|
||||
|
||||
if (!rows.length) {
|
||||
container.innerHTML = '<p class="empty" style="padding: 14px;">No expired batches match the selected filters.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rowsHtml = rows.map(row => {
|
||||
const expiryText = row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : 'Unknown';
|
||||
const quantityText = `${row.quantity} ${row.unit || 'units'}`;
|
||||
const statusText = 'Expired';
|
||||
const isExpired = true;
|
||||
|
||||
const packState = row.current_loose_base_units > 0
|
||||
? `${row.current_full_pack_count || 0} full packs + ${row.current_loose_base_units} loose ${row.unit || 'units'}`
|
||||
: `${row.current_full_pack_count || 0} full packs`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(row.drug_name || '')}</td>
|
||||
<td>${escapeHtml(row.strength || '-')}</td>
|
||||
<td>${escapeHtml(row.batch_number || '')}</td>
|
||||
<td>${escapeHtml(quantityText)}</td>
|
||||
<td>${escapeHtml(packState)}</td>
|
||||
<td>${escapeHtml(row.location || '-')}</td>
|
||||
<td>${escapeHtml(expiryText)}</td>
|
||||
<td>${escapeHtml(statusText)}</td>
|
||||
<td>${isExpired ? `<button type="button" class="btn btn-danger btn-small" onclick="disposeBatchFromReport(${row.batch_id}, '${String(row.batch_number || '').replace(/'/g, "\\'")}')">Dispose Expired Batch</button>` : '-'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="reports-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Drug</th>
|
||||
<th>Strength</th>
|
||||
<th>Batch</th>
|
||||
<th>Quantity</th>
|
||||
<th>Pack State</th>
|
||||
<th>Location</th>
|
||||
<th>Expiry</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rowsHtml}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function disposeBatchFromReport(batchId, batchNumber) {
|
||||
const modal = document.getElementById('disposeBatchModal');
|
||||
const batchIdInput = document.getElementById('disposeBatchId');
|
||||
const batchNameInput = document.getElementById('disposeBatchName');
|
||||
const notesInput = document.getElementById('disposeBatchNotes');
|
||||
|
||||
if (!modal || !batchIdInput || !batchNameInput || !notesInput) {
|
||||
showToast('Dispose batch modal is unavailable.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
batchIdInput.value = String(batchId);
|
||||
batchNameInput.value = batchNumber;
|
||||
notesInput.value = '';
|
||||
openModal(modal);
|
||||
}
|
||||
|
||||
async function handleDisposeBatch(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const batchId = parseInt(document.getElementById('disposeBatchId')?.value || '', 10);
|
||||
const notes = document.getElementById('disposeBatchNotes')?.value.trim() || '';
|
||||
|
||||
if (!batchId) {
|
||||
showToast('Batch disposal context is unavailable.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/batches/${batchId}/dispose`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notes: notes || null })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to dispose batch');
|
||||
}
|
||||
|
||||
closeDisposeBatchModal();
|
||||
await loadActiveReport();
|
||||
showToast('Expired batch marked as disposed.', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error disposing batch from report:', error);
|
||||
showToast('Failed to dispose batch: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function applyCurrentFilters() {
|
||||
const userFilter = document.getElementById('reportUserFilter');
|
||||
const drugFilter = document.getElementById('reportDrugFilter');
|
||||
@@ -346,6 +485,16 @@ function applyCurrentFilters() {
|
||||
formatDispenseAllocation(row)
|
||||
].join(' ').toLowerCase();
|
||||
textMatch = haystack.includes(searchText);
|
||||
} else if (activeReportType === 'batch_attention') {
|
||||
const haystack = [
|
||||
row.drug_name || '',
|
||||
row.strength || '',
|
||||
row.batch_number || '',
|
||||
row.location || '',
|
||||
row.status || '',
|
||||
row.unit || ''
|
||||
].join(' ').toLowerCase();
|
||||
textMatch = haystack.includes(searchText);
|
||||
} else {
|
||||
const actionText = (row.action || '').toLowerCase();
|
||||
const entityText = (row.entity_type || '').toLowerCase();
|
||||
@@ -361,12 +510,18 @@ function applyCurrentFilters() {
|
||||
});
|
||||
|
||||
if (reportsSummary) {
|
||||
const reportName = activeReportType === 'dispensing' ? 'dispensing records' : 'audit events';
|
||||
const reportName = activeReportType === 'dispensing'
|
||||
? 'dispensing records'
|
||||
: activeReportType === 'batch_attention'
|
||||
? 'expired batches'
|
||||
: 'audit events';
|
||||
reportsSummary.textContent = `Showing ${filteredRows.length} of ${sourceRows.length} ${reportName}`;
|
||||
}
|
||||
|
||||
if (activeReportType === 'dispensing') {
|
||||
renderDispensingTable(filteredRows);
|
||||
} else if (activeReportType === 'batch_attention') {
|
||||
renderBatchAttentionTable(filteredRows);
|
||||
} else {
|
||||
renderAuditTable(filteredRows);
|
||||
}
|
||||
@@ -375,14 +530,21 @@ function applyCurrentFilters() {
|
||||
function updateReportHeading() {
|
||||
const heading = document.getElementById('reportsHeading');
|
||||
const searchInput = document.getElementById('reportActionSearch');
|
||||
const userFilter = document.getElementById('reportUserFilter')?.closest('.report-control');
|
||||
if (!heading || !searchInput) return;
|
||||
|
||||
if (activeReportType === 'dispensing') {
|
||||
heading.textContent = 'Dispensing History';
|
||||
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
|
||||
if (userFilter) userFilter.style.display = '';
|
||||
} else if (activeReportType === 'batch_attention') {
|
||||
heading.textContent = 'Expired Batches';
|
||||
searchInput.placeholder = 'Search drug, batch, location...';
|
||||
if (userFilter) userFilter.style.display = 'none';
|
||||
} else {
|
||||
heading.textContent = 'Audit Trail (Raw)';
|
||||
searchInput.placeholder = 'Search action, entity, details...';
|
||||
if (userFilter) userFilter.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,7 +585,11 @@ async function loadActiveReport() {
|
||||
const container = document.getElementById('reportsTableContainer');
|
||||
const reportsSummary = document.getElementById('reportsSummary');
|
||||
if (container) {
|
||||
const loadingText = activeReportType === 'dispensing' ? 'Loading dispensing history...' : 'Loading audit trail...';
|
||||
const loadingText = activeReportType === 'dispensing'
|
||||
? 'Loading dispensing history...'
|
||||
: activeReportType === 'batch_attention'
|
||||
? 'Loading expired batches...'
|
||||
: 'Loading audit trail...';
|
||||
container.innerHTML = `<p class="loading" style="padding: 14px;">${loadingText}</p>`;
|
||||
}
|
||||
if (reportsSummary) reportsSummary.textContent = '';
|
||||
@@ -438,6 +604,14 @@ async function loadActiveReport() {
|
||||
dispensingRows = await response.json();
|
||||
await ensureBatchLookupForDispensing(dispensingRows);
|
||||
populateCommonFilters(dispensingRows);
|
||||
} else if (activeReportType === 'batch_attention') {
|
||||
const response = await apiCall('/reports/batch-attention');
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to load batch attention report');
|
||||
}
|
||||
batchAttentionRows = await response.json();
|
||||
populateCommonFilters(batchAttentionRows);
|
||||
} else {
|
||||
const response = await apiCall('/reports/audit-trail');
|
||||
if (!response.ok) {
|
||||
@@ -484,6 +658,9 @@ function setupEventListeners() {
|
||||
const backBtn = document.getElementById('backToInventoryBtn');
|
||||
const logoutBtn = document.getElementById('reportsLogoutBtn');
|
||||
const goToLoginBtn = document.getElementById('goToLoginBtn');
|
||||
const disposeBatchForm = document.getElementById('disposeBatchForm');
|
||||
const cancelDisposeBatchBtn = document.getElementById('cancelDisposeBatchBtn');
|
||||
const closeButtons = document.querySelectorAll('.close');
|
||||
|
||||
const userFilter = document.getElementById('reportUserFilter');
|
||||
const drugFilter = document.getElementById('reportDrugFilter');
|
||||
@@ -538,6 +715,28 @@ function setupEventListeners() {
|
||||
if (goToLoginBtn) goToLoginBtn.addEventListener('click', () => {
|
||||
window.location.href = 'index.html';
|
||||
});
|
||||
|
||||
if (disposeBatchForm) disposeBatchForm.addEventListener('submit', handleDisposeBatch);
|
||||
if (cancelDisposeBatchBtn) cancelDisposeBatchBtn.addEventListener('click', closeDisposeBatchModal);
|
||||
|
||||
closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
|
||||
const modal = e.target.closest('.modal');
|
||||
if (modal?.id === 'disposeBatchModal') {
|
||||
closeDisposeBatchModal();
|
||||
return;
|
||||
}
|
||||
closeModal(modal);
|
||||
}));
|
||||
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal')) {
|
||||
if (e.target.id === 'disposeBatchModal') {
|
||||
closeDisposeBatchModal();
|
||||
return;
|
||||
}
|
||||
closeModal(e.target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeReportsPage() {
|
||||
|
||||
Reference in New Issue
Block a user