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

294 lines
18 KiB
JavaScript

// routes/rejectionRegister.js
// Rejection Register — in-process rejection/rework counting per batch/stage,
// with admin-maintained causes (each rolled up to a root cause) and QA edit-
// after-submit. See services/rejectionRegisterStore.js for the data layer.
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken, requireApprovalStep, hasStepAssigned, hasStepPerm } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const store = () => require('../services/rejectionRegisterStore');
const appSettings = () => require('../services/appSettingsStore');
// ── Stages (master list) ────────────────────────────────────────────────
// The manufacturing Stage list (EBB, Sheet Welding, Moulding, …) itself is
// admin-extensible data, not hardcoded — stored via appSettingsStore
// (rejectionStages, JSON [{v,label,active}]) so it survives restarts
// without a dedicated table. Read is open to anyone on the workflow (the
// entry wizard needs it to populate the Stage picker) and ALWAYS returns
// every stage including soft-deleted ones — the client decides what to
// filter for a picker vs. a historical label lookup. Write is gated by
// 'causes_setup':'edit' — deliberately NOT admin-only, since whoever
// manages Rejection Causes should be able to add the Stage a new cause set
// belongs to without needing separate System Settings access.
// "Deleting" a stage is a SOFT delete (active:false) — existing Rejection
// Causes and Entries still carry its key as plain text, so removing it from
// this list entirely would leave their Stage label unresolvable. A stage's
// key ("v") is treated as a stable identifier once created — the client
// only lets label/active change on an existing entry.
// Gate is just verifyToken (any authenticated user), not a Rejection
// Register workflow assignment — a stage label list is low-sensitivity read
// data, and it now has a SECOND consumer beyond the entry/QA screens:
// admin.html's User Management fetches it to build the per-user Stage
// allow-list checklist under the rejection_register:entry approval step,
// for whoever manages users (a sub-admin doesn't necessarily hold any
// Rejection Register step themselves). Requiring workflow assignment here
// would 403 that fetch and silently show "No Stages configured" even
// though stages exist.
router.get('/stages', verifyToken, async (req, res) => {
res.json({ success: true, data: appSettings().rejectionStages() });
});
router.put('/stages', verifyToken, requireApprovalStep('rejection_register:causes_setup', 'edit'), async (req, res) => {
try {
const stages = Array.isArray(req.body?.stages) ? req.body.stages : [];
const seen = new Set();
const cleaned = [];
for (const s of stages) {
const v = String(s?.v || '').trim();
const label = String(s?.label || '').trim();
if (!v || !label) continue;
const key = v.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
cleaned.push({ v, label, active: s?.active !== false });
}
if (!cleaned.length) return res.status(400).json({ success: false, message: 'At least one Stage is required' });
await appSettings().setMany({ rejectionStages: JSON.stringify(cleaned) }, req.user.username);
res.json({ success: true, data: appSettings().rejectionStages() });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// ── Causes (Setup) ──────────────────────────────────────────────────────
// Any Entry-assigned user can READ the cause list (they need it to fill the
// count-rejections step) — only 'causes_setup' can add/edit/delete.
router.get('/causes', verifyToken, async (req, res) => {
if (!hasStepAssigned(req.user, 'rejection_register:entry') && !hasStepAssigned(req.user, 'rejection_register:causes_setup'))
return res.status(403).json({ success: false, message: 'You are not assigned to the Rejection Register workflow.' });
try {
const data = await store().listCauses({ stage: req.query.stage, company: req.query.company, includeInactive: req.query.includeInactive === '1' });
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.post('/causes', verifyToken, requireApprovalStep('rejection_register:causes_setup', 'add'), async (req, res) => {
try {
const b = req.body || {};
const saved = await store().createCause({ stage: b.stage, name: b.name, rootCause: b.rootCause, company: b.company, createdBy: req.user.username });
res.json({ success: true, data: saved });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
router.put('/causes/:id', verifyToken, requireApprovalStep('rejection_register:causes_setup', 'edit'), async (req, res) => {
try {
const b = req.body || {};
const saved = await store().updateCause(req.params.id, { name: b.name, rootCause: b.rootCause, active: b.active });
if (!saved) return res.status(404).json({ success: false, message: 'Cause not found' });
res.json({ success: true, data: saved });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// Soft delete only (ACTIVE=0) — never a real row removal. Existing entries
// already recorded against this cause keep their data (they store a
// snapshot of the cause name at submission time, not a live link), and the
// row can be restored later by setting Active back on via PUT /causes/:id.
router.delete('/causes/:id', verifyToken, requireApprovalStep('rejection_register:causes_setup', 'delete'), async (req, res) => {
try {
const saved = await store().updateCause(req.params.id, { active: false });
if (!saved) return res.status(404).json({ success: false, message: 'Cause not found' });
res.json({ success: true, data: saved });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Batch MFG dates in this app may be a plain date OR "MMM/YYYY" (see
// [[mfg-exp-date-format]]) — only usable to pre-fill a <input type=date>
// when it's actually a real calendar date. Returns '' otherwise (never
// guesses a day-of-month for a month-only value).
function toDateInputValue(v) {
if (!v) return '';
const d = new Date(v);
return isNaN(d) ? '' : d.toISOString().slice(0, 10);
}
// ── Batch lookup — best-effort product/size/date resolve, returning EVERY
// distinct item this batch number resolves to (not just the first match) —
// the same batch number can genuinely belong to more than one item (reused
// across products, or a coincidental/typo collision), so silently picking
// one would risk recording the rejection against the wrong item entirely.
// Checked across four sources, each contributing any item it resolves
// (never stopping early):
// 1. This portal's own Work Order record(s) sharing the same Batch No.
// (this app's existing "batch record" concept — see
// services/workOrderStore.js) — carries Batch Size (totalUnits) and MFG
// Date, when usable.
// 2. SAP's batch STOCK MOVEMENT log (IBT1) — BsDocType=202 + Direction=0 is
// specifically "received into stock FROM a Production Order", i.e. the
// FG receipt for this batch, which carries both the received quantity
// (a real, better "batch size" than any plan figure) and the date.
// 3. SAP's own batch master (OBTN — DistNumber is the batch number column) —
// registers a batch as soon as it's created, even before any IBT1
// receipt movement exists for it; carries no size/date of its own.
// 4. SAP Production Orders' own Remarks/Comments field (OWOR.Comments) —
// since a Production Order has no dedicated Batch No. field, this portal
// (and, evidently, direct SAP entry too) always writes the batch number
// there instead (see [[production-order-workflow]]'s Batch No. fallback
// convention) — this is what actually catches a batch that's Released
// but still sitting at 0 Completed. Admin-gated (see
// appSettingsStore.rejectionLookupProdOrderRemarks) since it's a looser,
// free-text match. Batch Size/Date come from Planned Qty / Posting Date.
// Response is always an ARRAY (possibly empty) of {productCode, productName,
// batchSize, prodDate, source} — the client auto-picks when there's exactly
// one, and asks the user to choose when there's more than one.
router.get('/lookup-batch/:batchNo', verifyToken, async (req, res) => {
try {
const raw = String(req.params.batchNo || '').trim();
const needle = raw.toUpperCase();
if (!needle) return res.json({ success: true, data: [] });
const esc = raw.replace(/'/g, "''");
const byCode = new Map(); // productCode (upper) -> candidate — first source to resolve an item wins its size/date
const add = (code, name, batchSize, prodDate, source) => {
const key = String(code || '').trim().toUpperCase();
if (!key || byCode.has(key)) return;
byCode.set(key, { productCode: code, productName: name || '', batchSize: batchSize || '', prodDate: prodDate || '', source });
};
try {
const wos = await require('../services/workOrderStore').listWorkOrders({ company: req.query.company });
wos.filter(w => (w.batchNumber || '').trim().toUpperCase() === needle)
.forEach(w => add(w.productCode, w.productName, w.totalUnits, toDateInputValue(w.mfgDate), 'work_order'));
} catch (_e) { /* non-fatal */ }
try {
const pool = await getPool(req.query.company || null);
const result = await pool.request().query(`SELECT "ItemCode","ItemName","Quantity","DocDate" FROM [dbo]."IBT1" WHERE "BatchNum"='${esc}' AND "BsDocType"=202 AND "Direction"=0 ORDER BY "DocDate" DESC`);
(result.recordset || []).forEach(row => add(row.ItemCode, row.ItemName, row.Quantity, toDateInputValue(row.DocDate), 'sap_batch_receipt'));
} catch (_e) { /* non-fatal */ }
try {
const pool = await getPool(req.query.company || null);
// Same query shape as routes/sap.js's /lookup/batch-exists (Batch
// Issuance Intimation's own uniqueness check) — OBTN carries the item's
// name directly, no OITM join needed.
const result = await pool.request().query(`SELECT "ItemCode","itemName" FROM [dbo]."OBTN" WHERE "DistNumber"='${esc}'`);
(result.recordset || []).forEach(row => add(row.ItemCode, row.itemName, '', '', 'sap_batch'));
} catch (_e) { /* non-fatal */ }
if (require('../services/appSettingsStore').rejectionLookupProdOrderRemarks()) {
try {
const pool = await getPool(req.query.company || null);
const result = await pool.request().query(`SELECT T0."ItemCode", T1."ItemName", T0."PlannedQty", T0."PostDate" FROM [dbo]."OWOR" T0 LEFT JOIN [dbo]."OITM" T1 ON T1."ItemCode"=T0."ItemCode" WHERE T0."Comments"='${esc}' ORDER BY T0."DocEntry" DESC`);
(result.recordset || []).forEach(row => add(row.ItemCode, row.ItemName, row.PlannedQty, toDateInputValue(row.PostDate), 'production_order'));
} catch (_e) { /* non-fatal */ }
}
res.json({ success: true, data: [...byCode.values()] });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── Entries ─────────────────────────────────────────────────────────────
// Listing/analytics are QA-side views (Analytics/Setup) — plain Entry
// (submit-only) access does NOT grant this, even though it does need
// GET /causes and /stages to fill in its own wizard (see those routes
// below). A line operator who can submit entries must not be able to see
// plant-wide analytics or every other operator's batch log.
router.get('/entries', verifyToken, async (req, res) => {
if (!hasStepAssigned(req.user, 'rejection_register:qa_edit') && !hasStepAssigned(req.user, 'rejection_register:causes_setup'))
return res.status(403).json({ success: false, message: 'You are not assigned to view Rejection Analytics.' });
try {
const data = await store().listEntries({ company: req.query.company, stage: req.query.stage, from: req.query.from, to: req.query.to });
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Aggregated analytics over the SAME filtered set /entries would return —
// kept as its own endpoint so a client doesn't have to re-implement the
// rollup math itself.
router.get('/analytics', verifyToken, async (req, res) => {
if (!hasStepAssigned(req.user, 'rejection_register:qa_edit') && !hasStepAssigned(req.user, 'rejection_register:causes_setup'))
return res.status(403).json({ success: false, message: 'You are not assigned to view Rejection Analytics.' });
try {
const entries = await store().listEntries({ company: req.query.company, stage: req.query.stage, from: req.query.from, to: req.query.to });
res.json({ success: true, data: store().analyticsFor(entries) });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// A user's rejection_register:entry step can optionally carry a `stages`
// allow-list (set in User Management) — e.g. a line operator who only ever
// counts rejections at "EBB" shouldn't be able to submit an entry against
// some other Stage, whether by mistake or by tampering with the client's
// dropdown. Empty/absent `stages` = unrestricted (every active Stage),
// same convention as allowedCompanies/allowedDepartments elsewhere. Reads
// req.user.approvalSteps directly (the raw JWT payload) rather than going
// through hasStepPerm()'s normalizeSteps(), which strips unknown fields
// like `stages` down to just {step,perms}.
function entryStageAllowed(user, stage) {
if (user?.role === 'admin') return true;
const steps = Array.isArray(user?.approvalSteps) ? user.approvalSteps : [];
const entry = steps.find(s => s && typeof s === 'object' && s.step === 'rejection_register:entry');
const allowList = entry && Array.isArray(entry.stages) ? entry.stages : [];
return !allowList.length || allowList.includes(stage);
}
router.post('/entries', verifyToken, requireApprovalStep('rejection_register:entry', 'add'), async (req, res) => {
try {
const b = req.body || {};
if (!entryStageAllowed(req.user, b.stage))
return res.status(403).json({ success: false, message: `You are not assigned to record rejections for Stage: ${b.stage}` });
const saved = await store().createEntry({
batchNo: b.batchNo, productCode: b.productCode, productName: b.productName,
stage: b.stage, shift: b.shift, prodDate: b.prodDate, batchSize: b.batchSize,
causes: b.causes, remarks: b.remarks, company: b.company,
createdBy: req.user.username, createdByName: req.user.name || req.user.username,
});
res.json({ success: true, data: saved });
// Either the Stage had zero active causes at submission (free-text
// fallback), or real causes existed but the operator used the "Other /
// Unknown" bucket for some/all of the qty — either way, email whoever
// holds Root Cause Setup right away so they can add/assign the right
// cause and re-code this entry. Fire-and-forget: never blocks or fails
// the response above.
if (saved.needsCauseSetup) {
require('../services/notifyStore').notify({
stepFullKey: 'rejection_register:causes_setup',
title: `Rejection Register — "${saved.stage}" needs cause review (Batch ${saved.batchNo})`,
lines: [
['Stage', saved.stage], ['Batch No.', saved.batchNo],
['Product', saved.productName || saved.productCode || '-'],
['Recorded By', saved.createdByName], ['Remarks', saved.remarks || '-'],
],
url: `${process.env.APP_BASE_URL || ''}/rejection-analytics`,
excludeUsernames: [req.user.username],
}).catch(() => {});
}
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// "Needs Causes" summary for Rejection Analytics → Root Cause Setup — every
// Stage with at least one free-text-fallback entry still awaiting QA
// follow-up (add causes, then re-code the entry via PUT /entries/:id).
router.get('/pending-cause-setup', verifyToken, async (req, res) => {
if (!hasStepAssigned(req.user, 'rejection_register:qa_edit') && !hasStepAssigned(req.user, 'rejection_register:causes_setup'))
return res.status(403).json({ success: false, message: 'You are not assigned to view Rejection Analytics.' });
try {
const data = await store().pendingCauseSetupSummary({ company: req.query.company });
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// QA-only edit after submission.
router.put('/entries/:id', verifyToken, requireApprovalStep('rejection_register:qa_edit', 'approve'), async (req, res) => {
try {
const saved = await store().updateEntry(req.params.id, req.body || {}, { by: req.user.username, byName: req.user.name || req.user.username });
res.json({ success: true, data: saved });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
router.delete('/entries/:id', verifyToken, requireApprovalStep('rejection_register:qa_edit', 'delete'), async (req, res) => {
try {
await store().softDeleteEntry(req.params.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;