Reporting and batch management

This commit is contained in:
2026-04-06 11:04:06 -04:00
parent b958ca493b
commit 36f0a5b07e
5 changed files with 392 additions and 36 deletions
+115
View File
@@ -245,6 +245,7 @@ class DispensingCreate(BaseModel):
drug_variant_id: int drug_variant_id: int
quantity: Optional[float] = None quantity: Optional[float] = None
dispense_mode: str = "subunit" dispense_mode: str = "subunit"
dispense_source: str = "batch"
requested_pack_id: Optional[int] = None requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None requested_pack_count: Optional[float] = None
animal_name: Optional[str] = None animal_name: Optional[str] = None
@@ -496,12 +497,16 @@ def resolve_requested_allocations(
requested_quantity: float, requested_quantity: float,
requested_allocations: List[DispensingAllocationCreate], requested_allocations: List[DispensingAllocationCreate],
dispense_mode: str, dispense_mode: str,
dispense_source: str,
requested_pack_id: Optional[int], requested_pack_id: Optional[int],
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Validate explicit batch allocations against in-date stock for the variant.""" """Validate explicit batch allocations against in-date stock for the variant."""
today = date.today() 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()) 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) 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 = ( eligible_batches = (
db.query(Batch) db.query(Batch)
.filter( .filter(
@@ -513,6 +518,20 @@ def resolve_requested_allocations(
.all() .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 not eligible_batches:
if dispense_mode == "pack": if dispense_mode == "pack":
raise HTTPException(status_code=400, detail="Whole-pack dispensing requires batched stock with pack information") 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_quantity=dispense_qty,
requested_allocations=dispensing.allocations, requested_allocations=dispensing.allocations,
dispense_mode=dispense_mode, dispense_mode=dispense_mode,
dispense_source=dispensing.dispense_source,
requested_pack_id=resolved["pack_id"], 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 user_name = dispensing.user_name or current_user.username
primary_batch_id = allocations[0]["batch"].id if allocations else None 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, "drug_variant_id": dispensing.drug_variant_id,
"requested_quantity": dispense_qty, "requested_quantity": dispense_qty,
"dispense_mode": dispense_mode, "dispense_mode": dispense_mode,
"dispense_source": selected_source,
"requested_pack_id": resolved["pack_id"], "requested_pack_id": resolved["pack_id"],
"requested_pack_count": resolved["pack_count"], "requested_pack_count": resolved["pack_count"],
"allocations": allocation_payload, "allocations": allocation_payload,
@@ -2054,6 +2077,98 @@ def report_stock_by_location(
return result 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") @router.get("/reports/batch-attention")
def report_batch_attention( def report_batch_attention(
format: str = "json", format: str = "json",
+182 -34
View File
@@ -260,6 +260,7 @@ function setupEventListeners() {
const variantStrengthInput = document.getElementById('variantStrength'); const variantStrengthInput = document.getElementById('variantStrength');
const editVariantUnitSelect = document.getElementById('editVariantUnit'); const editVariantUnitSelect = document.getElementById('editVariantUnit');
const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]'); const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]');
const dispenseSourceInputs = document.querySelectorAll('input[name="dispenseSource"]');
const dispensePrintEnabled = document.getElementById('dispensePrintEnabled'); const dispensePrintEnabled = document.getElementById('dispensePrintEnabled');
const showAllBtn = document.getElementById('showAllBtn'); const showAllBtn = document.getElementById('showAllBtn');
const showLowStockBtn = document.getElementById('showLowStockBtn'); const showLowStockBtn = document.getElementById('showLowStockBtn');
@@ -311,6 +312,11 @@ function setupEventListeners() {
}); });
} }
dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi)); dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi));
dispenseSourceInputs.forEach(input => input.addEventListener('change', () => {
renderDispenseInventorySourceView();
toggleDispensePrintFields();
updateDispenseAllocationSummary();
}));
if (dispensePrintEnabled) { if (dispensePrintEnabled) {
dispensePrintEnabled.addEventListener('change', toggleDispensePrintFields); dispensePrintEnabled.addEventListener('change', toggleDispensePrintFields);
} }
@@ -503,6 +509,8 @@ function updateDispenseDrugSelect() {
const packCount = document.getElementById('dispensePackCount'); const packCount = document.getElementById('dispensePackCount');
const packPreview = document.getElementById('dispensePackPreview'); const packPreview = document.getElementById('dispensePackPreview');
const quantityModeRadio = document.getElementById('dispenseModeQuantity'); const quantityModeRadio = document.getElementById('dispenseModeQuantity');
const batchSourceRadio = document.getElementById('dispenseSourceBatch');
const legacySourceRadio = document.getElementById('dispenseSourceLegacy');
if (packSelect) { if (packSelect) {
packSelect.innerHTML = '<option value="">-- Select pack --</option>'; packSelect.innerHTML = '<option value="">-- Select pack --</option>';
} }
@@ -515,6 +523,12 @@ function updateDispenseDrugSelect() {
if (packPreview) { if (packPreview) {
packPreview.textContent = 'Select a pack and whole-number count.'; packPreview.textContent = 'Select a pack and whole-number count.';
} }
if (batchSourceRadio) {
batchSourceRadio.checked = true;
}
if (legacySourceRadio) {
legacySourceRadio.checked = false;
}
resetDispensePrintFields(); resetDispensePrintFields();
@@ -528,8 +542,78 @@ function getSelectedDispenseMode() {
return document.querySelector('input[name="dispenseMode"]:checked')?.value || 'subunit'; return document.querySelector('input[name="dispenseMode"]:checked')?.value || 'subunit';
} }
function hasLegacyDispenseStock() { function hasLegacyDispenseQuantity() {
return currentDispenseBatches.length === 0 && currentDispenseLegacyQuantity > 0; 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() { function getDefaultLabelExpiryDate() {
@@ -546,7 +630,7 @@ function toggleDispensePrintFields() {
const legacyExpiryGroup = document.getElementById('dispenseLegacyExpiryGroup'); const legacyExpiryGroup = document.getElementById('dispenseLegacyExpiryGroup');
const legacyExpiryInput = document.getElementById('dispenseLegacyExpiry'); const legacyExpiryInput = document.getElementById('dispenseLegacyExpiry');
const isEnabled = Boolean(printEnabled?.checked); const isEnabled = Boolean(printEnabled?.checked);
const legacyStockOnly = hasLegacyDispenseStock(); const legacyStockOnly = isLegacyDispenseSelected();
if (printFields) { if (printFields) {
printFields.style.display = isEnabled ? '' : 'none'; printFields.style.display = isEnabled ? '' : 'none';
@@ -691,10 +775,9 @@ function updateDispenseModeUi() {
packCount.required = mode === 'pack'; packCount.required = mode === 'pack';
} }
if (currentDispenseBatches.length > 0) { updateDispenseSourceUi();
renderDispenseBatchAllocationRows(currentDispenseBatches); renderDispenseInventorySourceView();
} toggleDispensePrintFields();
autoAllocateDispenseBatches();
} }
function updateDispenseQuantityFromPack() { function updateDispenseQuantityFromPack() {
@@ -982,14 +1065,14 @@ function getBatchAvailableDispenseQuantity(batch, mode = getSelectedDispenseMode
} }
function getTotalAvailableDispenseQuantity(mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) { function getTotalAvailableDispenseQuantity(mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) {
if (hasLegacyDispenseStock()) { if (getSelectedDispenseSource() === 'legacy') {
return mode === 'pack' ? 0 : currentDispenseLegacyQuantity; return mode === 'pack' ? 0 : currentDispenseLegacyQuantity;
} }
return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0); return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0);
} }
function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) { function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) {
if (hasLegacyDispenseStock()) { if (getSelectedDispenseSource() === 'legacy') {
return 0; return 0;
} }
if (!selectedPack) { if (!selectedPack) {
@@ -1134,6 +1217,52 @@ function renderExpiredDispenseBatches(expiredBatches) {
}).join(''); }).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 = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Whole-pack dispensing is unavailable.</div>`;
updateDispenseAllocationSummary();
} else {
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
updateDispenseAllocationSummary();
}
return;
}
if (isLegacyDispenseSelected()) {
const extraText = hasBatchDispenseStock() ? ' Batch stock is also available; switch source to allocate from batches.' : ' Dispense by quantity only.';
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy loose stock selected.</strong> ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system.${extraText}</div>`;
updateDispenseAllocationSummary();
return;
}
if (hasBatchDispenseStock()) {
renderDispenseBatchAllocationRows(currentDispenseBatches);
autoAllocateDispenseBatches();
return;
}
if (hasLegacyDispenseQuantity()) {
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Dispense by quantity only.</div>`;
updateDispenseAllocationSummary();
return;
}
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
updateDispenseAllocationSummary();
}
// Update batch info display when variant is selected // Update batch info display when variant is selected
async function updateBatchInfo() { async function updateBatchInfo() {
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
@@ -1147,6 +1276,7 @@ async function updateBatchInfo() {
currentDispenseBatches = []; currentDispenseBatches = [];
currentDispenseLegacyQuantity = 0; currentDispenseLegacyQuantity = 0;
renderExpiredDispenseBatches([]); renderExpiredDispenseBatches([]);
updateDispenseSourceUi();
toggleDispensePrintFields(); toggleDispensePrintFields();
updateDispenseAllocationSummary(); updateDispenseAllocationSummary();
return; return;
@@ -1175,32 +1305,28 @@ async function updateBatchInfo() {
currentDispenseLegacyQuantity = Math.max(0, Number(variant?.quantity || 0) - totalBatchQuantity); currentDispenseLegacyQuantity = Math.max(0, Number(variant?.quantity || 0) - totalBatchQuantity);
currentDispenseBatches = activeBatches; currentDispenseBatches = activeBatches;
renderExpiredDispenseBatches(expiredBatches); renderExpiredDispenseBatches(expiredBatches);
if (activeBatches.length === 0) { if (!activeBatches.length && currentDispenseLegacyQuantity <= 0 && expiredBatches.length > 0) {
if (currentDispenseLegacyQuantity > 0) { updateDispenseSourceUi();
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant?.unit || 'units')} available outside the batch system. Dispense by quantity only; whole-pack allocation is unavailable.</div>`; batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No in-date batches available for this variant. Expired batches are hidden from selection.</p>';
} else { toggleDispensePrintFields();
batchInfoContent.innerHTML = expiredBatches.length > 0
? '<p style="color: #d32f2f; margin: 0;">⚠️ No in-date batches available for this variant. Expired batches are hidden from selection.</p>'
: '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
}
toggleDispensePrintFields();
updateDispenseAllocationSummary(); updateDispenseAllocationSummary();
return; return;
} }
// Sort by expiry date (FEFO order) // Sort by expiry date (FEFO order)
activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
currentDispenseBatches = activeBatches; currentDispenseBatches = activeBatches;
renderDispenseBatchAllocationRows(activeBatches); updateDispenseSourceUi();
renderDispenseInventorySourceView();
toggleDispensePrintFields(); toggleDispensePrintFields();
autoAllocateDispenseBatches();
} catch (error) { } catch (error) {
console.error('Error loading batches:', error); console.error('Error loading batches:', error);
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error loading batches</p>'; batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error loading batches</p>';
currentDispenseBatches = []; currentDispenseBatches = [];
currentDispenseLegacyQuantity = 0; currentDispenseLegacyQuantity = 0;
renderExpiredDispenseBatches([]); renderExpiredDispenseBatches([]);
updateDispenseSourceUi();
toggleDispensePrintFields(); toggleDispensePrintFields();
updateDispenseAllocationSummary(); updateDispenseAllocationSummary();
} }
@@ -1217,6 +1343,14 @@ function autoAllocateDispenseBatches() {
return; return;
} }
if (isLegacyDispenseSelected()) {
allocationInputs.forEach(input => {
input.value = '0';
});
updateDispenseAllocationSummary();
return;
}
let remaining = mode === 'pack' let remaining = mode === 'pack'
? Math.max(0, Math.round(parseFloat(document.getElementById('dispensePackCount')?.value || '0')) || 0) ? Math.max(0, Math.round(parseFloat(document.getElementById('dispensePackCount')?.value || '0')) || 0)
: requestedQuantity; : requestedQuantity;
@@ -1254,7 +1388,7 @@ function updateDispenseAllocationSummary() {
const inputs = Array.from(document.querySelectorAll('.dispense-batch-allocation')); const inputs = Array.from(document.querySelectorAll('.dispense-batch-allocation'));
const mode = getSelectedDispenseMode(); const mode = getSelectedDispenseMode();
const selectedPack = getSelectedDispensePack(); const selectedPack = getSelectedDispensePack();
const legacyStockOnly = hasLegacyDispenseStock(); const legacyStockOnly = isLegacyDispenseSelected();
const totalAvailableQuantity = getTotalAvailableDispenseQuantity(mode, selectedPack); const totalAvailableQuantity = getTotalAvailableDispenseQuantity(mode, selectedPack);
const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0; const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0;
@@ -1536,6 +1670,7 @@ async function handleDispenseDrug(e) {
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
let quantity = parseFloat(document.getElementById('dispenseQuantity').value); let quantity = parseFloat(document.getElementById('dispenseQuantity').value);
const dispenseMode = getSelectedDispenseMode(); const dispenseMode = getSelectedDispenseMode();
const dispenseSource = getSelectedDispenseSource();
const requestedPackIdValue = document.getElementById('dispensePackSelect').value; const requestedPackIdValue = document.getElementById('dispensePackSelect').value;
const requestedPackCountValue = document.getElementById('dispensePackCount').value; const requestedPackCountValue = document.getElementById('dispensePackCount').value;
const animalName = document.getElementById('dispenseAnimal').value; const animalName = document.getElementById('dispenseAnimal').value;
@@ -1547,7 +1682,7 @@ async function handleDispenseDrug(e) {
const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null; const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null;
const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null; const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null;
const variant = getVariantById(variantId); const variant = getVariantById(variantId);
const legacyStockOnly = hasLegacyDispenseStock(); const legacyStockOnly = isLegacyDispenseSelected();
const selectedPack = variant && selectedPackId const selectedPack = variant && selectedPackId
? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId) ? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId)
: null; : null;
@@ -1660,6 +1795,7 @@ async function handleDispenseDrug(e) {
dispense_mode: dispenseMode, dispense_mode: dispenseMode,
requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null, requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null,
requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null, requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null,
dispense_source: dispenseSource,
animal_name: animalName || null, animal_name: animalName || null,
notes: notes || null, notes: notes || null,
allocations allocations
@@ -1726,12 +1862,12 @@ function openEditModal(drugId) {
document.getElementById('editDrugDescription').value = drug.description || ''; document.getElementById('editDrugDescription').value = drug.description || '';
document.getElementById('editDrugIsControlled').checked = drug.is_controlled || false; document.getElementById('editDrugIsControlled').checked = drug.is_controlled || false;
document.getElementById('editModal').classList.add('show'); openModal(document.getElementById('editModal'));
} }
// Close edit modal // Close edit modal
function closeEditModal() { function closeEditModal() {
document.getElementById('editModal').classList.remove('show'); closeModal(document.getElementById('editModal'));
document.getElementById('editForm').reset(); document.getElementById('editForm').reset();
} }
@@ -1772,7 +1908,7 @@ function openAddVariantModal(drugId) {
if (form) form.reset(); if (form) form.reset();
document.getElementById('variantDrugId').value = drug.id; document.getElementById('variantDrugId').value = drug.id;
initializeVariantPackRows(); initializeVariantPackRows();
document.getElementById('addVariantModal').classList.add('show'); openModal(document.getElementById('addVariantModal'));
} }
function inferBaseUnitFromStrength(strength) { function inferBaseUnitFromStrength(strength) {
@@ -2063,7 +2199,7 @@ function openEditVariantModal(variantId) {
setEditVariantFieldLockState(hasInventoryContext); setEditVariantFieldLockState(hasInventoryContext);
initializeEditVariantPackRows(); initializeEditVariantPackRows();
document.getElementById('editVariantModal').classList.add('show'); openModal(document.getElementById('editVariantModal'));
} }
// Handle edit variant form // Handle edit variant form
@@ -2987,7 +3123,9 @@ function wireDeliveryLineEvents(line) {
variantSelect.addEventListener('change', () => { variantSelect.addEventListener('change', () => {
const variantId = parseInt(variantSelect.value || '', 10); const variantId = parseInt(variantSelect.value || '', 10);
const variant = getVariantById(variantId); 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 = ''; if (packCountInput) packCountInput.value = '';
updateDeliveryLineQuantityDisplay(line); updateDeliveryLineQuantityDisplay(line);
}); });
@@ -3018,9 +3156,11 @@ function appendDeliveryLine(prefill = {}) {
line.className = 'delivery-line'; line.className = 'delivery-line';
line.dataset.lineId = lineId; 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 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 || ''; const initialPackCount = prefill.packCount || '';
line.innerHTML = ` line.innerHTML = `
@@ -3039,7 +3179,7 @@ function appendDeliveryLine(prefill = {}) {
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Pack Count</label> <label>Pack Count</label>
<input type="number" class="delivery-pack-count" min="0.0001" step="0.0001" value="${initialPackCount}" required> <input type="number" class="delivery-pack-count" min="1" step="1" value="${initialPackCount}" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Batch Number</label> <label>Batch Number</label>
@@ -3087,12 +3227,15 @@ function refreshDeliveryVariantSelects() {
if (!select) return; if (!select) return;
const currentVariantId = select.value; 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)); const variant = getVariantById(parseInt(select.value || '', 10));
if (packSelect) { if (packSelect) {
const currentPackId = packSelect.value; 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); updateDeliveryLineQuantityDisplay(line);
@@ -3176,6 +3319,11 @@ async function handleReceiveDelivery(e) {
return; 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 variant = drug.variants.find(v => v.id === variantId);
const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null; const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null;
if (!selectedPack) { if (!selectedPack) {
+15
View File
@@ -222,6 +222,21 @@
</div> </div>
</div> </div>
<div class="form-group" id="dispenseSourceGroup" style="display: none;">
<label>Stock Source *</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="dispenseSource" id="dispenseSourceBatch" value="batch" checked>
Batch stock
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="dispenseSource" id="dispenseSourceLegacy" value="legacy">
Legacy loose stock
</label>
</div>
<small id="dispenseSourceHelp" style="display: block; margin-top: 6px; color: #666;"></small>
</div>
<div id="batchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;"> <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> <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> <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>
+1
View File
@@ -31,6 +31,7 @@
<label for="reportTypeSelect">Report</label> <label for="reportTypeSelect">Report</label>
<select id="reportTypeSelect"> <select id="reportTypeSelect">
<option value="dispensing" selected>Dispensing History</option> <option value="dispensing" selected>Dispensing History</option>
<option value="global_inventory">Global Inventory</option>
<option value="batch_attention">Expired Batches</option> <option value="batch_attention">Expired Batches</option>
<option value="audit">Audit Trail (Raw)</option> <option value="audit">Audit Trail (Raw)</option>
</select> </select>
+79 -2
View File
@@ -5,6 +5,7 @@ let currentUser = null;
let allDrugs = []; let allDrugs = [];
let auditTrailRows = []; let auditTrailRows = [];
let dispensingRows = []; let dispensingRows = [];
let globalInventoryRows = [];
let batchAttentionRows = []; let batchAttentionRows = [];
let activeReportType = 'dispensing'; let activeReportType = 'dispensing';
const batchLookupById = new Map(); const batchLookupById = new Map();
@@ -200,24 +201,28 @@ function detailsContainsText(details, searchText) {
function getActiveRows() { function getActiveRows() {
if (activeReportType === 'dispensing') return dispensingRows; if (activeReportType === 'dispensing') return dispensingRows;
if (activeReportType === 'global_inventory') return globalInventoryRows;
if (activeReportType === 'batch_attention') return batchAttentionRows; if (activeReportType === 'batch_attention') return batchAttentionRows;
return auditTrailRows; return auditTrailRows;
} }
function getRowUser(row) { function getRowUser(row) {
if (activeReportType === 'dispensing') return row.user_name || 'unknown'; if (activeReportType === 'dispensing') return row.user_name || 'unknown';
if (activeReportType === 'global_inventory') return '';
if (activeReportType === 'batch_attention') return ''; if (activeReportType === 'batch_attention') return '';
return row.actor_username || 'system'; return row.actor_username || 'system';
} }
function getRowDrug(row) { function getRowDrug(row) {
if (activeReportType === 'dispensing') return extractDrugLabelFromDispenseRow(row); if (activeReportType === 'dispensing') return extractDrugLabelFromDispenseRow(row);
if (activeReportType === 'global_inventory') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`;
if (activeReportType === 'batch_attention') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`; if (activeReportType === 'batch_attention') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`;
return extractDrugLabelFromAuditRow(row); return extractDrugLabelFromAuditRow(row);
} }
function getRowDate(row) { function getRowDate(row) {
if (activeReportType === 'dispensing') return new Date(row.dispensed_at); if (activeReportType === 'dispensing') return new Date(row.dispensed_at);
if (activeReportType === 'global_inventory') return row.expiry_date ? new Date(row.expiry_date) : null;
if (activeReportType === 'batch_attention') return new Date(row.expiry_date); if (activeReportType === 'batch_attention') return new Date(row.expiry_date);
return new Date(row.created_at); return new Date(row.created_at);
} }
@@ -348,6 +353,50 @@ function renderDispensingTable(rows) {
`; `;
} }
function renderGlobalInventoryTable(rows) {
const container = document.getElementById('reportsTableContainer');
if (!container) return;
if (!rows.length) {
container.innerHTML = '<p class="empty" style="padding: 14px;">No inventory lines match the selected filters.</p>';
return;
}
const rowsHtml = rows.map(row => {
const expiryText = row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : '-';
const quantityText = `${row.quantity} ${row.unit || 'units'}`;
const batchText = row.inventory_source === 'legacy' ? 'Legacy stock' : (row.batch_number || '');
const locationText = row.location_name || '-';
return `
<tr>
<td>${escapeHtml(row.drug_name || '')}</td>
<td>${escapeHtml(row.strength || '-')}</td>
<td>${escapeHtml(batchText)}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(locationText)}</td>
<td>${escapeHtml(expiryText)}</td>
</tr>
`;
}).join('');
container.innerHTML = `
<table class="reports-table">
<thead>
<tr>
<th>Drug</th>
<th>Variant</th>
<th>Batch</th>
<th>Quantity</th>
<th>Location</th>
<th>Expiry</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
function renderBatchAttentionTable(rows) { function renderBatchAttentionTable(rows) {
const container = document.getElementById('reportsTableContainer'); const container = document.getElementById('reportsTableContainer');
if (!container) return; if (!container) return;
@@ -469,8 +518,8 @@ function applyCurrentFilters() {
const userMatch = !selectedUser || getRowUser(row) === selectedUser; const userMatch = !selectedUser || getRowUser(row) === selectedUser;
const drugMatch = !selectedDrug || getRowDrug(row) === selectedDrug; const drugMatch = !selectedDrug || getRowDrug(row) === selectedDrug;
const rowDate = getRowDate(row); const rowDate = getRowDate(row);
const fromMatch = !fromDate || rowDate >= fromDate; const fromMatch = !fromDate || !rowDate || rowDate >= fromDate;
const toMatch = !toDate || rowDate <= toDate; const toMatch = !toDate || !rowDate || rowDate <= toDate;
let textMatch = true; let textMatch = true;
if (searchText) { if (searchText) {
@@ -485,6 +534,16 @@ function applyCurrentFilters() {
formatDispenseAllocation(row) formatDispenseAllocation(row)
].join(' ').toLowerCase(); ].join(' ').toLowerCase();
textMatch = haystack.includes(searchText); textMatch = haystack.includes(searchText);
} else if (activeReportType === 'global_inventory') {
const haystack = [
row.drug_name || '',
row.strength || '',
row.batch_number || '',
row.inventory_source || '',
row.location_name || '',
row.unit || ''
].join(' ').toLowerCase();
textMatch = haystack.includes(searchText);
} else if (activeReportType === 'batch_attention') { } else if (activeReportType === 'batch_attention') {
const haystack = [ const haystack = [
row.drug_name || '', row.drug_name || '',
@@ -512,6 +571,8 @@ function applyCurrentFilters() {
if (reportsSummary) { if (reportsSummary) {
const reportName = activeReportType === 'dispensing' const reportName = activeReportType === 'dispensing'
? 'dispensing records' ? 'dispensing records'
: activeReportType === 'global_inventory'
? 'inventory lines'
: activeReportType === 'batch_attention' : activeReportType === 'batch_attention'
? 'expired batches' ? 'expired batches'
: 'audit events'; : 'audit events';
@@ -520,6 +581,8 @@ function applyCurrentFilters() {
if (activeReportType === 'dispensing') { if (activeReportType === 'dispensing') {
renderDispensingTable(filteredRows); renderDispensingTable(filteredRows);
} else if (activeReportType === 'global_inventory') {
renderGlobalInventoryTable(filteredRows);
} else if (activeReportType === 'batch_attention') { } else if (activeReportType === 'batch_attention') {
renderBatchAttentionTable(filteredRows); renderBatchAttentionTable(filteredRows);
} else { } else {
@@ -537,6 +600,10 @@ function updateReportHeading() {
heading.textContent = 'Dispensing History'; heading.textContent = 'Dispensing History';
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...'; searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
if (userFilter) userFilter.style.display = ''; if (userFilter) userFilter.style.display = '';
} else if (activeReportType === 'global_inventory') {
heading.textContent = 'Global Inventory';
searchInput.placeholder = 'Search drug, variant, batch, location...';
if (userFilter) userFilter.style.display = 'none';
} else if (activeReportType === 'batch_attention') { } else if (activeReportType === 'batch_attention') {
heading.textContent = 'Expired Batches'; heading.textContent = 'Expired Batches';
searchInput.placeholder = 'Search drug, batch, location...'; searchInput.placeholder = 'Search drug, batch, location...';
@@ -587,6 +654,8 @@ async function loadActiveReport() {
if (container) { if (container) {
const loadingText = activeReportType === 'dispensing' const loadingText = activeReportType === 'dispensing'
? 'Loading dispensing history...' ? 'Loading dispensing history...'
: activeReportType === 'global_inventory'
? 'Loading global inventory...'
: activeReportType === 'batch_attention' : activeReportType === 'batch_attention'
? 'Loading expired batches...' ? 'Loading expired batches...'
: 'Loading audit trail...'; : 'Loading audit trail...';
@@ -604,6 +673,14 @@ async function loadActiveReport() {
dispensingRows = await response.json(); dispensingRows = await response.json();
await ensureBatchLookupForDispensing(dispensingRows); await ensureBatchLookupForDispensing(dispensingRows);
populateCommonFilters(dispensingRows); populateCommonFilters(dispensingRows);
} else if (activeReportType === 'global_inventory') {
const response = await apiCall('/reports/global-inventory');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load global inventory');
}
globalInventoryRows = await response.json();
populateCommonFilters(globalInventoryRows);
} else if (activeReportType === 'batch_attention') { } else if (activeReportType === 'batch_attention') {
const response = await apiCall('/reports/batch-attention'); const response = await apiCall('/reports/batch-attention');
if (!response.ok) { if (!response.ok) {