'use strict'; // routes/batchIntimations.js — "Batch Issuance Intimation" (data sourced from Requirements) const express = require('express'); const router = express.Router(); const { verifyToken } = require('../middleware/auth'); const { getPool } = require('../services/sqlPool'); const store = () => require('../services/batchIntimationStore'); const notify = () => require('../services/notifyStore'); const workOrderStore = () => require('../services/workOrderStore'); const requirementStore = () => require('../services/requirementStore'); const appSettings = require('../services/appSettingsStore'); // Server-side enforcement of "batch total can't exceed the requirement's // pending qty" — the frontend already blocks this on CREATE (using pendingQty // supplied by GET /requirements, which already nets out every existing // intimation), but that guard was skipped entirely in Edit mode and easy to // bypass anyway since it's client-side only. `excludeId` (the intimation // being edited) makes sure THIS document's own already-saved qty doesn't // count against its own pending total. async function checkQtyWithinPending(clean, requirementId, refNo, company, excludeId) { let reqDoc = requirementId ? await requirementStore().findById(requirementId) : null; if (!reqDoc && refNo) { const all = await requirementStore().listRequirements({ company }); reqDoc = all.find(r => r.refNo === refNo) || null; } if (!reqDoc) return null; // requirement no longer resolvable — nothing to validate against const reqByItem = {}; (reqDoc.lines || []).forEach(l => { reqByItem[l.itemCode] = Number(l.requiredQty) || 0; }); const intMap = await store().intimatedByRequirement({ company, excludeId }); const info = intMap['rid:' + reqDoc.id] || intMap['ref:' + reqDoc.refNo] || { byItem: {} }; for (const p of clean) { const reqQty = reqByItem[p.itemCode]; if (reqQty == null) continue; // item isn't on this requirement — nothing to cap against const doneElsewhere = Number(info.byItem[p.itemCode]) || 0; const thisDocQty = p.batches.reduce((s, b) => s + (Number(b.batchSize) || 0), 0); const pending = Math.max(reqQty - doneElsewhere, 0); if (thisDocQty > pending + 1e-9) return `${p.itemCode}: batch total ${thisDocQty} exceeds pending ${pending} (required ${reqQty}, already intimated elsewhere ${doneElsewhere})`; } return null; } // Does this batch number already exist in SAP at all (any item, regardless // of current stock — OBTN is the batch MASTER table, unlike OIBT which only // has batches with stock on hand)? Mirrors GET /api/sap/lookup/batch-exists; // duplicated here (rather than an HTTP self-call) so the "Batch No. Required" // server-side check stays a single request. async function batchExistsInSap(batchNo, company) { const needle = (batchNo || '').trim().replace(/'/g, "''"); if (!needle) return null; try { const pool = await getPool(company); const r = await pool.request().query(`SELECT TOP 1 "ItemCode" FROM [dbo].[OBTN] WHERE "DistNumber"='${needle}'`); return r.recordset[0] || null; } catch (e) { console.warn('[BII] batchExistsInSap failed:', e.message); return null; } } // Which of the given item codes SAP itself tracks by batch (OITM.ManBtchNum // = 'Y')? Item-wise "Batch No. Required" is auto-derived from this — never a // manual per-document toggle — so it can never disagree with what SAP will // actually accept. Mirrors GET /api/sap/batch-managed-items. async function batchManagedItemCodes(itemCodes, company) { const codes = [...new Set(itemCodes)].filter(Boolean); if (!codes.length) return new Set(); try { const pool = await getPool(company); const list = codes.map(c => `'${c.replace(/'/g, "''")}'`).join(','); const r = await pool.request().query(`SELECT "ItemCode" FROM [dbo].[OITM] WHERE "ItemCode" IN (${list}) AND "ManBtchNum"='Y'`); return new Set(r.recordset.map(x => x.ItemCode)); } catch (e) { console.warn('[BII] batchManagedItemCodes failed:', e.message); return new Set(); } } // Server-side "Batch No. Required" enforcement, ITEM-WISE: only items SAP // itself tracks by batch require Batch No./MFG/EXP; every batch row with a // Batch No. entered (required or not) is still checked for uniqueness // against every OTHER saved Intimation and against SAP itself. Returns an // error string, or null if everything checks out. `excludeId` lets an edit // ignore its own row. async function checkBatchNoRequired(clean, company, excludeId) { const managedSet = await batchManagedItemCodes(clean.map(p => p.itemCode), company); const seen = new Map(); // batchNo (upper) -> itemCode, to catch dupes WITHIN this submission for (const p of clean) { const required = managedSet.has(p.itemCode); for (const b of p.batches) { if (required) { if (!b.batchNo) return `${p.itemCode}: Batch No. is required (SAP tracks this item by batch)`; if (!b.mfgDate) return `${p.itemCode}: MFG Date is required (SAP tracks this item by batch)`; if (!b.expDate) return `${p.itemCode}: EXP Date is required (SAP tracks this item by batch)`; } if (b.batchNo) { const key = b.batchNo.toUpperCase(); if (seen.has(key)) return `Duplicate Batch No. "${b.batchNo}" used for both ${seen.get(key)} and ${p.itemCode} in this document`; seen.set(key, p.itemCode); } } } for (const [batchNo, itemCode] of seen) { const usedHere = await store().findBatchNoUsage(batchNo, excludeId); if (usedHere) return `Batch No. "${batchNo}" is already used in intimation ${usedHere.docNo} (${usedHere.itemCode})`; const usedInSap = await batchExistsInSap(batchNo, company); if (usedInSap) return `Batch No. "${batchNo}" already exists in SAP (item ${usedInSap.ItemCode})`; } return null; } // Once a (non-deleted) Work Order has been generated FROM one of an // Intimation's (product, batch) lines, THAT specific line is locked — // editing/removing it afterwards would silently drift out of sync with the // Work Order that already copied a snapshot of its data at creation time. // Deleting the WHOLE Intimation document stays blocked as long as ANY line // is locked (routes below); editing only blocks the locked lines // themselves — see batchKey()/lockedLineViolation(). Returns the list of // {id, woNo, productCode, batchNumber} Work Orders referencing it (empty = // nothing locked at all). // A REJECTED Work Order doesn't count as "linked" for locking purposes — it // never reached Production Order/SAP issuance, so there's nothing for the // Intimation to silently drift out of sync with. Without this, correcting a // wrong Product/batch on the Intimation (the actual fix path once the WO // that was generated from it gets rejected) was impossible: the line stayed // locked forever even though the WO built from it was dead. async function linkedWorkOrders(intimationId) { const wos = await workOrderStore().listWorkOrders({}); return wos .filter(w => !w.isDeleted && w.status !== 'REJECTED' && String(w.intimationId) === String(intimationId)) .map(w => ({ id: w.id, woNo: w.woNo, productCode: w.productCode || '', batchNumber: w.batchNumber || '' })); } // Same key convention the frontend's captureStatus()/startEdit() use. function batchKey(itemCode, batchNo) { return `${itemCode || ''}|${batchNo || ''}`; } // Fields that must stay byte-identical on a locked line — anything a // generated Work Order could have copied a snapshot of. const LOCKED_BATCH_FIELDS = ['batchNo', 'mfgDate', 'expDate', 'market', 'batchSize', 'batchVolume']; // Compares the submitted product/batch list against what's currently stored, // for ONLY the lines a Work Order already exists for. Returns a clear error // message on the first violation found (removed, or any field changed), or // null if every locked line is present and untouched — everything else in // the document (other batches, whole new products, non-locked edits) is // left free to change by design. function lockedLineViolation(existingProducts, submittedProducts, linked) { const lockedKeys = new Set(linked.map(l => batchKey(l.productCode, l.batchNumber))); if (!lockedKeys.size) return null; const indexBatches = (products) => { const map = new Map(); (products || []).forEach(p => (p.batches || []).forEach(b => { const key = batchKey(p.itemCode, b.batchNo); if (lockedKeys.has(key)) map.set(key, b); })); return map; }; const before = indexBatches(existingProducts); const after = indexBatches(submittedProducts); for (const key of lockedKeys) { const [itemCode, batchNo] = key.split('|'); const prev = before.get(key); const next = after.get(key); if (!prev) continue; // shouldn't happen (a WO exists but the line is gone from the stored doc already) — nothing to compare against if (!next) return `Cannot remove ${itemCode} batch "${batchNo}" — a Work Order has already been generated from it.`; const changedField = LOCKED_BATCH_FIELDS.find(f => String(prev[f] ?? '') !== String(next[f] ?? '')); if (changedField) return `Cannot change ${itemCode} batch "${batchNo}" (${changedField}) — a Work Order has already been generated from it.`; } return null; } // MFG/EXP accept any of 6 formats (empty allowed) — same set as the // picker-only date fields in public/work-order.html, public/batch-issuance.html // and public/receipt-production.html: DD-MMM-YYYY, DD-MM-YYYY, MMM-YYYY, // MM-YYYY, YYYY-MMM, YYYY-MM. Kept in sync with those — this backend check // must never fall behind the frontend's accepted formats again. const DATE_RES = [ /^(\d{1,2})[-/]([A-Za-z]{3})[-/](\d{4})$/, // DD-MMM-YYYY /^(\d{1,2})-(\d{1,2})-(\d{4})$/, // DD-MM-YYYY /^([A-Za-z]{3})[-/](\d{4})$/, // MMM-YYYY /^(\d{1,2})-(\d{4})$/, // MM-YYYY /^(\d{4})-([A-Za-z]{3})$/, // YYYY-MMM /^(\d{4})-(\d{1,2})$/, // YYYY-MM ]; function isValidMEDate(v) { const t = String(v || '').trim(); return !t || DATE_RES.some(re => re.test(t)); } function badDates(products) { const bad = []; (products || []).forEach(p => (p.batches || []).forEach(b => { if (b.mfgDate && !isValidMEDate(b.mfgDate)) bad.push(`${p.itemCode} MFG "${b.mfgDate}"`); if (b.expDate && !isValidMEDate(b.expDate)) bad.push(`${p.itemCode} EXP "${b.expDate}"`); })); return bad; } function normProducts(products) { const clean = []; (products || []).forEach(p => { const itemCode = (p.itemCode || '').trim(); if (!itemCode) return; const batches = (p.batches || []) .filter(b => (b.batchNo || '').trim() || b.batchSize || b.mfgDate || b.expDate || (b.market || '').trim()) .map(b => ({ batchNo: (b.batchNo || '').trim(), mfgDate: b.mfgDate || null, expDate: b.expDate || null, market: (b.market || '').trim(), batchSize: b.batchSize === '' || b.batchSize == null ? null : Number(b.batchSize), // Batch Size (Volume, Ltr) — feeds Work Order's Solution Batch Size // when a WO is generated from this batch (see pickBatch() in // work-order.html). Distinct from batchSize (a quantity/"Total Units"). batchVolume: b.batchVolume === '' || b.batchVolume == null ? null : Number(b.batchVolume), })); clean.push({ itemCode, itemName: (p.itemName || '').trim(), itemDesc: (p.itemDesc || '').trim(), requiredQty: p.requiredQty === '' || p.requiredQty == null ? null : Number(p.requiredQty), issueDate: p.issueDate || null, batches, }); }); return clean; } // List intimations router.get('/', verifyToken, async (req, res) => { try { const { mine, company, status, refNo } = req.query; const data = await store().listIntimations({ mine: mine === '1' ? req.user.username : undefined, company, status, refNo, }); res.json({ success: true, data }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Which Work Order(s), if any, were generated from this Intimation — used by // the frontend to lock Edit/Delete once a Work Order exists. router.get('/:id/linked-work-orders', verifyToken, async (req, res) => { try { res.json({ success: true, data: await linkedWorkOrders(req.params.id) }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Same, but for every intimation at once (one query) — used by the list view // so each card can show its locked state without an N+1 fetch. router.get('/linked-work-orders/all', verifyToken, async (req, res) => { try { const wos = await workOrderStore().listWorkOrders({}); const map = {}; // non-rejected only — whole-row/whole-document lock (Edit/Delete gating) const allMap = {}; // every non-deleted status, REJECTED included — used only to keep a // batch's own Batch No. (and its row) from being changed/removed once // ANY Work Order, even a rejected one awaiting edit+resubmit, already // references it by that number. Without this, correcting other fields // on a rejected batch's row (which IS meant to stay editable) could also // let the Batch No. itself drift, silently breaking the (productCode, // batchNumber) link back to that rejected Work Order. wos.forEach(w => { if (!w.intimationId || w.isDeleted) return; const k = String(w.intimationId); const entry = { id: w.id, woNo: w.woNo, productCode: w.productCode || '', batchNumber: w.batchNumber || '' }; (allMap[k] || (allMap[k] = [])).push(entry); if (w.status === 'REJECTED') return; (map[k] || (map[k] = [])).push(entry); }); res.json({ success: true, data: map, allData: allMap }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Live "is this batch number already used?" check — against other saved // Intimations AND SAP itself (OBTN). Used by the Create form to give // immediate feedback as the user types, ahead of the authoritative check // that also runs server-side on submit. Placed before GET /:id so "usage" // isn't swallowed as an :id param. router.get('/batch-no-usage', verifyToken, async (req, res) => { try { const { batchNo, excludeId, company } = req.query; if (!batchNo) return res.json({ success: true, used: false }); const local = await store().findBatchNoUsage(batchNo, excludeId); if (local) return res.json({ success: true, used: true, where: 'intimation', ...local }); const sap = await batchExistsInSap(batchNo, company); if (sap) return res.json({ success: true, used: true, where: 'sap', itemCode: sap.ItemCode }); res.json({ success: true, used: false }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Get one router.get('/:id', verifyToken, async (req, res) => { try { const r = await store().findById(req.params.id); if (!r || r.isDeleted) return res.status(404).json({ success: false, message: 'Not found' }); res.json({ success: true, data: r }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Create router.post('/', verifyToken, async (req, res) => { try { const { refNo, requirementId, date, issueDate, remarks, products, company } = req.body || {}; if (!refNo) { // No Requirement given — only acceptable via the "Without Intimation" // tab (Admin → System Settings → "Batch Issuance → Enable SFG // Workflow"). Any item is allowed through that tab — no per-item // classification check. if (!appSettings.biSfgWorkflowEnabled()) return res.status(400).json({ success: false, message: 'Requirement Ref No is required' }); } // Hard gate (Admin → System Settings → "Requirement — Store Review // Workflow" — both the whole-feature switch AND its own hard-gate // toggle must be ON): a Batch Intimation can't be raised from a // Requirement that hasn't completed the Production↔Store review round // trip yet (REVIEW_STAGE 2). Off by default — most sites never see this. if (appSettings.requirementStoreReviewEnabled() && appSettings.requirementStoreReviewHardGate()) { let reqDoc = requirementId ? await requirementStore().findById(requirementId) : null; if (!reqDoc && refNo) { const all = await requirementStore().listRequirements({ company }); reqDoc = all.find(r => r.refNo === refNo) || null; } if (reqDoc && reqDoc.reviewStage !== 2) return res.status(409).json({ success: false, message: `${reqDoc.refNo} hasn't completed the Store review yet — it must be shared with Store and reverted back to Production first (currently ${reqDoc.reviewStage === 1 ? 'awaiting Store review' : 'not yet shared'}).` }); } const clean = normProducts(products); if (!clean.length) return res.status(400).json({ success: false, message: 'No products to submit' }); if (!clean.some(p => p.batches.length)) return res.status(400).json({ success: false, message: 'Add at least one batch' }); const noIssueDate = clean.find(p => p.batches.length && !p.issueDate); if (noIssueDate) return res.status(400).json({ success: false, message: `${noIssueDate.itemCode}: Issue Date is required` }); const bad = badDates(clean); if (bad.length) return res.status(400).json({ success: false, message: 'Invalid MFG/EXP date (use DD-MMM-YYYY or MMM/YYYY): ' + bad.join(', ') }); // Item-wise, auto-derived from SAP — not a manual toggle. See checkBatchNoRequired(). const batchErr = await checkBatchNoRequired(clean, company, null); if (batchErr) return res.status(400).json({ success: false, message: batchErr }); if (!appSettings.biAllowExceedPending()) { const qtyErr = await checkQtyWithinPending(clean, requirementId, refNo, company, null); if (qtyErr) return res.status(400).json({ success: false, message: qtyErr }); } const saved = await store().insertIntimation({ refNo, requirementId, date: date || null, issueDate: issueDate || null, remarks: remarks || '', products: clean, company: company || '', createdBy: req.user.username, createdByName: req.user.name || req.user.username, }); res.json({ success: true, data: saved }); // No approval-step workflow on this module (see services/batchIntimationStore.js) // — the "concerned user" is whoever holds the Batch Issuance sidebar module. notify().notify({ moduleKey: 'production-batch-issuance', title: `Batch Issuance Intimation ${saved.refNo} — New Intimation`, lines: [['Ref No', saved.refNo], ['Products', clean.length], ['Created By', saved.createdByName]], url: `${process.env.APP_BASE_URL || ''}/batch-issuance`, excludeUsernames: [req.user.username], }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Modify router.put('/:id', verifyToken, async (req, res) => { try { const existing = await store().findById(req.params.id); if (!existing || existing.isDeleted) return res.status(404).json({ success: false, message: 'Not found' }); const linked = await linkedWorkOrders(req.params.id); const { date, issueDate, remarks, products, company } = req.body || {}; const clean = normProducts(products); if (!clean.length) return res.status(400).json({ success: false, message: 'No products to submit' }); // Lines a Work Order has already been generated from must stay exactly // as they are — everything else in the document can be freely edited, // added, or removed. See lockedLineViolation()'s own doc comment. const lockViol = lockedLineViolation(existing.products, clean, linked); if (lockViol) return res.status(409).json({ success: false, message: lockViol }); const noIssueDate = clean.find(p => p.batches.length && !p.issueDate); if (noIssueDate) return res.status(400).json({ success: false, message: `${noIssueDate.itemCode}: Issue Date is required` }); const bad = badDates(clean); if (bad.length) return res.status(400).json({ success: false, message: 'Invalid MFG/EXP date (use DD-MMM-YYYY or MMM/YYYY): ' + bad.join(', ') }); const batchErr = await checkBatchNoRequired(clean, company || existing.company, req.params.id); if (batchErr) return res.status(400).json({ success: false, message: batchErr }); if (!appSettings.biAllowExceedPending()) { const qtyErr = await checkQtyWithinPending(clean, existing.requirementId, existing.refNo, company || existing.company, req.params.id); if (qtyErr) return res.status(400).json({ success: false, message: qtyErr }); } const updated = await store().updateIntimation(req.params.id, { date: date || null, issueDate: issueDate || null, remarks: remarks || '', products: clean, }); res.json({ success: true, data: updated }); notify().notify({ moduleKey: 'production-batch-issuance', title: `Batch Issuance Intimation ${updated.refNo} — Updated`, lines: [['Ref No', updated.refNo], ['Products', clean.length], ['Updated By', req.user.name || req.user.username]], url: `${process.env.APP_BASE_URL || ''}/batch-issuance`, excludeUsernames: [req.user.username], }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Soft delete router.delete('/:id', verifyToken, async (req, res) => { try { const existing = await store().findById(req.params.id); if (!existing) return res.status(404).json({ success: false, message: 'Not found' }); const linked = await linkedWorkOrders(req.params.id); if (linked.length) return res.status(409).json({ success: false, message: `Cannot delete — a Work Order has already been generated from this Intimation (${linked.map(l => l.woNo).join(', ')}).` }); await store().softDelete(req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); module.exports = router;