'use strict'; // routes/requirements.js — Logistics "Requirements Management" const express = require('express'); const router = express.Router(); const { verifyToken, requireApprovalStep, requireWorkflowPerm } = require('../middleware/auth'); const store = () => require('../services/requirementStore'); const biStore = () => require('../services/batchIntimationStore'); const appSettings = require('../services/appSettingsStore'); // Attach intimated/pending qty per line + an overall intimation status. function enrich(req, intMap) { const info = intMap['rid:' + req.id] || intMap['ref:' + req.refNo] || null; const byItem = info ? info.byItem : {}; let anyIntimated = false, allFull = true, totalPending = 0; const lines = (req.lines || []).map(l => { const reqQty = Number(l.requiredQty) || 0; const done = Number(byItem[l.itemCode]) || 0; const pending = Math.max(reqQty - done, 0); if (done > 0) anyIntimated = true; if (pending > 0) allFull = false; totalPending += pending; return { ...l, intimatedQty: done, pendingQty: pending }; }); const hasIntimations = !!info && info.docs > 0; const intimationStatus = !hasIntimations ? 'OPEN' : (allFull ? 'FULLY_INTIMATED' : 'PARTIAL'); return { ...req, lines, hasIntimations, intimationStatus, totalPending }; } // Preview the next Ref No (REQ-MM-YY-NNNN) router.get('/next-ref', verifyToken, requireWorkflowPerm('requirement', 'view'), async (req, res) => { try { res.json({ success: true, refNo: await store().generateRefNo(req.query.company) }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // List requirements — deliberately NOT gated by requirement:manage/'view': // Batch Issuance's own requirement picker (public/batch-issuance.html) // calls this same endpoint cross-module, for users who may have Batch // Issuance access but no Requirements-page permission at all. Page-level // access to the Requirements MANAGEMENT screen itself is still gated on the // frontend (CAN_VIEW_REQ) and on every mutating route below. router.get('/', verifyToken, async (req, res) => { try { const { mine, company, status, excludeIntimationId } = req.query; const data = await store().listRequirements({ mine: mine === '1' ? req.user.username : undefined, company, status, }); // excludeIntimationId: when editing a Batch Intimation, that document's // own already-saved qty shouldn't count against its own requirement's // pending total — see batch-issuance.html's startEdit(). const intMap = await biStore().intimatedByRequirement({ company, excludeId: excludeIntimationId || undefined }); res.json({ success: true, data: data.map(r => enrich(r, intMap)) }); } 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) 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 requirement router.post('/', verifyToken, requireApprovalStep('requirement:manage', 'add'), async (req, res) => { try { const { periodFrom, periodTo, lines, remarks, company } = req.body || {}; if (!Array.isArray(lines) || lines.length === 0) return res.status(400).json({ success: false, message: 'At least one requirement line is required' }); // Normalise + validate lines const clean = []; lines.forEach((l, i) => { const itemCode = (l.itemCode || '').trim(); const requiredQty = Number(l.requiredQty); if (!itemCode) return; // skip empty rows clean.push({ srNo: i + 1, itemCode, itemName: (l.itemName || '').trim(), itemDesc: (l.itemDesc || '').trim(), requiredQty: isNaN(requiredQty) ? 0 : requiredQty, }); }); if (!clean.length) return res.status(400).json({ success: false, message: 'Please select at least one item' }); const saved = await store().insertRequirement({ periodFrom: periodFrom || null, periodTo: periodTo || null, lines: clean, remarks: remarks || '', company: company || '', createdBy: req.user.username, createdByName: req.user.name || req.user.username, }); res.json({ success: true, data: saved }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Modify an existing requirement (period / lines / remarks). Ref No stays the same. router.put('/:id', verifyToken, requireApprovalStep('requirement:manage', 'edit'), async (req, res) => { try { const existing = await store().findById(req.params.id); if (!existing || existing.isDeleted) return res.status(404).json({ success: false, message: 'Requirement not found' }); // Locked once any batch intimation exists against it const intMap = await biStore().intimatedByRequirement({ company: existing.company }); const info = intMap['rid:' + existing.id] || intMap['ref:' + existing.refNo]; if (info && info.docs > 0) return res.status(409).json({ success: false, message: `Cannot edit ${existing.refNo}: ${info.docs} batch intimation(s) exist against it. Requirements with issued batches are locked.`, }); const { periodFrom, periodTo, lines, remarks } = req.body || {}; if (!Array.isArray(lines) || lines.length === 0) return res.status(400).json({ success: false, message: 'At least one requirement line is required' }); const clean = []; lines.forEach((l, i) => { const itemCode = (l.itemCode || '').trim(); if (!itemCode) return; const requiredQty = Number(l.requiredQty); clean.push({ srNo: i + 1, itemCode, itemName: (l.itemName || '').trim(), itemDesc: (l.itemDesc || '').trim(), requiredQty: isNaN(requiredQty) ? 0 : requiredQty, }); }); if (!clean.length) return res.status(400).json({ success: false, message: 'Please select at least one item' }); const updated = await store().updateRequirement(req.params.id, { periodFrom: periodFrom || null, periodTo: periodTo || null, lines: clean, remarks: remarks || '', }); res.json({ success: true, data: updated }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Production → Store: shares this Requirement for Store to cross-check // against SAP's own MRP Wizard output. Whole-feature switch (Admin → System // Settings → "Requirement — Store Review Workflow") must be ON — when it's // OFF this route 403s so it can never be reached even by a direct call. router.post('/:id/share-with-store', verifyToken, requireApprovalStep('requirement:production_review', 'add'), async (req, res) => { try { if (!appSettings.requirementStoreReviewEnabled()) return res.status(403).json({ success: false, message: 'The Requirement Store Review workflow is disabled in Admin → System Settings' }); const existing = await store().findById(req.params.id); if (!existing || existing.isDeleted) return res.status(404).json({ success: false, message: 'Requirement not found' }); if (existing.reviewStage !== 0) return res.status(409).json({ success: false, message: `Already ${existing.reviewStage === 1 ? 'shared with Store, awaiting their review' : 'been through Store review'}` }); const updated = await store().shareWithStore(req.params.id, { by: req.user.username, byName: req.user.name || req.user.username }); res.json({ success: true, data: updated }); try { require('../services/notifyStore').notify({ stepFullKey: 'requirement:store_review', title: `Requirement ${existing.refNo} shared for Store review`, lines: [['Requirement', existing.refNo], ['Shared By', req.user.name || req.user.username]], url: `${process.env.APP_BASE_URL || ''}/requirements`, excludeUsernames: [req.user.username], }); } catch (e) { console.warn('[REQ] notify failed (non-fatal):', e.message); } } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Store → Production: reverts the Requirement with Store's own revised line // items (based on what SAP's MRP Wizard recommended) — a single round trip, // no further back-and-forth. ORIGINAL_LINES (snapshotted at share time) // stays untouched, so Production can see exactly what changed. router.post('/:id/revert-to-production', verifyToken, requireApprovalStep('requirement:store_review', 'add'), async (req, res) => { try { if (!appSettings.requirementStoreReviewEnabled()) return res.status(403).json({ success: false, message: 'The Requirement Store Review workflow is disabled in Admin → System Settings' }); const existing = await store().findById(req.params.id); if (!existing || existing.isDeleted) return res.status(404).json({ success: false, message: 'Requirement not found' }); if (existing.reviewStage !== 1) return res.status(409).json({ success: false, message: existing.reviewStage === 0 ? 'This requirement has not been shared by Production yet' : 'Already reverted to Production' }); const { lines, remarks } = req.body || {}; if (!Array.isArray(lines) || !lines.length) return res.status(400).json({ success: false, message: 'At least one requirement line is required' }); const clean = []; lines.forEach((l, i) => { const itemCode = (l.itemCode || '').trim(); if (!itemCode) return; const requiredQty = Number(l.requiredQty); clean.push({ srNo: i + 1, itemCode, itemName: (l.itemName || '').trim(), itemDesc: (l.itemDesc || '').trim(), requiredQty: isNaN(requiredQty) ? 0 : requiredQty }); }); if (!clean.length) return res.status(400).json({ success: false, message: 'Please select at least one item' }); const updated = await store().revertToProduction(req.params.id, { by: req.user.username, byName: req.user.name || req.user.username, lines: clean, remarks: remarks || '', }); res.json({ success: true, data: updated }); try { require('../services/notifyStore').notify({ stepFullKey: 'requirement:production_review', title: `Requirement ${existing.refNo} reviewed by Store — back with Production`, lines: [['Requirement', existing.refNo], ['Reviewed By', req.user.name || req.user.username], ['Remarks', remarks || '']], url: `${process.env.APP_BASE_URL || ''}/requirements`, excludeUsernames: [req.user.username], }); } catch (e) { console.warn('[REQ] notify failed (non-fatal):', e.message); } } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Update status (e.g. close a requirement) router.patch('/:id/status', verifyToken, requireApprovalStep('requirement:manage', 'edit'), async (req, res) => { try { await store().updateStatus(req.params.id, req.body.status || 'OPEN'); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Soft delete — blocked if any batch intimation references this requirement router.delete('/:id', verifyToken, requireApprovalStep('requirement:manage', 'delete'), async (req, res) => { try { const existing = await store().findById(req.params.id); if (!existing) return res.status(404).json({ success: false, message: 'Requirement not found' }); const intMap = await biStore().intimatedByRequirement({ company: existing.company }); const info = intMap['rid:' + existing.id] || intMap['ref:' + existing.refNo]; if (info && info.docs > 0) return res.status(409).json({ success: false, message: `Cannot delete ${existing.refNo}: ${info.docs} batch intimation(s) exist against it. Requirements with issued batches are locked.`, }); await store().softDelete(req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); module.exports = router;