294 lines
18 KiB
JavaScript
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;
|