From 36f0a5b07e5c17f4933f824aaa4270412f3417d6 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Mon, 6 Apr 2026 11:04:06 -0400 Subject: [PATCH] Reporting and batch management --- backend/app/main.py | 115 ++++++++++++++++++++++ frontend/app.js | 216 +++++++++++++++++++++++++++++++++++------- frontend/index.html | 15 +++ frontend/reports.html | 1 + frontend/reports.js | 81 +++++++++++++++- 5 files changed, 392 insertions(+), 36 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index f5a0a3b..b580382 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -245,6 +245,7 @@ class DispensingCreate(BaseModel): drug_variant_id: int quantity: Optional[float] = None dispense_mode: str = "subunit" + dispense_source: str = "batch" requested_pack_id: Optional[int] = None requested_pack_count: Optional[float] = None animal_name: Optional[str] = None @@ -496,12 +497,16 @@ def resolve_requested_allocations( requested_quantity: float, requested_allocations: List[DispensingAllocationCreate], dispense_mode: str, + dispense_source: str, requested_pack_id: Optional[int], ) -> List[Dict[str, Any]]: """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) + selected_source = (dispense_source or "batch").strip().lower() + if selected_source not in {"batch", "legacy"}: + raise HTTPException(status_code=400, detail="dispense_source must be either 'batch' or 'legacy'") eligible_batches = ( db.query(Batch) .filter( @@ -513,6 +518,20 @@ def resolve_requested_allocations( .all() ) + if selected_source == "legacy": + 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 legacy loose stock is available for this variant") + if requested_quantity - legacy_unbatched_quantity > 1e-6: + raise HTTPException( + status_code=400, + detail=f"Insufficient unbatched stock. Available: {legacy_unbatched_quantity}, Requested: {requested_quantity}", + ) + return [] + if not eligible_batches: if dispense_mode == "pack": raise HTTPException(status_code=400, detail="Whole-pack dispensing requires batched stock with pack information") @@ -1359,9 +1378,12 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c requested_quantity=dispense_qty, requested_allocations=dispensing.allocations, dispense_mode=dispense_mode, + dispense_source=dispensing.dispense_source, requested_pack_id=resolved["pack_id"], ) + selected_source = (dispensing.dispense_source or ("legacy" if not allocations else "batch")).strip().lower() + user_name = dispensing.user_name or current_user.username primary_batch_id = allocations[0]["batch"].id if allocations else None @@ -1402,6 +1424,7 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c "drug_variant_id": dispensing.drug_variant_id, "requested_quantity": dispense_qty, "dispense_mode": dispense_mode, + "dispense_source": selected_source, "requested_pack_id": resolved["pack_id"], "requested_pack_count": resolved["pack_count"], "allocations": allocation_payload, @@ -2054,6 +2077,98 @@ def report_stock_by_location( return result +@router.get("/reports/global-inventory") +def report_global_inventory( + format: str = "json", + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + variant_rows = ( + db.query(DrugVariant, Drug) + .join(Drug, DrugVariant.drug_id == Drug.id) + .order_by(Drug.name.asc(), DrugVariant.strength.asc()) + .all() + ) + + result: List[Dict[str, Any]] = [] + for variant, drug in variant_rows: + batch_rows = ( + db.query(Batch, Location) + .join(Location, Batch.location_id == Location.id) + .filter(Batch.drug_variant_id == variant.id, Batch.quantity > 0) + .order_by(Batch.expiry_date.asc(), Location.name.asc(), Batch.batch_number.asc()) + .all() + ) + + total_batch_quantity = 0.0 + for batch, location in batch_rows: + total_batch_quantity += float(batch.quantity or 0) + 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_name": location.name, + "expiry_date": batch.expiry_date, + "inventory_source": "batch", + "is_controlled": bool(drug.is_controlled), + } + ) + + legacy_quantity = max(0.0, float(variant.quantity or 0) - total_batch_quantity) + if legacy_quantity > 1e-6: + result.append( + { + "batch_id": None, + "batch_number": "Legacy stock", + "drug_name": drug.name, + "strength": variant.strength, + "quantity": legacy_quantity, + "unit": variant.unit, + "location_name": None, + "expiry_date": None, + "inventory_source": "legacy", + "is_controlled": bool(drug.is_controlled), + } + ) + + if format.lower() == "csv": + csv_rows = [ + [ + item["drug_name"], + item["strength"], + item["batch_number"], + item["quantity"], + item["unit"], + item["location_name"], + item["expiry_date"], + item["inventory_source"], + item["is_controlled"], + ] + for item in result + ] + return _csv_response( + "global_inventory.csv", + [ + "drug_name", + "strength", + "batch_number", + "quantity", + "unit", + "location_name", + "expiry_date", + "inventory_source", + "is_controlled", + ], + csv_rows, + ) + + return result + + @router.get("/reports/batch-attention") def report_batch_attention( format: str = "json", diff --git a/frontend/app.js b/frontend/app.js index d5f733c..9cc4fdc 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -260,6 +260,7 @@ function setupEventListeners() { const variantStrengthInput = document.getElementById('variantStrength'); const editVariantUnitSelect = document.getElementById('editVariantUnit'); const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]'); + const dispenseSourceInputs = document.querySelectorAll('input[name="dispenseSource"]'); const dispensePrintEnabled = document.getElementById('dispensePrintEnabled'); const showAllBtn = document.getElementById('showAllBtn'); const showLowStockBtn = document.getElementById('showLowStockBtn'); @@ -311,6 +312,11 @@ function setupEventListeners() { }); } dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi)); + dispenseSourceInputs.forEach(input => input.addEventListener('change', () => { + renderDispenseInventorySourceView(); + toggleDispensePrintFields(); + updateDispenseAllocationSummary(); + })); if (dispensePrintEnabled) { dispensePrintEnabled.addEventListener('change', toggleDispensePrintFields); } @@ -503,6 +509,8 @@ function updateDispenseDrugSelect() { const packCount = document.getElementById('dispensePackCount'); const packPreview = document.getElementById('dispensePackPreview'); const quantityModeRadio = document.getElementById('dispenseModeQuantity'); + const batchSourceRadio = document.getElementById('dispenseSourceBatch'); + const legacySourceRadio = document.getElementById('dispenseSourceLegacy'); if (packSelect) { packSelect.innerHTML = ''; } @@ -515,6 +523,12 @@ function updateDispenseDrugSelect() { if (packPreview) { packPreview.textContent = 'Select a pack and whole-number count.'; } + if (batchSourceRadio) { + batchSourceRadio.checked = true; + } + if (legacySourceRadio) { + legacySourceRadio.checked = false; + } resetDispensePrintFields(); @@ -528,8 +542,78 @@ function getSelectedDispenseMode() { return document.querySelector('input[name="dispenseMode"]:checked')?.value || 'subunit'; } -function hasLegacyDispenseStock() { - return currentDispenseBatches.length === 0 && currentDispenseLegacyQuantity > 0; +function hasLegacyDispenseQuantity() { + return currentDispenseLegacyQuantity > 0; +} + +function hasBatchDispenseStock() { + return currentDispenseBatches.length > 0; +} + +function getSelectedDispenseSource() { + if (getSelectedDispenseMode() === 'pack') { + return 'batch'; + } + + const selected = document.querySelector('input[name="dispenseSource"]:checked')?.value; + if (selected) { + return selected; + } + + if (hasLegacyDispenseQuantity() && !hasBatchDispenseStock()) { + return 'legacy'; + } + + return 'batch'; +} + +function isLegacyDispenseSelected() { + return getSelectedDispenseMode() === 'subunit' && getSelectedDispenseSource() === 'legacy' && hasLegacyDispenseQuantity(); +} + +function updateDispenseSourceUi() { + const sourceGroup = document.getElementById('dispenseSourceGroup'); + const sourceHelp = document.getElementById('dispenseSourceHelp'); + const batchRadio = document.getElementById('dispenseSourceBatch'); + const legacyRadio = document.getElementById('dispenseSourceLegacy'); + const hasBatches = hasBatchDispenseStock(); + const hasLegacy = hasLegacyDispenseQuantity(); + + if (!sourceGroup || !batchRadio || !legacyRadio) { + return; + } + + if (getSelectedDispenseMode() === 'pack' || (!hasBatches && !hasLegacy)) { + sourceGroup.style.display = 'none'; + batchRadio.checked = true; + batchRadio.disabled = !hasBatches; + legacyRadio.checked = false; + legacyRadio.disabled = true; + if (sourceHelp) sourceHelp.textContent = ''; + return; + } + + batchRadio.disabled = !hasBatches; + legacyRadio.disabled = !hasLegacy; + + if (hasLegacy && !hasBatches) { + legacyRadio.checked = true; + } else if (!hasLegacy && hasBatches) { + batchRadio.checked = true; + } else if (!batchRadio.checked && !legacyRadio.checked) { + batchRadio.checked = true; + } + + sourceGroup.style.display = hasLegacy ? '' : 'none'; + if (sourceHelp) { + if (hasLegacy && hasBatches) { + sourceHelp.textContent = `Batch stock available alongside ${formatDisplayNumber(currentDispenseLegacyQuantity)} loose legacy units.`; + } else if (hasLegacy) { + sourceHelp.textContent = `Legacy loose stock available: ${formatDisplayNumber(currentDispenseLegacyQuantity)}.`; + } else { + sourceHelp.textContent = ''; + } + } } function getDefaultLabelExpiryDate() { @@ -546,7 +630,7 @@ function toggleDispensePrintFields() { const legacyExpiryGroup = document.getElementById('dispenseLegacyExpiryGroup'); const legacyExpiryInput = document.getElementById('dispenseLegacyExpiry'); const isEnabled = Boolean(printEnabled?.checked); - const legacyStockOnly = hasLegacyDispenseStock(); + const legacyStockOnly = isLegacyDispenseSelected(); if (printFields) { printFields.style.display = isEnabled ? '' : 'none'; @@ -691,10 +775,9 @@ function updateDispenseModeUi() { packCount.required = mode === 'pack'; } - if (currentDispenseBatches.length > 0) { - renderDispenseBatchAllocationRows(currentDispenseBatches); - } - autoAllocateDispenseBatches(); + updateDispenseSourceUi(); + renderDispenseInventorySourceView(); + toggleDispensePrintFields(); } function updateDispenseQuantityFromPack() { @@ -982,14 +1065,14 @@ function getBatchAvailableDispenseQuantity(batch, mode = getSelectedDispenseMode } function getTotalAvailableDispenseQuantity(mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) { - if (hasLegacyDispenseStock()) { + if (getSelectedDispenseSource() === 'legacy') { return mode === 'pack' ? 0 : currentDispenseLegacyQuantity; } return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0); } function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) { - if (hasLegacyDispenseStock()) { + if (getSelectedDispenseSource() === 'legacy') { return 0; } if (!selectedPack) { @@ -1134,6 +1217,52 @@ function renderExpiredDispenseBatches(expiredBatches) { }).join(''); } +function renderDispenseInventorySourceView() { + const batchInfoContent = document.getElementById('batchInfoContent'); + const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10); + const variant = getVariantById(variantId); + + if (!batchInfoContent || !variant) { + return; + } + + if (getSelectedDispenseMode() === 'pack') { + if (hasBatchDispenseStock()) { + renderDispenseBatchAllocationRows(currentDispenseBatches); + autoAllocateDispenseBatches(); + } else if (hasLegacyDispenseQuantity()) { + batchInfoContent.innerHTML = `
Legacy stock only. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Whole-pack dispensing is unavailable.
`; + updateDispenseAllocationSummary(); + } else { + batchInfoContent.innerHTML = '

⚠️ No active batches available for this variant

'; + updateDispenseAllocationSummary(); + } + return; + } + + if (isLegacyDispenseSelected()) { + const extraText = hasBatchDispenseStock() ? ' Batch stock is also available; switch source to allocate from batches.' : ' Dispense by quantity only.'; + batchInfoContent.innerHTML = `
Legacy loose stock selected. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system.${extraText}
`; + updateDispenseAllocationSummary(); + return; + } + + if (hasBatchDispenseStock()) { + renderDispenseBatchAllocationRows(currentDispenseBatches); + autoAllocateDispenseBatches(); + return; + } + + if (hasLegacyDispenseQuantity()) { + batchInfoContent.innerHTML = `
Legacy stock only. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Dispense by quantity only.
`; + updateDispenseAllocationSummary(); + return; + } + + batchInfoContent.innerHTML = '

⚠️ No active batches available for this variant

'; + updateDispenseAllocationSummary(); +} + // Update batch info display when variant is selected async function updateBatchInfo() { const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); @@ -1147,6 +1276,7 @@ async function updateBatchInfo() { currentDispenseBatches = []; currentDispenseLegacyQuantity = 0; renderExpiredDispenseBatches([]); + updateDispenseSourceUi(); toggleDispensePrintFields(); updateDispenseAllocationSummary(); return; @@ -1175,32 +1305,28 @@ async function updateBatchInfo() { currentDispenseLegacyQuantity = Math.max(0, Number(variant?.quantity || 0) - totalBatchQuantity); currentDispenseBatches = activeBatches; renderExpiredDispenseBatches(expiredBatches); - - if (activeBatches.length === 0) { - if (currentDispenseLegacyQuantity > 0) { - batchInfoContent.innerHTML = `
Legacy stock only. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant?.unit || 'units')} available outside the batch system. Dispense by quantity only; whole-pack allocation is unavailable.
`; - } else { - 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

'; - } - toggleDispensePrintFields(); + + if (!activeBatches.length && currentDispenseLegacyQuantity <= 0 && expiredBatches.length > 0) { + updateDispenseSourceUi(); + batchInfoContent.innerHTML = '

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

'; + toggleDispensePrintFields(); updateDispenseAllocationSummary(); return; } - + // Sort by expiry date (FEFO order) activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); currentDispenseBatches = activeBatches; - renderDispenseBatchAllocationRows(activeBatches); + updateDispenseSourceUi(); + renderDispenseInventorySourceView(); toggleDispensePrintFields(); - autoAllocateDispenseBatches(); } catch (error) { console.error('Error loading batches:', error); batchInfoContent.innerHTML = '

Error loading batches

'; currentDispenseBatches = []; currentDispenseLegacyQuantity = 0; renderExpiredDispenseBatches([]); + updateDispenseSourceUi(); toggleDispensePrintFields(); updateDispenseAllocationSummary(); } @@ -1217,6 +1343,14 @@ function autoAllocateDispenseBatches() { return; } + if (isLegacyDispenseSelected()) { + allocationInputs.forEach(input => { + input.value = '0'; + }); + updateDispenseAllocationSummary(); + return; + } + let remaining = mode === 'pack' ? Math.max(0, Math.round(parseFloat(document.getElementById('dispensePackCount')?.value || '0')) || 0) : requestedQuantity; @@ -1254,7 +1388,7 @@ function updateDispenseAllocationSummary() { const inputs = Array.from(document.querySelectorAll('.dispense-batch-allocation')); const mode = getSelectedDispenseMode(); const selectedPack = getSelectedDispensePack(); - const legacyStockOnly = hasLegacyDispenseStock(); + const legacyStockOnly = isLegacyDispenseSelected(); const totalAvailableQuantity = getTotalAvailableDispenseQuantity(mode, selectedPack); const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0; @@ -1536,6 +1670,7 @@ async function handleDispenseDrug(e) { const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); let quantity = parseFloat(document.getElementById('dispenseQuantity').value); const dispenseMode = getSelectedDispenseMode(); + const dispenseSource = getSelectedDispenseSource(); const requestedPackIdValue = document.getElementById('dispensePackSelect').value; const requestedPackCountValue = document.getElementById('dispensePackCount').value; const animalName = document.getElementById('dispenseAnimal').value; @@ -1547,7 +1682,7 @@ async function handleDispenseDrug(e) { const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null; const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null; const variant = getVariantById(variantId); - const legacyStockOnly = hasLegacyDispenseStock(); + const legacyStockOnly = isLegacyDispenseSelected(); const selectedPack = variant && selectedPackId ? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId) : null; @@ -1660,6 +1795,7 @@ async function handleDispenseDrug(e) { dispense_mode: dispenseMode, requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null, requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null, + dispense_source: dispenseSource, animal_name: animalName || null, notes: notes || null, allocations @@ -1726,12 +1862,12 @@ function openEditModal(drugId) { document.getElementById('editDrugDescription').value = drug.description || ''; document.getElementById('editDrugIsControlled').checked = drug.is_controlled || false; - document.getElementById('editModal').classList.add('show'); + openModal(document.getElementById('editModal')); } // Close edit modal function closeEditModal() { - document.getElementById('editModal').classList.remove('show'); + closeModal(document.getElementById('editModal')); document.getElementById('editForm').reset(); } @@ -1772,7 +1908,7 @@ function openAddVariantModal(drugId) { if (form) form.reset(); document.getElementById('variantDrugId').value = drug.id; initializeVariantPackRows(); - document.getElementById('addVariantModal').classList.add('show'); + openModal(document.getElementById('addVariantModal')); } function inferBaseUnitFromStrength(strength) { @@ -2063,7 +2199,7 @@ function openEditVariantModal(variantId) { setEditVariantFieldLockState(hasInventoryContext); initializeEditVariantPackRows(); - document.getElementById('editVariantModal').classList.add('show'); + openModal(document.getElementById('editVariantModal')); } // Handle edit variant form @@ -2987,7 +3123,9 @@ function wireDeliveryLineEvents(line) { variantSelect.addEventListener('change', () => { const variantId = parseInt(variantSelect.value || '', 10); const variant = getVariantById(variantId); - packSelect.innerHTML = buildDeliveryPackOptions(variant, ''); + const activePacks = getActivePacksForVariant(variant); + const nextPackId = activePacks.length === 1 ? activePacks[0].id : ''; + packSelect.innerHTML = buildDeliveryPackOptions(variant, nextPackId); if (packCountInput) packCountInput.value = ''; updateDeliveryLineQuantityDisplay(line); }); @@ -3018,9 +3156,11 @@ function appendDeliveryLine(prefill = {}) { line.className = 'delivery-line'; line.dataset.lineId = lineId; - const initialVariant = drug.variants.find(v => String(v.id) === String(prefill.variantId)) || drug.variants[0] || null; + const initialVariant = prefill.variantId + ? drug.variants.find(v => String(v.id) === String(prefill.variantId)) || null + : drug.variants.length === 1 ? drug.variants[0] : null; const initialVariantId = prefill.variantId || (initialVariant ? initialVariant.id : ''); - const initialPackId = prefill.packId || ''; + const initialPackId = prefill.packId || (getActivePacksForVariant(initialVariant).length === 1 ? getActivePacksForVariant(initialVariant)[0].id : ''); const initialPackCount = prefill.packCount || ''; line.innerHTML = ` @@ -3039,7 +3179,7 @@ function appendDeliveryLine(prefill = {}) {
- +
@@ -3087,12 +3227,15 @@ function refreshDeliveryVariantSelects() { if (!select) return; const currentVariantId = select.value; - select.innerHTML = buildDeliveryVariantOptions(drug, currentVariantId); + const nextVariantId = currentVariantId || (drug.variants.length === 1 ? String(drug.variants[0].id) : ''); + select.innerHTML = buildDeliveryVariantOptions(drug, nextVariantId); const variant = getVariantById(parseInt(select.value || '', 10)); if (packSelect) { const currentPackId = packSelect.value; - packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId); + const activePacks = getActivePacksForVariant(variant); + const nextPackId = currentPackId || (activePacks.length === 1 ? String(activePacks[0].id) : ''); + packSelect.innerHTML = buildDeliveryPackOptions(variant, nextPackId); } updateDeliveryLineQuantityDisplay(line); @@ -3176,6 +3319,11 @@ async function handleReceiveDelivery(e) { return; } + if (Math.abs(packCount - Math.round(packCount)) > 1e-6) { + showToast(`Delivery line ${i + 1} pack count must be a whole number`, 'warning'); + return; + } + const variant = drug.variants.find(v => v.id === variantId); const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null; if (!selectedPack) { diff --git a/frontend/index.html b/frontend/index.html index 3fc1db2..882f87d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -222,6 +222,21 @@
+ +