From 5b5e17ec3ef709ee05218f2e2300d495100e9d56 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Mon, 6 Apr 2026 09:15:38 -0400 Subject: [PATCH] Better dispensing --- backend/app/main.py | 228 ++++++++++++--- frontend/app.js | 635 ++++++++++++++++++++++++++++++------------ frontend/index.html | 67 ++--- frontend/reports.html | 1 + frontend/reports.js | 111 +++++++- 5 files changed, 773 insertions(+), 269 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index a9d6ce1..d3da4d0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -228,17 +228,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): @@ -473,14 +477,15 @@ def resolve_pack_quantity( } -def select_batches_for_dispense( +def resolve_requested_allocations( db: Session, variant_id: int, 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() eligible_batches = ( db.query(Batch) @@ -496,47 +501,96 @@ def select_batches_for_dispense( if not eligible_batches: raise HTTPException(status_code=400, detail="No in-date stock batches available for this variant") - remaining = requested_quantity + 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 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") + if dispense_mode == "pack": + if requested_pack_id is None: + raise HTTPException(status_code=400, detail="Pack dispense requires a requested pack") - 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] + 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") - if remaining > 0 and not allow_split: + 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"Preferred batch cannot fully satisfy request. Remaining required: {remaining}", + 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 +1326,17 @@ 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, 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 db_dispensing = Dispensing( drug_variant_id=dispensing.drug_variant_id, @@ -1916,6 +1971,97 @@ 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) + .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: + is_expired = batch.expiry_date < today + is_partial = bool((batch.current_loose_base_units or 0) > 1e-6) + + if not is_expired and not is_partial: + continue + + if is_expired and is_partial: + status = "expired_partial" + elif is_expired: + status = "expired" + else: + status = "partial" + + 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": status, + "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, diff --git a/frontend/app.js b/frontend/app.js index 08010fa..955a11f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -11,6 +11,7 @@ let accessToken = null; let deliveryDrugId = null; let deliveryLineCounter = 0; let deliveryLocations = []; +let currentDispenseBatches = []; // Toast notification system function showToast(message, type = 'info', duration = 3000) { @@ -235,7 +236,7 @@ function setupEventListeners() { const variantUnitSelect = document.getElementById('variantUnit'); const variantStrengthInput = document.getElementById('variantStrength'); const editVariantUnitSelect = document.getElementById('editVariantUnit'); - const dispenseModeSelect = document.getElementById('dispenseMode'); + const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]'); const showAllBtn = document.getElementById('showAllBtn'); const showLowStockBtn = document.getElementById('showLowStockBtn'); const locationFilterSelect = document.getElementById('locationFilterSelect'); @@ -284,7 +285,7 @@ function setupEventListeners() { refreshVariantPackRowLabels(); }); } - if (dispenseModeSelect) dispenseModeSelect.addEventListener('change', updateDispenseModeUi); + dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi)); if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal)); if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal)); @@ -376,7 +377,7 @@ function setupEventListeners() { const dispenseQuantityInput = document.getElementById('dispenseQuantity'); if (dispenseQuantityInput) { dispenseQuantityInput.addEventListener('input', () => { - const mode = document.getElementById('dispenseMode')?.value || 'subunit'; + const mode = getSelectedDispenseMode(); if (mode !== 'subunit') { return; } @@ -392,6 +393,8 @@ function setupEventListeners() { if (packPreview && variant) { packPreview.textContent = `Enter direct quantity in ${variant.unit}.`; } + + autoAllocateDispenseBatches(); }); } @@ -464,23 +467,29 @@ function updateDispenseDrugSelect() { const packSelect = document.getElementById('dispensePackSelect'); const packCount = document.getElementById('dispensePackCount'); const packPreview = document.getElementById('dispensePackPreview'); - const modeSelect = document.getElementById('dispenseMode'); + const quantityModeRadio = document.getElementById('dispenseModeQuantity'); if (packSelect) { packSelect.innerHTML = ''; } if (packCount) { packCount.value = ''; } - if (modeSelect) { - modeSelect.value = 'subunit'; + if (quantityModeRadio) { + quantityModeRadio.checked = true; } if (packPreview) { packPreview.textContent = 'Select a pack and whole-number count.'; } + currentDispenseBatches = []; + updateDispenseModeUi(); } +function getSelectedDispenseMode() { + return document.querySelector('input[name="dispenseMode"]:checked')?.value || 'subunit'; +} + function populateDispensePackSelect(variant) { const packSelect = document.getElementById('dispensePackSelect'); const packCount = document.getElementById('dispensePackCount'); @@ -506,7 +515,7 @@ function populateDispensePackSelect(variant) { } function updateDispenseModeUi() { - const mode = document.getElementById('dispenseMode')?.value || 'subunit'; + const mode = getSelectedDispenseMode(); const quantityGroup = document.getElementById('dispenseQuantityGroup'); const packRow = document.getElementById('dispensePackRow'); const quantityInput = document.getElementById('dispenseQuantity'); @@ -530,11 +539,14 @@ function updateDispenseModeUi() { packCount.required = mode === 'pack'; } - updateAllocationPreview(); + if (currentDispenseBatches.length > 0) { + renderDispenseBatchAllocationRows(currentDispenseBatches); + } + autoAllocateDispenseBatches(); } function updateDispenseQuantityFromPack() { - const mode = document.getElementById('dispenseMode')?.value || 'subunit'; + const mode = getSelectedDispenseMode(); if (mode !== 'pack') return; const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10); @@ -547,21 +559,35 @@ function updateDispenseQuantityFromPack() { if (!quantityInput || !preview || !variant) return; const selectedPack = getActivePacksForVariant(variant).find(pack => pack.id === packId); + const totalAvailablePacks = selectedPack ? getTotalAvailableDispensePackCount(selectedPack) : 0; if (selectedPack && !Number.isNaN(packCount) && packCount > 0) { if (Math.abs(packCount - Math.round(packCount)) > 1e-6) { preview.textContent = 'Whole-pack mode requires a whole-number pack count.'; return; } + if (totalAvailablePacks <= 0) { + quantityInput.value = String(packCount * selectedPack.pack_size_in_base_units); + preview.textContent = `No full ${selectedPack.pack_unit_name} packs are currently available.`; + autoAllocateDispenseBatches(); + return; + } + if (packCount > totalAvailablePacks) { + quantityInput.value = String(packCount * selectedPack.pack_size_in_base_units); + preview.textContent = `Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`; + autoAllocateDispenseBatches(); + return; + } const quantity = packCount * selectedPack.pack_size_in_base_units; quantityInput.value = String(quantity); - preview.textContent = `${packCount} × ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit}`; - updateAllocationPreview(); + preview.textContent = `${packCount} × ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit} | ${totalAvailablePacks} full pack${totalAvailablePacks === 1 ? '' : 's'} available`; + autoAllocateDispenseBatches(); return; } preview.textContent = selectedPack - ? `1 ${selectedPack.pack_unit_name} = ${selectedPack.pack_size_in_base_units} ${variant.unit}` + ? `${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available | 1 ${selectedPack.pack_unit_name} = ${selectedPack.pack_size_in_base_units} ${variant.unit}` : `Select a pack to calculate quantity.`; + autoAllocateDispenseBatches(); } function formatDisplayDate(value) { @@ -583,6 +609,17 @@ function formatDisplayNumber(value) { return Number.isInteger(numeric) ? String(numeric) : String(Number(numeric.toFixed(3))); } +function isBatchExpired(batch) { + if (!batch?.expiry_date) { + return false; + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const expiryDate = new Date(`${batch.expiry_date}T00:00:00`); + return expiryDate < today; +} + function renderVariantInventoryDetails(variant) { const activePacks = getActivePacksForVariant(variant); const batches = [...(variant.batches || [])] @@ -674,27 +711,204 @@ function updateLocationFilterOptions() { } } -function populateDispenseBatchSelect(activeBatches) { - const batchSelect = document.getElementById('dispenseBatchSelect'); - const selectedVariantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10); - const unitLabel = getVariantById(selectedVariantId)?.unit || 'units'; - const previousValue = batchSelect.value; +function getDispenseRequestedQuantity() { + const quantity = parseFloat(document.getElementById('dispenseQuantity')?.value || ''); + return Number.isNaN(quantity) || quantity <= 0 ? 0 : quantity; +} - batchSelect.innerHTML = ''; - - activeBatches.forEach((batch, index) => { - const option = document.createElement('option'); - const expiryLabel = formatDisplayDate(batch.expiry_date); - const locationLabel = getBatchLocationLabel(batch); - const fefoLabel = index === 0 ? ' [FEFO default]' : ''; - option.value = batch.id; - option.textContent = `${batch.batch_number} | ${batch.quantity} ${unitLabel} | ${locationLabel} | Expires ${expiryLabel}${fefoLabel}`; - batchSelect.appendChild(option); - }); - - if (previousValue && activeBatches.some(batch => String(batch.id) === previousValue)) { - batchSelect.value = previousValue; +function getSelectedDispensePack() { + const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10); + const packId = parseInt(document.getElementById('dispensePackSelect')?.value || '', 10); + const variant = getVariantById(variantId); + if (!variant || Number.isNaN(packId)) { + return null; } + + return getActivePacksForVariant(variant).find(pack => pack.id === packId) || null; +} + +function batchMatchesSelectedPack(batch, selectedPack) { + if (!batch || !selectedPack) { + return false; + } + + if (Number(batch.received_pack_id) === Number(selectedPack.id)) { + return true; + } + + const batchPackLabel = String(batch.received_pack_label || '').trim().toLowerCase(); + const selectedPackLabel = String(selectedPack.label || '').trim().toLowerCase(); + if (batchPackLabel && selectedPackLabel && batchPackLabel === selectedPackLabel) { + return true; + } + + const batchPackSize = Number(batch.received_pack_size_snapshot || 0); + const selectedPackSize = Number(selectedPack.pack_size_in_base_units || 0); + if (batchPackSize > 0 && selectedPackSize > 0 && Math.abs(batchPackSize - selectedPackSize) <= 1e-6) { + return true; + } + + return false; +} + +function getBatchAvailableDispenseQuantity(batch, mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) { + if (mode !== 'pack') { + return Number(batch.quantity || 0); + } + + if (!batchMatchesSelectedPack(batch, selectedPack)) { + return 0; + } + + const fullPackCount = Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))); + return fullPackCount * Number(selectedPack.pack_size_in_base_units || 0); +} + +function getTotalAvailableDispenseQuantity(mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) { + return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0); +} + +function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) { + if (!selectedPack) { + return 0; + } + + return currentDispenseBatches.reduce((sum, batch) => { + if (!batchMatchesSelectedPack(batch, selectedPack)) { + return sum; + } + return sum + Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))); + }, 0); +} + +function renderDispenseBatchAllocationRows(activeBatches) { + const batchInfoContent = document.getElementById('batchInfoContent'); + const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10); + const variant = getVariantById(variantId); + const unitLabel = variant?.unit || 'units'; + + if (!batchInfoContent) return; + + if (!activeBatches.length) { + batchInfoContent.innerHTML = '

⚠️ No active batches available for this variant

'; + return; + } + + const mode = getSelectedDispenseMode(); + const selectedPack = getSelectedDispensePack(); + + batchInfoContent.innerHTML = activeBatches.map((batch, index) => { + const expiryDate = new Date(batch.expiry_date); + const locationLabel = getBatchLocationLabel(batch); + const expiryLabel = formatDisplayDate(batch.expiry_date); + const today = new Date(); + const isExpired = expiryDate < today; + const daysToExpiry = Math.ceil((expiryDate - today) / (1000 * 60 * 60 * 24)); + + let expiryStatus = 'OK'; + let statusColor = '#4caf50'; + if (isExpired) { + expiryStatus = 'EXPIRED'; + statusColor = '#d32f2f'; + } else if (daysToExpiry <= 7) { + expiryStatus = `${daysToExpiry}d left`; + statusColor = '#ff9800'; + } + + const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack) + ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))) + : 0; + const allocationLabel = mode === 'pack' ? 'Allocate Packs' : 'Allocate'; + const allocationMax = mode === 'pack' ? availableFullPacks : getBatchAvailableDispenseQuantity(batch, mode, selectedPack); + const allocationStep = mode === 'pack' ? 1 : 0.1; + const batchAvailabilityNote = mode === 'pack' + ? (selectedPack && batchMatchesSelectedPack(batch, selectedPack) && availableFullPacks <= 0 + ? 'No full packs available in this batch' + : '') + : `Available to allocate: ${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`; + + return ` +
+
+
+
${escapeHtml(batch.batch_number)}${index === 0 ? ' FEFO' : ''}
+
+ Available: ${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)} | + Location: ${escapeHtml(locationLabel)} | + Expiry: ${expiryLabel} (${expiryStatus}) +
+
+
+ ${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label + ? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose` + : ''} + ${batchAvailabilityNote ? `
${batchAvailabilityNote}
` : ''} +
+
+ + +
+
+
+ `; + }).join(''); + + batchInfoContent.querySelectorAll('.dispense-batch-allocation').forEach(input => { + input.addEventListener('input', updateDispenseAllocationSummary); + }); +} + +function renderExpiredDispenseBatches(expiredBatches) { + const expiredDetails = document.getElementById('expiredBatchDetails'); + const expiredContent = document.getElementById('expiredBatchContent'); + const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10); + const variant = getVariantById(variantId); + const unitLabel = variant?.unit || 'units'; + + if (!expiredDetails || !expiredContent) { + return; + } + + if (!expiredBatches.length) { + expiredDetails.style.display = 'none'; + expiredDetails.open = false; + expiredContent.innerHTML = ''; + return; + } + + expiredDetails.style.display = 'block'; + expiredContent.innerHTML = expiredBatches.map(batch => { + const locationLabel = getBatchLocationLabel(batch); + const expiryLabel = formatDisplayDate(batch.expiry_date); + const stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label + ? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose` + : `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`; + + return ` +
+
+ ${escapeHtml(batch.batch_number)} + Expired ${expiryLabel} +
+
+ Qty: ${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)} | + Location: ${escapeHtml(locationLabel)} +
+
${stocktakeLabel}
+
+ `; + }).join(''); } // Update batch info display when variant is selected @@ -702,13 +916,14 @@ async function updateBatchInfo() { const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); const batchInfoSection = document.getElementById('batchInfoSection'); const batchInfoContent = document.getElementById('batchInfoContent'); - const batchSelect = document.getElementById('dispenseBatchSelect'); if (!variantId) { batchInfoSection.style.display = 'none'; - batchSelect.innerHTML = ''; const packSelect = document.getElementById('dispensePackSelect'); if (packSelect) packSelect.innerHTML = ''; + currentDispenseBatches = []; + renderExpiredDispenseBatches([]); + updateDispenseAllocationSummary(); return; } @@ -720,6 +935,7 @@ async function updateBatchInfo() { batchInfoSection.style.display = 'block'; batchInfoContent.innerHTML = '

Loading batches...

'; + renderExpiredDispenseBatches([]); try { const response = await apiCall(`/variants/${variantId}/batches`); @@ -727,165 +943,172 @@ async function updateBatchInfo() { const batches = await response.json(); - // Filter out empty batches - const activeBatches = batches.filter(b => b.quantity > 0); + const stockedBatches = batches.filter(b => b.quantity > 0); + const expiredBatches = stockedBatches.filter(isBatchExpired); + const activeBatches = stockedBatches.filter(batch => !isBatchExpired(batch)); + currentDispenseBatches = activeBatches; + renderExpiredDispenseBatches(expiredBatches); if (activeBatches.length === 0) { - populateDispenseBatchSelect([]); - batchInfoContent.innerHTML = '

⚠️ No active batches available for this variant

'; + batchInfoContent.innerHTML = expiredBatches.length > 0 + ? '

⚠️ No in-date batches available for this variant. Expired batches are hidden from selection.

' + : '

⚠️ No active batches available for this variant

'; + updateDispenseAllocationSummary(); return; } // Sort by expiry date (FEFO order) activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); - populateDispenseBatchSelect(activeBatches); - - const batchHtml = activeBatches.map((batch, index) => { - const expiryDate = new Date(batch.expiry_date); - const locationLabel = getBatchLocationLabel(batch); - const expiryLabel = formatDisplayDate(batch.expiry_date); - const today = new Date(); - const isExpired = expiryDate < today; - const daysToExpiry = Math.ceil((expiryDate - today) / (1000 * 60 * 60 * 24)); - - let expiryStatus = '✓ OK'; - let statusColor = '#4caf50'; - if (isExpired) { - expiryStatus = '✕ EXPIRED'; - statusColor = '#d32f2f'; - } else if (daysToExpiry <= 7) { - expiryStatus = `⚠️ ${daysToExpiry}d left`; - statusColor = '#ff9800'; - } - - const isFEFO = index === 0; - - return ` -
-
-
- ${batch.batch_number} ${isFEFO ? 'FIRST' : ''} -
- Qty: ${batch.quantity} | - Location: ${escapeHtml(locationLabel)} | - Expiry: ${expiryLabel} (${expiryStatus}) -
-
-
-
- `; - }).join(''); - - batchInfoContent.innerHTML = batchHtml; + currentDispenseBatches = activeBatches; + renderDispenseBatchAllocationRows(activeBatches); + autoAllocateDispenseBatches(); } catch (error) { console.error('Error loading batches:', error); batchInfoContent.innerHTML = '

Error loading batches

'; + currentDispenseBatches = []; + renderExpiredDispenseBatches([]); + updateDispenseAllocationSummary(); } - - // Update allocation preview when batches load - updateAllocationPreview(); } -// Update allocation preview based on quantity and allow_split flag -async function updateAllocationPreview() { - const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); - const unitLabel = getVariantById(variantId)?.unit || 'units'; - const quantity = parseFloat(document.getElementById('dispenseQuantity').value); - const allowSplit = document.getElementById('dispenseAllowSplit').checked; - const preferredBatchId = parseInt(document.getElementById('dispenseBatchSelect').value); - const allocationPreviewSection = document.getElementById('allocationPreviewSection'); - const allocationPreviewContent = document.getElementById('allocationPreviewContent'); - - if (!variantId || isNaN(quantity) || quantity <= 0) { - allocationPreviewSection.style.display = 'none'; +function autoAllocateDispenseBatches() { + const requestedQuantity = getDispenseRequestedQuantity(); + const allocationInputs = Array.from(document.querySelectorAll('.dispense-batch-allocation')); + const mode = getSelectedDispenseMode(); + const selectedPack = getSelectedDispensePack(); + + if (!allocationInputs.length) { + updateDispenseAllocationSummary(); return; } - - allocationPreviewSection.style.display = 'block'; - allocationPreviewContent.innerHTML = '

Calculating allocation...

'; - - try { - const response = await apiCall(`/variants/${variantId}/batches`); - if (!response.ok) throw new Error('Failed to load batches'); - - const batches = await response.json(); - let activeBatches = batches.filter(b => b.quantity > 0) - .sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); - - if (activeBatches.length === 0) { - allocationPreviewContent.innerHTML = '

⚠️ No active batches available

'; + + let remaining = mode === 'pack' + ? Math.max(0, Math.round(parseFloat(document.getElementById('dispensePackCount')?.value || '0')) || 0) + : requestedQuantity; + allocationInputs.forEach(input => { + const batchId = parseInt(input.dataset.batchId || '', 10); + const batch = currentDispenseBatches.find(row => row.id === batchId); + if (!batch || requestedQuantity <= 0) { + input.value = '0'; return; } - if (!Number.isNaN(preferredBatchId)) { - const preferredBatch = activeBatches.find(batch => batch.id === preferredBatchId); - if (!preferredBatch) { - allocationPreviewContent.innerHTML = '

✕ Selected preferred batch is no longer available.

'; - return; - } + let allocation = 0; + if (mode === 'pack' && selectedPack) { + const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack) + ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))) + : 0; + allocation = Math.min(availableFullPacks, Math.max(remaining, 0)); + input.value = allocation > 0 ? String(allocation) : '0'; + } else { + allocation = Math.min(getBatchAvailableDispenseQuantity(batch, mode, selectedPack), Math.max(remaining, 0)); + input.value = allocation > 0 ? String(Number(allocation.toFixed(3))) : '0'; + } + remaining -= allocation; + }); - activeBatches = [preferredBatch, ...activeBatches.filter(batch => batch.id !== preferredBatchId)]; - } - - // Simulate FEFO allocation - const allocations = []; - let remainingQty = quantity; - - for (const batch of activeBatches) { - if (remainingQty <= 0) break; - - const allocQty = Math.min(remainingQty, batch.quantity); - allocations.push({ - batchNumber: batch.batch_number, - batchId: batch.id, - quantity: allocQty, - location: getBatchLocationLabel(batch), - expiryDate: batch.expiry_date, - preferred: !Number.isNaN(preferredBatchId) && batch.id === preferredBatchId - }); - remainingQty -= allocQty; - - if (!allowSplit) break; - } - - if (remainingQty > 0 && !allowSplit) { - const failureContext = !Number.isNaN(preferredBatchId) - ? 'Preferred batch cannot fully satisfy this request. Enable split to fall through to FEFO batches.' - : 'Insufficient stock in first batch. Check "Allow Split" to use multiple batches.'; - allocationPreviewContent.innerHTML = `

✕ ${failureContext}

`; - return; - } - - if (remainingQty > 0 && allowSplit) { - allocationPreviewContent.innerHTML = ` -

✕ Warning: Only ${quantity - remainingQty} ${escapeHtml(unitLabel)} available across all batches (${remainingQty} short)

-
${allocations.map(a => ` -
- ${a.batchNumber}${a.preferred ? ' (preferred)' : ''}: ${a.quantity} ${escapeHtml(unitLabel)} (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)} -
- `).join('')}
- `; - return; - } - - const allocationHtml = allocations.map(a => ` -
- ${a.batchNumber}${a.preferred ? ' (preferred)' : ''}: ${a.quantity} ${escapeHtml(unitLabel)} (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)} -
- `).join(''); - - const pluralText = allocations.length === 1 ? 'batch' : 'batches'; - const introText = !Number.isNaN(preferredBatchId) - ? `✓ Will start from your preferred batch, then use FEFO for any remainder across ${allocations.length} ${pluralText}:` - : `✓ Will dispense from ${allocations.length} ${pluralText}:`; - allocationPreviewContent.innerHTML = ` -

${introText}

-
${allocationHtml}
- `; - } catch (error) { - console.error('Error calculating allocation:', error); - allocationPreviewContent.innerHTML = '

Error calculating allocation

'; + updateDispenseAllocationSummary(); +} + +function updateDispenseAllocationSummary() { + const summarySection = document.getElementById('batchAllocationSummary'); + const summaryContent = document.getElementById('batchAllocationSummaryContent'); + const requestedQuantity = getDispenseRequestedQuantity(); + const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10); + const unitLabel = getVariantById(variantId)?.unit || 'units'; + const inputs = Array.from(document.querySelectorAll('.dispense-batch-allocation')); + const mode = getSelectedDispenseMode(); + const selectedPack = getSelectedDispensePack(); + const totalAvailableQuantity = getTotalAvailableDispenseQuantity(mode, selectedPack); + const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0; + + if (!summarySection || !summaryContent || !variantId || !inputs.length) { + if (summarySection) summarySection.style.display = 'none'; + return; } + + const allocated = inputs.reduce((sum, input) => { + const value = parseFloat(input.value || '0'); + return sum + (Number.isNaN(value) ? 0 : value); + }, 0); + const allocatedQuantity = mode === 'pack' && selectedPack + ? allocated * selectedPack.pack_size_in_base_units + : allocated; + const invalidInput = inputs.find(input => { + const batchId = parseInt(input.dataset.batchId || '', 10); + const batch = currentDispenseBatches.find(row => row.id === batchId); + const value = parseFloat(input.value || '0'); + if (!batch || Number.isNaN(value)) { + return false; + } + if (mode === 'pack' && selectedPack) { + const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack) + ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))) + : 0; + return value - availableFullPacks > 1e-6 || Math.abs(value - Math.round(value)) > 1e-6; + } + + const maxAllocation = getBatchAvailableDispenseQuantity(batch, mode, selectedPack); + return value - maxAllocation > 1e-6; + }); + + const difference = requestedQuantity - allocatedQuantity; + summarySection.style.display = 'block'; + + if (requestedQuantity <= 0) { + summaryContent.innerHTML = `Enter a dispense amount to allocate batches.`; + return; + } + + if (mode === 'pack' && selectedPack) { + const requestedPackCount = parseFloat(document.getElementById('dispensePackCount')?.value || '0'); + if (totalAvailablePacks <= 0) { + summaryContent.innerHTML = `No full ${escapeHtml(selectedPack.pack_unit_name)} packs are available to dispense.`; + return; + } + if (!Number.isNaN(requestedPackCount) && requestedPackCount > totalAvailablePacks) { + summaryContent.innerHTML = `Only ${totalAvailablePacks} full ${escapeHtml(selectedPack.pack_unit_name)} pack${totalAvailablePacks === 1 ? '' : 's'} are available.`; + return; + } + } + + if (requestedQuantity - totalAvailableQuantity > 1e-6) { + summaryContent.innerHTML = `Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(totalAvailableQuantity)} ${escapeHtml(unitLabel)} available.`; + return; + } + + if (invalidInput) { + summaryContent.innerHTML = `One or more batch allocations exceed available stock or are not valid full-pack amounts.`; + return; + } + + if (Math.abs(difference) <= 1e-6) { + if (mode === 'pack' && selectedPack) { + const requestedPackCount = parseFloat(document.getElementById('dispensePackCount')?.value || '0'); + summaryContent.innerHTML = `Allocated ${formatDisplayNumber(allocated)} pack${allocated === 1 ? '' : 's'} of ${formatDisplayNumber(requestedPackCount)} requested (${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)}).`; + } else { + summaryContent.innerHTML = `Allocated ${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)} of ${formatDisplayNumber(requestedQuantity)} requested.`; + } + return; + } + + if (difference > 0) { + if (mode === 'pack' && selectedPack) { + const differencePacks = difference / selectedPack.pack_size_in_base_units; + summaryContent.innerHTML = `Allocate ${formatDisplayNumber(differencePacks)} more pack${Math.abs(differencePacks - 1) <= 1e-6 ? '' : 's'} to match the requested total.`; + } else { + summaryContent.innerHTML = `Allocate ${formatDisplayNumber(difference)} more ${escapeHtml(unitLabel)} to match the requested total.`; + } + return; + } + + if (mode === 'pack' && selectedPack) { + const differencePacks = Math.abs(difference) / selectedPack.pack_size_in_base_units; + summaryContent.innerHTML = `Reduce allocations by ${formatDisplayNumber(differencePacks)} pack${Math.abs(differencePacks - 1) <= 1e-6 ? '' : 's'} to match the requested total.`; + return; + } + + summaryContent.innerHTML = `Reduce allocations by ${formatDisplayNumber(Math.abs(difference))} ${escapeHtml(unitLabel)} to match the requested total.`; } // Render drugs list @@ -1061,14 +1284,11 @@ async function handleDispenseDrug(e) { const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); let quantity = parseFloat(document.getElementById('dispenseQuantity').value); - const dispenseMode = (document.getElementById('dispenseMode').value || 'subunit').toLowerCase(); - const preferredBatchIdValue = document.getElementById('dispenseBatchSelect').value; + const dispenseMode = getSelectedDispenseMode(); const requestedPackIdValue = document.getElementById('dispensePackSelect').value; const requestedPackCountValue = document.getElementById('dispensePackCount').value; const animalName = document.getElementById('dispenseAnimal').value; - const userName = document.getElementById('dispenseUser').value; const notes = document.getElementById('dispenseNotes').value; - const allowSplit = document.getElementById('dispenseAllowSplit').checked; const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null; const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null; @@ -1099,8 +1319,63 @@ async function handleDispenseDrug(e) { quantity = selectedPackCount * selectedPack.pack_size_in_base_units; } - if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) { - showToast('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)', 'warning'); + const allocationEntries = Array.from(document.querySelectorAll('.dispense-batch-allocation')) + .map(input => ({ + batch_id: parseInt(input.dataset.batchId || '', 10), + entered_value: parseFloat(input.value || '0') + })) + .filter(entry => !Number.isNaN(entry.batch_id) && !Number.isNaN(entry.entered_value) && entry.entered_value > 0); + + const allocations = allocationEntries + .map(entry => ({ + batch_id: entry.batch_id, + quantity: dispenseMode === 'pack' && selectedPack + ? entry.entered_value * selectedPack.pack_size_in_base_units + : entry.entered_value + })); + + const allocatedTotal = allocations.reduce((sum, entry) => sum + entry.quantity, 0); + const totalAvailableQuantity = getTotalAvailableDispenseQuantity(dispenseMode, selectedPack); + const totalAvailablePacks = dispenseMode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0; + + if (!variantId || isNaN(quantity) || quantity <= 0) { + showToast('Please fill in all required fields (Drug Variant and Quantity > 0)', 'warning'); + return; + } + + if (quantity - totalAvailableQuantity > 1e-6) { + if (dispenseMode === 'pack' && selectedPack) { + showToast(`Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`, 'warning'); + } else { + showToast(`Requested quantity exceeds available stock (${formatDisplayNumber(totalAvailableQuantity)} available).`, 'warning'); + } + return; + } + + if (allocations.length === 0) { + showToast('Allocate quantity against at least one batch.', 'warning'); + return; + } + + if (dispenseMode === 'pack' && selectedPack) { + const invalidPackAllocation = allocationEntries.find(entry => { + const batch = currentDispenseBatches.find(row => row.id === entry.batch_id); + const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack) + ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))) + : 0; + return !batch + || entry.entered_value - availableFullPacks > 1e-6 + || Math.abs(entry.entered_value - Math.round(entry.entered_value)) > 1e-6; + }); + + if (invalidPackAllocation) { + showToast('Whole-pack allocations must use batches with available full packs and whole-pack multiples only.', 'warning'); + return; + } + } + + if (Math.abs(allocatedTotal - quantity) > 1e-6) { + showToast('Batch allocations must exactly match the requested dispense quantity.', 'warning'); return; } @@ -1108,13 +1383,11 @@ async function handleDispenseDrug(e) { drug_variant_id: variantId, quantity: quantity, dispense_mode: dispenseMode, - batch_id: preferredBatchIdValue ? parseInt(preferredBatchIdValue) : null, requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null, requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null, animal_name: animalName || null, - user_name: userName, notes: notes || null, - allow_split: allowSplit + allocations }; try { diff --git a/frontend/index.html b/frontend/index.html index ea85bd9..fb6e4cc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -188,33 +188,24 @@ - -