Files
sap-erp/routes/requirements.js
T
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

244 lines
12 KiB
JavaScript

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