Files
sap-erp/routes/batchIntimations.js
T
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

417 lines
22 KiB
JavaScript

'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;