first commit
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s

This commit is contained in:
John
2026-09-23 17:31:02 +05:30
commit 69b4e68baf
51657 changed files with 3864077 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
// routes/appSettings.js — Admin-only editor for the application settings that
// used to live in .env (see services/appSettingsStore.js). GET returns the
// current raw values; PUT updates one or more of them. Changes take effect
// immediately (the store reloads its in-memory cache on write).
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken, verifyUserAdmin } = require('../middleware/auth');
const settings = require('../services/appSettingsStore');
const mailer = require('../services/mailer');
router.get('/', verifyToken, verifyUserAdmin, (req, res) => {
res.json({ success: true, data: settings.getAll() });
});
router.put('/', verifyToken, verifyUserAdmin, async (req, res) => {
try {
const updated = await settings.setMany(req.body || {}, req.user?.username);
res.json({ success: true, data: updated });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Verify SMTP connectivity/credentials without sending anything — used by
// the "Test Connection" button in Admin Settings.
router.get('/test-email/verify', verifyToken, verifyUserAdmin, async (req, res) => {
const r = await mailer.verifyConnection();
res.json({ success: r.ok, message: r.ok ? 'SMTP connection OK' : r.message });
});
// Send an actual test email to a chosen address — used by the "Send Test
// Email" button in Admin Settings, so an admin can confirm delivery end to
// end (not just that SMTP accepted the connection).
router.post('/test-email', verifyToken, verifyUserAdmin, async (req, res) => {
const to = String(req.body?.to || '').trim();
if (!to) return res.status(400).json({ success: false, message: 'Recipient email is required' });
if (!mailer.isConfigured()) return res.status(400).json({ success: false, message: 'SMTP_HOST is not set in .env — configure SMTP first' });
const sent = await mailer.sendMail({
to,
subject: 'SAP ERP Portal — Test Email',
html: `<p>This is a test email from the SAP ERP Portal's Production module notifications.</p><p>Sent by <b>${req.user?.username || ''}</b> at ${new Date().toLocaleString()}.</p>`,
});
if (sent) res.json({ success: true, message: `Test email sent to ${to} — check the inbox (and spam folder).` });
else res.status(500).json({ success: false, message: 'Send failed — check server console logs ([MAILER] ...) for the exact SMTP error.' });
});
module.exports = router;
+33
View File
@@ -0,0 +1,33 @@
'use strict';
// routes/audit.js — read-only Audit Trail viewer API. Access: full Admin and
// System Admin roles always; any other user must be granted the 'audit' module.
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const store = require('../services/auditStore');
async function requireAuditView(req, res, next) {
const u = req.user || {};
if (u.role === 'admin' || u.role === 'system_admin') return next();
// Module grant — read fresh from DB (JWT may not carry the modules list).
try {
const full = await require('../services/hanaUsers').findById(u.id);
const mods = Array.isArray(full && full.modules) ? full.modules : [];
if (mods.includes('audit')) return next();
} catch (_e) {}
return res.status(403).json({ success: false, message: 'You do not have access to the Audit Trail.' });
}
router.get('/', verifyToken, requireAuditView, async (req, res) => {
try {
const { data, total } = await store.list(req.query);
res.json({ success: true, data, total });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/facets', verifyToken, requireAuditView, async (req, res) => {
try { res.json({ success: true, data: await store.facets() }); }
catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+143
View File
@@ -0,0 +1,143 @@
// backend/routes/auth.js
// Uses HANA ZCUST_USERS table for authentication.
// Roles:
// manager → can view list, open detail, verify/reject, fill manager fields
// sap_adder → all manager perms + approve & push to SAP B1
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const userDb = require('../services/hanaUsers');
const cryptoUtil = require('../services/cryptoUtil');
const sap = require('../services/sapServiceLayer');
const { verifyToken } = require('../middleware/auth');
const SECRET = process.env.JWT_SECRET || 'sap-portal-secret';
const EXPIRES = '12h';
// Build the signed JWT payload for a user, embedding their per-user SAP login
// (SAP password AES-encrypted) so downstream SAP calls can act as them without
// a per-request DB lookup. `creds` is the decrypted { sapUser, sapPassword }
// or null when the user hasn't set one.
function buildPayload(user, creds) {
return {
id: user.id, username: user.username, role: user.role,
name: user.fullName, email: user.email || '',
sapUser: creds ? creds.sapUser : '',
sapPwdEnc: creds ? cryptoUtil.encrypt(creds.sapPassword) : '',
};
}
// ── POST /auth/login ──────────────────────────────────────────────────────────
router.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password)
return res.status(400).json({ success: false, message: 'Username and password required' });
try {
const user = await userDb.findByUsername(username);
if (!user)
return res.status(401).json({ success: false, message: 'Invalid username or password' });
const ok = await userDb.verifyPassword(password, user.passwordHash);
if (!ok)
return res.status(401).json({ success: false, message: 'Invalid username or password' });
await userDb.touchLastLogin(user.id);
const creds = await userDb.getSapCredentials(user.id); // decrypted or null
const payload = buildPayload(user, creds);
const token = jwt.sign(payload, SECRET, { expiresIn: EXPIRES });
res.json({
success: true,
token,
user: { id: user.id, username: user.username, name: user.fullName, role: user.role, email: user.email, hasSapLogin: !!creds, sapUser: creds ? creds.sapUser : '' },
});
} catch (err) {
console.error('[AUTH] login error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
// ── GET /auth/me ──────────────────────────────────────────────────────────────
router.get('/me', (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
try {
const user = jwt.verify(token, SECRET);
res.json({ success: true, user });
} catch {
res.status(401).json({ success: false });
}
});
// ── GET /auth/profile — full profile from DB ──────────────────────────────────
router.get('/profile', async (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
try {
const decoded = jwt.verify(token, SECRET);
const user = await userDb.findById(decoded.id);
if (!user) return res.status(404).json({ success: false, message: 'User not found' });
res.json({ success: true, user });
} catch (err) {
res.status(401).json({ success: false, message: err.message });
}
});
// ── GET /auth/sap-credentials — own SAP login status (never the password) ─────
router.get('/sap-credentials', verifyToken, async (req, res) => {
try {
const user = await userDb.findById(req.user.id);
res.json({ success: true, sapUser: user?.sapLoginUser || '', hasSapLogin: !!user?.hasSapLogin });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── PUT /auth/sap-credentials — set own SAP login (validated against SAP) ──────
// Returns a fresh token carrying the new credentials so they take effect
// immediately without re-login.
router.put('/sap-credentials', verifyToken, async (req, res) => {
const sapUser = (req.body?.sapUser || '').trim();
const sapPassword = req.body?.sapPassword || '';
if (!sapUser || !sapPassword)
return res.status(400).json({ success: false, message: 'SAP User ID and Password are required' });
try {
await sap.testSapLogin(null, sapUser, sapPassword); // validate before saving
} catch (e) {
return res.status(400).json({ success: false, message: 'SAP login failed — check your SAP User ID/Password. (' + e.message + ')' });
}
try {
await userDb.setSapCredentials(req.user.id, sapUser, sapPassword);
const user = await userDb.findById(req.user.id);
const token = jwt.sign(buildPayload(user, { sapUser, sapPassword }), SECRET, { expiresIn: EXPIRES });
res.json({ success: true, token, sapUser, hasSapLogin: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── PUT /auth/profile — update own name / email / password ────────────────────
router.put('/profile', async (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
try {
const decoded = jwt.verify(token, SECRET);
const { fullName, email, currentPassword, newPassword } = req.body;
if (newPassword) {
const user = await userDb.findByUsername(decoded.username);
const ok = await userDb.verifyPassword(currentPassword || '', user.passwordHash);
if (!ok) return res.status(400).json({ success: false, message: 'Current password is incorrect' });
}
const patch = {};
if (fullName !== undefined) patch.fullName = fullName.trim();
if (email !== undefined) patch.email = email.trim();
if (newPassword) patch.password = newPassword;
await userDb.updateUser(decoded.id, patch);
res.json({ success: true, message: 'Profile updated successfully' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+133
View File
@@ -0,0 +1,133 @@
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const store = () => require('../services/balanceSheetStore');
// ── Bootstrap on first require ────────────────────────────────────────────────
store().bootstrap().catch(e => console.error('[BalanceSheet] bootstrap error:', e));
// GET /api/balance-sheet/config
router.get('/config', verifyToken, async (req, res) => {
try {
const cfg = await store().getConfig();
res.json({ success: true, data: cfg, items: store().BS_ITEMS });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// POST /api/balance-sheet/config body: { group_key, acct_codes[], fixed_value, notes }
router.post('/config', verifyToken, async (req, res) => {
try {
const { group_key, acct_codes, opening_acct_codes, fixed_value, notes } = req.body;
if (!group_key) return res.status(400).json({ success: false, message: 'group_key required' });
await store().saveConfig(group_key, acct_codes, opening_acct_codes, fixed_value, notes);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/balance-sheet/data?asOf=YYYY-MM-DD&from=YYYY-MM-DD&to=YYYY-MM-DD
router.get('/data', verifyToken, async (req, res) => {
try {
const { asOf, from, to, snapshotId, netProfitOverride } = req.query;
if (!asOf) return res.status(400).json({ success: false, message: 'asOf date required' });
if (!from || !to) return res.status(400).json({ success: false, message: 'from and to required for P&L data' });
const s = store();
const pool = await getPool();
const cfg = await s.getConfig();
const [netProfit, closingStock, totalProvisions] = await Promise.all([
netProfitOverride != null && netProfitOverride !== ''
? Promise.resolve(parseFloat(netProfitOverride))
: snapshotId
? s.fetchNetProfitFromSnapshot(pool, snapshotId)
: s.fetchNetProfit(pool, from, to),
s.fetchClosingStock(pool, from, to),
s.fetchTotalProvisions(pool, from, to),
]);
const values = {};
let reservesDebug = null;
await Promise.all(
s.BS_ITEMS.map(async item => {
const c = cfg[item.key] || {};
let val = 0;
if (item.src === 'fixed') {
val = c.fixed_value != null ? c.fixed_value * 100000 : 0;
} else if (item.src === 'pl_net') {
val = netProfit;
} else if (item.src === 'closing_stock') {
val = closingStock;
} else if (item.src === 'provisions') {
const ledger = await s.fetchGroupBalance(pool, c.acct_codes || [], null, asOf);
val = -ledger + totalProvisions;
} else if (item.src === 'reserves_formula') {
const rd = await s.fetchReservesSurplusDebug(
pool, c.acct_codes || [], c.opening_acct_codes || [], from, to, asOf
);
val = rd.result;
reservesDebug = rd;
} else {
val = await s.fetchGroupBalance(pool, c.acct_codes || [], null, asOf);
}
values[item.key] = val;
})
);
res.json({
success: true,
asOf, from, to,
values,
meta: { net_profit: netProfit, closing_stock: closingStock, total_provisions: totalProvisions },
reserves_debug: reservesDebug,
});
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/balance-sheet/snapshot-profit?id= — compute net profit from snapshot period
router.get('/snapshot-profit', verifyToken, async (req, res) => {
try {
const { id } = req.query;
if (!id) return res.status(400).json({ success: false, message: 'id required' });
const pool = await getPool();
const s = store();
const net = await s.fetchNetProfitFromSnapshot(pool, id);
res.json({ success: true, net_profit: net });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/balance-sheet/snapshots — list available P&L snapshots
router.get('/snapshots', verifyToken, async (req, res) => {
try {
const snaps = await require('../services/costingStore').getSnapshots();
res.json({ success: true, data: snaps });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/balance-sheet/accounts?q= — search chart of accounts for configure panel
router.get('/accounts', verifyToken, async (req, res) => {
try {
const { q } = req.query;
const pool = await getPool();
const req2 = pool.request();
let where = `WHERE T0.GroupMask IN (1,2,3)`;
if (q) {
req2.input('q', `%${q}%`);
where += ` AND (T0.AcctCode LIKE @q OR T0.AcctName LIKE @q)`;
}
const r = await req2.query(`
SELECT TOP 100 T0.AcctCode, T0.AcctName, T0.GroupMask
FROM OACT T0
${where}
ORDER BY T0.AcctCode
`);
res.json({ success: true, data: r.recordset });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+406
View File
@@ -0,0 +1,406 @@
'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 = {};
wos.forEach(w => {
if (!w.intimationId || w.isDeleted || w.status === 'REJECTED') return;
const k = String(w.intimationId);
(map[k] || (map[k] = [])).push({ id: w.id, woNo: w.woNo, productCode: w.productCode || '', batchNumber: w.batchNumber || '' });
});
res.json({ success: true, data: map });
} 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;
+383
View File
@@ -0,0 +1,383 @@
'use strict';
// routes/board.js — Board Member analytics: company-growth summary numbers
// straight from the SAP company DB (net sales = AR Invoices − AR Credit
// Memos; purchases = AP Invoices). Read-only, one endpoint, gated to the
// board_member role (and admin).
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const appSettings = require('../services/appSettingsStore');
async function hana(sqlText) {
const pool = await getPool();
return (await pool.request().query(sqlText)).recordset || [];
}
// FG EQUIPMENT (item group 103) holds both blood-bag and CAPD machines. These
// two item codes are the CAPD ones; everything else in 103 is BB. Used to
// split "FG EQUIPMENT" into "FG EQUIPMENT BB" / "FG EQUIPMENT CAPD" in the
// Sales-by-Product-Group card and its multi-select filter.
const EQUIP_GRP = 103;
const CAPD_EQUIP = "'MEAPD20','m.CYCLER'";
// Resolves the effective Item Group filter for a request: the caller's own
// ?groups= selection, narrowed by (or defaulted to) the admin-configured
// restriction (System Settings → Company Growth Dashboard), which is always
// a HARD CEILING — applied even when the caller picked no filter of their
// own. Shared by every endpoint that needs to respect it (breakdown, monthly
// trend, …) so the restriction can never be bypassed by hitting a route that
// forgot to check it. `t1Alias`/`t2Alias` let callers match whatever line/
// OITM table aliases their own query already uses (T1/T2 in /breakdown, but
// /monthly's UNION query needs its own since it has 3 separate branches).
function resolveGroupFilter(req, t1Alias = 'T1', t2Alias = 'T2') {
let rawGroups = (req.query.groups || '').split(',').map(s => s.trim()).filter(Boolean);
const allowedGroups = appSettings.boardProductGroups();
if (allowedGroups.length) {
rawGroups = rawGroups.length ? rawGroups.filter(g => allowedGroups.includes(g)) : allowedGroups.slice();
}
const plainIds = rawGroups.filter(g => /^\d+$/.test(g)).map(Number).filter(n => n !== EQUIP_GRP);
const gClauses = [];
if (plainIds.length) gClauses.push(`${t2Alias}.ItmsGrpCod IN (${plainIds.join(',')})`);
if (rawGroups.includes(String(EQUIP_GRP))) gClauses.push(`${t2Alias}.ItmsGrpCod=${EQUIP_GRP}`);
if (rawGroups.includes('103bb')) gClauses.push(`(${t2Alias}.ItmsGrpCod=${EQUIP_GRP} AND ${t1Alias}.ItemCode NOT IN (${CAPD_EQUIP}))`);
if (rawGroups.includes('103capd')) gClauses.push(`(${t2Alias}.ItmsGrpCod=${EQUIP_GRP} AND ${t1Alias}.ItemCode IN (${CAPD_EQUIP}))`);
const hasGrpFilter = gClauses.length > 0;
return { rawGroups, hasGrpFilter, grpCond: hasGrpFilter ? ` AND (${gClauses.join(' OR ')})` : '' };
}
function requireBoard(req, res, next) {
const u = req.user || {};
if (['board_member', 'admin'].includes(u.role)) return next();
// Any role explicitly granted the Board Dashboard MODULE may view it too —
// the module checkbox is how admins hand out this dashboard.
if (Array.isArray(u.modules) && u.modules.includes('board-dashboard')) return next();
return res.status(403).json({ success: false, message: 'Board Dashboard access requires the Board Member role or the "Board Dashboard" module' });
}
router.get('/summary', verifyToken, requireBoard, async (req, res) => {
try {
// ── Monthly net sales & purchases, last 24 calendar months ───────────
const monthly = await hana(`
SELECT y, m, SUM(sales) AS sales, SUM(purch) AS purch FROM (
SELECT YEAR(DocDate) y, MONTH(DocDate) m, SUM(DocTotal-VatSum) sales, 0 purch
FROM OINV WHERE CANCELED='N' AND DocDate>=DATEADD(month,-24,GETDATE()) GROUP BY YEAR(DocDate),MONTH(DocDate)
UNION ALL
SELECT YEAR(DocDate), MONTH(DocDate), -SUM(DocTotal-VatSum), 0
FROM ORIN WHERE CANCELED='N' AND DocDate>=DATEADD(month,-24,GETDATE()) GROUP BY YEAR(DocDate),MONTH(DocDate)
UNION ALL
SELECT YEAR(DocDate), MONTH(DocDate), 0, SUM(DocTotal-VatSum)
FROM OPCH WHERE CANCELED='N' AND DocDate>=DATEADD(month,-24,GETDATE()) GROUP BY YEAR(DocDate),MONTH(DocDate)
) x GROUP BY y, m ORDER BY y, m`);
// ── Top customers & items, trailing 12 months ────────────────────────
const topCustomers = await hana(`
SELECT TOP 6 CardName AS name, SUM(DocTotal-VatSum) AS total
FROM OINV WHERE CANCELED='N' AND DocDate>=DATEADD(month,-12,GETDATE())
GROUP BY CardName ORDER BY total DESC`);
const topItems = await hana(`
SELECT TOP 6 T1.ItemCode AS code, MAX(T1.Dscription) AS name, SUM(T1.LineTotal) AS total
FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry
WHERE T0.CANCELED='N' AND T0.DocDate>=DATEADD(month,-12,GETDATE())
GROUP BY T1.ItemCode ORDER BY total DESC`);
// ── Headline KPIs ────────────────────────────────────────────────────
const kpi = (await hana(`
SELECT
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE()))
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE())) AS ytdSales,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE())-1 AND DocDate<=DATEADD(year,-1,GETDATE()))
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE())-1 AND DocDate<=DATEADD(year,-1,GETDATE())) AS lastYtdSales,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE()) AND MONTH(DocDate)=MONTH(GETDATE())) AS monthSales,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OPCH WHERE CANCELED='N' AND YEAR(DocDate)=YEAR(GETDATE())) AS ytdPurchases,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OINV WHERE DocStatus='O' AND CANCELED='N') AS openAR,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OPCH WHERE DocStatus='O' AND CANCELED='N') AS openAP,
(SELECT COUNT(*) FROM OWOR WHERE Status='R') AS openProdOrders,
(SELECT COUNT(DISTINCT CardCode) FROM OINV WHERE CANCELED='N' AND DocDate>=DATEADD(month,-12,GETDATE())) AS activeCustomers`))[0] || {};
res.json({ success: true, data: {
monthly: monthly.map(r => ({ y: r.y, m: r.m, sales: Number(r.sales) || 0, purchases: Number(r.purch) || 0 })),
topCustomers: topCustomers.map(r => ({ name: r.name, total: Number(r.total) || 0 })),
topItems: topItems.map(r => ({ code: r.code, name: r.name, total: Number(r.total) || 0 })),
kpi: {
ytdSales: Number(kpi.ytdSales) || 0,
lastYtdSales: Number(kpi.lastYtdSales) || 0,
monthSales: Number(kpi.monthSales) || 0,
ytdPurchases: Number(kpi.ytdPurchases) || 0,
openAR: Number(kpi.openAR) || 0,
openAP: Number(kpi.openAP) || 0,
openProdOrders: Number(kpi.openProdOrders) || 0,
activeCustomers:Number(kpi.activeCustomers) || 0,
},
}});
} catch (err) {
console.error('[BOARD] summary failed:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
// ── Monthly trend for a caller-chosen date range (defaults 24 months) ─────
// Respects the Product Groups filter (own ?groups= selection, narrowed by
// the admin-configured restriction) the same way /breakdown's KPI cards do —
// otherwise the trend chart would keep showing whole-company figures while
// every other card on the page is filtered, which is exactly the mismatch
// that was reported.
router.get('/monthly', verifyToken, requireBoard, async (req, res) => {
try {
const DS = /^\d{4}-\d{2}-\d{2}$/;
const from = DS.test(req.query.from || '') ? req.query.from : null;
const to = DS.test(req.query.to || '') ? req.query.to : null;
const { hasGrpFilter, grpCond } = resolveGroupFilter(req);
const monthly = hasGrpFilter ? await hana(`
SELECT y, m, SUM(sales) AS sales, SUM(purch) AS purch FROM (
SELECT YEAR(T0.DocDate) y, MONTH(T0.DocDate) m, SUM(T1.LineTotal) sales, 0 purch
FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
WHERE T0.CANCELED='N' AND ${(from && to) ? `T0.DocDate>='${from}' AND T0.DocDate<='${to}'` : `T0.DocDate>=DATEADD(month,-24,GETDATE())`}${grpCond}
GROUP BY YEAR(T0.DocDate),MONTH(T0.DocDate)
UNION ALL
SELECT YEAR(T0.DocDate), MONTH(T0.DocDate), -SUM(T1.LineTotal), 0
FROM RIN1 T1 JOIN ORIN T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
WHERE T0.CANCELED='N' AND ${(from && to) ? `T0.DocDate>='${from}' AND T0.DocDate<='${to}'` : `T0.DocDate>=DATEADD(month,-24,GETDATE())`}${grpCond}
GROUP BY YEAR(T0.DocDate),MONTH(T0.DocDate)
UNION ALL
SELECT YEAR(T0.DocDate), MONTH(T0.DocDate), 0, SUM(T1.LineTotal)
FROM PCH1 T1 JOIN OPCH T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
WHERE T0.CANCELED='N' AND ${(from && to) ? `T0.DocDate>='${from}' AND T0.DocDate<='${to}'` : `T0.DocDate>=DATEADD(month,-24,GETDATE())`}${grpCond}
GROUP BY YEAR(T0.DocDate),MONTH(T0.DocDate)
) x GROUP BY y, m ORDER BY y, m`)
: await hana(`
SELECT y, m, SUM(sales) AS sales, SUM(purch) AS purch FROM (
SELECT YEAR(DocDate) y, MONTH(DocDate) m, SUM(DocTotal-VatSum) sales, 0 purch
FROM OINV WHERE CANCELED='N' AND ${(from && to) ? `DocDate>='${from}' AND DocDate<='${to}'` : `DocDate>=DATEADD(month,-24,GETDATE())`} GROUP BY YEAR(DocDate),MONTH(DocDate)
UNION ALL
SELECT YEAR(DocDate), MONTH(DocDate), -SUM(DocTotal-VatSum), 0
FROM ORIN WHERE CANCELED='N' AND ${(from && to) ? `DocDate>='${from}' AND DocDate<='${to}'` : `DocDate>=DATEADD(month,-24,GETDATE())`} GROUP BY YEAR(DocDate),MONTH(DocDate)
UNION ALL
SELECT YEAR(DocDate), MONTH(DocDate), 0, SUM(DocTotal-VatSum)
FROM OPCH WHERE CANCELED='N' AND ${(from && to) ? `DocDate>='${from}' AND DocDate<='${to}'` : `DocDate>=DATEADD(month,-24,GETDATE())`} GROUP BY YEAR(DocDate),MONTH(DocDate)
) x GROUP BY y, m ORDER BY y, m`);
res.json({ success: true, data: monthly.map(r => ({ y: r.y, m: r.m, sales: Number(r.sales) || 0, purchases: Number(r.purch) || 0 })) });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Every Item Group SAP actually has items in, with the FG EQUIPMENT (103)
// split applied — the raw, UNRESTRICTED list. Shared by both routes below.
async function allGroups() {
const rows = await hana(`
SELECT T3.ItmsGrpCod AS code, T3.ItmsGrpNam AS name
FROM OITB T3
WHERE EXISTS (SELECT 1 FROM OITM T2 WHERE T2.ItmsGrpCod=T3.ItmsGrpCod)
ORDER BY T3.ItmsGrpNam`);
// Codes are strings; FG EQUIPMENT (103) becomes two virtual selectable
// entries so BB vs CAPD equipment can be filtered separately.
const data = [];
rows.forEach(r => {
if (Number(r.code) === EQUIP_GRP) {
data.push({ code: '103bb', name: 'FG EQUIPMENT BB' });
data.push({ code: '103capd', name: 'FG EQUIPMENT CAPD' });
} else {
data.push({ code: String(r.code), name: r.name });
}
});
return data;
}
// ── Item Group list for the multi-select filter — ADMIN-RESTRICTED ────────
// Admin → System Settings → "Company Growth Dashboard — Product Groups" can
// limit this to a chosen subset; empty configuration = no restriction (every
// group SAP has shows, as before).
router.get('/groups', verifyToken, requireBoard, async (req, res) => {
try {
const data = await allGroups();
const allowed = appSettings.boardProductGroups();
const restricted = allowed.length ? data.filter(g => allowed.includes(g.code)) : data;
// `total` = how many groups the company actually has, so the frontend can
// tell "All (5 of 32 groups)" apart from a genuinely unrestricted "All" —
// without exposing the unrestricted group NAMES themselves to a
// restricted viewer, just the count.
res.json({ success: true, data: restricted, total: data.length });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── UNRESTRICTED group list — for the admin settings checklist itself (an
// admin configuring the restriction must see every group to choose from,
// not the already-restricted set — same gate as the dashboard, admin-only in
// practice since only admins reach the System Settings page).
router.get('/groups/all', verifyToken, requireBoard, async (req, res) => {
try { res.json({ success: true, data: await allGroups() }); }
catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── Filterable breakdown: top customers / products / product groups over a
// caller-chosen date range (defaults to trailing 12 months) ─────────────
router.get('/breakdown', verifyToken, requireBoard, async (req, res) => {
try {
const DS = /^\d{4}-\d{2}-\d{2}$/;
const iso = d => d.toISOString().slice(0, 10);
// Resolve to EXPLICIT dates (default trailing 12 months) so the previous
// equal-length comparison period can be computed for the growth badge.
let from = DS.test(req.query.from || '') ? req.query.from : null;
let to = DS.test(req.query.to || '') ? req.query.to : null;
if (!from || !to) {
const now = new Date(); to = iso(now);
const f = new Date(now); f.setMonth(f.getMonth() - 12); from = iso(f);
}
const fromD = new Date(from + 'T00:00:00Z'), toD = new Date(to + 'T00:00:00Z');
const lenDays = Math.max(1, Math.round((toD - fromD) / 86400000) + 1);
const prevTo = iso(new Date(fromD.getTime() - 86400000));
const prevFrom = iso(new Date(fromD.getTime() - lenDays * 86400000));
const cond = `T0.DocDate>='${from}' AND T0.DocDate<='${to}'`;
const condP = `DocDate>='${from}' AND DocDate<='${to}'`;
const condPrev = `DocDate>='${prevFrom}' AND DocDate<='${prevTo}'`;
// Optional multi-select Item Group filter (?groups=101,102,103bb,103capd),
// narrowed by (or defaulted to) the admin-configured restriction — see
// resolveGroupFilter(). Computed BEFORE the KPI query below so the KPI
// cards respect it too (not just the Top Customers/Items/Groups cards).
const { hasGrpFilter, grpCond } = resolveGroupFilter(req);
// Period KPIs — net sales/purchases/customers for the range + previous
// period. Two shapes: company-wide (header-level, cheap) when no group
// filter is active — same as before — or LINE-LEVEL (joined to the
// item's group, only lines in the selected groups count) when a filter
// is active, so the KPI cards agree with the Top Customers/Items/Groups
// cards instead of always showing the whole company regardless of the
// Product Groups filter.
// NOTE: Open AR / Open AP (outstanding, unpaid amount) are intentionally
// NEVER group-filtered — SAP tracks PaidToDate at the DOCUMENT level, not
// per line, so there's no sound way to allocate "how much of this
// invoice's unpaid balance belongs to which item group" without making
// up an allocation rule. They always show the whole company's
// outstanding balance for docs dated in the period.
const k = hasGrpFilter ? (await hana(`SELECT
(SELECT ISNULL(SUM(T1.LineTotal),0) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND ${cond}${grpCond})
- (SELECT ISNULL(SUM(T1.LineTotal),0) FROM RIN1 T1 JOIN ORIN T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND ${cond}${grpCond}) AS netSales,
(SELECT ISNULL(SUM(T1.LineTotal),0) FROM PCH1 T1 JOIN OPCH T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND ${cond}${grpCond}) AS purchases,
(SELECT COUNT(DISTINCT T0.CardCode) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND ${cond}${grpCond}) AS activeCustomers,
(SELECT COUNT(DISTINCT T0.DocEntry) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND ${cond}${grpCond}) AS invoices,
(SELECT ISNULL(SUM(T1.LineTotal),0) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.DocDate>='${prevFrom}' AND T0.DocDate<='${prevTo}'${grpCond})
- (SELECT ISNULL(SUM(T1.LineTotal),0) FROM RIN1 T1 JOIN ORIN T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.DocDate>='${prevFrom}' AND T0.DocDate<='${prevTo}'${grpCond}) AS prevNetSales,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OINV WHERE DocStatus='O' AND CANCELED='N' AND ${condP}) AS openAR,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OPCH WHERE DocStatus='O' AND CANCELED='N' AND ${condP}) AS openAP,
(SELECT ISNULL(SUM(T1.LineTotal),0) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.U_CustomerCategory='Direct Export' AND ${cond}${grpCond})
- (SELECT ISNULL(SUM(T1.LineTotal),0) FROM RIN1 T1 JOIN ORIN T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.U_CustomerCategory='Direct Export' AND ${cond}${grpCond}) AS exportDirect,
(SELECT ISNULL(SUM(T1.LineTotal),0) FROM INV1 T1 JOIN OINV T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.U_CustomerCategory='Indirect Export' AND ${cond}${grpCond})
- (SELECT ISNULL(SUM(T1.LineTotal),0) FROM RIN1 T1 JOIN ORIN T0 ON T0.DocEntry=T1.DocEntry JOIN OITM T2 ON T2.ItemCode=T1.ItemCode WHERE T0.CANCELED='N' AND T0.U_CustomerCategory='Indirect Export' AND ${cond}${grpCond}) AS exportIndirect`))[0] || {}
: (await hana(`SELECT
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND ${condP})
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND ${condP}) AS netSales,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OPCH WHERE CANCELED='N' AND ${condP}) AS purchases,
(SELECT COUNT(DISTINCT CardCode) FROM OINV WHERE CANCELED='N' AND ${condP}) AS activeCustomers,
(SELECT COUNT(*) FROM OINV WHERE CANCELED='N' AND ${condP}) AS invoices,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND ${condPrev})
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND ${condPrev}) AS prevNetSales,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OINV WHERE DocStatus='O' AND CANCELED='N' AND ${condP}) AS openAR,
(SELECT ISNULL(SUM(DocTotal-PaidToDate),0) FROM OPCH WHERE DocStatus='O' AND CANCELED='N' AND ${condP}) AS openAP,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND U_CustomerCategory='Direct Export' AND ${condP})
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND U_CustomerCategory='Direct Export' AND ${condP}) AS exportDirect,
(SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM OINV WHERE CANCELED='N' AND U_CustomerCategory='Indirect Export' AND ${condP})
- (SELECT ISNULL(SUM(DocTotal-VatSum),0) FROM ORIN WHERE CANCELED='N' AND U_CustomerCategory='Indirect Export' AND ${condP}) AS exportIndirect`))[0] || {};
// Division Summary — EXACT same segment logic as the Sales Report
// (routes/reports.js buildRevSubq: G/L-account + item-subtype based,
// incl. excluded docs). Matches the management figures to the paisa.
// Now ALSO respects the Product Groups filter: buildRevSubq() is shared
// with routes/reports.js's Sales Report, so it's never touched directly —
// instead the group condition is appended into the `dateWhere` string
// buildRevSubq already splices verbatim into its own WHERE clause, using
// ITS aliases (t1=OITM, t2=OITB, lowercase — NOT the T1/T2 used by the
// rest of this route) so it resolves against columns actually in scope.
let divisions = null;
try {
const { buildRevSubq } = require('./reports');
const divGrp = resolveGroupFilter(req, 't1', 't2');
const dw = `i.DocDate BETWEEN '${from}' AND '${to}'${divGrp.grpCond}`;
const div = (await hana(`
SELECT ROUND(SUM(TM),2) TM, ROUND(SUM(PD),2) PD, ROUND(SUM(ED),2) ED, ROUND(SUM(EI),2) EI,
ROUND(SUM(TM)+SUM(PD)+SUM(ED)+SUM(EI),2) Total
FROM ( ${buildRevSubq(dw, false)} UNION ALL ${buildRevSubq(dw, true)} ) CK`))[0];
if (div) divisions = {
tm: Number(div.TM) || 0, pd: Number(div.PD) || 0,
ed: Number(div.ED) || 0, ei: Number(div.EI) || 0,
total: Number(div.Total) || 0,
};
} catch (e) { console.warn('[BOARD] division summary failed (non-fatal):', e.message); }
// Optional per-card search text (?custQ= / ?itemQ= / ?grpQ=) — searched
// rows are still ranked by sales; quote-escaped and length-capped.
const like = s => String(s || '').replace(/'/g, "''").slice(0, 60).trim();
const custQ = like(req.query.custQ), itemQ = like(req.query.itemQ), grpQ = like(req.query.grpQ);
const custCond = custQ ? ` AND T0.CardName LIKE '%${custQ}%'` : '';
const itemCond = itemQ ? ` AND (T1.ItemCode LIKE '%${itemQ}%' OR T1.Dscription LIKE '%${itemQ}%')` : '';
const grpNameCond = grpQ ? ` AND T3.ItmsGrpNam LIKE '%${grpQ}%'` : '';
// Top customers: header totals normally; when a group filter is active the
// sum has to be line-level (only lines of the selected groups count).
const topCustomers = hasGrpFilter
? await hana(`
SELECT TOP ${custQ?10:6} T0.CardName AS name, SUM(T1.LineTotal) AS total
FROM INV1 T1
JOIN OINV T0 ON T0.DocEntry=T1.DocEntry
JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
WHERE T0.CANCELED='N' AND ${cond}${grpCond}${custCond}
GROUP BY T0.CardName ORDER BY total DESC`)
: await hana(`
SELECT TOP ${custQ?10:6} T0.CardName AS name, SUM(T0.DocTotal-T0.VatSum) AS total
FROM OINV T0 WHERE T0.CANCELED='N' AND ${cond}${custCond}
GROUP BY T0.CardName ORDER BY total DESC`);
const topItems = await hana(`
SELECT TOP ${itemQ?10:6} T1.ItemCode AS code, MAX(T1.Dscription) AS name, SUM(T1.LineTotal) AS total
FROM INV1 T1
JOIN OINV T0 ON T0.DocEntry=T1.DocEntry
JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
WHERE T0.CANCELED='N' AND ${cond}${grpCond}${itemCond}
GROUP BY T1.ItemCode ORDER BY total DESC`);
// Product Groups — same hygiene as the Sales Report (net of credit notes,
// excluded docs, status condition) and SPLIT Domestic vs Export per group
// (export = the report's export revenue accounts), so the domestic part of
// FG BLOOD BAG reconciles with the Division Summary's Blood Bag (TM).
const { REV_CONST } = require('./reports');
const EXP_ACCTS = REV_CONST.EXCL_ED + ',' + REV_CONST.EXCL_EI;
const invExtras = ` AND T0.DocNum NOT IN (${REV_CONST.EXCL_DOCS})
AND (T3.ItmsGrpNam IN ('FG CAPD','FG CAPD Accessories') OR (T0.DocStatus<>'C' OR T0.InvntSttus='O'))`;
const grpLeg = (tbl, hdr, sign, extras) => `
SELECT CASE WHEN T2.ItmsGrpCod=${EQUIP_GRP} AND T1.ItemCode IN (${CAPD_EQUIP}) THEN 'FG EQUIPMENT CAPD'
WHEN T2.ItmsGrpCod=${EQUIP_GRP} THEN 'FG EQUIPMENT BB'
ELSE T3.ItmsGrpNam END AS name,
CASE WHEN T1.AcctCode IN (${EXP_ACCTS}) THEN 0 ELSE ${sign}T1.LineTotal END AS domestic,
CASE WHEN T1.AcctCode IN (${EXP_ACCTS}) THEN ${sign}T1.LineTotal ELSE 0 END AS export
FROM ${tbl} T1
JOIN ${hdr} T0 ON T0.DocEntry=T1.DocEntry
JOIN OITM T2 ON T2.ItemCode=T1.ItemCode
JOIN OITB T3 ON T3.ItmsGrpCod=T2.ItmsGrpCod
WHERE T0.CANCELED='N' AND ${cond}${grpCond}${grpNameCond}${extras}`;
const topGroups = await hana(`
SELECT TOP ${grpQ?12:8} name, SUM(domestic) AS domestic, SUM(export) AS export, SUM(domestic)+SUM(export) AS total
FROM ( ${grpLeg('INV1','OINV','',invExtras)} UNION ALL ${grpLeg('RIN1','ORIN','-','')} ) x
GROUP BY name ORDER BY total DESC`);
res.json({ success: true, data: {
from, to, prevFrom, prevTo,
divisions,
kpi: {
netSales: Number(k.netSales) || 0,
purchases: Number(k.purchases) || 0,
activeCustomers: Number(k.activeCustomers) || 0,
invoices: Number(k.invoices) || 0,
prevNetSales: Number(k.prevNetSales) || 0,
openAR: Number(k.openAR) || 0,
openAP: Number(k.openAP) || 0,
exportDirect: Number(k.exportDirect) || 0,
exportIndirect: Number(k.exportIndirect) || 0,
},
topCustomers: topCustomers.map(r => ({ name: r.name, total: Number(r.total) || 0 })),
topItems: topItems.map(r => ({ code: r.code, name: r.name, total: Number(r.total) || 0 })),
topGroups: topGroups.map(r => ({ name: r.name, domestic: Number(r.domestic) || 0, export: Number(r.export) || 0, total: Number(r.total) || 0 })),
}});
} catch (err) {
console.error('[BOARD] breakdown failed:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+218
View File
@@ -0,0 +1,218 @@
const express = require('express');
const router = express.Router();
const { body, validationResult } = require('express-validator');
const { sapRequest } = require('../services/sapServiceLayer');
const { verifyToken } = require('../middleware/auth');
const { resolve: resolveCompany } = require('../services/companyConfig');
// ─── TreeType enum ─────────────────────────────────────────
const TREE_TYPE_MAP = {
production: 'iProductionTree',
sales: 'iSalesTree',
assembly: 'iAssemblyTree',
template: 'iTemplateTree',
disassembly: 'iDisassemblyTree',
};
// ─── IssueMethod map ───────────────────────────────────────
const ISSUE_METHOD_MAP = {
Manual: 'im_Manual',
Backflush: 'im_Backflush',
// backward compatibility
Stock: 'im_Backflush',
'Non-Stock': 'im_Manual',
Phantom: 'im_Manual',
Fixed: 'im_Backflush',
};
// ──────────────────────────────────────────────────────────
// CREATE BOM
// ──────────────────────────────────────────────────────────
router.post(
'/create',
verifyToken,
[
body('itemCode').notEmpty(),
body('itemName').notEmpty(),
body('qty').isFloat({ gt: 0 }),
body('components').isArray({ min: 1 }),
],
async (req, res) => {
const errs = validationResult(req);
if (!errs.isEmpty()) {
return res.status(400).json({ success: false, errors: errs.array() });
}
try {
const b = req.body;
const companyDB = resolveCompany(b.company);
const treeCode = b.itemCode.trim().toUpperCase();
const productTreeLines = (b.components || []).map((c) => {
const issueRaw = c.issueMethod || c.compType || 'Manual';
const line = {
ItemCode: c.itemCode?.trim().toUpperCase(),
Quantity: Number(c.qty) || 1,
IssueMethod: ISSUE_METHOD_MAP[issueRaw] || 'im_Manual',
};
if (c.warehouse?.trim()) {
line.Warehouse = c.warehouse.trim();
} else if (b.warehouse?.trim()) {
line.Warehouse = b.warehouse.trim();
}
if (Number(c.unitCost) > 0) {
line.Price = Number(c.unitCost);
line.Currency = 'INR';
}
if (c.note?.trim()) {
line.Comment = c.note.trim().slice(0, 100);
}
return line;
});
const payload = {
TreeCode: treeCode,
TreeType:
TREE_TYPE_MAP[(b.bomType || 'production').toLowerCase()] ||
'iProductionTree',
Quantity: Number(b.qty) || 1,
ProductDescription: b.itemName.trim().toUpperCase().slice(0, 100),
ProductTreeLines: productTreeLines,
};
if (b.warehouse?.trim()) payload.Warehouse = b.warehouse.trim();
if (b.distrRule?.trim()) payload.DistributionRule = b.distrRule.trim();
if (b.project?.trim()) payload.Project = b.project.trim();
if (b.drawingNo) payload.U_DrawingNo = b.drawingNo.slice(0, 50);
if (b.revision) payload.U_Revision = b.revision.slice(0, 20);
if (b.remarks) payload.Remark = b.remarks.slice(0, 254);
console.log('[BOM CREATE]', JSON.stringify(payload, null, 2));
const result = await sapRequest('POST', 'ProductTrees', payload, companyDB);
res.json({
success: true,
message: 'BOM created successfully',
treeCode: result?.TreeCode || treeCode,
});
} catch (err) {
console.error('[BOM ERROR]', err.message);
let msg = err.message;
if (msg.includes('-1035')) msg = 'BOM already exists';
else if (msg.includes('-1020')) msg = 'Item not found';
else if (msg.includes('-1034')) msg = 'Invalid warehouse';
else if (msg.includes('IssueMethod')) msg = 'Invalid issue method';
res.status(500).json({ success: false, message: msg });
}
}
);
// ──────────────────────────────────────────────────────────
// LIST BOM
// ──────────────────────────────────────────────────────────
router.get('/list', verifyToken, async (req, res) => {
try {
const top = Number(req.query.top) || 50;
const skip = Number(req.query.skip) || 0;
const companyDB = resolveCompany(req.query.company);
const result = await sapRequest(
'GET',
`ProductTrees?$select=TreeCode,TreeType,Quantity,Warehouse,ProductDescription&$top=${top}&$skip=${skip}`,
null, companyDB
);
res.json({
success: true,
data: result.value || [],
});
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ──────────────────────────────────────────────────────────
// GET SINGLE
// ──────────────────────────────────────────────────────────
router.get('/:treeCode', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.query.company);
try {
const code = encodeURIComponent(req.params.treeCode);
const result = await sapRequest('GET', `ProductTrees('${code}')`, null, companyDB);
res.json({ success: true, data: result });
} catch (err) {
res.status(404).json({ success: false, message: err.message });
}
});
// ──────────────────────────────────────────────────────────
// UPDATE BOM
// ──────────────────────────────────────────────────────────
router.patch('/:treeCode', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const code = req.params.treeCode.trim();
const b = req.body;
const patch = {};
if (b.qty != null) patch.Quantity = Number(b.qty);
if (b.warehouse) patch.Warehouse = b.warehouse;
if (b.itemName)
patch.ProductDescription = b.itemName.toUpperCase().slice(0, 100);
if (b.bomType) {
const type = TREE_TYPE_MAP[b.bomType.toLowerCase()];
if (type) patch.TreeType = type;
}
if (b.components?.length) {
patch.ProductTreeLines = b.components.map((c) => ({
ItemCode: c.itemCode.toUpperCase(),
Quantity: Number(c.qty) || 1,
IssueMethod: ISSUE_METHOD_MAP[c.issueMethod] || 'im_Manual',
}));
}
await sapRequest(
'PATCH',
`ProductTrees('${encodeURIComponent(code)}')`,
patch, companyDB
);
res.json({ success: true, message: 'BOM updated' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ──────────────────────────────────────────────────────────
// DELETE BOM
// ──────────────────────────────────────────────────────────
router.delete('/:treeCode', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.query.company);
try {
const code = req.params.treeCode.trim();
await sapRequest(
'DELETE',
`ProductTrees('${encodeURIComponent(code)}')`,
null, companyDB
);
res.json({ success: true, message: 'BOM deleted' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+110
View File
@@ -0,0 +1,110 @@
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken, verifyAdmin } = require('../middleware/auth');
const { resolve: resolveCompany } = require('../services/companyConfig');
function getSap() { return require('../services/sapServiceLayer'); }
// Fields returned in the list view (kept small for performance)
const LIST_SELECT = [
'CardCode','CardName','CardType','GroupCode','Currency','Phone1','EmailAddress',
'Valid','Frozen','CreditLimit','Cellular','Address','Country',
].join(',');
// Fields the UI is allowed to edit via PATCH
const EDITABLE_FIELDS = [
'CardName','CardForeignName','GroupCode','Currency','Phone1','Phone2','Cellular','Fax',
'EmailAddress','Website','ContactPerson','Notes','Valid','Frozen','CreditLimit',
'PayTermsGrpCode','Address','MailAddress','City','MailCity','Country','MailCountry',
'ZipCode','MailZipCode','County','MailCounty','Block','MailBlock',
'FederalTaxID','VatLiable','GSTType','LicTradNum',
];
// Array/sub-object fields the UI may replace wholesale
const EDITABLE_ARRAYS = ['BPAddresses', 'BPBankAccounts', 'ContactEmployees'];
function extractPatch(body) {
const p = {};
EDITABLE_FIELDS.forEach(f => { if (body[f] !== undefined) p[f] = body[f]; });
EDITABLE_ARRAYS.forEach(f => { if (Array.isArray(body[f])) p[f] = body[f]; });
// Any user-defined field (U_*) is passed through as-is
Object.keys(body).forEach(k => {
if (k.startsWith('U_')) p[k] = body[k];
});
return p;
}
// ══ LIST ═════════════════════════════════════════════════════════════════════
// GET /api/business-master?type=cCustomer|cSupplier|cLid&search=abc&top=50&skip=0
router.get('/', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.query.company);
const top = Math.min(parseInt(req.query.top, 10) || 50, 200);
const skip = parseInt(req.query.skip, 10) || 0;
const type = (req.query.type || '').trim();
const search = (req.query.search || '').trim().replace(/'/g, "''");
const filters = [];
if (type) filters.push(`CardType eq '${type}'`);
if (search) {
filters.push(`(contains(CardName,'${search}') or contains(CardCode,'${search}'))`);
}
let endpoint = `BusinessPartners?$select=${LIST_SELECT}&$top=${top}&$skip=${skip}&$orderby=CardName`;
if (filters.length) endpoint += `&$filter=${encodeURIComponent(filters.join(' and '))}`;
try {
const result = await getSap().sapRequest('GET', endpoint, null, companyDB, true, { Prefer: `odata.maxpagesize=${top}` });
res.json({ success: true, data: result.value || [], count: result['odata.count'] });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ══ GET ONE ══════════════════════════════════════════════════════════════════
router.get('/:cardCode', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.query.company);
const cardCode = encodeURIComponent(req.params.cardCode);
try {
const result = await getSap().sapRequest('GET', `BusinessPartners('${cardCode}')`, null, companyDB);
res.json({ success: true, data: result });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ══ UPDATE ═══════════════════════════════════════════════════════════════════
// PATCH /api/business-master/:cardCode
router.patch('/:cardCode', verifyToken, verifyAdmin, async (req, res) => {
const companyDB = resolveCompany(req.body.company);
const cardCode = encodeURIComponent(req.params.cardCode);
const patch = extractPatch(req.body);
if (!Object.keys(patch).length) {
return res.status(400).json({ success: false, message: 'No editable fields provided' });
}
try {
await getSap().sapRequest('PATCH', `BusinessPartners('${cardCode}')`, patch, companyDB);
console.log(`[BUSINESS-MASTER] ${req.user.username} updated ${req.params.cardCode}:`, Object.keys(patch).join(', '));
res.json({ success: true, message: 'Business partner updated' });
} catch (err) {
console.error('[BUSINESS-MASTER] update error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
// ══ DELETE ═══════════════════════════════════════════════════════════════════
// DELETE /api/business-master/:cardCode
router.delete('/:cardCode', verifyToken, verifyAdmin, async (req, res) => {
const companyDB = resolveCompany(req.query.company);
const cardCode = encodeURIComponent(req.params.cardCode);
try {
await getSap().sapRequest('DELETE', `BusinessPartners('${cardCode}')`, null, companyDB);
console.log(`[BUSINESS-MASTER] ${req.user.username} deleted ${req.params.cardCode}`);
res.json({ success: true, message: 'Business partner deleted' });
} catch (err) {
console.error('[BUSINESS-MASTER] delete error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+172
View File
@@ -0,0 +1,172 @@
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const store = () => require('../services/cashFlowStore');
const bsStore = () => require('../services/balanceSheetStore');
store().bootstrap().catch(e => console.error('[CashFlow] bootstrap error:', e));
function dayBefore(dateStr) {
const d = new Date(dateStr);
d.setDate(d.getDate() - 1);
return d.toISOString().split('T')[0];
}
function periodLabel(to) {
const d = new Date(to);
const months = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
return `Upto ${months[d.getMonth()]} ${d.getFullYear()}`;
}
// GET /api/cash-flow/items
router.get('/items', verifyToken, async (req, res) => {
try { res.json({ success: true, data: await store().getItems() }); }
catch(err) { res.status(500).json({ success: false, message: err.message }); }
});
// POST /api/cash-flow/items — create or update
router.post('/items', verifyToken, async (req, res) => {
try {
const { id, section, item_type, label, acct_codes, sort_order, notes } = req.body;
if (!label || !section) return res.status(400).json({ success: false, message: 'section and label required' });
// cash section always forces item_type=cash
const type = section === 'Cash' ? 'cash' : (item_type || 'working');
await store().saveItem({ id: id || null, section, item_type: type, label, acct_codes, sort_order, notes });
res.json({ success: true });
} catch(err) { res.status(500).json({ success: false, message: err.message }); }
});
// DELETE /api/cash-flow/items/:id
router.delete('/items/:id', verifyToken, async (req, res) => {
try {
await store().deleteItem(parseInt(req.params.id));
res.json({ success: true });
} catch(err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/cash-flow/data?from=&to=[&snapshotId=][&monthlySnapId=]
router.get('/data', verifyToken, async (req, res) => {
try {
const { from, to, snapshotId, monthlySnapId } = req.query;
if (!from || !to) return res.status(400).json({ success: false, message: 'from and to required' });
const s = store();
const items = await s.getItems();
const values = {}; // detailed table values
const period_values = {}; // period D-C for all items (used for statement CapEx/WC)
const opening_values = {}; // opening balance for cash items
const openDate = dayBefore(from);
if (snapshotId) {
// pl_snapshots lives on the app DB — read via costingStore, not the SAP pool.
const snap = await require('../services/costingStore').getSnapshot(parseInt(snapshotId));
if (!snap) return res.status(404).json({ success: false, message: 'Snapshot not found' });
const snapData = JSON.parse(snap.data_json);
const accounts = snapData.accounts || snapData;
items.forEach(item => {
if (item.item_type === 'cash') {
opening_values[item.id] = s.fetchOpeningBalanceFromSnapshot(accounts, item.acct_codes);
values[item.id] = s.fetchItemClosingBalanceFromSnapshot(accounts, item.acct_codes);
period_values[item.id] = s.fetchItemValueFromSnapshot(accounts, item.acct_codes);
} else if (item.item_type === 'capital') {
values[item.id] = s.fetchItemClosingBalanceFromSnapshot(accounts, item.acct_codes);
period_values[item.id] = s.fetchItemValueFromSnapshot(accounts, item.acct_codes);
} else {
values[item.id] = s.fetchItemValueFromSnapshot(accounts, item.acct_codes);
period_values[item.id] = values[item.id];
}
});
} else {
const pool = await getPool();
await Promise.all(items.map(async item => {
if (item.item_type === 'cash') {
[opening_values[item.id], values[item.id], period_values[item.id]] = await Promise.all([
s.fetchItemClosingBalance(pool, item.acct_codes, openDate),
s.fetchItemClosingBalance(pool, item.acct_codes, to),
s.fetchItemValue(pool, item.acct_codes, from, to),
]);
} else if (item.item_type === 'capital') {
[values[item.id], period_values[item.id]] = await Promise.all([
s.fetchItemClosingBalance(pool, item.acct_codes, to),
s.fetchItemValue(pool, item.acct_codes, from, to),
]);
} else {
values[item.id] = await s.fetchItemValue(pool, item.acct_codes, from, to);
period_values[item.id] = values[item.id];
}
}));
}
// Net profit — always from Monthly Accounts snapshot if provided,
// otherwise from the main snapshot (if selected), otherwise live DB
const pool = await getPool();
const profitSnapId = monthlySnapId || (!snapshotId ? null : snapshotId);
const netProfit = profitSnapId
? await bsStore().fetchNetProfitFromSnapshot(pool, profitSnapId)
: await bsStore().fetchNetProfit(pool, from, to);
// Aggregate summary components
let wc_change = 0, capex = 0, opening_cash = 0, closing_cash = 0;
items.forEach(item => {
const pv = period_values[item.id] || 0;
if (item.item_type === 'cash') {
opening_cash += opening_values[item.id] || 0;
closing_cash += values[item.id] || 0;
} else if (item.item_type === 'working') {
// WC increase = cash decrease → negate period D-C
wc_change -= pv;
} else if (item.item_type === 'capital') {
// CapEx: asset D-C > 0 (bought) = cash out = negative
capex -= pv;
}
});
const total = netProfit + wc_change;
const net_increase = total + capex;
const summary = {
net_profit: netProfit,
wc_change,
total,
capex,
net_increase,
opening_cash,
closing_cash,
period_label: periodLabel(to),
};
res.json({ success: true, from, to, items, values, period_values, opening_values, summary });
} catch(err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/cash-flow/snapshots
router.get('/snapshots', verifyToken, async (req, res) => {
try {
const snaps = await require('../services/costingStore').getSnapshots();
res.json({ success: true, data: snaps });
} catch(err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/cash-flow/accounts?q= — search accounts for config panel
router.get('/accounts', verifyToken, async (req, res) => {
try {
const { q } = req.query;
const pool = await getPool();
const req2 = pool.request();
let where = `WHERE T0.GroupMask IN (1,2,3,4,5)`;
if (q) { req2.input('q', `%${q}%`); where += ` AND (T0.AcctCode LIKE @q OR T0.AcctName LIKE @q)`; }
const r = await req2.query(`
SELECT TOP 100 T0.AcctCode, T0.AcctName, T0.GroupMask
FROM OACT T0 ${where} ORDER BY T0.AcctCode
`);
res.json({ success: true, data: r.recordset });
} catch(err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+434
View File
@@ -0,0 +1,434 @@
'use strict';
const express = require('express');
const router = express.Router();
const { GoogleGenerativeAI } = require('@google/generative-ai');
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const SYSTEM_PROMPT = `You are an intelligent AI assistant embedded in the MITRA ERP Portal for Mitra Industries — a manufacturing company that produces Blood Bags and CAPD products.
You help users with:
1. Portal navigation & how-to: Explain how to use any module (BOM, P&L, Cost Sheet, Salary, Purchase, Production, GSTR, Projects, etc.)
2. Live data queries: When asked about sales revenue or BOM costs, call the appropriate tool to fetch real data from SAP B1 and present it clearly.
== PORTAL MODULES ==
- BOM: Multi-level bill of materials with 3-level approval (Manager → Sr. Manager → SAP Adder).
- Purchase Request / Quotation / Order: Full procurement workflow.
- GRPO: Goods receipt against POs.
- Production: Issue, Receipt, Close production orders.
- Monthly Accounts P&L: 8-tab trial balance (Revenue, Purchase, Employee, Factory, Admin, SND, Finance, OtherIncome). Snapshot save supported.
- P&L Comparison: Side-by-side snapshot comparison across periods.
- Cost Sheet: Item-wise cost (Blood Bag & CAPD). RM Consumption = Qty × BOM Price × 115%.
- Salary: Department salary periods (Blood Bag, CAPD, QA, QC).
- GSTR-1 / GSTR-2: GST return filing.
- Project Forms & Approvals.
== DATA TOOLS ==
- get_revenue_data: Fetches revenue from SAP B1. Supports specific dates — if user says "17 May sales" or "sales on 16 May 2026", pass date="2026-05-17" and period_type="daily". For "monthly sales", pass period_type="monthly". For "yearly", pass period_type="yearly". For all periods, pass period_type="all". ALWAYS call this tool for any sales/revenue question — never say data is unavailable without trying.
- get_bom_costs: Fetches BOM item codes, names, and standard prices for a given product group. Call when the user asks about BOM prices or item costs.
- get_fg_inventory: Fetches FG inventory report via stored procedure INVENTORY_FG_AVG_PRICE. Returns opening balance, received from production, issues, balance, and live stock per item. Call when user asks about FG stock, inventory levels, warehouse stock, or item balances.
- get_open_production_orders: Fetches open (Released + Planned) production orders from SAP B1. Returns summary totals, overdue count, segment breakdown, and order details. Call when the user asks about production orders, what is in production, overdue orders, or production status.
== RESPONSE STYLE ==
- Be concise. Use tables or bullet points for data results.
- Format currency in Indian Rupees (₹) with commas, 2 decimal places.
- If a question is unrelated to Mitra Industries, politely redirect.
- Respond in the same language the user writes in.`;
// ── Account code constants (from stored procedure) ────────────────────────
const EXCL_DOCS = `'552520145','552520146','552520147','552520465','552522652','552522653','552620186'`;
const EXCL_TM = `'4110201001','4110202001','4110201003','4110202003','4110201007','4110201002','4110201005'`;
const EXCL_PD = EXCL_TM + `,'4110202005','4110202007','4110201004'`;
const EXCL_ED = `'4110201001','4110201003','4110201007','4110201002','4110201005','4110201004'`;
const EXCL_EI = `'4110202001','4110202003','4110202005','4110202007'`;
// ── Date ranges (mirrors stored procedure logic) ──────────────────────────
function getDateRanges() {
const t = new Date();
t.setDate(t.getDate() - 1); // yesterday
const dd = t.getDate(), mm = t.getMonth() + 1, yyyy = t.getFullYear();
const yearStart = (dd < 16 && mm <= 1)
? `${yyyy - 1}-01-16` : `${yyyy}-01-16`;
let monthStart;
if (dd < 16) {
const pMm = mm === 1 ? 12 : mm - 1;
const pYyyy = mm === 1 ? yyyy - 1 : yyyy;
monthStart = `${pYyyy}-${String(pMm).padStart(2, '0')}-16`;
} else {
monthStart = `${yyyy}-${String(mm).padStart(2, '0')}-16`;
}
return { toDate: t.toISOString().split('T')[0], yearStart, monthStart };
}
// ── Revenue subquery builder ───────────────────────────────────────────────
function buildRevenueSubq(dateWhere, isCreditNote) {
const tbl = isCreditNote ? 'RIN1' : 'INV1';
const hdr = isCreditNote ? 'ORIN' : 'OINV';
const sign = isCreditNote ? '-' : '';
const extras = isCreditNote ? '' : `
AND i.DocNum NOT IN (${EXCL_DOCS})
AND (t2.ItmsGrpNam IN ('FG CAPD','FG CAPD Accessories')
OR (i.DocStatus<>'C' OR i.InvntSttus='O'))`;
return `
SELECT
CASE WHEN (t2.ItmsGrpNam='FG BLOOD BAG'
OR (t2.ItmsGrpNam IN ('FG EQUIPMENT','SEMI FINISHED') AND t1.U_BagType='Equipment'))
AND t.AcctCode NOT IN (${EXCL_TM})
THEN ${sign}t.LineTotal ELSE 0 END AS TM,
CASE WHEN (t2.ItmsGrpNam IN ('FG CAPD','FG CAPD Accessories')
OR t1.U_PDGroup='Equipment')
AND t.AcctCode NOT IN (${EXCL_PD})
THEN ${sign}t.LineTotal ELSE 0 END AS PD,
CASE WHEN t.AcctCode IN (${EXCL_ED}) THEN ${sign}t.LineTotal ELSE 0 END AS ED,
CASE WHEN t.AcctCode IN (${EXCL_EI}) THEN ${sign}t.LineTotal ELSE 0 END AS EI
FROM ${tbl} t
LEFT JOIN OITM t1 ON t.ItemCode = t1.ItemCode
LEFT JOIN OITB t2 ON t1.ItmsGrpCod = t2.ItmsGrpCod
INNER JOIN ${hdr} i ON t.DocEntry = i.DocEntry
WHERE ${dateWhere} ${extras}`;
}
// ── Fetch revenue data ─────────────────────────────────────────────────────
// options.date : 'YYYY-MM-DD' (default = yesterday)
// options.periodType : 'daily' | 'monthly' | 'yearly' | 'all' (default = 'all')
async function fetchRevenue(pool, options = {}) {
// Resolve target date
let target;
if (options.date) {
target = new Date(options.date);
if (isNaN(target)) target = new Date();
} else {
target = new Date();
target.setDate(target.getDate() - 1); // yesterday by default
}
const toDate = target.toISOString().split('T')[0];
const dd = target.getDate(), mm = target.getMonth() + 1, yyyy = target.getFullYear();
// Year start (mirrors stored procedure)
const yearStart = (dd < 16 && mm <= 1) ? `${yyyy - 1}-01-16` : `${yyyy}-01-16`;
// Month start
let monthStart;
if (dd < 16) {
const pMm = mm === 1 ? 12 : mm - 1;
const pYyyy = mm === 1 ? yyyy - 1 : yyyy;
monthStart = `${pYyyy}-${String(pMm).padStart(2, '0')}-16`;
} else {
monthStart = `${yyyy}-${String(mm).padStart(2, '0')}-16`;
}
const pt = (options.periodType || 'all').toLowerCase();
const periods = [];
if (pt === 'daily' || pt === 'all') periods.push([`Daily (${toDate})`, `i.DocDate = '${toDate}'`]);
if (pt === 'monthly' || pt === 'all') periods.push([`Monthly (${monthStart} to ${toDate})`, `i.DocDate BETWEEN '${monthStart}' AND '${toDate}'`]);
if (pt === 'yearly' || pt === 'all') periods.push([`Yearly (${yearStart} to ${toDate})`, `i.DocDate BETWEEN '${yearStart}' AND '${toDate}'`]);
const rows = [];
for (const [label, dw] of periods) {
const sql = `
SELECT
'${label}' AS Period,
ROUND(SUM(TM), 2) AS TM_BloodBag,
ROUND(SUM(PD), 2) AS PD_CAPD,
ROUND(SUM(ED), 2) AS ExportDirect,
ROUND(SUM(EI), 2) AS ExportIndirect,
ROUND(SUM(TM)+SUM(PD)+SUM(ED)+SUM(EI), 2) AS Total
FROM (
${buildRevenueSubq(dw, false)}
UNION ALL
${buildRevenueSubq(dw, true)}
) CK`;
try {
const r = await pool.request().query(sql);
if (r.recordset?.[0]) rows.push(r.recordset[0]);
} catch (e) {
rows.push({ Period: label, error: e.message });
}
}
return {
queriedDate: toDate,
note: 'TM = Blood Bag, PD = CAPD. All amounts in INR (₹).',
data: rows,
};
}
// ── Fetch open production orders ──────────────────────────────────────────
async function fetchOpenProductionOrders(pool, options = {}) {
const to = options.to_date || new Date().toISOString().split('T')[0];
const frDate = new Date(to);
frDate.setDate(frDate.getDate() - (options.days ? parseInt(options.days) - 1 : 14));
const from = options.from_date || frDate.toISOString().split('T')[0];
const statusClause = options.status && options.status !== 'all'
? `AND OWOR.Status = '${options.status === 'Released' ? 'R' : 'P'}'`
: `AND OWOR.Status IN ('R','P')`;
const sql = `
SELECT DISTINCT
OWOR.DocNum,
k.SeriesName,
CASE WHEN OWOR.Type = 'S' THEN 'Standard' ELSE 'Other' END AS Type,
CASE WHEN OWOR.Status = 'R' THEN 'Released' WHEN OWOR.Status = 'P' THEN 'Planned' ELSE 'Unknown' END AS Status,
OWOR.ItemCode AS ProductCode,
CASE
WHEN OITM.U_ItemSubGroup LIKE '%SFG Equipment%' OR OITM.U_ItemSubGroup LIKE '%EQUIPMENT%' THEN 'Equipment'
WHEN OITM.U_ItemSubGroup LIKE '%Consumable%' OR OITM.U_ItemSubGroup LIKE '%Consumables%' THEN 'Consumable'
WHEN OITM.U_ItemSubGroup LIKE '%FG CAPD ACCESSORIES%' THEN 'FG CAPD ACCESSORIES'
WHEN OITM.U_ItemSubGroup LIKE '%FG CAPD%' THEN 'FG CAPD'
WHEN OITM.U_ItemSubGroup LIKE '%CAPD%' THEN 'CAPD'
WHEN OITM.U_ItemSubGroup LIKE '%FG Blood Bag%' THEN 'FG Blood Bag'
WHEN OITM.U_ItemSubGroup LIKE '%SFG Blood bag%' THEN 'SFG Blood bag'
WHEN OITM.U_ItemSubGroup LIKE '%BLOOD BAG%' THEN 'BLOOD BAG'
WHEN OITM.U_ItemSubGroup LIKE '%FG Equipment%' THEN 'FG Equipment'
WHEN OITM.U_ItemSubGroup LIKE '%NEEDLE%' THEN 'NEEDLE'
WHEN OITM.U_ItemSubGroup LIKE '%MOULDING%' THEN 'MOULDING'
WHEN OITM.U_ItemSubGroup LIKE '%PLASTIC%' THEN 'PLASTIC'
ELSE 'Other'
END AS ProductSegment,
OWOR.ProdName AS ProductName,
OWOR.PlannedQty AS PlannedQty,
OWOR.CmpltQty AS CompletedQty,
CONVERT(VARCHAR(10), OWOR.PostDate, 105) AS PostDate,
CONVERT(VARCHAR(10), OWOR.DueDate, 105) AS DueDate
FROM OWOR
INNER JOIN WOR1 ON OWOR.DocEntry = WOR1.DocEntry
INNER JOIN OITM ON OWOR.ItemCode = OITM.ItemCode
LEFT JOIN NNM1 k ON k.Series = OWOR.Series
WHERE OWOR.PostDate BETWEEN '${from}' AND '${to}'
${statusClause}
AND OITM.U_ItemSubGroup <> 'FG Scrap'
ORDER BY OWOR.DocNum DESC`;
const r = await pool.request().query(sql);
const rows = r.recordset || [];
const today = new Date(); today.setHours(0,0,0,0);
const overdue = rows.filter(row => {
if (!row.DueDate) return false;
const [d, m, y] = row.DueDate.split('-');
return new Date(`${y}-${m}-${d}`) < today;
}).length;
const bySegment = {};
rows.forEach(row => {
const seg = row.ProductSegment || 'Other';
bySegment[seg] = (bySegment[seg] || 0) + 1;
});
return {
period: `${from} to ${to}`,
total: rows.length,
released: rows.filter(r => r.Status === 'Released').length,
planned: rows.filter(r => r.Status === 'Planned').length,
overdue,
bySegment,
orders: rows.slice(0, 50), // cap at 50 rows for AI context
};
}
// ── Fetch BOM cost data ────────────────────────────────────────────────────
async function fetchBom(pool, itemGroup) {
const grp = (itemGroup || 'FG BLOOD BAG').trim();
const r = await pool.request()
.input('grp', grp)
.query(`
SELECT T1.Code AS ItemCode, T0.ItemName, MIN(P.Price) AS Price
FROM OITT T1
INNER JOIN OITM T0 ON T0.ItemCode = T1.Code
LEFT JOIN ITM1 P ON P.ItemCode = T1.Code
LEFT JOIN OITB T2 ON T2.ItmsGrpCod = T0.ItmsGrpCod
WHERE T2.ItmsGrpNam = @grp AND P.Price > 0
GROUP BY T1.Code, T0.ItemName
ORDER BY T1.Code`);
return {
itemGroup: grp,
count: r.recordset?.length || 0,
items: r.recordset || [],
};
}
// ── Fetch FG inventory via stored procedure ───────────────────────────────
async function fetchFgInventory(pool, options = {}) {
const to = options.to_date || new Date().toISOString().split('T')[0];
const from = options.from_date || (() => {
const d = new Date(to);
return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split('T')[0];
})();
const warehouse = options.warehouse || '01';
const itemGroup = options.item_group || 'FG BLOOD BAG';
const r = await pool.request()
.input('FromDate', from)
.input('ToDate', to)
.input('WhsCode', warehouse)
.input('ItmsGrp', itemGroup)
.execute('INVENTORY_FG_AVG_PRICE');
const data = r.recordset || [];
return {
period: `${from} to ${to}`,
warehouse, itemGroup,
count: data.length,
summary: {
totalOpening: data.reduce((s, r) => s + (parseFloat(r.OpeningBalanceQTY) || 0), 0).toFixed(2),
totalReceivedProd: data.reduce((s, r) => s + (parseFloat(r.ReceivedFromPrd) || 0), 0).toFixed(2),
totalIssued: data.reduce((s, r) => s + (parseFloat(r.IssueStock) || 0), 0).toFixed(2),
totalBalance: data.reduce((s, r) => s + (parseFloat(r.Balance) || 0), 0).toFixed(2),
totalLiveStock: data.reduce((s, r) => s + (parseFloat(r.QtyInWhse) || 0), 0).toFixed(2),
},
items: data.slice(0, 40),
};
}
// ── Gemini tool definitions ────────────────────────────────────────────────
const TOOLS = [{
functionDeclarations: [
{
name: 'get_revenue_data',
description: 'Fetch sales revenue from SAP B1. Supports any specific date, month, or year. Always call this for questions about sales, revenue, or turnover — including specific dates like "17 May sales" or "16 May 2026". Pass the date in YYYY-MM-DD format and choose the appropriate period_type.',
parameters: {
type: 'object',
properties: {
date: {
type: 'string',
description: 'Target date in YYYY-MM-DD format. For "17 May sales" use "2026-05-17". Defaults to yesterday if omitted.',
},
period_type: {
type: 'string',
enum: ['daily', 'monthly', 'yearly', 'all'],
description: 'Which period to fetch: "daily" = just that date, "monthly" = 16th of prev/current month to that date, "yearly" = Jan 16 to that date, "all" = all three. Use "daily" when user asks about a specific date.',
},
},
},
},
{
name: 'get_bom_costs',
description: 'Fetch BOM (Bill of Materials) item codes, names, and standard prices for a given product group. Call this when user asks about BOM prices, item costs, or material rates.',
parameters: {
type: 'object',
properties: {
item_group: {
type: 'string',
description: 'Product group name. Valid values: "FG BLOOD BAG", "FG CAPD", "FG CAPD Accessories"',
},
},
required: ['item_group'],
},
},
{
name: 'get_fg_inventory',
description: 'Fetch FG (Finished Goods) inventory report from SAP B1. Returns opening balance, received from production, issues, stock transfers, closing balance and live stock for each item. Call when user asks about inventory, stock levels, FG stock, or warehouse stock.',
parameters: {
type: 'object',
properties: {
item_group: { type: 'string', description: 'Item group name. E.g. "FG BLOOD BAG", "FG CAPD", "FG CAPD Accessories".' },
warehouse: { type: 'string', description: 'Warehouse code. Default is "01".' },
from_date: { type: 'string', description: 'Start date YYYY-MM-DD. Defaults to 1st of current month.' },
to_date: { type: 'string', description: 'End date YYYY-MM-DD. Defaults to today.' },
},
},
},
{
name: 'get_open_production_orders',
description: 'Fetch open production orders from SAP B1. Returns Released and Planned orders with segment breakdown and overdue count. Call when user asks about production orders, open orders, what is in production, overdue orders, or production status.',
parameters: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['all', 'Released', 'Planned'],
description: 'Filter by order status. Default is "all".',
},
days: {
type: 'string',
description: 'Number of days to look back from today (e.g. "15" for last 15 days). Default is 15.',
},
from_date: {
type: 'string',
description: 'Start date in YYYY-MM-DD format. Use with to_date for a specific range.',
},
to_date: {
type: 'string',
description: 'End date in YYYY-MM-DD format.',
},
},
},
},
],
}];
// ── Execute a Gemini tool call ─────────────────────────────────────────────
async function executeTool(name, args) {
const pool = await getPool();
if (name === 'get_revenue_data') return fetchRevenue(pool, { date: args?.date, periodType: args?.period_type });
if (name === 'get_bom_costs') return fetchBom(pool, args?.item_group);
if (name === 'get_fg_inventory') return fetchFgInventory(pool, args || {});
if (name === 'get_open_production_orders') return fetchOpenProductionOrders(pool, args || {});
return { error: `Unknown tool: ${name}` };
}
// ── POST /api/chat/message ─────────────────────────────────────────────────
router.post('/message', verifyToken, async (req, res) => {
try {
const { message, history = [] } = req.body;
if (!message?.trim()) return res.status(400).json({ success: false, message: 'Message is required' });
if (!process.env.GEMINI_API_KEY) {
return res.status(503).json({ success: false, message: 'AI service not configured. Add GEMINI_API_KEY to .env' });
}
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
model: 'gemini-flash-lite-latest',
systemInstruction: SYSTEM_PROMPT,
tools: TOOLS,
});
const geminiHistory = history.slice(-20).map(m => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }],
}));
const chat = model.startChat({ history: geminiHistory });
let result = await chat.sendMessage(message.trim());
let response = result.response;
let reply = '';
// Gemini function-calling loop (up to 3 round-trips)
for (let i = 0; i < 3; i++) {
const parts = response.candidates?.[0]?.content?.parts || [];
const fnPart = parts.find(p => p.functionCall);
if (!fnPart) { reply = response.text(); break; }
const { name, args } = fnPart.functionCall;
let toolResult;
try {
toolResult = await executeTool(name, args);
} catch (e) {
toolResult = { error: e.message };
}
const followUp = await chat.sendMessage([{
functionResponse: { name, response: { result: toolResult } },
}]);
response = followUp.response;
}
if (!reply) {
try { reply = response.text(); } catch { reply = 'Sorry, I could not generate a response. Please try again.'; }
}
res.json({ success: true, reply });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+204
View File
@@ -0,0 +1,204 @@
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const store = () => require('../services/costingStore');
function defaultSheet(groupMask) {
return groupMask === 4 ? 'Revenue' : 'Other';
}
const GROUP_LABELS = { 1:'Assets', 2:'Liabilities', 3:'Equity', 4:'Revenue', 5:'Expenses' };
// GET /api/costing/trial-balance?from=&to=
router.get('/trial-balance', verifyToken, async (req, res) => {
try {
const { from, to } = req.query;
if (!from || !to)
return res.status(400).json({ success: false, message: 'from and to dates required' });
const [rows, overrides] = await Promise.all([
store().fetchAllTrialBalance(from, to),
store().getOverrides(from, to),
]);
const ovByCode = {};
overrides.forEach(o => { ovByCode[o.acct_code] = o; });
const data = rows.map(r => ({
acct_code: r.acct_code,
acct_name: r.acct_name,
group_mask: r.group_mask,
group_label: GROUP_LABELS[r.group_mask] || `Group ${r.group_mask}`,
opening_balance: Number(r.opening_balance) || 0,
period_debit: Number(r.period_debit) || 0,
period_credit: Number(r.period_credit) || 0,
closing_balance: Number(r.closing_balance) || 0,
provision: ovByCode[r.acct_code]?.override_val ?? null,
note: ovByCode[r.acct_code]?.note ?? '',
}));
// Stock values
const stockOpen = ovByCode['__STOCK_OPEN__']?.override_val ?? null;
const stockClose = ovByCode['__STOCK_CLOSE__']?.override_val ?? null;
res.json({ success: true, data, opening_stock: stockOpen, closing_stock: stockClose });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/costing/data?from=YYYY-MM-DD&to=YYYY-MM-DD
router.get('/data', verifyToken, async (req, res) => {
try {
const { from, to } = req.query;
if (!from || !to)
return res.status(400).json({ success: false, message: 'from and to dates required' });
const [tb, mapping, overrides] = await Promise.all([
store().fetchTrialBalance(from, to),
store().getMapping(),
store().getOverrides(from, to),
]);
const mapByCode = {};
mapping.forEach(m => { mapByCode[m.acct_code] = m; });
const ovByCode = {};
overrides.forEach(o => { ovByCode[o.acct_code] = o; });
const data = tb.map(row => ({
acct_code: row.acct_code,
acct_name: row.acct_name,
group_mask: row.group_mask,
opening_balance: Number(row.opening_balance) || 0,
period_debit: Number(row.period_debit) || 0,
period_credit: Number(row.period_credit) || 0,
closing_balance: Number(row.closing_balance) || 0,
sheet: mapByCode[row.acct_code]?.sheet ?? defaultSheet(row.group_mask),
sort_order: mapByCode[row.acct_code]?.sort_order ?? 999,
head: mapByCode[row.acct_code]?.head ?? '',
override_val: ovByCode[row.acct_code]?.override_val ?? null,
note: ovByCode[row.acct_code]?.note ?? '',
}));
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// POST /api/costing/mapping body: [{acct_code, sheet, sort_order}]
router.post('/mapping', verifyToken, async (req, res) => {
try {
const entries = Array.isArray(req.body) ? req.body : [req.body];
await store().saveMapping(entries);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/costing/stock?from=&to=
router.get('/stock', verifyToken, async (req, res) => {
try {
const { from, to } = req.query;
if (!from || !to) return res.status(400).json({ success: false, message: 'from and to required' });
const overrides = await store().getOverrides(from, to);
const open = overrides.find(o => o.acct_code === '__STOCK_OPEN__');
const close = overrides.find(o => o.acct_code === '__STOCK_CLOSE__');
res.json({ success: true, opening_stock: open?.override_val ?? 0, closing_stock: close?.override_val ?? 0 });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// POST /api/costing/stock body: {from, to, opening_stock, closing_stock}
router.post('/stock', verifyToken, async (req, res) => {
try {
const { from, to, opening_stock, closing_stock } = req.body;
if (!from || !to) return res.status(400).json({ success: false, message: 'from and to required' });
await Promise.all([
store().saveOverride('__STOCK_OPEN__', from, to, opening_stock ?? 0, null, req.user.username),
store().saveOverride('__STOCK_CLOSE__', from, to, closing_stock ?? 0, null, req.user.username),
]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/costing/stock-detail?from=&to=
router.get('/stock-detail', verifyToken, async (req, res) => {
try {
const { from, to } = req.query;
if (!from || !to) return res.status(400).json({ success: false, message: 'from and to required' });
const keys = ['__OPEN_RM__','__OPEN_WIP__','__OPEN_FG__','__OPEN_TRADE__','__OPEN_CONV__',
'__CLOSE_RM__','__CLOSE_WIP__','__CLOSE_FG__','__CLOSE_TRADE__','__CLOSE_CONV__'];
const overrides = await store().getOverrides(from, to);
const data = {};
keys.forEach(k => {
const ov = overrides.find(o => o.acct_code === k);
data[k] = ov ? Number(ov.override_val) || 0 : 0;
});
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// POST /api/costing/override body: {acct_code, from, to, override_val, note}
router.post('/override', verifyToken, async (req, res) => {
try {
const { acct_code, from, to, override_val, note } = req.body;
if (!acct_code || !from || !to)
return res.status(400).json({ success: false, message: 'acct_code, from, to required' });
await store().saveOverride(acct_code, from, to, override_val, note, req.user.username);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// DELETE /api/costing/override?acct_code=&from=&to=
router.delete('/override', verifyToken, async (req, res) => {
try {
const { acct_code, from, to } = req.query;
if (!acct_code || !from || !to)
return res.status(400).json({ success: false, message: 'acct_code, from, to required' });
await store().deleteOverride(acct_code, from, to);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/costing/provisions - all provisions ever entered, month-wise
router.get('/provisions', verifyToken, async (req, res) => {
try {
const data = await store().getAllOverrides();
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/costing/snapshots
router.get('/snapshots', verifyToken, async (req, res) => {
try {
const snaps = await store().getSnapshots();
res.json({ success: true, data: snaps });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// POST /api/costing/snapshot body: {from, to, name, data: {accounts, opening_stock, closing_stock, stock_detail}}
router.post('/snapshot', verifyToken, async (req, res) => {
try {
const { from, to, name, data } = req.body;
if (!from || !to || !data)
return res.status(400).json({ success: false, message: 'from, to, data required' });
await store().saveSnapshot(from, to, name || `${from} to ${to}`, JSON.stringify(data), req.user.username);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// GET /api/costing/snapshot/:id
router.get('/snapshot/:id', verifyToken, async (req, res) => {
try {
const snap = await store().getSnapshot(parseInt(req.params.id));
if (!snap) return res.status(404).json({ success: false, message: 'Snapshot not found' });
res.json({ success: true, data: JSON.parse(snap.data_json) });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// DELETE /api/costing/snapshot/:id
router.delete('/snapshot/:id', verifyToken, async (req, res) => {
try {
await store().deleteSnapshot(parseInt(req.params.id));
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+404
View File
@@ -0,0 +1,404 @@
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const costingStore = () => require('../services/costingStore');
// pl_account_map / pl_overrides live on the app's own database now — the
// ledger functions below used to JOIN them with OACT/JDT1 (real SAP data)
// in one query, which only worked when both lived on the same server. Now
// they fetch each side separately (SAP pool for OACT/JDT1, costingStore for
// the app tables) and merge in JS. This helper sums override_val for a set
// of account codes from costingStore.getOverrides()'s result.
function sumOverridesForCodes(overrides, codes) {
const set = new Set(codes);
return overrides
.filter(o => set.has(o.acct_code))
.reduce((s, o) => s + (parseFloat(o.override_val) || 0), 0);
}
const BB_GROUPS = ['FG BLOOD BAG'];
const CAPD_GROUPS = ['FG CAPD', 'FG CAPD ACCESSORIES'];
// Employee P&L accounts to exclude from cost
// 5103030xxx = S&M department accounts; 5240040xxx = other excluded codes
const EXCLUDE_EMP_CODES = [
'5103030001','5103030002','5103030003','5103030004','5103030005',
'5103030006','5103030008','5103030010','5103030012','5103030013',
'5240040001','5240040002','5240040003','5240040004','5240040005',
'5240040006','5240040008','5240040009','5240040010','5240040012',
'5240040013','5240040015',
];
// ── Production: stock transfer receipts into WH01 (TransType=67) via OINM ──
async function fetchProduction(pool, from, to) {
const r = await pool.request()
.input('from', from).input('to', to)
.query(`
SELECT O.ItemCode,
MAX(T0.ItemName) AS ItemDescription,
MAX(T2.ItmsGrpNam) AS ItemGroup,
SUM(O.InQty) AS ProductionQty
FROM OINM O
INNER JOIN OITM T0 ON T0.ItemCode = O.ItemCode
INNER JOIN OITB T2 ON T2.ItmsGrpCod = T0.ItmsGrpCod
WHERE O.TransType = 67
AND O.DocDate >= @from AND O.DocDate <= @to
AND O.Warehouse = '01'
AND T2.ItmsGrpNam IN ('FG BLOOD BAG','FG CAPD','FG CAPD ACCESSORIES')
GROUP BY O.ItemCode
ORDER BY MAX(T2.ItmsGrpNam), O.ItemCode
`);
return r.recordset || [];
}
// ── Sales: invoices minus credit notes ─────────────────────────────────────
async function fetchSales(pool, from, to) {
const r = await pool.request()
.input('from', from).input('to', to)
.query(`
SELECT ItemCode, SUM(SalesQty) AS SalesQty
FROM (
SELECT T0.ItemCode, SUM(T0.Quantity) AS SalesQty
FROM INV1 T0
INNER JOIN OINV T1 ON T1.DocEntry = T0.DocEntry
WHERE T1.DocDate >= @from AND T1.DocDate <= @to
AND T1.CANCELED = 'N' AND T0.ItemCode <> ''
GROUP BY T0.ItemCode
UNION ALL
SELECT T0.ItemCode, -SUM(T0.Quantity) AS SalesQty
FROM RIN1 T0
INNER JOIN ORIN T1 ON T1.DocEntry = T0.DocEntry
WHERE T1.DocDate >= @from AND T1.DocDate <= @to
AND T1.CANCELED = 'N' AND T0.ItemCode <> ''
GROUP BY T0.ItemCode
) S
GROUP BY ItemCode
`);
return r.recordset || [];
}
// ── BOM price per finished item (any price list, Price > 0) ─────────────────
async function fetchBOM(pool) {
const r = await pool.request().query(`
SELECT T1.Code AS ItemCode,
MIN(P.Price) AS BOMPrice
FROM OITT T1
INNER JOIN OITM T0 ON T0.ItemCode = T1.Code
LEFT JOIN ITM1 P ON P.ItemCode = T1.Code
LEFT JOIN OITB T2 ON T2.ItmsGrpCod = T0.ItmsGrpCod
WHERE T2.ItmsGrpNam IN ('FG BLOOD BAG','FG CAPD','FG CAPD ACCESSORIES')
AND P.Price > 0
GROUP BY T1.Code
`);
return r.recordset || [];
}
// ── Employee pool from P&L trial balance ────────────────────────────────────
// Formula: Employee-mapped accounts (excl. S&M names + listed codes)
// + MEDICAL EXPENSES (5190090026) + FESTIVAL EXPENSES (5190090029)
// + NET = period_debit - period_credit + provision (pl_overrides)
async function fetchEmployeePool(pool, from, to) {
try {
// pl_account_map (app DB): which accounts are on the "Employee" sheet.
const mapping = await costingStore().getMapping();
const empCodes = mapping
.filter(m => m.sheet === 'Employee' && !EXCLUDE_EMP_CODES.includes(m.acct_code))
.map(m => m.acct_code);
let net = 0;
if (empCodes.length) {
const req = pool.request().input('from', from).input('to', to);
const inList = empCodes.map((c, i) => { req.input(`e${i}`, c); return `@e${i}`; }).join(',');
const r = await req.query(`
SELECT
SUM(CASE WHEN T1.RefDate BETWEEN @from AND @to
THEN ISNULL(T1.Debit,0) - ISNULL(T1.Credit,0) ELSE 0 END) AS net
FROM OACT T0
LEFT JOIN JDT1 T1 ON T0.AcctCode = T1.Account
WHERE T0.AcctCode IN (${inList})
AND T0.AcctName NOT LIKE '%S&M%'
`);
net += parseFloat(r.recordset[0]?.net || 0);
}
// Medical Expenses + Festival Expenses: gross Debit only (not net)
const rMed = await pool.request()
.input('from', from).input('to', to)
.query(`
SELECT
SUM(CASE WHEN T1.RefDate BETWEEN @from AND @to
THEN ISNULL(T1.Debit,0) ELSE 0 END) AS net
FROM OACT T0
LEFT JOIN JDT1 T1 ON T0.AcctCode = T1.Account
WHERE T0.AcctCode IN ('5190090026', '5190090029')
`);
net += parseFloat(rMed.recordset[0]?.net || 0);
// pl_overrides (app DB): provisions for every matched account this period.
const overrides = await costingStore().getOverrides(from, to);
const prov = sumOverridesForCodes(overrides, [...empCodes, '5190090026', '5190090029']);
return net + prov;
} catch (_) {
return 0;
}
}
// Shared helper for the 4 fixed-account-list ledgers below: SAP debit-credit
// net for a set of account codes over the period, via the SAP pool.
async function fetchLedgerNet(pool, codes, from, to) {
const req = pool.request().input('fromDate', from).input('toDate', to);
const inList = codes.map((c, i) => { req.input(`c${i}`, c); return `@c${i}`; }).join(',');
const r = await req.query(`
SELECT
SUM(CASE WHEN T1.[RefDate] BETWEEN @fromDate AND @toDate
THEN ISNULL(T1.[Debit],0) - ISNULL(T1.[Credit],0) ELSE 0 END) AS net
FROM OACT T0
LEFT JOIN JDT1 T1 ON T0.[AcctCode] = T1.[Account]
WHERE T0.[AcctCode] IN (${inList})
`);
return parseFloat(r.recordset[0]?.net || 0);
}
// ── Boiler cost: mirrors P&L fetchTrialBalance formula exactly ───────────────
// NET = period_debit - period_credit + provision (pl_overrides.override_val)
async function fetchBoilerLedger(pool, from, to) {
try {
const codes = ['5110010010'];
const [net, overrides] = await Promise.all([
fetchLedgerNet(pool, codes, from, to),
costingStore().getOverrides(from, to),
]);
return net + sumOverridesForCodes(overrides, codes);
} catch (_) { return 0; }
}
// ── Power cost: GENSET (5110010008) + ELECTRICITY (5110010009) + provisions ───
async function fetchPowerLedger(pool, from, to) {
try {
const codes = ['5110010008', '5110010009'];
const [net, overrides] = await Promise.all([
fetchLedgerNet(pool, codes, from, to),
costingStore().getOverrides(from, to),
]);
return net + sumOverridesForCodes(overrides, codes);
} catch (_) { return 0; }
}
// ── R&D cost: R&D expense accounts + provisions ───────────────────────────────
async function fetchRdLedger(pool, from, to) {
try {
const codes = ['5240040018', '5240040019', '5240040020', '5240040021'];
const [net, overrides] = await Promise.all([
fetchLedgerNet(pool, codes, from, to),
costingStore().getOverrides(from, to),
]);
return net + sumOverridesForCodes(overrides, codes);
} catch (_) { return 0; }
}
// ── R&M cost: all Repair & Maintenance accounts + provisions ─────────────────
async function fetchRepairLedger(pool, from, to) {
try {
const codes = ['5190090010', '5190090011', '5190090012', '5190090013', '5190090028'];
const [net, overrides] = await Promise.all([
fetchLedgerNet(pool, codes, from, to),
costingStore().getOverrides(from, to),
]);
return net + sumOverridesForCodes(overrides, codes);
} catch (_) { return 0; }
}
// ── Split a pool into BB / CAPD using custom % or RM-based auto ──────────────
function splitPool(total, bbPctStr, bbRM, totalRM) {
const frac = bbPctStr != null
? Math.min(100, Math.max(0, parseFloat(bbPctStr) || 0)) / 100
: totalRM > 0 ? bbRM / totalRM : 0;
return [total * frac, total * (1 - frac)];
}
// ── Main data endpoint ───────────────────────────────────────────────────────
router.get('/data', verifyToken, async (req, res) => {
try {
const {
from, to, salaryPeriod, overrideEmpPool, overrideBoilerLedger, overridePowerLedger, overrideRepairLedger, overrideRdLedger,
bbEmpPct, bbQaPct, bbQcPct, bbBoilerPct, bbPowerPct, bbRepairPct, bbRdPct,
empPowerPct, qaPowerPct, qcPowerPct, boilerPowerPct, repairPowerPct, rdPowerPct,
} = req.query;
if (!from || !to)
return res.status(400).json({ success: false, message: 'from and to dates are required' });
const pool = await getPool();
const [production, sales, bom, liveEmpPool, liveBoilerLedger, livePowerLedger, liveRepairLedger, liveRdLedger] = await Promise.all([
fetchProduction(pool, from, to),
fetchSales(pool, from, to),
fetchBOM(pool),
overrideEmpPool != null ? Promise.resolve(null) : fetchEmployeePool(pool, from, to),
overrideBoilerLedger != null ? Promise.resolve(null) : fetchBoilerLedger(pool, from, to),
overridePowerLedger != null ? Promise.resolve(null) : fetchPowerLedger(pool, from, to),
overrideRepairLedger != null ? Promise.resolve(null) : fetchRepairLedger(pool, from, to),
overrideRdLedger != null ? Promise.resolve(null) : fetchRdLedger(pool, from, to),
]);
const boilerLedger = overrideBoilerLedger != null ? parseFloat(overrideBoilerLedger) : liveBoilerLedger;
const powerLedger = overridePowerLedger != null ? parseFloat(overridePowerLedger) : livePowerLedger;
const repairLedger = overrideRepairLedger != null ? parseFloat(overrideRepairLedger) : liveRepairLedger;
const rdLedger = overrideRdLedger != null ? parseFloat(overrideRdLedger) : liveRdLedger;
const plEmpPool = overrideEmpPool != null ? parseFloat(overrideEmpPool) : liveEmpPool;
const salesMap = {};
sales.forEach(s => { salesMap[s.ItemCode] = parseFloat(s.SalesQty) || 0; });
const bomMap = {};
bom.forEach(b => { bomMap[b.ItemCode] = parseFloat(b.BOMPrice) || 0; });
// Salary module: supports comma-separated multiple periods
let salBB = 0, salCAPD = 0, salQA = 0, salQC = 0, salBoiler = 0, salRD = 0, grandTotalSalary = 0;
if (salaryPeriod) {
try {
const ss = require('../services/salaryStore');
const periods = salaryPeriod.split(',').map(p => p.trim()).filter(Boolean);
for (const sp of periods) {
const summary = await ss.getSummary(sp);
// Case-insensitive lookup for each department
const dept = k => {
const entry = Object.entries(summary.total)
.find(([d]) => d.toLowerCase() === k);
return entry ? parseFloat(entry[1] || 0) : 0;
};
salBB += dept('blood bag');
salCAPD += dept('capd');
salQA += dept('qa');
salQC += dept('qc');
salBoiler += dept('boiler');
salRD += dept('r&d');
grandTotalSalary += Object.values(summary.total)
.reduce((s, v) => s + (parseFloat(v) || 0), 0);
}
} catch (_) {}
}
// Boiler: ledger (5110010010 + provision) + BOILER dept salary from salary module
const boilerSalary = salBoiler;
const totalBoiler = boilerLedger + boilerSalary;
// Power: GENSET (5110010008) + ELECTRICITY (5110010009) — no salary component
const totalPower = powerLedger;
// R&M: all Repair & Maintenance accounts — no salary component
const totalRepair = repairLedger;
// R&D: ledger (5240040018-21 + provisions) + R&D dept salary
const rdSalary = salRD;
const totalRd = rdLedger + rdSalary;
const bbItems = production.filter(p => BB_GROUPS.includes(p.ItemGroup));
const capdItems = production.filter(p => CAPD_GROUPS.includes(p.ItemGroup));
// RM Consumption per item = Production Qty × BOM Price × 115%
const rmOf = p =>
parseFloat(((parseFloat(p.ProductionQty) || 0) * (bomMap[p.ItemCode] || 0) * 1.15).toFixed(2));
// Section RM totals
const bbRM = bbItems.reduce((s, p) => s + rmOf(p), 0);
const capdRM = capdItems.reduce((s, p) => s + rmOf(p), 0);
const totalRM = bbRM + capdRM; // QA & QC use combined RM as base
// ── Distribute Power pool into other cost heads ───────────────────────────
const pctVal = v => Math.min(100, Math.max(0, parseFloat(v) || 0)) / 100;
const distPowerEmp = totalPower * pctVal(empPowerPct);
const distPowerQa = totalPower * pctVal(qaPowerPct);
const distPowerQc = totalPower * pctVal(qcPowerPct);
const distPowerBoiler = totalPower * pctVal(boilerPowerPct);
const distPowerRepair = totalPower * pctVal(repairPowerPct);
const distPowerRd = totalPower * pctVal(rdPowerPct);
const distPowerTotal = distPowerEmp + distPowerQa + distPowerQc + distPowerBoiler + distPowerRepair + distPowerRd;
const standalonePower = Math.max(0, totalPower - distPowerTotal);
// Adjusted pools: base + absorbed power share
const adjEmpPool = plEmpPool + distPowerEmp;
const adjQaPool = salQA + distPowerQa;
const adjQcPool = salQC + distPowerQc;
const adjBoilerPool = totalBoiler + distPowerBoiler;
const adjRepairPool = totalRepair + distPowerRepair;
const adjRdPool = totalRd + distPowerRd;
// ── Section pool allocation ────────────────────────────────────────────────
// Employee: custom % override OR salary-dept-ratio (BB/CAPD dept salary)
let bbEmpPool, capdEmpPool;
if (bbEmpPct != null) {
[bbEmpPool, capdEmpPool] = splitPool(adjEmpPool, bbEmpPct, bbRM, totalRM);
} else {
bbEmpPool = (salBB > 0 && grandTotalSalary > 0) ? adjEmpPool * salBB / grandTotalSalary : adjEmpPool;
capdEmpPool = (salCAPD > 0 && grandTotalSalary > 0) ? adjEmpPool * salCAPD / grandTotalSalary : 0;
}
// QA, QC, Boiler, Power: custom % override OR RM-proportional (auto)
const [bbQaPool, capdQaPool] = splitPool(adjQaPool, bbQaPct, bbRM, totalRM);
const [bbQcPool, capdQcPool] = splitPool(adjQcPool, bbQcPct, bbRM, totalRM);
const [bbBoilerPool, capdBoilerPool] = splitPool(adjBoilerPool, bbBoilerPct, bbRM, totalRM);
const [bbPowerPool, capdPowerPool] = splitPool(standalonePower,bbPowerPct, bbRM, totalRM);
const [bbRepairPool, capdRepairPool] = splitPool(adjRepairPool, bbRepairPct, bbRM, totalRM);
const [bbRdPool, capdRdPool] = splitPool(adjRdPool, bbRdPct, bbRM, totalRM);
// ── Build rows: per-item cost = sectionPool × itemRM / sectionRM ──────────
const buildRows = (items, sectionRM, empPool, qaPool, qcPool, boilerPool, powerPool, repairPool, rdPool) => items.map(p => {
const prodQty = parseFloat(p.ProductionQty) || 0;
const salesQty = salesMap[p.ItemCode] || 0;
const rmCost = rmOf(p);
const calc = pool => sectionRM > 0 ? parseFloat((pool * rmCost / sectionRM).toFixed(2)) : 0;
return {
itemCode: p.ItemCode,
itemDescription: p.ItemDescription,
itemGroup: p.ItemGroup,
productionQty: prodQty,
salesQty,
rmConsumption: rmCost,
employeeCost: calc(empPool),
qaCost: calc(qaPool),
qcCost: calc(qcPool),
boilerCost: calc(boilerPool),
powerCost: calc(powerPool),
repairCost: calc(repairPool),
rdCost: calc(rdPool),
};
});
const bloodbag = buildRows(bbItems, bbRM, bbEmpPool, bbQaPool, bbQcPool, bbBoilerPool, bbPowerPool, bbRepairPool, bbRdPool);
const capd = buildRows(capdItems, capdRM, capdEmpPool, capdQaPool, capdQcPool, capdBoilerPool, capdPowerPool, capdRepairPool, capdRdPool);
// Auto % for reference (returned so frontend can show baseline)
const rmBbPct = totalRM > 0 ? bbRM / totalRM * 100 : 0;
const empBbPct = grandTotalSalary > 0 ? salBB / grandTotalSalary * 100 : (bbRM > 0 ? rmBbPct : 0);
const pct = (part, total) => total > 0 ? part / total * 100 : 0;
const allocation = {
emp: { bbPool: bbEmpPool, capdPool: capdEmpPool, bbPct: pct(bbEmpPool, adjEmpPool), autoPct: empBbPct, powerAbsorbed: distPowerEmp },
qa: { bbPool: bbQaPool, capdPool: capdQaPool, bbPct: pct(bbQaPool, adjQaPool), autoPct: rmBbPct, powerAbsorbed: distPowerQa },
qc: { bbPool: bbQcPool, capdPool: capdQcPool, bbPct: pct(bbQcPool, adjQcPool), autoPct: rmBbPct, powerAbsorbed: distPowerQc },
boiler: { bbPool: bbBoilerPool, capdPool: capdBoilerPool, bbPct: pct(bbBoilerPool, adjBoilerPool), autoPct: rmBbPct, powerAbsorbed: distPowerBoiler },
power: { bbPool: bbPowerPool, capdPool: capdPowerPool, bbPct: pct(bbPowerPool, standalonePower), autoPct: rmBbPct, powerAbsorbed: 0 },
repair: { bbPool: bbRepairPool, capdPool: capdRepairPool, bbPct: pct(bbRepairPool, adjRepairPool), autoPct: rmBbPct, powerAbsorbed: distPowerRepair },
rd: { bbPool: bbRdPool, capdPool: capdRdPool, bbPct: pct(bbRdPool, adjRdPool), autoPct: rmBbPct, powerAbsorbed: distPowerRd },
};
res.json({
success: true,
data: { bloodbag, capd },
boilerInfo: { ledger: boilerLedger, salary: boilerSalary, total: totalBoiler },
powerInfo: { total: totalPower },
repairInfo: { total: totalRepair },
rdInfo: { ledger: rdLedger, salary: rdSalary, total: totalRd },
allocation,
});
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ── Salary periods (for the dropdown) ──────────────────────────────────────
router.get('/salary-periods', verifyToken, async (req, res) => {
try {
const periods = await require('../services/salaryStore').getPeriods();
res.json({ success: true, data: periods });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+288
View File
@@ -0,0 +1,288 @@
// backend/routes/customers.js — HANA-backed version
// Replaces the in-memory array store with SAP HANA (TEST_OIL_15122025.ZCUST_PORTAL)
const express = require('express');
const router = express.Router();
const { body, validationResult } = require('express-validator');
const { createCustomer, getNextCardCode } = require('../services/sapServiceLayer');
const { verifyToken, verifyAdmin } = require('../middleware/auth');
const store = require('../services/hanaStore'); // ← HANA persistence
const { resolve: resolveCompany } = require('../services/companyConfig');
function mapCurrency(label) {
return {
'Indian Rupee':'INR','US Dollar':'USD','Euro':'EUR',
'British Pound':'GBP','UAE Dirham':'AED',
}[label] || 'INR';
}
function mapCountryCode(name) {
return {
'India':'IN','United States':'US','United Kingdom':'GB',
'UAE':'AE','Singapore':'SG','Germany':'DE','Japan':'JP','Australia':'AU',
}[name] || 'IN';
}
// Fields the customer form / verifier can touch
const CUSTOMER_EDITABLE = [
'cardName','foreignName','typeOfBusiness','industry','mobile','email','website',
'contactFirst','contactLast','contactMobile','contactEmail','contactTitle',
'currency','gstin','pan','remarks',
'hasMsme','msmeNo','msmeType','msmeBType','attachments',
'billAddressName','billStreet','billBlock','billCity','billZip','billState','billCountry',
'shipAddressName','shipStreet','shipBlock','shipCity','shipZip','shipState','shipCountry',
'sameAsBill','allBillAddresses','allShipAddresses',
];
const MANAGER_EDITABLE = [
'mgrCardCodePrefix','mgrGroupCode','mgrGroup','mgrCurrency','mgrChain','mgrMainGroup',
'mgrBranch','mgrCountry','mgrCity','mgrZone','mgrArea','mgrSubarea','mgrCountryHead',
'mgrRsm','mgrAsm','mgrSo','mgrSr','mgrPromoter',
'mgrSalesEmployee','mgrSalesPersonCode',
'mgrSchemeType','mgrTerritory','mgrNotes',
'mgrCreditLimit',
'mgrPayTerms','mgrPayTermsCode',
'mgrArAccount','mgrArAccountName','mgrLanguage',
];
const ALL_EDITABLE = [...CUSTOMER_EDITABLE, ...MANAGER_EDITABLE];
// Build a patch object from request body — only keys in allowedFields
function extractPatch(body, fields) {
const patch = {};
fields.forEach(f => {
if (body[f] !== undefined) patch[f] = body[f];
});
return patch;
}
// ── POST /submit ──────────────────────────────────────────────────────────────
router.post('/submit', [
body('cardName').notEmpty().trim().withMessage('Company name is required'),
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('mobile').notEmpty().trim().withMessage('Mobile is required'),
body('contactFirst').notEmpty().trim().withMessage('Contact first name is required'),
body('contactLast').notEmpty().trim().withMessage('Contact last name is required'),
body('billStreet').notEmpty().trim().withMessage('Street is required'),
body('billCity').notEmpty().trim().withMessage('City is required'),
], async (req, res) => {
const errs = validationResult(req);
if (!errs.isEmpty()) return res.status(400).json({ success: false, errors: errs.array() });
const b = req.body;
const companyDB = resolveCompany(b.company);
try {
const customer = await store.insertCustomer({
customerType: b.customerType || 'B2B',
cardName: b.cardName,
foreignName: b.foreignName || '',
typeOfBusiness: b.typeOfBusiness || 'Company',
industry: b.industry || '',
mobile: b.mobile,
email: b.email,
website: b.website || '',
contactFirst: b.contactFirst,
contactLast: b.contactLast,
contactMobile: b.contactMobile || b.mobile,
contactEmail: b.contactEmail || b.email || '',
contactTitle: b.contactTitle || '',
billAddressName: b.billAddressName || b.cardName,
billStreet: b.billStreet,
billBlock: b.billBlock || '',
billCity: b.billCity,
billZip: b.billZip || '',
billState: b.billState || '',
billCountry: b.billCountry || 'India',
sameAsBill: b.sameAsBill || false,
shipAddressName: b.sameAsBill ? (b.billAddressName || b.cardName) : (b.shipAddressName || b.billAddressName || b.cardName),
shipStreet: b.sameAsBill ? b.billStreet : (b.shipStreet || b.billStreet),
shipBlock: b.sameAsBill ? b.billBlock : (b.shipBlock || b.billBlock || ''),
shipCity: b.sameAsBill ? b.billCity : (b.shipCity || b.billCity),
shipZip: b.sameAsBill ? b.billZip : (b.shipZip || b.billZip || ''),
shipState: b.sameAsBill ? b.billState : (b.shipState || b.billState || ''),
shipCountry: b.sameAsBill ? b.billCountry : (b.shipCountry || b.billCountry || 'India'),
allBillAddresses: Array.isArray(b.allBillAddresses) ? b.allBillAddresses : [],
allShipAddresses: Array.isArray(b.allShipAddresses) ? b.allShipAddresses : [],
currency: b.currency || 'Indian Rupee',
gstin: b.gstin || '',
pan: b.pan || '',
remarks: b.remarks || '',
hasMsme: b.hasMsme || false,
msmeNo: b.msmeNo || '',
msmeType: b.msmeType || '',
msmeBType: b.msmeBType|| '',
attachments: b.attachments || {},
// Manager defaults
mgrCardCodePrefix: 'CUSTA',
mgrArAccount: '1101001', mgrArAccountName: 'SUNDRY DEBTORS GT',
mgrCurrency: 'Indian Rupee', mgrLanguage: 'English (UK)',
}, companyDB);
const attKeys = Object.keys(b.attachments || {}).filter(k => b.attachments[k]);
console.log(`[APP] Submitted: ${b.cardName} (id=${customer.id}) | Attachments: ${attKeys.join(',') || 'none'}`);
res.json({ success: true, message: 'Submitted', id: customer.id });
} catch (err) {
console.error('[APP] submit error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
// ── GET list ──────────────────────────────────────────────────────────────────
router.get('/', verifyToken, async (req, res) => {
const st = (req.query.status || 'PENDING').toUpperCase();
const companyDB = resolveCompany(req.query.company);
try {
const data = await store.listByStatus(st, companyDB);
res.json({ success: true, data });
} catch (err) {
console.error('[APP] list error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
// ── GET single ────────────────────────────────────────────────────────────────
router.get('/:id', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.query.company);
try {
const c = await store.findById(req.params.id, companyDB);
if (!c) return res.status(404).json({ success: false, message: 'Not found' });
res.json({ success: true, data: c });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ── PATCH /verify ─────────────────────────────────────────────────────────────
router.patch('/:id/verify', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const c = await store.findById(req.params.id, companyDB);
if (!c) return res.status(404).json({ success: false, message: 'Not found' });
if (c.status !== 'PENDING')
return res.status(400).json({ success: false, message: 'Only PENDING can be verified. Current: ' + c.status });
const patch = extractPatch(req.body, ALL_EDITABLE);
patch.status = req.body.approved ? 'VERIFIED' : 'REJECTED';
patch.verifiedAt = new Date().toISOString();
await store.updateCustomer(c.id, patch, companyDB);
console.log(`[APP] ${c.id} (${c.cardName}) → ${patch.status}`);
res.json({ success: true, message: `Customer ${patch.status.toLowerCase()}` });
} catch (err) {
console.error('[APP] verify error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
// ── PATCH /approve ────────────────────────────────────────────────────────────
router.patch('/:id/approve', verifyToken, verifyAdmin, async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const c = await store.findById(req.params.id, companyDB);
if (!c) return res.status(404).json({ success: false, message: 'Not found' });
if (!req.body.approved) {
await store.updateCustomer(c.id, { status: 'REJECTED' }, companyDB);
return res.json({ success: true, message: 'Customer rejected' });
}
if (c.status !== 'VERIFIED')
return res.status(400).json({ success: false, message: `Must be VERIFIED. Current: ${c.status}` });
// Merge incoming manager edits into the record
const patch = extractPatch(req.body, ALL_EDITABLE);
const merged = { ...c, ...patch };
const prefix = merged.mgrCardCodePrefix || 'CUSTA';
const cardCode = await getNextCardCode(prefix, companyDB);
const payTermsGrpCode = merged.mgrPayTermsCode && !isNaN(parseInt(merged.mgrPayTermsCode))
? parseInt(merged.mgrPayTermsCode) : null;
const salesPersonCode = merged.mgrSalesPersonCode && !isNaN(parseInt(merged.mgrSalesPersonCode))
? parseInt(merged.mgrSalesPersonCode) : null;
const groupCode = merged.mgrGroupCode && !isNaN(parseInt(merged.mgrGroupCode))
? parseInt(merged.mgrGroupCode) : null;
console.log(`[APP] APPROVE ${cardCode}: PayTermsGrpCode=${payTermsGrpCode} SalesPersonCode=${salesPersonCode} GroupCode=${groupCode}`);
const result = await createCustomer({
cardCode,
cardName: merged.cardName,
currency: mapCurrency(merged.mgrCurrency || merged.currency),
phone1: merged.mobile,
email: merged.email,
website: merged.website,
creditLimit: parseFloat(merged.mgrCreditLimit) || 0,
remarks: merged.remarks,
typeOfBusiness: merged.typeOfBusiness,
groupCode,
payTermsGrpCode,
salesPersonCode,
contactFirst: merged.contactFirst,
contactLast: merged.contactLast,
contactMobile: merged.contactMobile || merged.mobile,
contactEmail: merged.contactEmail || merged.email,
contactTitle: merged.contactTitle,
billAddressName: merged.billAddressName,
billStreet: merged.billStreet,
billBlock: merged.billBlock,
billCity: merged.billCity,
billZip: merged.billZip,
billState: merged.billState,
billCountry: mapCountryCode(merged.billCountry),
shipAddressName: merged.shipAddressName,
shipStreet: merged.shipStreet,
shipBlock: merged.shipBlock,
shipCity: merged.shipCity,
shipZip: merged.shipZip,
shipState: merged.shipState,
shipCountry: mapCountryCode(merged.shipCountry),
allBillAddresses: merged.allBillAddresses || [],
allShipAddresses: merged.allShipAddresses || [],
mgrMainGroup: merged.mgrMainGroup,
mgrChain: merged.mgrChain,
mgrArAccount: merged.mgrArAccount,
hasMsme: merged.hasMsme,
msmeNo: merged.msmeNo || '',
msmeType: merged.msmeType || '',
msmeBType: merged.msmeBType|| '',
gstin: merged.gstin,
pan: merged.pan,
attachments: merged.attachments || {},
}, companyDB);
await store.updateCustomer(c.id, {
...patch,
status: 'APPROVED',
sapCardCode: cardCode,
approvedAt: new Date().toISOString(),
approvedBy: req.user.username,
sapAttachmentEntry: result?.attachmentEntry || null,
}, companyDB);
console.log(`[APP] ✅ ${merged.cardName} → SAP B1 as ${cardCode}`);
res.json({ success: true, message: 'Customer created in SAP B1!', cardCode, cardName: merged.cardName });
} catch (err) {
console.error('[APP] approve error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
// ── PATCH /draft ──────────────────────────────────────────────────────────────
router.patch('/:id/draft', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const c = await store.findById(req.params.id, companyDB);
if (!c) return res.status(404).json({ success: false, message: 'Not found' });
if (c.status === 'APPROVED' || c.status === 'REJECTED')
return res.status(400).json({ success: false, message: 'Cannot edit ' + c.status + ' records' });
const patch = extractPatch(req.body, ALL_EDITABLE);
await store.updateCustomer(c.id, patch, companyDB);
console.log(`[APP] Draft saved: id=${c.id} (${c.cardName})`);
res.json({ success: true, message: 'Draft saved' });
} catch (err) {
console.error('[APP] draft error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
// ── GET /api/general-ledger/accounts ─────────────────────────────────────────
router.get('/accounts', verifyToken, async (_req, res) => {
try {
const pool = await getPool();
const result = await pool.request().query(`
SELECT AcctCode, AcctName, GroupMask,
CASE GroupMask
WHEN 1 THEN 'Assets'
WHEN 2 THEN 'Liabilities'
WHEN 3 THEN 'Equity'
WHEN 4 THEN 'Revenue'
WHEN 5 THEN 'Expenditure'
ELSE 'Group ' + CAST(GroupMask AS VARCHAR)
END AS GroupLabel
FROM OACT
ORDER BY GroupMask, AcctCode
`);
res.json({ success: true, data: result.recordset });
} catch (err) {
console.error('[GeneralLedger] accounts error:', err);
res.status(500).json({ success: false, message: err.message });
}
});
// ── GET /api/general-ledger/data ─────────────────────────────────────────────
router.get('/data', verifyToken, async (req, res) => {
try {
const { from, to, accounts } = req.query;
if (!from || !to) return res.status(400).json({ success: false, message: 'from and to dates required' });
if (!accounts) return res.status(400).json({ success: false, message: 'accounts required' });
// Sanitise: only allow numeric account codes (prevent SQL injection)
const acctList = accounts.split(',').map(c => c.trim()).filter(c => /^\d+$/.test(c));
if (acctList.length === 0) return res.status(400).json({ success: false, message: 'No valid account codes provided' });
const inClause = acctList.map(c => `'${c}'`).join(',');
const pool = await getPool();
// Opening balances
const obResult = await pool.request()
.input('from', from)
.query(`
SELECT T0.Account AS acct_code,
SUM(ISNULL(T0.Debit,0) - ISNULL(T0.Credit,0)) AS opening_balance
FROM JDT1 T0
WHERE T0.RefDate < @from
AND T0.Account IN (${inClause})
GROUP BY T0.Account
`);
const openingBalances = {};
for (const row of obResult.recordset) {
openingBalances[row.acct_code] = row.opening_balance;
}
// Transaction data
const txResult = await pool.request()
.input('from', from)
.input('to', to)
.query(`
SELECT
T2.AcctCode AS acct_code,
T2.AcctName AS acct_name,
T0.RefDate AS posting_date,
T0.DueDate AS due_date,
T0.TaxDate AS document_date,
T1.Series AS series,
T1.Number AS doc_no,
T0.TransId AS trans_no,
T0.Line_ID AS seq_no,
T0.LineMemo AS remarks,
T0.ContraAct AS offset_acct,
T3.AcctName AS offset_acct_name,
(T0.Debit - T0.Credit) AS deb_cred,
T0.Debit AS debit,
T0.Credit AS credit,
SUM(T0.Debit - T0.Credit) OVER (
PARTITION BY T0.Account
ORDER BY T0.RefDate, T0.TransId, T0.Line_ID
) AS cumulative_balance,
T0.Ref1 AS ref1
FROM JDT1 T0
INNER JOIN OJDT T1 ON T0.TransId = T1.TransId
LEFT JOIN OACT T2 ON T0.Account = T2.AcctCode
LEFT JOIN OACT T3 ON T0.ContraAct = T3.AcctCode
WHERE T0.RefDate BETWEEN @from AND @to
AND T0.Account IN (${inClause})
ORDER BY T0.Account, T0.RefDate, T0.TransId, T0.Line_ID
`);
res.json({ success: true, data: txResult.recordset, openingBalances });
} catch (err) {
console.error('[GeneralLedger] data error:', err);
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+1224
View File
File diff suppressed because it is too large Load Diff
+1021
View File
File diff suppressed because it is too large Load Diff
+299
View File
@@ -0,0 +1,299 @@
'use strict';
const express = require('express');
const router = express.Router();
const https = require('https');
const axios = require('axios');
const FormData = require('form-data');
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
// ── TCS GSP config ────────────────────────────────────────────────────────
const CFG = {
M2JW: {
mappingCd: process.env.ITC04_M2JW_MAPPING_CD || 'MFHGSTTN0000880',
templateCd: process.env.ITC04_M2JW_TEMPLATE_CD || 'ITC04M2JWTemplate880',
},
JW2M: {
mappingCd: process.env.ITC04_JW2M_MAPPING_CD || 'MFHGSTTN0000881',
templateCd: process.env.ITC04_JW2M_TEMPLATE_CD || 'ITC04JW2MTemplate881',
},
};
// DB column name → CSV header name mapping
// (desc is a SQL reserved word; SQL returns it as desc_field, CSV header must be desc)
const COL_MAP = [
['trans_type_code','trans_type_code'],
['fp', 'fp'],
['ack_sr_no', 'ack_sr_no'],
['self_gstin', 'self_gstin'],
['resultflag', 'resultflag'],
['ctin', 'ctin'],
['ctin_name', 'ctin_name'],
['jw_stcd', 'jw_stcd'],
['chnum', 'chnum'],
['chdt', 'chdt'],
['goods_ty', 'goods_ty'],
['uqc', 'uqc'],
['qty', 'qty'],
['desc_field', 'desc'], // SQL alias → TCS CSV header
['txval', 'txval'],
['tx_i', 'tx_i'],
['tx_c', 'tx_c'],
['tx_s', 'tx_s'],
['tx_cs', 'tx_cs'],
];
const COLS = COL_MAP.map(([db]) => db);
// ── Quarter period helpers ─────────────────────────────────────────────────
// SQL generates fp as MMYYYY (e.g. 042026 = Q1 Apr-Jun 2026)
// TCS GSP URL requires a different code: 13yyyy / 14yyyy / 15yyyy / 16yyyy
function toTcsPeriod(fp) {
// fp = 'MMYYYY' e.g. '042026'
const mm = parseInt(fp.substring(0, 2), 10);
const yyyy = fp.substring(2);
let code;
if (mm === 4) code = '13'; // Q1 Apr-Jun
else if (mm === 7) code = '14'; // Q2 Jul-Sep
else if (mm === 10) code = '15'; // Q3 Oct-Dec
else if (mm === 1) code = '16'; // Q4 Jan-Mar
else code = '13'; // fallback
return code + yyyy; // e.g. '132026'
}
// ── SQL query builders ────────────────────────────────────────────────────
function buildSQL(transType, series, resultflag = 'A') {
const selfGstin = process.env.TCS_GSP_GSTIN || '06AAACM4564C1ZT';
const flag = ['A','M','D'].includes(resultflag) ? resultflag : 'A';
return `
SELECT
'${transType}' AS trans_type_code,
-- TCS format: Q1=13yyyy, Q2=14yyyy, Q3=15yyyy, Q4=16yyyy
CAST(
CASE
WHEN MONTH(T0.DocDate) BETWEEN 4 AND 6 THEN '13'
WHEN MONTH(T0.DocDate) BETWEEN 7 AND 9 THEN '14'
WHEN MONTH(T0.DocDate) BETWEEN 10 AND 12 THEN '15'
ELSE '16'
END
+ CAST(
CASE WHEN MONTH(T0.DocDate) >= 4 THEN YEAR(T0.DocDate)
ELSE YEAR(T0.DocDate) - 1
END AS VARCHAR(4))
AS VARCHAR(6)) AS fp,
ROW_NUMBER() OVER (ORDER BY T0.DocEntry, T2.LineNum) AS ack_sr_no,
'${selfGstin}' AS self_gstin,
'${flag}' AS resultflag,
CASE WHEN CRD1.GSTRegnNo IS NOT NULL AND CRD1.GSTRegnNo <> ''
THEN CRD1.GSTRegnNo ELSE '' END AS ctin,
T0.CardName AS ctin_name,
CASE WHEN CRD1.GSTRegnNo IS NULL OR CRD1.GSTRegnNo = ''
THEN T0.U_State ELSE '' END AS jw_stcd,
CAST(T0.DocNum AS VARCHAR(16)) AS chnum,
CONVERT(VARCHAR(10), T0.DocDate, 105) AS chdt,
'8b' AS goods_ty,
T2.unitMsr AS uqc,
CAST(T2.Quantity AS DECIMAL(15,2)) AS qty,
-- Cast to VARCHAR, truncate to 70 chars, fall back to ItemCode if empty
CAST(LEFT(ISNULL(NULLIF(LTRIM(RTRIM(T2.Dscription)), ''), T2.ItemCode), 70) AS VARCHAR(70)) AS desc_field,
CAST(T2.LineTotal AS DECIMAL(11,2)) AS txval,
CAST(ISNULL(T2.U_IGST_Rate1, 0) AS DECIMAL(11,2)) AS tx_i,
CAST(ISNULL(T2.U_CGST_RATE1, 0) AS DECIMAL(11,2)) AS tx_c,
CAST(ISNULL(T2.U_SGST_RATE1, 0) AS DECIMAL(11,2)) AS tx_s,
CAST(0.00 AS DECIMAL(11,2)) AS tx_cs
FROM OWTR T0
INNER JOIN WTR1 T2 ON T0.DocEntry = T2.DocEntry
LEFT JOIN NNM1 T1 ON T0.Series = T1.Series
LEFT JOIN CRD1
ON CRD1.CardCode = T0.CardCode
AND CRD1.AdresType = 'S'
AND CRD1.Address = T0.ShipToCode
WHERE T0.DocDate BETWEEN @from AND @to
AND T1.SeriesName LIKE '${series}%'
ORDER BY T0.DocEntry, T2.LineNum`;
}
// ── Fetch data from SAP ───────────────────────────────────────────────────
async function fetchData(pool, from, to, type, resultflag = 'A') {
const series = type === 'JW2M' ? 'JWR' : 'JW';
const sql = buildSQL(type, series, resultflag);
const r = await pool.request()
.input('from', from)
.input('to', to)
.query(sql);
return r.recordset || [];
}
// ── Sanitize description for TCS ITC04 ────────────────────────────────────
// TCS CLNDESC validator rejects: & ( ) . and other special chars.
// Allowed: A-Z a-z 0-9 space / - , _ '
function sanitizeDesc(v) {
return String(v || '')
.replace(/[\r\n\t]/g, ' ') // newlines/tabs → space
.replace(/[^\x20-\x7E]/g, '') // strip non-ASCII
.replace(/&/g, 'and') // & → and
.replace(/\(/g, ' ') // ( → space
.replace(/\)/g, ' ') // ) → space
.replace(/\./g, ' ') // . → space (TCS rejects decimal points in desc)
.replace(/[^A-Za-z0-9 /,\-_']/g, ' ') // strip remaining special chars
.replace(/\s+/g, ' ') // collapse multiple spaces
.trim()
.substring(0, 50); // TCS ITC04 template: max 50 chars
}
// Numeric columns — written as plain numbers, no quotes ever
const NUMERIC_COLS = new Set(['ack_sr_no','qty','txval','tx_i','tx_c','tx_s','tx_cs']);
// ── Build CSV buffer ──────────────────────────────────────────────────────
function buildCsv(rows) {
const header = COL_MAP.map(([, csvName]) => csvName).join(',');
const lines = rows.map(r =>
COL_MAP.map(([dbCol]) => {
const raw = r[dbCol] ?? '';
if (NUMERIC_COLS.has(dbCol)) return raw; // plain number, no quoting
const v = dbCol === 'desc_field' ? sanitizeDesc(raw) : String(raw).trim();
// TCS does not expect RFC-quoted CSV — strip commas instead of quoting
return v.replace(/,/g, ' ');
}).join(',')
);
return Buffer.from([header, ...lines].join('\r\n'), 'utf8');
}
// ── TCS auth token ────────────────────────────────────────────────────────
async function getTcsToken() {
const baseUrl = process.env.TCS_GSP_BASE_URL || 'https://g31.tcsgsp.in';
const username = process.env.TCS_GSP_USERNAME || '';
const password = process.env.TCS_GSP_PASSWORD || '';
const resp = await axios.get(
`${baseUrl}/Tax-Tool-Core/services/accessMgmt/generateToken`,
{ headers: { 'Content-Type': 'application/json', username, password }, httpsAgent }
);
const token = resp.data?.access_token || resp.data?.token || resp.data?.accessToken;
if (!token) throw new Error('TCS token missing: ' + JSON.stringify(resp.data));
return token;
}
// ── GET /api/itc04/data ───────────────────────────────────────────────────
router.get('/data', verifyToken, async (req, res) => {
try {
const { from, to, type = 'M2JW', resultflag = 'A' } = req.query;
if (!from || !to) return res.status(400).json({ success: false, message: 'from and to dates are required' });
if (!['M2JW','JW2M'].includes(type)) return res.status(400).json({ success: false, message: 'type must be M2JW or JW2M' });
const pool = await getPool();
const rows = await fetchData(pool, from, to, type, resultflag);
const total = rows.reduce((s, r) => s + (parseFloat(r.txval) || 0), 0);
res.json({ success: true, data: rows, count: rows.length, totalValue: parseFloat(total.toFixed(2)) });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ── GET /api/itc04/download?from=&to=&type= ───────────────────────────────
router.get('/download', verifyToken, async (req, res) => {
try {
const { from, to, type = 'M2JW', resultflag = 'A', period } = req.query;
if (!from || !to) return res.status(400).json({ success: false, message: 'from and to required' });
const pool = await getPool();
const rows = await fetchData(pool, from, to, type, resultflag);
if (!rows.length) return res.status(404).json({ success: false, message: 'No data found for the selected period' });
const fp = period || rows[0]?.fp || 'period';
const csv = buildCsv(rows);
const filename = `ITC04_${type}_${fp}.csv`;
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(csv);
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ── POST /api/itc04/upload ────────────────────────────────────────────────
router.post('/upload', verifyToken, async (req, res) => {
try {
const { from, to, type = 'M2JW', resultflag = 'A', period } = req.body;
if (!from || !to) return res.status(400).json({ success: false, message: 'from and to dates are required' });
const baseUrl = process.env.TCS_GSP_BASE_URL || 'https://g31.tcsgsp.in';
const gstin = process.env.TCS_GSP_GSTIN || '';
const clientCode = process.env.TCS_GSP_CLIENT_CODE || '';
const cfg = CFG[type] || CFG.M2JW;
if (!gstin || !clientCode) return res.status(400).json({ success: false, message: 'TCS_GSP_GSTIN and TCS_GSP_CLIENT_CODE must be set in .env' });
const pool = await getPool();
const rows = await fetchData(pool, from, to, type, resultflag);
if (!rows.length) return res.status(404).json({ success: false, message: 'No data found for the selected period' });
// Use manually entered period if provided, otherwise derive from data
const fp = (period && /^\d{6}$/.test(period)) ? period : (rows[0]?.fp || '132026');
const csv = buildCsv(rows);
const token = await getTcsToken();
console.log(`[ITC04] fp=${fp} records=${rows.length}`);
const form = new FormData();
form.append('upldGstinLst', `"${gstin}"`);
form.append('file', csv, { filename: `ITC04_${type}_${fp}.csv`, contentType: 'text/csv' });
const url = `${baseUrl}/Tax-Tool-Core/services/auth/invoiceUpload/challanUploadITC04CSV/${clientCode}/${cfg.mappingCd}/${cfg.templateCd}/${gstin}/CSV/${fp}?oprFlag=SALES`;
const uploadResp = await axios.post(url, form, {
headers: {
...form.getHeaders(),
authorization: `Bearer ${token}`,
clientCode,
gstin,
},
httpsAgent,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
const d = uploadResp.data;
console.log('[ITC04] Upload HTTP status:', uploadResp.status);
console.log('[ITC04] Upload response :', JSON.stringify(d, null, 2));
// TCS GSP can return ackNo at root level OR nested inside data/result/response
function extractAckNo(obj) {
if (!obj || typeof obj !== 'object') return null;
const keys = ['ackNo','AckNo','ack_no','ackno','acknowledgeNo','acknowledgementNo','ackNumber'];
for (const k of keys) {
if (obj[k] != null && obj[k] !== '') return String(obj[k]);
}
// Check one level deeper (data / result / response wrappers)
for (const wrap of ['data','result','response','Result','Data']) {
if (obj[wrap] && typeof obj[wrap] === 'object') {
const nested = extractAckNo(obj[wrap]);
if (nested) return nested;
}
}
return null;
}
const ackNo = extractAckNo(d);
const status = d.Status || d.status || d.statusCd || uploadResp.status;
const msg = d.Message || d.message || d.msg || 'Upload submitted';
// Treat statusCd 400 as error
if (String(status) === '400') {
return res.status(400).json({ success: false, message: msg, raw: d });
}
res.json({ success: true, ackNo, status, message: msg, raw: d });
} catch (err) {
const msg = err.response?.data ? JSON.stringify(err.response.data) : err.message;
console.error('[ITC04] Upload error:', msg);
res.status(500).json({ success: false, message: msg });
}
});
module.exports = router;
+258
View File
@@ -0,0 +1,258 @@
// backend/routes/itemApproval.js
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
let _store = null;
function getStore() {
if (!_store) _store = require('../services/hanaItemStore');
return _store;
}
let _sapSvc = null;
function getSap() {
if (!_sapSvc) _sapSvc = require('../services/sapServiceLayer');
return _sapSvc;
}
const cq = (req) => req.query?.company || req.body?.company || null;
async function pushItemToSap(data, co) {
const sap = getSap();
const payload = buildSapPayload(data);
await sap.sapRequest('POST', 'Items', payload, co);
if (data.DefaultWarehouse) {
try {
await sap.sapRequest('PATCH',
`Items('${encodeURIComponent(payload.ItemCode)}')`,
{ DefaultWarehouse: data.DefaultWarehouse },
co
);
} catch (we) {
console.warn('[ITEM-APPROVAL] DefaultWarehouse PATCH failed (non-fatal):', we.message);
}
}
return payload;
}
// ── Build SAP item payload from stored itemData ────────────────────────────────
function buildSapPayload(data) {
const d = data || {};
const payload = {
ItemCode: (d.ItemCode || '').trim().toUpperCase(),
ItemName: (d.ItemName || '').trim(),
ItemType: d.ItemType || 'itItems',
ItemClass: d.ItemClass || 'itcMaterial',
InventoryItem: d.InventoryItem || 'tYES',
SalesItem: d.SalesItem || 'tYES',
PurchaseItem: d.PurchaseItem || 'tYES',
VatLiable: 'tYES',
Valid: 'tYES',
Frozen: d.Frozen || 'tNO',
ManageSerialNumbers: d.ManageSerialNumbers || 'tNO',
ManageBatchNumbers: d.ManageBatchNumbers || 'tNO',
SRIAndBatchManageMethod: d.SRIAndBatchManageMethod || 'bomm_OnEveryTransaction',
WTLiable: d.WTLiable || 'tNO',
NoDiscounts: d.NoDiscounts || 'tNO',
TreeType: d.TreeType || 'iNotATree',
AssetItem: 'tNO',
GLMethod: d.GLMethod || 'glm_ItemClass',
CostAccountingMethod: d.CostAccountingMethod || 'bis_MovingAverage',
TaxType: 'tt_Yes',
IssueMethod: d.IssueMethod || 'im_Manual',
PlanningSystem: d.PlanningSystem || 'bop_None',
ProcurementMethod: d.ProcurementMethod || 'bom_Buy',
ComponentWarehouse: d.ComponentWarehouse || 'bomcw_BOM',
MaterialType: d.MaterialType || 'mt_FinishedGoods',
ProductSource: d.ProductSource || '0',
ManageStockByWarehouse: 'tNO',
InCostRollup: d.InCostRollup || 'tYES',
};
if (d.ItemsGroupCode) payload.ItemsGroupCode = parseInt(d.ItemsGroupCode);
if (d.UoMGroupEntry) payload.UoMGroupEntry = parseInt(d.UoMGroupEntry);
if (d.InventoryUOM) payload.InventoryUOM = String(d.InventoryUOM).toUpperCase();
if (d.SalesUnit) payload.SalesUnit = String(d.SalesUnit).toUpperCase();
if (d.PurchaseUnit) payload.PurchaseUnit = String(d.PurchaseUnit).toUpperCase();
// accept both form-style keys and frontend SAP-style keys
const gst = d.GSTRelevant || d.GSTRelevnt;
if (gst) payload.GSTRelevnt = gst;
if (d.GSTTaxCategory) payload.GSTTaxCategory = d.GSTTaxCategory;
if (d.Excisable) payload.Excisable = d.Excisable;
if (d.AssessableValue) payload.AssessableValue = Number(d.AssessableValue) || 0;
if (d.HsnCode && /^\d+$/.test(String(d.HsnCode))) payload.ChapterID = parseInt(d.HsnCode);
else if (d.ChapterID) payload.ChapterID = d.ChapterID;
if (d.DefaultWarehouse) payload.DefaultWarehouse = d.DefaultWarehouse;
if (d.DefaultVendor) payload.Mainsupplier = d.DefaultVendor;
else if (d.Mainsupplier) payload.Mainsupplier = d.Mainsupplier;
if (d.LeadTime) payload.LeadTime = parseInt(d.LeadTime);
if (d.UserText) payload.User_Text = d.UserText;
else if (d.User_Text) payload.User_Text = d.User_Text;
if (d.ProductionStandardCost) payload.ProdStdCost = Number(d.ProductionStandardCost) || 0;
else if (d.ProdStdCost) payload.ProdStdCost = Number(d.ProdStdCost) || 0;
if (d.PurchaseQtyPerPackage) payload.PurchaseQtyPerPackUnit = Number(d.PurchaseQtyPerPackage) || 1;
else if (d.PurchaseQtyPerPackUnit) payload.PurchaseQtyPerPackUnit = Number(d.PurchaseQtyPerPackUnit) || 1;
if (d.PurchaseItemsPerUnit) payload.PurchaseItemsPerUnit = Number(d.PurchaseItemsPerUnit) || 1;
if (d.SalesQtyPerPackage) payload.SalesQtyPerPackUnit = Number(d.SalesQtyPerPackage) || 1;
else if (d.SalesQtyPerPackUnit) payload.SalesQtyPerPackUnit = Number(d.SalesQtyPerPackUnit) || 1;
if (d.MinimumOrderQuantity) payload.MinOrderQuantity = Number(d.MinimumOrderQuantity) || 0;
else if (d.MinOrderQuantity) payload.MinOrderQuantity = Number(d.MinOrderQuantity) || 0;
if (d.MinInventory !== undefined) payload.MinInventory = Number(d.MinInventory) || 0;
if (d.MaxInventory !== undefined) payload.MaxInventory = Number(d.MaxInventory) || 0;
if (d.DesiredInventory !== undefined) payload.DesiredInventory = Number(d.DesiredInventory) || 0;
// Remove blank/null keys
Object.keys(payload).forEach(k => {
if (payload[k] === '' || payload[k] === null || payload[k] === undefined) delete payload[k];
});
return payload;
}
// ── POST /api/item-approvals/submit ─────────────────────────────────────────
// admin/sap_adder → push directly to SAP (no approval queue)
// all other roles → save as PENDING for admin review
router.post('/submit', verifyToken, async (req, res) => {
try {
const store = getStore();
const b = req.body;
if (!b.ItemCode || !b.ItemName) {
return res.status(400).json({ success: false, message: 'ItemCode and ItemName are required' });
}
const { company, ...itemData } = b;
const itemCode = b.ItemCode.trim().toUpperCase();
const itemName = b.ItemName.trim();
const co = company || null;
const baseItem = {
itemCode, itemName, itemData,
submittedBy: req.user.username,
submittedByName: req.user.name || req.user.username,
company: company || '',
};
if (req.user.role === 'admin' || req.user.role === 'sap_adder') {
const payload = await pushItemToSap(itemData, co);
const logEntry = {
username: req.user.username, name: req.user.name || req.user.username,
role: req.user.role, action: 'approve', comment: 'Direct push',
timestamp: new Date().toISOString(),
};
const request = await store.insertItem({ ...baseItem, status: 'PENDING', approvalLog: [logEntry] });
await store.updateItem(request.id, {
status: 'SAP_PUSHED',
approvedBy: req.user.username,
approvedAt: new Date().toISOString(),
sapItemCode: payload.ItemCode,
});
console.log(`[ITEM-APPROVAL] ✅ Direct push — Item ${payload.ItemCode} created in SAP`);
return res.json({ success: true, message: `Item ${payload.ItemCode} created in SAP B1!`, id: request.id, status: 'SAP_PUSHED', itemCode: payload.ItemCode });
}
// user / manager / sr_manager — queue for approval
const request = await store.insertItem({ ...baseItem, status: 'PENDING', approvalLog: [] });
res.json({ success: true, message: 'Item submitted for admin approval', id: request.id, status: 'PENDING' });
} catch (err) {
console.error('[ITEM-APPROVAL] submit error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
// ── GET /api/item-approvals ──────────────────────────────────────────────────
router.get('/', verifyToken, async (req, res) => {
try {
const store = getStore();
const status = (req.query.status || 'ALL').toUpperCase();
const company = cq(req);
// 'user' role can only see their own submissions
const submittedBy = req.user.role === 'user' ? req.user.username : null;
const data = await store.listByStatus(status, company, submittedBy);
res.json({ success: true, data });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ── GET /api/item-approvals/:id ──────────────────────────────────────────────
router.get('/:id', verifyToken, async (req, res) => {
try {
const item = await getStore().findById(req.params.id);
if (!item) return res.status(404).json({ success: false, message: 'Not found' });
res.json({ success: true, data: item });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ── PATCH /api/item-approvals/:id/action ────────────────────────────────────
// action: 'approve' | 'reject'
// Admin can also pass editedData to override item fields before pushing
router.patch('/:id/action', verifyToken, async (req, res) => {
if (req.user.role !== 'admin' && req.user.role !== 'sap_adder') {
return res.status(403).json({ success: false, message: 'Only admin can approve items' });
}
try {
const store = getStore();
const item = await store.findById(req.params.id);
if (!item) return res.status(404).json({ success: false, message: 'Not found' });
const { action, comment, editedData } = req.body;
if (!['approve', 'reject'].includes(action)) {
return res.status(400).json({ success: false, message: 'action must be approve or reject' });
}
if (item.status === 'SAP_PUSHED') {
return res.status(400).json({ success: false, message: 'Item already pushed to SAP' });
}
if (item.status === 'REJECTED') {
return res.status(400).json({ success: false, message: 'Item is already rejected. Ask submitter to re-submit.' });
}
if (item.status !== 'PENDING') {
return res.status(400).json({ success: false, message: `Cannot act on item with status: ${item.status}` });
}
const logEntry = {
username: req.user.username,
name: req.user.name || req.user.username,
role: req.user.role,
action,
comment: comment || '',
timestamp: new Date().toISOString(),
};
if (action === 'reject') {
await store.updateItem(item.id, {
status: 'REJECTED',
approvalLog: [...(item.approvalLog || []), logEntry],
rejectedBy: req.user.username,
rejectedAt: new Date().toISOString(),
adminNotes: comment || '',
});
return res.json({ success: true, message: 'Item request rejected', status: 'REJECTED' });
}
// ── APPROVE: merge editedData if admin changed fields, then push to SAP ──
const baseData = item.itemData || {};
const mergedData = editedData ? { ...baseData, ...editedData } : baseData;
const co = cq(req) || item.company || null;
console.log('[ITEM-APPROVAL] Pushing to SAP:', (mergedData.ItemCode || '').trim().toUpperCase());
const payload = await pushItemToSap(mergedData, co);
await store.updateItem(item.id, {
status: 'SAP_PUSHED',
approvalLog: [...(item.approvalLog || []), logEntry],
adminNotes: comment || '',
adminEditedData: editedData || null,
approvedBy: req.user.username,
approvedAt: new Date().toISOString(),
sapItemCode: payload.ItemCode,
});
console.log(`[ITEM-APPROVAL] ✅ Item ${payload.ItemCode} created in SAP`);
res.json({ success: true, message: `Item ${payload.ItemCode} created in SAP B1!`, status: 'SAP_PUSHED', itemCode: payload.ItemCode });
} catch (err) {
console.error('[ITEM-APPROVAL] action error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+45
View File
@@ -0,0 +1,45 @@
'use strict';
// routes/itemGroupClassification.js — admin-configurable Item Group → Work
// Order table (Raw Material / Packing Material / Component) mapping.
const express = require('express');
const router = express.Router();
const { verifyToken, verifyUserAdmin } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const store = () => require('../services/itemGroupClassStore');
async function fetchItemGroups() {
const pool = await getPool();
const result = await pool.request().query(`SELECT "ItmsGrpCod","ItmsGrpNam" FROM [dbo]."OITB" ORDER BY "ItmsGrpNam"`);
return result.recordset || [];
}
// List every real SAP item group, merged with its saved classification (if any)
router.get('/', verifyToken, async (req, res) => {
try {
const [groups, saved] = await Promise.all([fetchItemGroups(), store().getAll()]);
const data = groups.map(g => ({
code: g.ItmsGrpCod,
name: g.ItmsGrpNam || String(g.ItmsGrpCod),
classification: saved[g.ItmsGrpCod] || '',
}));
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Save one group's classification — admin only
router.put('/:code', verifyToken, verifyUserAdmin, async (req, res) => {
try {
const code = parseInt(req.params.code, 10);
if (isNaN(code)) return res.status(400).json({ success: false, message: 'Invalid item group code' });
const { name, classification } = req.body || {};
const tokens = String(classification || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
const validTokens = [...store().VALID_TOKENS];
if (tokens.some(t => !validTokens.includes(t)))
return res.status(400).json({ success: false, message: `classification tokens must be ${validTokens.join(', ')}` });
await store().setClassification(code, name || '', classification || '', req.user.username);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+28
View File
@@ -0,0 +1,28 @@
'use strict';
// routes/mailLogs.js — read-only Mail Log viewer API. Access: full Admin and
// System Admin roles always; any other user must be granted the 'mail_log'
// module. Mirrors routes/audit.js exactly.
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const store = require('../services/mailLogStore');
async function requireMailLogView(req, res, next) {
const u = req.user || {};
if (u.role === 'admin' || u.role === 'system_admin') return next();
try {
const full = await require('../services/hanaUsers').findById(u.id);
const mods = Array.isArray(full && full.modules) ? full.modules : [];
if (mods.includes('mail_log')) return next();
} catch (_e) {}
return res.status(403).json({ success: false, message: 'You do not have access to the Mail Log.' });
}
router.get('/', verifyToken, requireMailLogView, async (req, res) => {
try {
const { data, total } = await store.list(req.query);
res.json({ success: true, data, total });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+226
View File
@@ -0,0 +1,226 @@
'use strict';
// routes/manpower.js — "Man Power" module. CRUD over the SAP User-Defined
// Object "Production_Data" (a Document UDO): one header per Date, with 10
// child grids (one per production area / tab). Everything goes through the
// SAP Service Layer entity /Production_Data, so SAP keeps the keys, numbering
// and integrity. SAP has delete disabled for this UDO (CanDelete=N) — removal
// is done via Cancel (CanCancel=Y).
const express = require('express');
const router = express.Router();
const { verifyToken, requireApprovalStep } = require('../middleware/auth');
let _sap = null;
function sap() { if (!_sap) _sap = require('../services/sapServiceLayer'); return _sap; }
const { getPool } = require('../services/sqlPool');
async function hana(sqlText) { const pool = await getPool(); return (await pool.request().query(sqlText)).recordset || []; }
const cq = (req) => req.query?.company || req.body?.company || null;
// Each tab's Section valid-values live in UFD1 (SAP's valid-values table) for
// that UDT, on the Section user field (FieldID 0).
const SQL_TABLE = {
bb: '@PRODUCTION_BB', packing: '@PRODUCTION_PACKING', autoclave: '@PRODUCTION_AUTO',
capd: '@PRODUCTION_CAPD', moulding: '@PRODUCTION_MOULDING', sheet: '@PRODUCTION_SHEET',
lamination: '@PRO_NEEDLE_LAMI', stent: '@PRODUCTION_STENT', equipment: '@PRODUCTION_EQUIP',
needle: '@PRO_NEEDLE',
};
// Tab → Service-Layer child collection, and the (mostly-shared) "present
// manpower" field name — Autoclave uniquely uses U_Prst_mp_star.
const TABS = [
{ key: 'bb', label: 'BB Production', coll: 'PRODUCTION_BBCollection', present: 'U_Prst_manpower_star' },
{ key: 'packing', label: 'Packing', coll: 'PRODUCTION_PACKINGCollection', present: 'U_Prst_manpower_star' },
{ key: 'autoclave', label: 'Autoclave', coll: 'PRODUCTION_AUTOCollection', present: 'U_Prst_mp_star' },
{ key: 'capd', label: 'CAPD Production', coll: 'PRODUCTION_CAPDCollection', present: 'U_Prst_manpower_star' },
{ key: 'moulding', label: 'Moulding', coll: 'PRODUCTION_MOULDINGCollection', present: 'U_Prst_manpower_star' },
{ key: 'sheet', label: 'Sheet Plant', coll: 'PRODUCTION_SHEETCollection', present: 'U_Prst_manpower_star' },
{ key: 'lamination', label: 'Lamination', coll: 'PRO_NEEDLE_LAMICollection', present: 'U_Prst_manpower_star' },
{ key: 'stent', label: 'Stent', coll: 'PRODUCTION_STENTCollection', present: 'U_Prst_manpower_star' },
{ key: 'equipment', label: 'Equipment', coll: 'PRODUCTION_EQUIPCollection', present: 'U_Prst_manpower_star' },
{ key: 'needle', label: 'Needle', coll: 'PRO_NEEDLECollection', present: 'U_Prst_manpower_star' },
];
function num(v) { const n = parseFloat(v); return isNaN(n) ? 0 : n; }
function lineHasData(l) {
if (!l) return false;
if ((l.section || '').trim() || (l.remarks || '').trim()) return true;
return ['target','appManpower','prntWorker','totalSaction','approvedEt','present','overtime','actual','rejection']
.some(k => num(l[k]) !== 0);
}
// UI line → Service-Layer line for a given tab
function toSL(tab, l) {
const o = {
U_Section: (l.section || '') || null,
U_Prd_Target: num(l.target),
U_App_Manpower: num(l.appManpower),
U_Prnt_Worker: num(l.prntWorker),
U_Total_saction: num(l.totalSaction),
U_Approved_et: num(l.approvedEt),
U_Overtime_star: num(l.overtime),
U_Actual_Production: num(l.actual),
U_Rejection: num(l.rejection),
U_Remarks: (l.remarks || '') || null,
};
o[tab.present] = num(l.present);
return o;
}
// Service-Layer line → UI line
function fromSL(tab, l) {
return {
section: l.U_Section || '',
target: l.U_Prd_Target || 0,
appManpower: l.U_App_Manpower || 0,
prntWorker: l.U_Prnt_Worker || 0,
totalSaction: l.U_Total_saction || 0,
approvedEt: l.U_Approved_et || 0,
present: l[tab.present] || 0,
overtime: l.U_Overtime_star || 0,
actual: l.U_Actual_Production || 0,
rejection: l.U_Rejection || 0,
remarks: l.U_Remarks || '',
};
}
// A row is "in use" (must be saved) if it has a Section OR any data.
// Completely blank rows are ignored.
const lineInUse = (l) => !!(l && ((l.section || '').trim() || lineHasData(l)));
// Build the /Production_Data payload from a { date, tabs } body.
// Every in-use row MUST have a Section — throws otherwise (nothing is saved).
function buildPayload(body) {
const payload = {};
if (body.date) payload.U_Date = body.date; // 'YYYY-MM-DD'
if (body.remark !== undefined) payload.Remark = body.remark || null;
for (const tab of TABS) {
const src = (body.tabs?.[tab.key] || []).filter(lineInUse);
src.forEach((l, i) => {
if (!(l.section || '').trim())
throw new Error(`Section is required — ${tab.label}, row ${i + 1}`);
});
payload[tab.coll] = src.map(l => toSL(tab, l)); // always send (empty replaces on edit)
}
const totalLines = TABS.reduce((n, t) => n + (payload[t.coll]?.length || 0), 0);
if (totalLines === 0) throw new Error('Add at least one row with a Section before saving.');
return payload;
}
// Expose the tab metadata so the frontend renders the right tabs/labels.
router.get('/tabs', verifyToken, (req, res) => {
res.json({ success: true, data: TABS.map(t => ({ key: t.key, label: t.label })) });
});
// Section dropdown values per tab, from UFD1 (SAP-defined valid values).
router.get('/sections', verifyToken, requireApprovalStep('man_power:entry', 'view'), async (req, res) => {
try {
const list = TABS.map(t => `'${SQL_TABLE[t.key]}'`).join(',');
const rows = await hana(`SELECT "TableID","FldValue" FROM [dbo]."UFD1" WHERE "TableID" IN (${list}) AND "FieldID"=0 ORDER BY "IndexID"`);
const byTable = {};
rows.forEach(r => { (byTable[r.TableID] = byTable[r.TableID] || []).push(r.FldValue); });
const out = {};
TABS.forEach(t => { out[t.key] = byTable[SQL_TABLE[t.key]] || []; });
res.json({ success: true, data: out });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── List (view) ───────────────────────────────────────────────────
router.get('/', verifyToken, requireApprovalStep('man_power:entry', 'view'), async (req, res) => {
const co = cq(req);
const top = Math.min(Number(req.query.top) || 20, 100);
const skip = Number(req.query.skip) || 0;
const date = (req.query.date || '').trim();
try {
let filter = '';
if (date) filter = `&$filter=U_Date eq '${date}T00:00:00Z'`;
const r = await sap().sapRequest('GET',
`Production_Data?$select=DocEntry,DocNum,U_Date,Canceled,CreateDate&$orderby=DocEntry desc&$top=${top}&$skip=${skip}${filter}`, null, co);
const docs = (r?.value || []).map(d => ({
docEntry: d.DocEntry, docNum: d.DocNum, date: d.U_Date,
canceled: d.Canceled === 'tYES' || d.Canceled === 'Y' || d.Canceled === true,
createDate: d.CreateDate, tabCounts: {}, totalRows: 0,
}));
// Row count per tab (section rows) for each listed document — one bulk
// query across all child tables. Non-fatal: on failure counts stay 0.
const ids = docs.map(d => d.docEntry).filter(x => x != null);
if (ids.length) {
try {
const idList = ids.join(',');
const unions = TABS.map(t =>
`SELECT '${t.key}' AS tab, "DocEntry" AS de, COUNT(*) AS n FROM [dbo].[${SQL_TABLE[t.key]}] WHERE "DocEntry" IN (${idList}) GROUP BY "DocEntry"`
).join(' UNION ALL ');
const rows = await hana(unions);
const byDoc = {};
rows.forEach(x => { (byDoc[x.de] = byDoc[x.de] || {})[x.tab] = Number(x.n) || 0; });
docs.forEach(d => { d.tabCounts = byDoc[d.docEntry] || {}; d.totalRows = Object.values(d.tabCounts).reduce((a, b) => a + b, 0); });
} catch (e) { console.warn('[MANPOWER] tab counts failed (non-fatal):', e.message); }
}
res.json({ success: true, data: docs });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── Single document with all tabs (view) ──────────────────────────
router.get('/:docEntry', verifyToken, requireApprovalStep('man_power:entry', 'view'), async (req, res) => {
const co = cq(req);
try {
const d = await sap().sapRequest('GET', `Production_Data(${parseInt(req.params.docEntry)})`, null, co);
const tabs = {};
for (const t of TABS) tabs[t.key] = (d[t.coll] || []).map(l => fromSL(t, l));
res.json({ success: true, data: {
docEntry: d.DocEntry, docNum: d.DocNum, date: d.U_Date, remark: d.Remark || '',
canceled: d.Canceled === 'tYES' || d.Canceled === 'Y' || d.Canceled === true, tabs,
} });
} catch (err) { res.status(404).json({ success: false, message: err.message }); }
});
// This user's allowed Man Power tabs (Admin → user → Man Power). Returns a Set,
// or null = no restriction (all tabs). Read fresh from DB.
async function allowedTabsFor(userId) {
try {
const u = await require('../services/hanaUsers').findById(userId);
const t = Array.isArray(u && u.manpowerTabs) ? u.manpowerTabs.map(String) : [];
return t.length ? new Set(t) : null;
} catch (_e) { return null; }
}
// ── Create (add) ──────────────────────────────────────────────────
router.post('/', verifyToken, requireApprovalStep('man_power:entry', 'add'), async (req, res) => {
const co = cq(req);
if (!req.body.date) return res.status(400).json({ success: false, message: 'Date is required' });
try {
// Restrict to this user's allowed tabs — drop any others they sent.
const allow = await allowedTabsFor(req.user.id);
if (allow) { req.body.tabs = req.body.tabs || {}; for (const t of TABS) if (!allow.has(t.key)) delete req.body.tabs[t.key]; }
const payload = buildPayload(req.body);
const r = await sap().sapRequest('POST', 'Production_Data', payload, co);
res.json({ success: true, data: { docEntry: r?.DocEntry, docNum: r?.DocNum } });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// ── Update (edit) ─────────────────────────────────────────────────
router.patch('/:docEntry', verifyToken, requireApprovalStep('man_power:entry', 'edit'), async (req, res) => {
const co = cq(req);
try {
// Restrict to allowed tabs. PATCH replaces whole collections, so for tabs
// this user CAN'T fill we re-inject the doc's EXISTING rows (never wipe, and
// ignore whatever the client sent for those tabs).
const allow = await allowedTabsFor(req.user.id);
if (allow) {
const existing = await sap().sapRequest('GET', `Production_Data(${parseInt(req.params.docEntry)})`, null, co);
req.body.tabs = req.body.tabs || {};
for (const t of TABS) if (!allow.has(t.key)) req.body.tabs[t.key] = (existing[t.coll] || []).map(l => fromSL(t, l));
}
const payload = buildPayload(req.body);
// Replace the whole child collections instead of merging line-by-line.
await sap().sapRequest('PATCH', `Production_Data(${parseInt(req.params.docEntry)})`, payload, co, true,
{ 'B1S-ReplaceCollectionsOnPatch': 'true' });
res.json({ success: true });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// ── Cancel (delete is disabled in SAP for this UDO) ───────────────
router.post('/:docEntry/cancel', verifyToken, requireApprovalStep('man_power:entry', 'edit'), async (req, res) => {
const co = cq(req);
try {
await sap().sapRequest('POST', `Production_Data(${parseInt(req.params.docEntry)})/Cancel`, null, co);
res.json({ success: true });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
module.exports = router;
+139
View File
@@ -0,0 +1,139 @@
'use strict';
// routes/notifications.js — aggregates "pending on me" items across the
// workflows that already have clear, per-user pending logic (Work Order,
// Production Order, BOM Requests, and — admin only — Password Reset
// requests), for the sidebar bell + dashboard "Pending Actions" widget.
// Deliberately scoped to these four for now; other approval workflows
// (Purchase Requests, Customer/Vendor registration, Project approvals,
// etc.) aren't included yet.
const express = require('express');
const router = express.Router();
const { verifyToken, hasStepPerm, hasStepAssigned } = require('../middleware/auth');
const appSettings = require('../services/appSettingsStore');
// Mirrors routes/workOrders.js's WORK_ORDER_STEP_KEYS (index-aligned with
// services/workOrderStore.js's STEPS) — duplicated here rather than
// exported, matching how work-order.html itself already keeps its own copy.
const WO_STEP_KEYS = ['prepared_qa', 'checked_qc', 'checked_production', 'checked_mgr_production', 'approved_mgr_qa'];
const WO_STEPS = ['Prepared By QA', 'Checked By QC', 'Checked By Store In-Charge', 'Checked By (Manager Production)', 'Approved By (Manager QA)'];
// Mirrors server.js's /api/config approvalMap for BOM Requests — which
// ROLE is on turn for a given status, at the admin-configured BOM levels
// (Settings → bomApprovalLevels; see services/appSettingsStore.js).
function bomApprovalMap() {
const levels = appSettings.bomApprovalLevels();
return {
2: { PENDING: 'manager', L1_APPROVED: 'sap_adder' },
3: { PENDING: 'manager', L1_APPROVED: 'sr_manager', L2_APPROVED: 'sap_adder' },
4: { PENDING: 'manager', L1_APPROVED: 'sr_manager', L2_APPROVED: 'sap_adder', L3_APPROVED: 'sap_adder' },
}[levels] || {};
}
router.get('/pending', verifyToken, async (req, res) => {
const user = req.user;
const items = [];
try {
const wos = await require('../services/workOrderStore').listWorkOrders({ status: 'IN_PROGRESS' });
wos.forEach(w => {
const key = WO_STEP_KEYS[w.stage];
if (key && hasStepPerm(user, `work_order:${key}`, 'approve')) {
items.push({
module: 'work_order', label: 'Work Order', title: w.woNo,
detail: `${WO_STEPS[w.stage]} — ${w.productName || w.productCode || ''}`,
link: `/work-order?open=${w.id}`, id: w.id, cardHref: '/work-order',
});
}
});
} catch (e) { console.warn('[notifications] work order scan failed:', e.message); }
try {
const poStore = require('../services/productionOrderStore');
// Which standalone page each stage's action actually happens on —
// Issue/Receipt/Close each have their own dedicated page (deep-linked
// via #order=<AbsoluteEntry>&company=<co>, same hash convention
// production.html's own action buttons already use to get there).
// Release and Transfer to Finished Goods have no standalone page of
// their own — those stay on production.html's detail popup.
const STAGE_CARD_HREF = { release: '/production', issuance: '/issue-production', receipt: '/receipt-production', transfer_fg: '/production', close: '/close-production' };
const pos = await poStore.listProductionOrders({ status: 'IN_PROGRESS' });
// REJECTED orders (receipt posted with rejection lines) still need to be
// CLOSED — surface them to the close-step users too.
const rejected = await poStore.listProductionOrders({ status: 'REJECTED' });
rejected.forEach(p => { pos.push(Object.assign({}, p, { stage: 4 })); }); // stage 4 = Close
pos.forEach(p => {
const key = poStore.STEP_KEYS[p.stage];
// Same rule as the Issue/Receipt/Close pages themselves: being ASSIGNED
// the step (any perm — view/add/edit/approve) means the action is yours,
// so it must show in the bell too. (Was 'approve'-only, which hid e.g.
// pending Receipts from users holding view/add/edit.)
if (key && hasStepAssigned(user, `production_order:${key}`)) {
const cardHref = STAGE_CARD_HREF[key] || '/production';
const link = p.sapAbsEntry
? (cardHref === '/production'
? `/production?open=${p.sapAbsEntry}`
: `${cardHref}#order=${p.sapAbsEntry}&company=${encodeURIComponent(p.company || '')}`)
: '/production';
items.push({
module: 'production_order', label: 'Production Order', title: p.sapDocNum ? `#${p.sapDocNum}` : `Item ${p.itemCode}`,
detail: `${poStore.STEPS[p.stage]} — ${p.itemName || p.itemCode || ''}`,
link, id: p.id, cardHref,
});
}
});
} catch (e) { console.warn('[notifications] production order scan failed:', e.message); }
// Batch Issuance intimations awaiting a Work Order — pending for whoever
// holds the "Prepared By QA" (create Work Order) step, same 'add' perm the
// WO create route itself requires. An intimation counts as pending until
// some Work Order references it (ZWORK_ORDERS.INTIMATION_ID).
try {
if (hasStepPerm(user, 'work_order:prepared_qa', 'add')) {
const ints = await require('../services/batchIntimationStore').listIntimations({ status: 'SENT_TO_QA' });
const wos = await require('../services/workOrderStore').listWorkOrders({});
const used = new Set(wos.filter(w => w.intimationId).map(w => String(w.intimationId)));
ints.forEach(bi => {
if (used.has(String(bi.id))) return;
const firstProd = Array.isArray(bi.products) && bi.products[0]
? (bi.products[0].productName || bi.products[0].name || bi.products[0].productCode || '') : '';
items.push({
module: 'batch_issuance', label: 'Batch Issuance', title: bi.docNo || ('#' + bi.id),
detail: `${firstProd ? firstProd + ' — ' : ''}awaiting Work Order`,
link: '/work-order', id: bi.id, cardHref: '/batch-issuance',
});
});
}
} catch (e) { console.warn('[notifications] batch issuance scan failed:', e.message); }
try {
const boms = await require('../services/bomRequestStore').listRequests({ status: 'ALL' });
const map = bomApprovalMap();
boms.forEach(b => {
const turnRole = map[b.status];
if (turnRole && turnRole === user.role) {
items.push({
module: 'bom', label: 'BOM Request', title: b.itemCode,
detail: `${b.itemName || ''} — awaiting your review`,
link: `/approvals?open=${b.id}`, id: b.id, cardHref: '/bom',
});
}
});
} catch (e) { console.warn('[notifications] bom scan failed:', e.message); }
if (user.role === 'admin' || user.role === 'sap_adder') {
try {
const pending = await require('../services/passwordResetStore').listRequests('PENDING');
pending.forEach(r => {
items.push({
module: 'password_reset', label: 'Password Reset', title: '@' + r.username,
detail: r.note || 'Requested a password reset',
link: `/admin?tab=pwresets&open=${r.id}`, id: r.id, cardHref: '/admin',
});
});
} catch (e) { console.warn('[notifications] password reset scan failed:', e.message); }
}
res.json({ success: true, count: items.length, data: items });
});
module.exports = router;
+128
View File
@@ -0,0 +1,128 @@
'use strict';
// routes/oee.js — "Overall Equipment Efficiency" (OEE) module. CRUD over the
// portal app-DB table ZOEE_ENTRIES (services/oeeStore.js). One document per
// (tab, month). Gated by the generic Approval Step 'oee:entry' (view/add/edit)
// and, additionally, by per-user allowed tabs (Admin → user → OEE) — mirrors
// the Man Power module. 'edit' also gates Cancel.
const express = require('express');
const router = express.Router();
const { verifyToken, requireApprovalStep } = require('../middleware/auth');
const store = require('../services/oeeStore');
// Tab catalogue (key → label). Only 'ebb' has a defined column format so far;
// the rest are placeholders until their sheets are provided. The full column
// schema + formulas live on the client (public/oee.html) — the server only
// needs the keys/labels to expose the tab list and validate the tab field.
const TABS = [
{ key: 'ebb', label: 'EBB Production', ready: true },
{ key: 'pd', label: 'PD Production', ready: true },
{ key: 'needle', label: 'PDS Needle Assembly', ready: false },
{ key: 'autoclave', label: 'Autoclave', ready: true },
{ key: 'packing', label: 'Packing', ready: false },
{ key: 'moulding', label: 'Moulding', ready: true },
{ key: 'sheet', label: 'Plastic Sheet Plant', ready: true },
];
const TAB_KEYS = new Set(TABS.map(t => t.key));
const cq = (req) => req.query?.company || req.body?.company || null;
// This user's allowed OEE tabs (Admin → user → OEE). Returns an array, or null
// = no restriction (all tabs). Read fresh from DB, like Man Power.
async function allowedTabsFor(userId) {
try {
const u = await require('../services/hanaUsers').findById(userId);
const t = Array.isArray(u && u.oeeTabs) ? u.oeeTabs.map(String) : [];
return t.length ? t : null;
} catch (_e) { return null; }
}
// Expose tab metadata so the frontend renders the right tabs/labels.
router.get('/tabs', verifyToken, (req, res) => {
res.json({ success: true, data: TABS.map(t => ({ key: t.key, label: t.label, ready: t.ready })) });
});
// ── List (view) ───────────────────────────────────────────────────
router.get('/', verifyToken, requireApprovalStep('oee:entry', 'view'), async (req, res) => {
try {
const allow = await allowedTabsFor(req.user.id);
const top = Math.min(Number(req.query.top) || 30, 100);
const skip = Number(req.query.skip) || 0;
const tab = (req.query.tab || '').trim() || null;
const month = (req.query.month || '').trim() || null;
// If a specific tab was requested but the user isn't allowed it, return none.
if (tab && allow && !allow.includes(tab)) return res.json({ success: true, data: [] });
const data = await store.list({ company: cq(req), tab, month, allowTabs: allow, top, skip });
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── Single document (view) ────────────────────────────────────────
router.get('/:id', verifyToken, requireApprovalStep('oee:entry', 'view'), async (req, res) => {
try {
const doc = await store.getById(req.params.id);
if (!doc) return res.status(404).json({ success: false, message: 'Not found' });
const allow = await allowedTabsFor(req.user.id);
if (allow && !allow.includes(doc.tab)) return res.status(403).json({ success: false, message: 'You are not permitted this OEE form.' });
res.json({ success: true, data: doc });
} catch (err) { res.status(404).json({ success: false, message: err.message }); }
});
function validateBody(body) {
const tab = (body.tab || '').trim();
const month = (body.month || '').trim();
if (!TAB_KEYS.has(tab)) throw new Error('Unknown or missing OEE form.');
if (!month) throw new Error('Month is required.');
const rows = Array.isArray(body.rows) ? body.rows : [];
// Keep only rows that actually carry data (a date or any non-empty value).
const used = rows.filter(r => r && Object.keys(r).some(k => {
const v = r[k];
if (typeof v === 'number') return v !== 0;
return v != null && String(v).trim() !== '';
}));
// Every used row must carry a Date.
used.forEach((r, i) => { if (!(r.date || '').toString().trim()) throw new Error(`Date is required — row ${i + 1}`); });
if (!used.length) throw new Error('Add at least one row with a Date before saving.');
return { tab, month, rows: used };
}
// ── Create (add) ──────────────────────────────────────────────────
router.post('/', verifyToken, requireApprovalStep('oee:entry', 'add'), async (req, res) => {
try {
const { tab, month, rows } = validateBody(req.body);
const allow = await allowedTabsFor(req.user.id);
if (allow && !allow.includes(tab)) return res.status(403).json({ success: false, message: 'You are not permitted this OEE form.' });
const id = await store.create({
company: cq(req), tab, month, rows, remark: req.body.remark,
createdBy: req.user.username || null, createdById: req.user.id || null,
});
res.json({ success: true, data: { id } });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// ── Update (edit) ─────────────────────────────────────────────────
router.patch('/:id', verifyToken, requireApprovalStep('oee:entry', 'edit'), async (req, res) => {
try {
const existing = await store.getById(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: 'Not found' });
if (existing.canceled) return res.status(400).json({ success: false, message: 'This document is cancelled.' });
const allow = await allowedTabsFor(req.user.id);
if (allow && !allow.includes(existing.tab)) return res.status(403).json({ success: false, message: 'You are not permitted this OEE form.' });
// Tab is fixed for a document; only month/rows/remark change.
const { month, rows } = validateBody({ ...req.body, tab: existing.tab });
await store.update(req.params.id, { month, rows, remark: req.body.remark });
res.json({ success: true });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// ── Cancel (soft) ─────────────────────────────────────────────────
router.post('/:id/cancel', verifyToken, requireApprovalStep('oee:entry', 'edit'), async (req, res) => {
try {
const existing = await store.getById(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: 'Not found' });
const allow = await allowedTabsFor(req.user.id);
if (allow && !allow.includes(existing.tab)) return res.status(403).json({ success: false, message: 'You are not permitted this OEE form.' });
await store.cancel(req.params.id);
res.json({ success: true });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
module.exports = router;
+41
View File
@@ -0,0 +1,41 @@
'use strict';
// routes/passwordReset.js — "Forgot password" request queue (see
// services/passwordResetStore.js for why this is a request queue rather
// than an email-based reset flow).
const express = require('express');
const router = express.Router();
const { verifyToken, verifyUserAdmin } = require('../middleware/auth');
const store = () => require('../services/passwordResetStore');
// Submit a request — public, no auth (the whole point is the user is locked out)
router.post('/', async (req, res) => {
try {
const username = (req.body?.username || '').trim();
const note = (req.body?.note || '').trim();
if (!username) return res.status(400).json({ success: false, message: 'Username is required' });
if (username.length > 50) return res.status(400).json({ success: false, message: 'Username too long' });
if (note.length > 500) return res.status(400).json({ success: false, message: 'Note too long' });
await store().createRequest(username, note);
res.json({ success: true, message: 'Request submitted — an admin will reset your password shortly.' });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// List requests — admin only
router.get('/', verifyToken, verifyUserAdmin, async (req, res) => {
try {
const data = await store().listRequests(req.query.status || null);
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Mark a request resolved/dismissed — admin only
router.post('/:id/resolve', verifyToken, verifyUserAdmin, async (req, res) => {
try {
const status = req.body?.status === 'DISMISSED' ? 'DISMISSED' : 'RESOLVED';
await store().resolveRequest(req.params.id, status, req.user.username);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+295
View File
@@ -0,0 +1,295 @@
// routes/ppc.js
// PPC (Production Planning & Control) report — one row per OPEN Sales
// Order, pivoted so each distinct Item Code appearing in the filtered
// result set becomes its own column showing that order's still-PENDING
// (undelivered/uninvoiced — RemainingOpenQuantity) quantity for that item.
// Pulled entirely from SAP B1 Service Layer (Orders + Items +
// BusinessPartners), per explicit requirement — no direct SQL against SAP's
// own database, unlike most other report pages in this app.
// NOTE: this deliberately does NOT look at Invoices at all — an invoiced
// line has nothing left pending by definition, so it's excluded already by
// only ever reading lines with RemainingOpenQuantity > 0.
'use strict';
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const { verifyToken } = require('../middleware/auth');
const appSettings = require('../services/appSettingsStore');
// Lets an EXTERNAL app (no portal login) call the report routes below with a
// dedicated key instead of a JWT — header "X-API-Key: <key>" (or ?apiKey=
// query param, for tools that can't set custom headers, e.g. some Power BI/
// Excel connectors). The key is set in Admin → System Settings → "PPC
// Report" and is empty by default, meaning external access is OFF until an
// admin turns it on. A normal portal Bearer token still works exactly as
// before — this only ADDS a second way in, never removes the first.
function timingSafeEqual(a, b) {
const bufA = Buffer.from(String(a)); const bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
function verifyApiKeyOrToken(req, res, next) {
const configured = appSettings.ppcApiKey();
const supplied = req.headers['x-api-key'] || req.query.apiKey;
if (configured && supplied && timingSafeEqual(supplied, configured)) {
req.user = { username: 'ppc-external-api', role: 'api' };
return next();
}
return verifyToken(req, res, next);
}
let _sapSvc = null;
function getSap(){
if(!_sapSvc) _sapSvc = require('../services/sapServiceLayer');
return _sapSvc;
}
const cq = (req) => req.query?.company || req.body?.company || null;
// Warehouse code -> name, cached per company for the life of the server
// (this list changes essentially never) — used to label the warehouse-wise
// stock breakdown shown on hover over a Current Stock cell.
const _warehouseNameCache = {};
async function getWarehouseNames(sap, co) {
const key = co || '';
if (_warehouseNameCache[key]) return _warehouseNameCache[key];
try {
const r = await sap.sapRequest('GET', 'Warehouses?$select=WarehouseCode,WarehouseName', null, co);
const map = {};
(r?.value || []).forEach(w => { map[w.WarehouseCode] = w.WarehouseName || w.WarehouseCode; });
_warehouseNameCache[key] = map;
return map;
} catch (_e) { return {}; }
}
// OR-batch a set of values into a Service Layer $filter clause, e.g.
// batchFilter('ItemCode', ['A','B']) -> "(ItemCode eq 'A' or ItemCode eq 'B')".
// Chunked to keep each request's URL length sane.
// SAP Service Layer silently truncates/fails an OR'd $filter batch once it
// gets too large (confirmed live: 30-40 DocEntry conditions dropped over a
// third of results with no error — 10 was reliable). Kept conservative for
// every batched lookup below, not just the one it was caught on.
const BATCH_SIZE = 10;
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
function orFilter(field, values) {
return `(${values.map(v => `${field} eq '${String(v).replace(/'/g, "''")}'`).join(' or ')})`;
}
// ════════════════════════════════════════════════════════════════
// PPC PENDING ORDER REPORT → GET /api/ppc/invoice-report
// (path kept for backward compat with the already-shipped frontend build;
// the DATA is now open Sales Orders, not Invoices — see file header.)
// Query: company, from (YYYY-MM-DD), to (YYYY-MM-DD), itemGroup (numeric
// ItemsGroupCode, optional), salesType ('Trade'|'Institute'|'Institute Ind', optional),
// customerType ('DOMESTIC'|'EXPORT', optional)
// ════════════════════════════════════════════════════════════════
router.get('/invoice-report', verifyApiKeyOrToken, async (req, res) => {
const co = cq(req);
const { from, to, itemGroup, salesType, customerType } = req.query;
try {
const sap = getSap();
// ── 1. Pull matching Sales Orders (paginated) — Date range is OPTIONAL:
// given, it narrows by DocDate as before; left blank, every open
// order shows regardless of when it was raised. Header
// DocumentStatus eq 'bost_Open' is always applied though — a
// Closed header can never have a pending line, so this is a pure
// efficiency filter (skips years of fully-fulfilled history)
// that never drops a genuinely pending line. ────────────────────
// Customer Type is NOT filtered via U_CustomerType here — that field is
// unreliably populated (blank on plenty of real orders). Confirmed
// convention instead: an order counts as Domestic when U_WEB_SO_NO has a
// value, Export when U_WEB_SO_NO_EX does — derived per-order below and
// filtered on AFTER fetching (see customerTypeFor()).
const filters = [`DocumentStatus eq 'bost_Open'`];
if (from) filters.push(`DocDate ge '${from}'`);
if (to) filters.push(`DocDate le '${to}'`);
if (salesType) filters.push(`U_SalesType eq '${String(salesType).replace(/'/g, "''")}'`);
const filterStr = encodeURIComponent(filters.join(' and '));
const select = encodeURIComponent('DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,U_SalesType,U_CustomerType,U_WEB_SO_NO,U_WEB_SO_NO_EX,DocumentLines');
const orders = [];
// NOTE: no $top here on purpose — confirmed live that passing $top made
// SAP Service Layer treat it as a TOTAL cap across the whole nextLink
// chain (silently stopping once that many results had been returned in
// total, each page's own $top counting DOWN — 200,180,160…), not a
// per-page size. That silently dropped genuine older still-open orders
// once a date range had more than 200 matches. Omitting $top lets SAP
// use its own default page size and nextLink keeps going for as long as
// results exist.
let url = `Orders?$filter=${filterStr}&$select=${select}&$orderby=DocDate desc`;
const MAX_PAGES = 500; // safety cap — ~20/page => up to ~10,000 orders per run
for (let page = 0; page < MAX_PAGES && url; page++) {
const result = await sap.sapRequest('GET', url, null, co);
orders.push(...(result?.value || []));
url = result?.['odata.nextLink'] || result?.['@odata.nextLink'] || null;
if (!result?.value?.length) break;
}
if (!orders.length) return res.json({ success: true, data: { itemCodes: [], rows: [] } });
// ── 2. Resolve item groups + current stock for every distinct item
// code on these orders (group only needed for the Item Group
// filter). Stock is warehouse-specific, not the item's blanket
// QuantityOnStock across every warehouse (QA/R&D/Quarantine/etc.
// aren't stock actually available to ship) — confirmed live:
// Warehouse 1A = "FG DOMESTIC", 1B = "FG EXPORT", the only two
// that matter for "what can I ship a customer". Service Layer has
// no nested $select on ItemWarehouseInfoCollection (tried, SAP
// rejects it), so the full per-warehouse collection comes back and
// is filtered down to just those two codes here. ────────────────
const STOCK_WAREHOUSES = { '1A': 'stock1A', '1B': 'stock1B' };
const warehouseNames = await getWarehouseNames(sap, co);
const allCodes = [...new Set(orders.flatMap(o => (o.DocumentLines || []).map(l => l.ItemCode).filter(Boolean)))];
let itemMeta = {}; // ItemCode -> { name, group, stock1A, stock1B, byWarehouse }
for (const part of chunk(allCodes, BATCH_SIZE)) {
try {
const r = await sap.sapRequest('GET', `Items?$filter=${encodeURIComponent(orFilter('ItemCode', part))}&$select=ItemCode,ItemName,ItemsGroupCode,ItemWarehouseInfoCollection&$top=${part.length}`, null, co);
(r?.value || []).forEach(it => {
const meta = { name: it.ItemName || it.ItemCode, group: it.ItemsGroupCode, stock1A: 0, stock1B: 0, byWarehouse: [] };
(it.ItemWarehouseInfoCollection || []).forEach(w => {
const key = STOCK_WAREHOUSES[w.WarehouseCode];
const qty = Number(w.InStock) || 0;
if (key) meta[key] = qty;
// Every warehouse with nonzero stock — full breakdown for the
// "warehouse-wise stock" hover tooltip (not just 1A/1B).
if (qty) meta.byWarehouse.push({ code: w.WarehouseCode, name: warehouseNames[w.WarehouseCode] || w.WarehouseCode, qty });
});
meta.byWarehouse.sort((a, b) => a.code.localeCompare(b.code));
itemMeta[it.ItemCode] = meta;
});
} catch (_e) { /* best-effort — missing metadata just falls back to code-as-name, no group filter match, 0 stock */ }
}
// Hard ceiling — Admin → System Settings → "PPC Report Item Groups"
// (appSettings.ppcItemGroups()). When configured, results can NEVER
// include a group outside that list, regardless of what's requested:
// no itemGroup picked -> restrict to the WHOLE allowed set; itemGroup
// picked but outside it -> empty set (matches nothing), never silently
// widened. Mirrors routes/board.js's resolveGroupFilter().
const allowedGroups = appSettings.ppcItemGroups().map(Number).filter(n => !isNaN(n));
let activeGroups = null; // null = no restriction at all
if (allowedGroups.length) {
activeGroups = new Set(itemGroup != null && itemGroup !== '' ? allowedGroups.filter(g => g === parseInt(itemGroup)) : allowedGroups);
} else if (itemGroup != null && itemGroup !== '') {
activeGroups = new Set([parseInt(itemGroup)]);
}
// ── 3. Resolve City for every distinct customer ─────────────────────
const cardCodes = [...new Set(orders.map(o => o.CardCode).filter(Boolean))];
let cityByCard = {};
for (const part of chunk(cardCodes, BATCH_SIZE)) {
try {
const r = await sap.sapRequest('GET', `BusinessPartners?$filter=${encodeURIComponent(orFilter('CardCode', part))}&$select=CardCode,City&$top=${part.length}`, null, co);
(r?.value || []).forEach(bp => { cityByCard[bp.CardCode] = bp.City || ''; });
} catch (_e) { /* non-fatal — City just shows blank */ }
}
// ── 4. Build rows + the set of item-code columns actually used —
// ONLY lines that still have something PENDING count: SAP tracks
// per-line RemainingOpenQuantity (confirmed live — NOT the
// "OpenQuantity" property name one might expect) and LineStatus.
// A line that's fully delivered/invoiced has RemainingOpenQuantity
// 0 and LineStatus bost_Closed — excluded here so a fully-filled
// order contributes nothing (this IS what makes it a "pending"
// report instead of a plain order-quantity dump). ──────────────
// Confirmed convention: an order counts as Domestic when U_WEB_SO_NO has
// a value, Export when U_WEB_SO_NO_EX does — takes priority over the
// (unreliable) U_CustomerType UDF, which is only a fallback here.
function customerTypeFor(o) {
if (o.U_WEB_SO_NO) return 'DOMESTIC';
if (o.U_WEB_SO_NO_EX) return 'EXPORT';
return o.U_CustomerType || '';
}
const usedCodes = new Set();
const rows = [];
orders.forEach((o) => {
const orderCustType = customerTypeFor(o);
if (customerType && orderCustType !== customerType) return; // Customer Type filter, applied post-fetch (see above)
const qtyByItem = {};
let any = false;
(o.DocumentLines || []).forEach(l => {
const code = l.ItemCode; if (!code) return;
const pending = Number(l.RemainingOpenQuantity) || 0;
if (pending <= 0 || l.LineStatus !== 'bost_Open') return;
const meta = itemMeta[code];
if (activeGroups && (!meta || !activeGroups.has(meta.group))) return; // outside the selected/allowed Item Group(s)
qtyByItem[code] = (qtyByItem[code] || 0) + pending;
usedCodes.add(code);
any = true;
});
if (!any) return; // nothing left pending on this order (or Item Group filter excluded it all)
rows.push({
srNo: rows.length + 1,
docEntry: o.DocEntry,
salesOrderNo: o.DocNum,
webSoNo: o.U_WEB_SO_NO || null,
webSoNoEx: o.U_WEB_SO_NO_EX || null,
cardCode: o.CardCode,
cardName: o.CardName,
city: cityByCard[o.CardCode] || '',
orderType: o.U_SalesType || '',
customerType: orderCustType,
dispatchDate: o.DocDueDate || '',
qtyByItem,
});
});
const itemCodes = [...usedCodes].sort().map(code => ({
code,
name: (itemMeta[code] && itemMeta[code].name) || code,
stock1A: (itemMeta[code] && itemMeta[code].stock1A) || 0,
stock1B: (itemMeta[code] && itemMeta[code].stock1B) || 0,
byWarehouse: (itemMeta[code] && itemMeta[code].byWarehouse) || [],
}));
res.json({ success: true, data: { itemCodes, rows } });
} catch (err) {
console.error('[PPC] invoice-report failed:', err.message);
res.status(400).json({ success: false, message: err.message });
}
});
// ════════════════════════════════════════════════════════════════
// ITEM GROUPS FOR THE FILTER → GET /api/ppc/item-groups
// Restricted to Admin → System Settings → "PPC Report Item Groups"
// (appSettings.ppcItemGroups()) when configured — e.g. just the FG groups
// (BB, CAPD, Equipment, …), not every raw-material/packing group SAP has.
// Empty setting = unrestricted (every group SAP has).
// ════════════════════════════════════════════════════════════════
router.get('/item-groups', verifyApiKeyOrToken, async (req, res) => {
const co = req.query.company || null;
try {
const r = await getSap().sapRequest('GET', 'ItemGroups?$select=Number,GroupName&$orderby=GroupName', null, co);
const all = (r?.value || []).map(g => ({ code: g.Number, name: g.GroupName || String(g.Number) }));
const allowed = appSettings.ppcItemGroups().map(Number);
const restricted = allowed.length ? all.filter(g => allowed.includes(g.code)) : all;
res.json({ success: true, data: restricted });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// ── UNRESTRICTED group list — for the admin settings checklist itself (an
// admin configuring the restriction must see every group to choose from).
router.get('/item-groups/all', verifyToken, async (req, res) => {
const co = req.query.company || null;
try {
const r = await getSap().sapRequest('GET', 'ItemGroups?$select=Number,GroupName&$orderby=GroupName', null, co);
res.json({ success: true, data: (r?.value || []).map(g => ({ code: g.Number, name: g.GroupName || String(g.Number) })) });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// ════════════════════════════════════════════════════════════════
// ORDER TYPES FOR THE FILTER → GET /api/ppc/order-types
// Admin → System Settings → "PPC Report" → Order Types
// (appSettings.ppcOrderTypes()) — SAP's U_SalesType is free text, not a
// fixed picklist, so this list is admin-maintained here instead of
// hardcoded in ppc-report.html; a new value used in SAP just needs adding
// here, no code change.
// ════════════════════════════════════════════════════════════════
router.get('/order-types', verifyApiKeyOrToken, async (req, res) => {
res.json({ success: true, data: appSettings.ppcOrderTypes() });
});
module.exports = router;
+176
View File
@@ -0,0 +1,176 @@
'use strict';
// routes/prePwo.js — "Pre-PWO" staging (Admin → System Settings → "Pre-PWO —
// Store Review Before SAP"). See services/prePwoStore.js for the full design
// note. This file is ONLY the staging record's CRUD + the Production<->Store
// review round trip. Actually creating the real SAP Production Order still
// goes through the existing, unmodified routes/sap.js + routes/productionOrders.js
// endpoints — the frontend, after a successful SAP creation, calls
// POST /:id/mark-converted here purely to lock the staging row.
const express = require('express');
const router = express.Router();
const { verifyToken, hasStepPerm, hasStepAssigned } = require('../middleware/auth');
const store = () => require('../services/prePwoStore');
const poStore = () => require('../services/productionOrderStore');
const woStore = () => require('../services/workOrderStore');
const appSettings = require('../services/appSettingsStore');
function requireAnyStep(fullKeys, perm) {
return (req, res, next) => {
if (fullKeys.some(k => hasStepPerm(req.user, k, perm))) return next();
res.status(403).json({ success: false, message: `You are not assigned "${perm}" on approval step: ${fullKeys.join(' or ')}` });
};
}
// For viewing (list/get), holding ANY perm on ANY of these steps is enough —
// e.g. a Store user who only has 'add' checked on prepwo_review (no 'view')
// still needs to reach the list to find what's been shared with them.
function requireAnyStepAssigned(fullKeys) {
return (req, res, next) => {
if (fullKeys.some(k => hasStepAssigned(req.user, k))) return next();
res.status(403).json({ success: false, message: `You are not assigned to any of: ${fullKeys.join(', ')}` });
};
}
const VIEW_STEPS = ['production_order:create', 'production_order:prepwo_share', 'production_order:prepwo_review'];
router.get('/', verifyToken, requireAnyStepAssigned(VIEW_STEPS), async (req, res) => {
try {
const { mine, company, workOrderId, status } = req.query;
const data = await store().listPrePwos({ mine: mine === '1' ? req.user.username : undefined, company, workOrderId, status });
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/:id', verifyToken, requireAnyStepAssigned(VIEW_STEPS), 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 the staging record — the app-DB-only substitute for what would
// otherwise be an immediate SAP write. Only from a Work Order (per the
// original ask: "After approval of WO"); manual/standalone Production
// Orders are unaffected by this feature and keep writing straight to SAP.
router.post('/', verifyToken, (req, res, next) => {
if (hasStepPerm(req.user, 'production_order:create', 'add')) return next();
res.status(403).json({ success: false, message: 'You are not assigned "add" on approval step: production_order:create' });
}, async (req, res) => {
try {
if (!appSettings.preWoStoreReviewEnabled())
return res.status(403).json({ success: false, message: 'The Pre-PWO Store Review workflow is disabled in Admin → System Settings' });
const b = req.body || {};
if (!b.workOrderId) return res.status(400).json({ success: false, message: 'workOrderId is required' });
const wo = await woStore().findById(b.workOrderId);
if (!wo || wo.isDeleted) return res.status(404).json({ success: false, message: 'Work order not found' });
// Same "one Production Order per Work Order" guarantee as the direct-to-SAP
// path (routes/sap.js), checked against BOTH a real PO already existing
// AND an open (not-yet-converted) Pre-PWO already staged for this WO.
const existingPo = await poStore().listProductionOrders({ workOrderId: b.workOrderId });
if (existingPo.length)
return res.status(409).json({ success: false, message: `A Production Order (${existingPo[0].sapDocNum || '#' + existingPo[0].id}) has already been generated for this Work Order.` });
const existingPre = await store().findOpenByWorkOrderId(b.workOrderId);
if (existingPre)
return res.status(409).json({ success: false, message: `A Pre-PWO is already staged for this Work Order (${existingPre.reviewStage === 0 ? 'not yet shared' : existingPre.reviewStage === 1 ? 'awaiting Store review' : 'reviewed by Store'}).` });
if (!Array.isArray(b.lines) || !b.lines.length)
return res.status(400).json({ success: false, message: 'At least one component line is required' });
const saved = await store().insertPrePwo({
workOrderId: b.workOrderId,
itemCode: b.itemCode || wo.productCode, itemName: b.itemName || wo.productName,
plannedQty: b.plannedQty || wo.totalUnits, batchNumber: b.batchNumber || wo.batchNumber,
mfgDate: b.mfgDate || wo.mfgDate, expDate: b.expDate || wo.expDate,
warehouse: b.warehouse || '', fgWarehouse: b.fgWarehouse || '',
lines: b.lines, remarks: b.remarks || '', company: b.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 }); }
});
// Production edits header/lines directly (with or without ever involving
// Store) — any time before the Pre-PWO is converted to a real SAP order.
router.put('/:id', verifyToken, requireAnyStep(['production_order:create'], '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: 'Not found' });
if (existing.status === 'CONVERTED') return res.status(409).json({ success: false, message: 'Already pushed to SAP — locked' });
const b = req.body || {};
if (!Array.isArray(b.lines) || !b.lines.length)
return res.status(400).json({ success: false, message: 'At least one component line is required' });
const updated = await store().updatePrePwo(req.params.id, {
itemCode: b.itemCode ?? existing.itemCode, itemName: b.itemName ?? existing.itemName,
plannedQty: b.plannedQty ?? existing.plannedQty, batchNumber: b.batchNumber ?? existing.batchNumber,
mfgDate: b.mfgDate ?? existing.mfgDate, expDate: b.expDate ?? existing.expDate,
warehouse: b.warehouse ?? existing.warehouse, fgWarehouse: b.fgWarehouse ?? existing.fgWarehouse,
lines: b.lines, remarks: b.remarks ?? existing.remarks,
});
res.json({ success: true, data: updated });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Production → Store (optional — no hard gate anywhere downstream).
router.post('/:id/share-with-store', verifyToken, requireAnyStep(['production_order:prepwo_share'], 'add'), async (req, res) => {
try {
if (!appSettings.preWoStoreReviewEnabled())
return res.status(403).json({ success: false, message: 'The Pre-PWO 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: 'Not found' });
if (existing.status === 'CONVERTED') return res.status(409).json({ success: false, message: 'Already pushed to SAP — locked' });
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 });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Store → Production: may add/remove/substitute component lines outright —
// single round trip, no further back-and-forth.
router.post('/:id/revert-to-production', verifyToken, requireAnyStep(['production_order:prepwo_review'], 'add'), async (req, res) => {
try {
if (!appSettings.preWoStoreReviewEnabled())
return res.status(403).json({ success: false, message: 'The Pre-PWO 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: 'Not found' });
if (existing.status === 'CONVERTED') return res.status(409).json({ success: false, message: 'Already pushed to SAP — locked' });
if (existing.reviewStage !== 1)
return res.status(409).json({ success: false, message: existing.reviewStage === 0 ? 'This Pre-PWO has not been shared by Production yet' : 'Already reverted to Production' });
const b = req.body || {};
if (!Array.isArray(b.lines) || !b.lines.length)
return res.status(400).json({ success: false, message: 'At least one component line is required' });
const updated = await store().revertToProduction(req.params.id, {
by: req.user.username, byName: req.user.name || req.user.username, lines: b.lines, remarks: b.remarks || '',
});
res.json({ success: true, data: updated });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Called by the frontend immediately after it has successfully created the
// real SAP Production Order (via the existing /api/sap/production-order +
// /api/production-orders routes, unchanged) from this Pre-PWO's current
// lines. Purely locks the staging row — does not itself touch SAP.
router.post('/:id/mark-converted', verifyToken, requireAnyStep(['production_order:create'], 'add'), 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' });
if (existing.status === 'CONVERTED') return res.json({ success: true, data: existing });
const { convertedPoId } = req.body || {};
if (!convertedPoId) return res.status(400).json({ success: false, message: 'convertedPoId is required' });
const updated = await store().markConverted(req.params.id, { convertedPoId });
res.json({ success: true, data: updated });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.delete('/:id', verifyToken, requireAnyStep(['production_order:create'], 'delete'), async (req, res) => {
try {
const existing = await store().findById(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: 'Not found' });
if (existing.status === 'CONVERTED') return res.status(409).json({ success: false, message: 'Already pushed to SAP — cannot delete' });
await store().softDelete(req.params.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
'use strict';
// routes/prodDashboard.js — Production Dashboard analytics, open to ANY
// logged-in user (page visibility is controlled by the 'production-dashboard'
// MODULE grant, like every other production page). Read-only aggregates:
// - SAP: OWOR (orders), OIGN/IGN1 BaseType=202 (receipts from production)
// - Local: ZPRODUCTION_ORDERS (portal workflow stages)
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const { getPool: getAppPool } = require('../services/appSqlPool');
async function hana(sqlText) {
const pool = await getPool();
return (await pool.request().query(sqlText)).recordset || [];
}
async function appq(sqlText) {
const pool = await getAppPool();
return (await pool.request().query(sqlText)).recordset || [];
}
// Superseded by the redesigned Production Dashboard (Work Order + Rejection
// Register + OEE) — this SAP OWOR/IGN1-based summary now backs the
// admin-only legacy page (public/production-dashboard-legacy.html) only,
// so it's admin-gated here too rather than relying on the client alone.
router.get('/summary', verifyToken, async (req, res) => {
if (req.user?.role !== 'admin')
return res.status(403).json({ success: false, message: 'This legacy dashboard is admin-only.' });
try {
const DS = /^\d{4}-\d{2}-\d{2}$/;
const iso = d => d.toISOString().slice(0, 10);
let from = DS.test(req.query.from || '') ? req.query.from : null;
let to = DS.test(req.query.to || '') ? req.query.to : null;
if (!from || !to) {
const now = new Date(); to = iso(now);
const f = new Date(now); f.setMonth(f.getMonth() - 6); from = iso(f);
}
const like = s => String(s || '').replace(/'/g, "''").slice(0, 60).trim();
const itemQ = like(req.query.itemQ);
const itemCond = itemQ ? ` AND (T1.ItemCode LIKE '%${itemQ}%' OR T1.Dscription LIKE '%${itemQ}%')` : '';
const rc = `T0.DocDate>='${from}' AND T0.DocDate<='${to}'`; // receipts range
const oc = `PostDate>='${from}' AND PostDate<='${to}'`; // orders range
// ── KPIs ─────────────────────────────────────────────────────────────
const k = (await hana(`SELECT
(SELECT ISNULL(SUM(T1.Quantity),0) FROM IGN1 T1 JOIN OIGN T0 ON T0.DocEntry=T1.DocEntry WHERE T1.BaseType=202 AND ${rc}) AS outputQty,
(SELECT COUNT(DISTINCT T0.DocEntry) FROM IGN1 T1 JOIN OIGN T0 ON T0.DocEntry=T1.DocEntry WHERE T1.BaseType=202 AND ${rc}) AS receiptDocs,
(SELECT COUNT(DISTINCT T1.ItemCode) FROM IGN1 T1 JOIN OIGN T0 ON T0.DocEntry=T1.DocEntry WHERE T1.BaseType=202 AND ${rc}) AS itemsProduced,
(SELECT COUNT(*) FROM OWOR WHERE ${oc}) AS ordersCreated,
(SELECT ISNULL(SUM(PlannedQty),0) FROM OWOR WHERE ${oc}) AS plannedQtyCreated,
(SELECT COUNT(*) FROM OWOR WHERE ${oc} AND Status='L') AS ordersClosed,
(SELECT COUNT(*) FROM OWOR WHERE Status='P') AS openPlanned,
(SELECT COUNT(*) FROM OWOR WHERE Status='R') AS openReleased`))[0] || {};
// ── Monthly production output (receipts from production) ─────────────
const monthly = await hana(`
SELECT YEAR(T0.DocDate) y, MONTH(T0.DocDate) m,
SUM(T1.Quantity) AS qty, COUNT(DISTINCT T0.DocEntry) AS docs
FROM IGN1 T1 JOIN OIGN T0 ON T0.DocEntry=T1.DocEntry
WHERE T1.BaseType=202 AND ${rc}
GROUP BY YEAR(T0.DocDate), MONTH(T0.DocDate)
ORDER BY y, m`);
// ── Top produced items in range (searchable) ─────────────────────────
const topItems = await hana(`
SELECT TOP ${itemQ ? 12 : 8} T1.ItemCode AS code, MAX(T1.Dscription) AS name, SUM(T1.Quantity) AS qty
FROM IGN1 T1 JOIN OIGN T0 ON T0.DocEntry=T1.DocEntry
WHERE T1.BaseType=202 AND ${rc}${itemCond}
GROUP BY T1.ItemCode ORDER BY qty DESC`);
// ── Portal workflow funnel (local tracking, point-in-time) ───────────
const STEPS = ['Release', 'Issuance', 'Receipt from Production', 'Transfer to Finished Goods', 'Close'];
let stages = STEPS.map((label, i) => ({ stage: i, label, count: 0 }));
let rejected = 0;
try {
const rows = await appq(`SELECT STAGE, STATUS, COUNT(*) n FROM [dbo].[ZPRODUCTION_ORDERS] WHERE IS_DELETED=0 AND STATUS IN ('IN_PROGRESS','REJECTED') GROUP BY STAGE, STATUS`);
rows.forEach(r => {
if (r.STATUS === 'REJECTED') rejected += Number(r.n) || 0;
else if (stages[r.STAGE]) stages[r.STAGE].count = Number(r.n) || 0;
});
} catch (e) { console.warn('[PROD-DASH] local stages failed (non-fatal):', e.message); }
res.json({ success: true, data: {
from, to,
kpi: {
outputQty: Number(k.outputQty) || 0,
receiptDocs: Number(k.receiptDocs) || 0,
itemsProduced: Number(k.itemsProduced) || 0,
ordersCreated: Number(k.ordersCreated) || 0,
plannedQtyCreated:Number(k.plannedQtyCreated) || 0,
ordersClosed: Number(k.ordersClosed) || 0,
openPlanned: Number(k.openPlanned) || 0,
openReleased: Number(k.openReleased) || 0,
},
monthly: monthly.map(r => ({ y: r.y, m: r.m, qty: Number(r.qty) || 0, docs: Number(r.docs) || 0 })),
topItems: topItems.map(r => ({ code: r.code, name: r.name, qty: Number(r.qty) || 0 })),
stages, rejected,
}});
} catch (err) {
console.error('[PROD-DASH] summary failed:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+211
View File
@@ -0,0 +1,211 @@
'use strict';
// routes/productionOrders.js — local tracking for SAP B1 Production Orders
// generated FROM a Work Order (see services/productionOrderStore.js for why
// this table exists — SAP alone doesn't know about the extra Transfer-to-
// Finished-Goods step or which Work Order originated an order).
// The actual SAP transactions (create/release/issue/receipt/transfer/close)
// live in routes/sap.js — this file is just the local link record's CRUD.
const express = require('express');
const router = express.Router();
const { verifyToken, requireApprovalStep, requireWorkflowPerm, hasStepPerm } = require('../middleware/auth');
const store = () => require('../services/productionOrderStore');
const woStore = () => require('../services/workOrderStore');
let _sapSvc = null;
function getSap(){
if(!_sapSvc) _sapSvc = require('../services/sapServiceLayer');
return _sapSvc;
}
// Passes if the user holds `perm` on ANY of the given approval steps. Used for
// the verify/receive endpoints below: 'work_order:verify'/'work_order:receive'
// are the steps actually wired to the "Verify Work Order" screen;
// 'production_order:verify' is kept (unused today) for possible future use —
// holding any of them grants the same access, so re-enabling the old screen
// later, or adding more sign-off types, needs no further backend change.
function requireAnyStep(fullKeys, perm) {
return (req, res, next) => {
if (fullKeys.some(k => hasStepPerm(req.user, k, perm))) return next();
res.status(403).json({ success: false, message: `You are not assigned "${perm}" on approval step: ${fullKeys.join(' or ')}` });
};
}
router.get('/', verifyToken, requireWorkflowPerm('production_order', 'view'), async (req, res) => {
try {
const { mine, company, workOrderId, status } = req.query;
const data = await store().listProductionOrders({
mine: mine === '1' ? req.user.username : undefined, company, workOrderId, status,
});
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// "+ PWO" shortcut support: which components of a given parent order already
// have their own sub-PWO raised against them (REF_PARENT_ENTRY), so the
// button can be disabled/labelled instead of letting someone raise a
// duplicate for the same requirement. No workflow 'view' gate (unlike the
// general list below) — anyone who can open a Production Order's detail
// view needs this, not just users with production_order 'view'.
router.get('/by-ref-parent/:entry', verifyToken, async (req, res) => {
try {
const entry = parseInt(req.params.entry);
const all = await store().listProductionOrders({ company: req.query.company });
const data = all.filter(p => !p.isDeleted && p.refParentEntry === entry)
.map(p => ({ itemCode: p.itemCode, sapAbsEntry: p.sapAbsEntry, sapDocNum: p.sapDocNum, status: p.status }));
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Every fully APPROVED Work Order — verification is a Work-Order-level
// sign-off, independent of whatever its linked Production Order's own
// Issue/Receipt/Close stage happens to be (a WO can be, and stay, APPROVED
// long before/after any of that). The linked Production Order (if one
// exists yet) is joined in ONLY for display context (stage/doc no.), never
// to filter the list. Gated by the dedicated 'verify' step (a verifier need
// not hold the general production_order 'view' permission).
router.get('/for-verification', verifyToken, requireAnyStep(['work_order:verify', 'work_order:receive', 'work_order:issue', 'production_order:verify'], 'view'), async (req, res) => {
try {
const company = req.query.company;
const wos = await woStore().listWorkOrders({ company, status: 'APPROVED' });
const pos = await store().listProductionOrders({ company });
const poByWoId = {};
pos.forEach(p => { if (!p.isDeleted && p.workOrderId != null) poByWoId[p.workOrderId] = p; });
// Every applicable row (raw+pack, or just pack under componentsOnly)
// carries its own Issued/Received/Verified stamp — a WO counts as
// "complete" for a given stamp only once none of its rows are still
// pending that one. Computed here (not on the client) since the
// lightweight list payload below doesn't otherwise carry rawMaterials/
// packingMaterials — used to drive the card grid's "Issued By"/"Received
// By"/"Verified By" status filters (each one shows WOs still pending
// that specific sign-off).
function stampComplete(w, which) {
const rows = w.componentsOnly ? (w.packingMaterials || []) : [...(w.rawMaterials || []), ...(w.packingMaterials || [])];
return rows.length > 0 && rows.every(r => !!r[which + 'At']);
}
const data = wos.filter(w => !w.isDeleted).map(w => {
const po = poByWoId[w.id] || null;
return {
id: w.id, woNo: w.woNo, workOrderId: w.id,
itemCode: w.productCode, itemName: w.productName,
plannedQty: w.totalUnits, batchNumber: w.batchNumber,
intimationDocNo: w.intimationDocNo || '', createdBy: w.createdBy, createdByName: w.createdByName,
status: w.status,
issuedComplete: stampComplete(w, 'issued'),
receivedComplete: stampComplete(w, 'received'),
verified: stampComplete(w, 'verified'),
po: po ? { id: po.id, sapDocNum: po.sapDocNum, sapAbsEntry: po.sapAbsEntry, stage: po.stage, currentStep: po.currentStep, status: po.status } : null,
};
});
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Perform the verification sign-off (portal-only — no SAP call).
router.post('/:id/verify', verifyToken, requireAnyStep(['work_order:verify', 'production_order:verify'], 'approve'), async (req, res) => {
try {
const updated = await store().verify(req.params.id, {
by: req.user.username, byName: req.user.name || req.user.username, remarks: req.body.remarks || '',
});
res.json({ success: true, data: updated });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// Perform the "Received By" manual sign-off (portal-only — no SAP call).
// Independent of Verify; always overrides the SAP receipt step's signer on
// the printed Work Order (see routes/workOrders.js computeIssuance()).
router.post('/:id/receive', verifyToken, requireAnyStep(['work_order:receive'], 'approve'), async (req, res) => {
try {
const updated = await store().receiveManual(req.params.id, {
by: req.user.username, byName: req.user.name || req.user.username, remarks: req.body.remarks || '',
});
res.json({ success: true, data: updated });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
router.get('/:id', verifyToken, requireWorkflowPerm('production_order', 'view'), 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 }); }
});
// Register the local tracking row AFTER the frontend has already created the
// SAP Production Order (POST /api/sap/production-order). Works for BOTH
// origins: linked to a Work Order (workOrderId set), or a manual/standalone
// order (workOrderId null) — manual orders MUST be tracked too, otherwise
// their later stages (Issue → Receipt → Close) never appear in the pending-
// actions bell. Permission mirrors the SAP create route: which create step
// applies depends on whether a Work Order is the source.
router.post('/', verifyToken, (req, res, next) => {
const perm = req.body?.workOrderId ? 'production_order:create'
: req.body?.fromComponent ? 'production_order:create_from_component'
: req.body?.fromConsumable ? 'production_order:consumable_create'
: 'production_order:manual_create';
if (hasStepPerm(req.user, perm, 'add')) return next();
res.status(403).json({ success: false, message: `You are not assigned "add" on approval step: ${perm}` });
}, async (req, res) => {
try {
const b = req.body || {};
let wo = null;
if (b.workOrderId) {
wo = await woStore().findById(b.workOrderId);
if (!wo || wo.isDeleted) return res.status(404).json({ success: false, message: 'Work order not found' });
}
wo = wo || {}; // manual order — no WO fallbacks below
if (!b.sapAbsEntry) return res.status(400).json({ success: false, message: 'sapAbsEntry (from the SAP creation response) is required' });
// Consumable Orders are raised by many departments — Department is
// required (an organizational tag only, see [[consumable-order-department]])
// and, when this creator's own department list is restricted (Admin →
// User → "PR Departments"), must be one of theirs — never trust the
// client's own dropdown filtering for this.
if (b.fromConsumable) {
if (!String(b.department || '').trim())
return res.status(400).json({ success: false, message: 'Department is required for a Consumable Order.' });
try {
const acting = await require('../services/hanaUsers').findById(req.user.id);
const allowed = Array.isArray(acting?.allowedDepartments) ? acting.allowedDepartments.map(String) : [];
if (allowed.length && !allowed.includes(String(b.department)))
return res.status(403).json({ success: false, message: `You are not permitted to raise a Consumable Order for department "${b.department}".` });
} catch (_e) { /* non-fatal — proceeds unrestricted if the lookup itself fails */ }
}
const saved = await store().insertProductionOrder({
workOrderId: b.workOrderId || null,
sapAbsEntry: b.sapAbsEntry, sapDocNum: b.sapDocNum || '',
itemCode: b.itemCode || wo.productCode, itemName: b.itemName || wo.productName,
plannedQty: b.plannedQty || wo.totalUnits, batchNumber: b.batchNumber || wo.batchNumber,
refWoNo: b.refWoNo || '', refBatchNumber: b.refBatchNumber || '', refParentEntry: b.refParentEntry || null,
isConsumable: !!b.fromConsumable,
department: b.fromConsumable ? (b.department || '') : '',
mfgDate: b.mfgDate || wo.mfgDate, expDate: b.expDate || wo.expDate,
warehouse: b.warehouse || '', fgWarehouse: b.fgWarehouse || '',
company: b.company || '',
createdBy: req.user.username, createdByName: req.user.name || req.user.username,
});
res.json({ success: true, data: saved });
// Stamp this local tracking record's own ID onto the just-created SAP
// Production Order (UDF U_ERP_SO_NO) — best-effort, after the response
// is already sent, so a failure here never blocks order creation. Lets
// anyone in the SAP B1 client see which PWOs were created through the
// portal (vs typed directly into SAP) by checking that field.
try {
await getSap().sapRequest('PATCH', `ProductionOrders(${parseInt(b.sapAbsEntry)})`, { U_ERP_SO_NO: String(saved.id) }, b.company || '');
} catch (e) { console.warn('[PROD-ORDER] U_ERP_SO_NO stamp failed (non-fatal):', e.message); }
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
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' });
// Deleting the local link record is tied to 'create'-level authority
// (same as who's allowed to originate one), not whichever SAP stage the
// order happens to be sitting at.
if (!hasStepPerm(req.user, 'production_order:create', 'delete'))
return res.status(403).json({ success: false, message: 'You are not assigned "delete" on approval step: production_order:create' });
await store().softDelete(req.params.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+103
View File
@@ -0,0 +1,103 @@
// routes/productionPlanning.js
// Daily Production Planning — plain CRUD list gated by a single approval
// step (production_planning:entry), same pattern as Man Power/OEE. See
// services/productionPlanningStore.js for the data layer.
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken, requireApprovalStep, requireWorkflowPerm, hasStepAssigned } = require('../middleware/auth');
const store = () => require('../services/productionPlanningStore');
const defaultsStore = () => require('../services/productionPlanningItemDefaultsStore');
// Item-code-wise master defaults (No. of Package / Per Cycle Qty /
// No. of Cycle/Day / Product Type) — viewable by anyone who can view EITHER
// step of the module (entry or approve — an approver-only user still needs
// to see these to review a plan), editable only by those who can edit plans.
router.get('/item-defaults', verifyToken, requireWorkflowPerm('production_planning', 'view'), async (req, res) => {
try {
const data = await defaultsStore().listDefaults();
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/item-defaults/:itemCode', verifyToken, requireWorkflowPerm('production_planning', 'view'), async (req, res) => {
try {
const data = await defaultsStore().findByItemCode(req.params.itemCode);
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.post('/item-defaults', verifyToken, requireApprovalStep('production_planning:entry', 'edit'), async (req, res) => {
try {
const b = req.body || {};
const saved = await defaultsStore().upsertDefault({
itemCode: b.itemCode, itemName: b.itemName, noOfPackage: b.noOfPackage,
perCycleQty: b.perCycleQty, noOfCycleDay: b.noOfCycleDay, productType: b.productType,
}, { 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('/item-defaults/:id', verifyToken, requireApprovalStep('production_planning:entry', 'edit'), async (req, res) => {
try {
await defaultsStore().softDeleteDefault(req.params.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/', verifyToken, requireWorkflowPerm('production_planning', 'view'), async (req, res) => {
try {
const data = await store().listPlans({ company: req.query.company, from: req.query.from, to: req.query.to, status: req.query.status, productType: req.query.productType, market: req.query.market, q: req.query.q });
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.post('/', verifyToken, requireApprovalStep('production_planning:entry', 'add'), async (req, res) => {
try {
const b = req.body || {};
const saved = await store().createPlan({
planDate: b.planDate, productName: b.productName, productType: b.productType, market: b.market, noOfPackage: b.noOfPackage, quantity: b.quantity,
perCycleQty: b.perCycleQty, noOfCycleDay: b.noOfCycleDay, status: b.status, batchNo: b.batchNo, company: b.company,
createdBy: req.user.username, createdByName: req.user.name || req.user.username,
});
res.json({ success: true, data: saved });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
router.put('/:id', verifyToken, requireApprovalStep('production_planning:entry', 'edit'), async (req, res) => {
try {
const saved = await store().updatePlan(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('/:id', verifyToken, requireApprovalStep('production_planning:entry', 'delete'), async (req, res) => {
try {
await store().softDeletePlan(req.params.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.put('/:id/status', verifyToken, requireApprovalStep('production_planning:entry', 'edit'), async (req, res) => {
try {
const saved = await store().updateStatus(req.params.id, (req.body || {}).status, { 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.put('/:id/approve', verifyToken, requireApprovalStep('production_planning:approve', 'approve'), async (req, res) => {
try {
const saved = await store().approvePlan(req.params.id, { 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.put('/:id/reject', verifyToken, requireApprovalStep('production_planning:approve', 'approve'), async (req, res) => {
try {
const saved = await store().rejectPlan(req.params.id, (req.body || {}).reason, { 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 }); }
});
module.exports = router;
+119
View File
@@ -0,0 +1,119 @@
'use strict';
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { verifyToken, hasStepPerm } = require('../middleware/auth');
const store = () => require('../services/projectStore');
// ── file upload (multer) ──────────────────────────────────────────────────────
// uploads/ is resolved from the CWD (the app root — every start script cd's
// there first), NOT __dirname: after esbuild bundling __dirname becomes the
// bundle's own folder, which would break the ../uploads hop.
const UPLOADS_ROOT = path.resolve('uploads');
const storage = multer.diskStorage({
destination(req, file, cb) {
const dir = path.join(UPLOADS_ROOT, 'projects', String(req.params.id));
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename(req, file, cb) {
const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
cb(null, Date.now() + '-' + safe);
},
});
const upload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } });
// ── next code preview ─────────────────────────────────────────────────────────
router.get('/next-code', verifyToken, async (req, res) => {
try {
res.json({ success: true, code: await store().nextCode() });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── CRUD ──────────────────────────────────────────────────────────────────────
router.get('/', verifyToken, async (req, res) => {
try {
res.json({ success: true, data: await store().list(req.query) });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.post('/', verifyToken, async (req, res) => {
try {
if (!req.body.project_name)
return res.status(400).json({ success: false, message: 'Project name is required' });
const result = await store().create({ ...req.body, submitted_by: req.user.username });
res.json({ success: true, ...result });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/:id', verifyToken, async (req, res) => {
try {
const p = await store().findById(req.params.id);
if (!p) return res.status(404).json({ success: false, message: 'Not found' });
res.json({ success: true, data: p });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.patch('/:id', verifyToken, async (req, res) => {
try {
await store().update(req.params.id, req.body);
res.json({ success: true, message: 'Updated' });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.post('/:id/approve', verifyToken, async (req, res) => {
try {
const step = req.body?.step;
if (!step || !hasStepPerm(req.user, `project:${step}`, 'approve'))
return res.status(403).json({ success: false, message: `You are not assigned "approve" on approval step: project:${step || '?'}` });
await store().stepApprove(req.params.id, req.body);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── file upload ───────────────────────────────────────────────────────────────
router.post('/:id/upload', verifyToken, upload.array('files', 10), async (req, res) => {
try {
const p = await store().findById(req.params.id);
if (!p) return res.status(404).json({ success: false, message: 'Project not found' });
const existing = Array.isArray(p.attachments) ? p.attachments : [];
const newFiles = (req.files || []).map(f => ({
name: f.filename,
originalName: f.originalname,
size: f.size,
url: `/uploads/projects/${req.params.id}/${f.filename}`,
}));
await store().update(req.params.id, { attachments: [...existing, ...newFiles] });
res.json({ success: true, files: newFiles });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.delete('/:id/attachments/:filename', verifyToken, async (req, res) => {
try {
const p = await store().findById(req.params.id);
if (!p) return res.status(404).json({ success: false, message: 'Project not found' });
const existing = Array.isArray(p.attachments) ? p.attachments : [];
const updated = existing.filter(f => f.name !== req.params.filename);
await store().update(req.params.id, { attachments: updated });
// delete file from disk
const filePath = path.join(UPLOADS_ROOT, 'projects', String(req.params.id), req.params.filename);
fs.unlink(filePath, () => {});
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.delete('/:id', verifyToken, async (req, res) => {
try {
const p = await store().findById(req.params.id);
if (!p) return res.status(404).json({ success: false, message: 'Not found' });
if (p.status !== 'DRAFT' && req.user.role !== 'admin')
return res.status(403).json({ success: false, message: 'Only DRAFT projects can be deleted' });
await store().remove(req.params.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+146
View File
@@ -0,0 +1,146 @@
// routes/pwoSourceAudit.js
// "PWO Source Report" — for each Production Order, shows whether each of its
// four lifecycle actions (Create / Release / Issue / Receipt) was done
// through this portal or directly in SAP B1. Pulled entirely from SAP B1
// Service Layer + this portal's own local tracking table.
//
// How each stage is told apart:
// - CREATE: SAP UDF U_ERP_SO_NO on the Production Order itself — stamped
// with this portal's own local record ID whenever it creates
// one (see routes/productionOrders.js). Blank = created
// directly in SAP.
// - RELEASE: no UDF exists for this — instead, compares SAP's live
// ProductionOrderStatus against this portal's own local
// tracking record's workflow log. A "Release" / "completed"
// entry only ever gets written by the portal's own Release
// route, so its presence (or absence, with SAP nonetheless
// showing Released) is what's checked here.
// - ISSUE / RECEIPT: no UDF exists on InventoryGenExits/InventoryGenEntries
// either, and DI API (needed to add one) isn't available on
// this SAP install — so the portal instead tags the Comments
// field of every Issue/Receipt document it posts with
// "[Portal PWO#<id>]" (see routes/sap.js). A document without
// that tag was posted directly in SAP.
//
// NOTE — this only works going forward from when each tagging mechanism was
// added: historical orders/documents posted before that have no tag either
// way and will show as "Direct (SAP)" even if they went through the portal.
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const poStore = () => require('../services/productionOrderStore');
let _sapSvc = null;
function getSap(){
if(!_sapSvc) _sapSvc = require('../services/sapServiceLayer');
return _sapSvc;
}
const cq = (req) => req.query?.company || req.body?.company || null;
// Follows SAP's nextLink until exhausted or MAX_PAGES hit — no $top on the
// initial request (confirmed elsewhere in this app: SAP treats an explicit
// $top as a TOTAL cap across the whole nextLink chain here, not a per-page
// size, silently truncating a large result set otherwise).
async function fetchAllPaged(sap, co, entity, filter, select, orderByField = 'DocDate') {
const out = [];
let url = `${entity}?$filter=${encodeURIComponent(filter)}&$select=${select}&$orderby=${orderByField} desc`;
const MAX_PAGES = 300;
for (let p = 0; p < MAX_PAGES && url; p++) {
const r = await sap.sapRequest('GET', url, null, co);
out.push(...(r?.value || []));
url = r?.['odata.nextLink'] || r?.['@odata.nextLink'] || null;
if (!r?.value?.length) break;
}
return out;
}
router.get('/report', verifyToken, async (req, res) => {
const co = cq(req);
const { from, to } = req.query;
if (!from || !to) return res.status(400).json({ success: false, message: 'from and to dates are required' });
try {
const sap = getSap();
// ProductionOrders has no "DocDate" field (confirmed live — filtering on
// it 502s instead of a clean error) — PostingDate is its equivalent
// "when created" field.
const orders = await fetchAllPaged(
sap, co, 'ProductionOrders',
`PostingDate ge '${from}' and PostingDate le '${to}'`,
'AbsoluteEntry,DocumentNumber,ItemNo,ProductDescription,ProductionOrderStatus,PostingDate,U_ERP_SO_NO',
'PostingDate'
);
if (!orders.length) return res.json({ success: true, data: [] });
// Issue/Receipt can, in principle, happen any time after an order is
// created — but scanning with no upper bound turned out to be far too
// heavy in practice (2000+ Goods Issue documents for a single month on
// this install, ~100+ SAP pages, occasionally 502-ing SAP's own gateway
// under the load). Scoped to the SAME [from,to] window as the orders
// themselves instead — the known trade-off: an order created near the
// END of the selected range whose Issue/Receipt happens AFTER `to` will
// show that stage as "not reached yet" here even though it has been.
// Widen `to` (or re-run with a later range) to see those.
const genFilter = `DocDate ge '${from}' and DocDate le '${to}'`;
const [genExits, genEntries] = await Promise.all([
fetchAllPaged(sap, co, 'InventoryGenExits', genFilter, 'DocEntry,DocNum,DocumentLines'),
fetchAllPaged(sap, co, 'InventoryGenEntries', genFilter, 'DocEntry,DocNum,DocumentLines'),
]);
const localRecs = await poStore().listProductionOrders({ company: co });
const localByEntry = {};
localRecs.forEach(p => { if (p.sapAbsEntry != null) localByEntry[p.sapAbsEntry] = p; });
// Portal-origin marker is now a genuine per-LINE UDF (IGE1/IGN1's own
// U_ERP_SO_NO — set directly in routes/sap.js's creation payload, holding
// the same local Production Order tracking ID as OWOR.U_ERP_SO_NO).
// Checked per matching LINE, not per whole document, since one document
// could in principle carry lines for more than one Production Order.
function groupByBaseEntry(docs) {
const map = {};
docs.forEach(d => {
(d.DocumentLines || []).forEach(l => {
if (l.BaseType === 202 && l.BaseEntry != null) (map[l.BaseEntry] = map[l.BaseEntry] || []).push(l);
});
});
return map;
}
const issueByEntry = groupByBaseEntry(genExits);
const receiptByEntry = groupByBaseEntry(genEntries);
function sourceLabel(lines) {
if (!lines || !lines.length) return null; // stage not reached yet
const tagged = lines.filter(l => !!l.U_ERP_SO_NO).length;
if (tagged === lines.length) return 'Portal';
if (tagged === 0) return 'Direct (SAP)';
return 'Mixed';
}
const rows = orders.map(o => {
const local = localByEntry[o.AbsoluteEntry];
const released = o.ProductionOrderStatus === 'boposReleased' || o.ProductionOrderStatus === 'boposClosed';
let releaseSource = null;
if (released) {
const hasPortalRelease = !!(local && Array.isArray(local.workflowLog) && local.workflowLog.some(l => l.step === 'Release' && l.action === 'completed'));
releaseSource = hasPortalRelease ? 'Portal' : 'Direct (SAP)';
}
return {
absoluteEntry: o.AbsoluteEntry,
docNum: o.DocumentNumber,
itemCode: o.ItemNo,
itemName: o.ProductDescription,
status: (o.ProductionOrderStatus || '').replace('bopos', ''),
creationSource: o.U_ERP_SO_NO ? 'Portal' : 'Direct (SAP)',
releaseSource,
issueSource: sourceLabel(issueByEntry[o.AbsoluteEntry]),
receiptSource: sourceLabel(receiptByEntry[o.AbsoluteEntry]),
};
});
res.json({ success: true, data: rows });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
module.exports = router;
+293
View File
@@ -0,0 +1,293 @@
// 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;
+252
View File
@@ -0,0 +1,252 @@
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
// ── Open Production Orders ────────────────────────────────────────────────
function buildOpenProductionOrdersSQL(from, to, status, segment) {
const statusClause = status && status !== 'all'
? `AND OWOR.Status = '${status === 'Released' ? 'R' : 'P'}'`
: `AND OWOR.Status IN ('R','P')`;
const segmentClause = segment && segment !== 'all'
? `AND (
CASE
WHEN OITM.U_ItemSubGroup LIKE '%SFG Equipment%' OR OITM.U_ItemSubGroup LIKE '%EQUIPMENT%' THEN 'Equipment'
WHEN OITM.U_ItemSubGroup LIKE '%Consumable%' OR OITM.U_ItemSubGroup LIKE '%Consumables%' THEN 'Consumable'
WHEN OITM.U_ItemSubGroup LIKE '%FG Stent%' THEN 'FG Stent'
WHEN OITM.U_ItemSubGroup LIKE '%FG TT Device%' THEN 'FG TT Device'
WHEN OITM.U_ItemSubGroup LIKE '%PLASTIC%' THEN 'PLASTIC'
WHEN OITM.U_ItemSubGroup LIKE '%NEEDLE%' THEN 'NEEDLE'
WHEN OITM.U_ItemSubGroup LIKE '%MOULDING%' THEN 'MOULDING'
WHEN OITM.U_ItemSubGroup LIKE '%FG CAPD ACCESSORIES%' THEN 'FG CAPD ACCESSORIES'
WHEN OITM.U_ItemSubGroup LIKE '%FG CAPD%' THEN 'FG CAPD'
WHEN OITM.U_ItemSubGroup LIKE '%CAPD%' THEN 'CAPD'
WHEN OITM.U_ItemSubGroup LIKE '%FG Equipment%' THEN 'FG Equipment'
WHEN OITM.U_ItemSubGroup LIKE '%FG Blood Bag%' THEN 'FG Blood Bag'
WHEN OITM.U_ItemSubGroup LIKE '%SFG Blood bag%' THEN 'SFG Blood bag'
WHEN OITM.U_ItemSubGroup LIKE '%BLOOD BAG%' THEN 'BLOOD BAG'
ELSE 'Other'
END
) = '${segment.replace(/'/g, "''")}'`
: '';
return `
SELECT DISTINCT
OWOR.DocEntry,
k.SeriesName,
OWOR.DocNum,
CASE WHEN OWOR.Type = 'S' THEN 'Standard' ELSE 'Other' END AS Type,
CASE
WHEN OWOR.Status = 'R' THEN 'Released'
WHEN OWOR.Status = 'P' THEN 'Planned'
ELSE 'Unknown'
END AS Status,
OWOR.ItemCode AS ProductCode,
CASE
WHEN OITM.U_ItemSubGroup LIKE '%SFG Equipment%' OR OITM.U_ItemSubGroup LIKE '%EQUIPMENT%' THEN 'Equipment'
WHEN OITM.U_ItemSubGroup LIKE '%Consumable%' OR OITM.U_ItemSubGroup LIKE '%Consumables%' THEN 'Consumable'
WHEN OITM.U_ItemSubGroup LIKE '%FG Stent%' THEN 'FG Stent'
WHEN OITM.U_ItemSubGroup LIKE '%FG TT Device%' THEN 'FG TT Device'
WHEN OITM.U_ItemSubGroup LIKE '%PLASTIC%' THEN 'PLASTIC'
WHEN OITM.U_ItemSubGroup LIKE '%NEEDLE%' THEN 'NEEDLE'
WHEN OITM.U_ItemSubGroup LIKE '%MOULDING%' THEN 'MOULDING'
WHEN OITM.U_ItemSubGroup LIKE '%FG CAPD ACCESSORIES%' THEN 'FG CAPD ACCESSORIES'
WHEN OITM.U_ItemSubGroup LIKE '%FG CAPD%' THEN 'FG CAPD'
WHEN OITM.U_ItemSubGroup LIKE '%CAPD%' THEN 'CAPD'
WHEN OITM.U_ItemSubGroup LIKE '%FG Equipment%' THEN 'FG Equipment'
WHEN OITM.U_ItemSubGroup LIKE '%FG Blood Bag%' THEN 'FG Blood Bag'
WHEN OITM.U_ItemSubGroup LIKE '%SFG Blood bag%' THEN 'SFG Blood bag'
WHEN OITM.U_ItemSubGroup LIKE '%BLOOD BAG%' THEN 'BLOOD BAG'
ELSE 'Other'
END AS ProductSegment,
OWOR.ProdName AS ProductName,
OWOR.PlannedQty AS PlannedQuantity,
OWOR.CmpltQty AS CompletedQty,
CONVERT(VARCHAR(10), OWOR.PostDate, 105) AS PostDate,
CONVERT(VARCHAR(10), OWOR.StartDate, 105) AS StartDate,
CONVERT(VARCHAR(10), OWOR.DueDate, 105) AS DueDate
FROM OWOR
INNER JOIN WOR1 ON OWOR.DocEntry = WOR1.DocEntry
INNER JOIN OITM ON OWOR.ItemCode = OITM.ItemCode
LEFT JOIN NNM1 k ON k.Series = OWOR.Series
WHERE OWOR.PostDate BETWEEN @from AND @to
${statusClause}
AND OITM.U_ItemSubGroup <> 'FG Scrap'
${segmentClause}
ORDER BY OWOR.DocNum DESC`;
}
// ── GET /api/reports/open-production-orders ───────────────────────────────
router.get('/open-production-orders', verifyToken, async (req, res) => {
try {
let { from, to, status = 'all', segment = 'all' } = req.query;
// Default: last 15 days
if (!from || !to) {
const toDate = new Date();
toDate.setDate(toDate.getDate() - 1);
const frDate = new Date(toDate);
frDate.setDate(frDate.getDate() - 14);
to = toDate.toISOString().split('T')[0];
from = frDate.toISOString().split('T')[0];
}
const pool = await getPool();
const sql = buildOpenProductionOrdersSQL(from, to, status, segment);
const r = await pool.request()
.input('from', from)
.input('to', to)
.query(sql);
res.json({ success: true, data: r.recordset || [], count: (r.recordset || []).length, from, to });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ── Revenue constants (mirrors chatbot.js) ────────────────────────────────
const EXCL_DOCS = `'552520145','552520146','552520147','552520465','552522652','552522653','552620186'`;
const EXCL_TM = `'4110201001','4110202001','4110201003','4110202003','4110201007','4110201002','4110201005'`;
const EXCL_PD = EXCL_TM + `,'4110202005','4110202007','4110201004'`;
const EXCL_ED = `'4110201001','4110201003','4110201007','4110201002','4110201005','4110201004'`;
const EXCL_EI = `'4110202001','4110202003','4110202005','4110202007'`;
function buildRevSubq(dateWhere, isCN) {
const tbl = isCN ? 'RIN1' : 'INV1';
const hdr = isCN ? 'ORIN' : 'OINV';
const sign = isCN ? '-' : '';
const extras = isCN ? '' : `
AND i.DocNum NOT IN (${EXCL_DOCS})
AND (t2.ItmsGrpNam IN ('FG CAPD','FG CAPD Accessories')
OR (i.DocStatus<>'C' OR i.InvntSttus='O'))`;
return `
SELECT
CASE WHEN (t2.ItmsGrpNam='FG BLOOD BAG'
OR (t2.ItmsGrpNam IN ('FG EQUIPMENT','SEMI FINISHED') AND t1.U_BagType='Equipment'))
AND t.AcctCode NOT IN (${EXCL_TM})
THEN ${sign}t.LineTotal ELSE 0 END AS TM,
CASE WHEN (t2.ItmsGrpNam IN ('FG CAPD','FG CAPD Accessories')
OR t1.U_PDGroup='Equipment')
AND t.AcctCode NOT IN (${EXCL_PD})
THEN ${sign}t.LineTotal ELSE 0 END AS PD,
CASE WHEN t.AcctCode IN (${EXCL_ED}) THEN ${sign}t.LineTotal ELSE 0 END AS ED,
CASE WHEN t.AcctCode IN (${EXCL_EI}) THEN ${sign}t.LineTotal ELSE 0 END AS EI
FROM ${tbl} t
LEFT JOIN OITM t1 ON t.ItemCode = t1.ItemCode
LEFT JOIN OITB t2 ON t1.ItmsGrpCod = t2.ItmsGrpCod
INNER JOIN ${hdr} i ON t.DocEntry = i.DocEntry
WHERE ${dateWhere} ${extras}`;
}
// ── GET /api/reports/sales?asOf=YYYY-MM-DD&period=all|daily|monthly|yearly ─
router.get('/sales', verifyToken, async (req, res) => {
try {
const { period = 'all' } = req.query;
const target = req.query.asOf ? new Date(req.query.asOf) : (() => {
const d = new Date(); d.setDate(d.getDate() - 1); return d;
})();
const toDate = target.toISOString().split('T')[0];
const dd = target.getDate(), mm = target.getMonth() + 1, yyyy = target.getFullYear();
const yearStart = (dd < 16 && mm <= 1) ? `${yyyy - 1}-01-16` : `${yyyy}-01-16`;
const pMm = mm === 1 ? 12 : mm - 1;
const pYyyy = mm === 1 ? yyyy - 1 : yyyy;
const monthStart = dd < 16
? `${pYyyy}-${String(pMm).padStart(2, '0')}-16`
: `${yyyy}-${String(mm).padStart(2, '0')}-16`;
const pt = period.toLowerCase();
const periods = [];
if (pt === 'daily' || pt === 'all') periods.push([`Daily (${toDate})`, `i.DocDate = '${toDate}'`]);
if (pt === 'monthly' || pt === 'all') periods.push([`Monthly (${monthStart} – ${toDate})`, `i.DocDate BETWEEN '${monthStart}' AND '${toDate}'`]);
if (pt === 'yearly' || pt === 'all') periods.push([`Yearly (${yearStart} – ${toDate})`, `i.DocDate BETWEEN '${yearStart}' AND '${toDate}'`]);
const pool = await getPool();
const rows = [];
for (const [label, dw] of periods) {
const sql = `
SELECT
'${label}' AS Period,
ROUND(SUM(TM), 2) AS TM_BloodBag,
ROUND(SUM(PD), 2) AS PD_CAPD,
ROUND(SUM(ED), 2) AS ExportDirect,
ROUND(SUM(EI), 2) AS ExportIndirect,
ROUND(SUM(TM)+SUM(PD)+SUM(ED)+SUM(EI), 2) AS Total
FROM (
${buildRevSubq(dw, false)}
UNION ALL
${buildRevSubq(dw, true)}
) CK`;
try {
const r = await pool.request().query(sql);
if (r.recordset?.[0]) rows.push(r.recordset[0]);
} catch (e) {
rows.push({ Period: label, error: e.message });
}
}
res.json({ success: true, data: rows, asOf: toDate });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ── GET /api/reports/fg-inventory?from=&to=&warehouse=&itemGroup= ─────────
router.get('/fg-inventory', verifyToken, async (req, res) => {
try {
let { from, to, warehouse = '01', itemGroup = 'FG BLOOD BAG' } = req.query;
if (!from || !to) {
const d = new Date();
to = d.toISOString().split('T')[0];
const f = new Date(d.getFullYear(), d.getMonth(), 1);
from = f.toISOString().split('T')[0];
}
const pool = await getPool();
const r = await pool.request()
.input('FromDate', from)
.input('ToDate', to)
.input('WhsCode', warehouse)
.input('ItmsGrp', itemGroup)
.execute('INVENTORY_FG_AVG_PRICE');
const data = r.recordset || [];
res.json({ success: true, data, count: data.length, from, to, warehouse, itemGroup });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ── GET /api/reports/bom-cost?itemGroup= ─────────────────────────────────
router.get('/bom-cost', verifyToken, async (req, res) => {
try {
const { itemGroup = 'FG BLOOD BAG' } = req.query;
const pool = await getPool();
const r = await pool.request()
.input('grp', itemGroup)
.query(`
SELECT T1.Code AS ItemCode, T0.ItemName, MIN(P.Price) AS Price
FROM OITT T1
INNER JOIN OITM T0 ON T0.ItemCode = T1.Code
LEFT JOIN ITM1 P ON P.ItemCode = T1.Code
LEFT JOIN OITB T2 ON T2.ItmsGrpCod = T0.ItmsGrpCod
WHERE T2.ItmsGrpNam = @grp AND P.Price > 0
GROUP BY T1.Code, T0.ItemName
ORDER BY T1.Code`);
const data = r.recordset || [];
res.json({ success: true, data, count: data.length, itemGroup });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
// Reused by routes/board.js so the Board Dashboard's Division Summary is
// computed by EXACTLY the same logic as this sales report (verified to match
// the management figures to the paisa).
module.exports.buildRevSubq = buildRevSubq;
module.exports.REV_CONST = { EXCL_DOCS, EXCL_TM, EXCL_PD, EXCL_ED, EXCL_EI };
+172
View File
@@ -0,0 +1,172 @@
'use strict';
// routes/requirementCalc.js — "Requirement Calculator": a worksheet under the
// Requirements module that computes, per item, a suggested production
// requirement from historical sale quantity and current stock, so a planner
// can push the result straight into a real Requirement record (POST
// /api/requirements already accepts {periodFrom,periodTo,lines[]} — this
// route only supplies the numbers that feed that form, it does not persist
// anything itself).
//
// Formula (per the reference worksheet):
// Average Sale/Month = (Qty sold in Period A + Qty sold in Period B) / (months spanned by A+B, typically 10)
// Total FG = Stock + Quarantine Stock
// Stock in Hand (mo) = Total FG / Average Sale per Month
// Requirement = Average Sale per Month × 2 − Total FG
//
// Admin → System Settings can disable either Period A or Period B (at least
// one must always stay enabled). With only one period enabled, Average
// Sale/Month instead uses just that period's qty ÷ the actual span of its
// own From/To dates (auto-derived — the manual "Months" divisor field is
// only meaningful when averaging A+B together).
//
// Sale Qty and Stock are looked up live from SAP (INV1/OINV net of RIN1/ORIN
// returns; OITW.OnHand). There is no "Quarantine" warehouse concept anywhere
// else in this codebase, so the caller must supply which warehouse code(s)
// represent normal Stock and Quarantine Stock for this calculation (picked
// from GET /api/sap/lookup/warehouses) — nothing is hardcoded/guessed here.
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const appSettings = require('../services/appSettingsStore');
// NOTE: getPool() connects to the single fixed SQL_DATABASE from .env (same
// as every other direct-SQL route in this app) — it is not company-switched
// per request, unlike the SAP Service-Layer calls elsewhere.
// Whole+fractional months between two ISO dates, inclusive on both ends —
// used to auto-derive the divisor when only ONE comparison period is enabled
// (Admin → System Settings → Requirement Calculator), instead of the manual
// "Months" field which only makes sense when averaging A+B together.
function monthSpan(from, to) {
const days = (new Date(to + 'T00:00:00Z') - new Date(from + 'T00:00:00Z')) / 86400000 + 1;
return Math.max(days / 30.44, 1 / 30.44); // never zero — a same-day range still spans a fraction of a month
}
// Net quantity sold (Invoices minus Returns/Credit Memos) for one item in one
// date range. DocDate is inclusive on both ends.
async function saleQty(pool, itemCode, from, to) {
const sql = `
SELECT
(
ISNULL((SELECT SUM(inv."Quantity") FROM [dbo].[INV1] inv
JOIN [dbo].[OINV] h ON h."DocEntry" = inv."DocEntry"
WHERE inv."ItemCode" = @itemCode AND h."DocDate" BETWEEN @from AND @to AND h."CANCELED" = 'N'), 0)
-
ISNULL((SELECT SUM(r."Quantity") FROM [dbo].[RIN1] r
JOIN [dbo].[ORIN] rh ON rh."DocEntry" = r."DocEntry"
WHERE r."ItemCode" = @itemCode AND rh."DocDate" BETWEEN @from AND @to AND rh."CANCELED" = 'N'), 0)
) AS "Qty"
`;
const r = await pool.request().input('itemCode', itemCode).input('from', from).input('to', to).query(sql);
return Number(r.recordset?.[0]?.Qty) || 0;
}
// On-hand quantity for one item, optionally restricted to one warehouse
// (blank/omitted = summed across all warehouses).
async function stockQty(pool, itemCode, whsCode) {
const req = pool.request().input('itemCode', itemCode);
let where = `"ItemCode" = @itemCode`;
if (whsCode) { req.input('whsCode', whsCode); where += ` AND "WhsCode" = @whsCode`; }
const r = await req.query(`SELECT ISNULL(SUM("OnHand"), 0) AS "Qty" FROM [dbo].[OITW] WHERE ${where}`);
return Number(r.recordset?.[0]?.Qty) || 0;
}
// GET /api/requirement-calc/line — one item's full computed row.
router.get('/line', verifyToken, async (req, res) => {
const { itemCode, periodAFrom, periodATo, periodBFrom, periodBTo, stockWhs, quarantineWhs, months } = req.query;
if (!itemCode) return res.status(400).json({ success: false, message: 'itemCode is required' });
// Which periods the admin has enabled — see Admin → System Settings.
const aOn = appSettings.reqCalcPeriodAEnabled();
const bOn = appSettings.reqCalcPeriodBEnabled();
if (aOn && (!periodAFrom || !periodATo)) return res.status(400).json({ success: false, message: 'Period A date range is required' });
if (bOn && (!periodBFrom || !periodBTo)) return res.status(400).json({ success: false, message: 'Period B date range is required' });
try {
const pool = await getPool();
const [qtyA, qtyB, stock, quarantine] = await Promise.all([
aOn ? saleQty(pool, itemCode, periodAFrom, periodATo) : Promise.resolve(0),
bOn ? saleQty(pool, itemCode, periodBFrom, periodBTo) : Promise.resolve(0),
stockQty(pool, itemCode, stockWhs || null),
quarantineWhs ? stockQty(pool, itemCode, quarantineWhs) : Promise.resolve(0),
]);
// Both enabled: existing behavior — (A+B) ÷ manual Months divisor.
// Only one enabled: that period's own qty ÷ the actual span of its own
// From/To dates (auto-calculated — the manual Months field doesn't apply).
let avgSalePerMonth;
if (aOn && bOn) {
const divisor = Math.max(1, parseFloat(months) || 10);
avgSalePerMonth = (qtyA + qtyB) / divisor;
} else if (aOn) {
avgSalePerMonth = qtyA / monthSpan(periodAFrom, periodATo);
} else {
avgSalePerMonth = qtyB / monthSpan(periodBFrom, periodBTo);
}
const totalFG = stock + quarantine;
const stockInHandMonths = avgSalePerMonth > 0 ? totalFG / avgSalePerMonth : 0;
const requirement = avgSalePerMonth * 2 - totalFG;
res.json({
success: true,
data: {
itemCode, saleQtyPeriodA: qtyA, saleQtyPeriodB: qtyB,
avgSalePerMonth, stock, quarantineStock: quarantine, totalFG,
stockInHandMonths, requirement,
},
});
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── Saved Item Groups (self-service presets for the Calculator) ──────────
// Shared company-wide: everyone with Requirements access can see and use any
// group; only its creator or an admin may edit/delete it.
const groupStore = require('../services/reqCalcGroupStore');
function canEditGroup(req, group) {
return req.user?.role === 'admin' || group.createdBy === req.user?.username;
}
router.get('/groups', verifyToken, async (req, res) => {
try {
const groups = await groupStore.listGroups(req.query.company || '');
res.json({ success: true, data: groups });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.post('/groups', verifyToken, async (req, res) => {
try {
const { name, items, company } = req.body || {};
if (!name || !String(name).trim()) return res.status(400).json({ success: false, message: 'Group name is required' });
if (!Array.isArray(items) || !items.length) return res.status(400).json({ success: false, message: 'Select at least one item' });
const group = await groupStore.createGroup({
name: String(name).trim(), items, company: company || '',
createdBy: req.user.username, createdByName: req.user.name || req.user.username,
});
res.json({ success: true, data: group });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.put('/groups/:id', verifyToken, async (req, res) => {
try {
const existing = await groupStore.getGroup(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: 'Group not found' });
if (!canEditGroup(req, existing)) return res.status(403).json({ success: false, message: 'Only the creator or an admin can edit this group' });
const { name, items } = req.body || {};
if (!name || !String(name).trim()) return res.status(400).json({ success: false, message: 'Group name is required' });
if (!Array.isArray(items) || !items.length) return res.status(400).json({ success: false, message: 'Select at least one item' });
const group = await groupStore.updateGroup(req.params.id, { name: String(name).trim(), items });
res.json({ success: true, data: group });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.delete('/groups/:id', verifyToken, async (req, res) => {
try {
const existing = await groupStore.getGroup(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: 'Group not found' });
if (!canEditGroup(req, existing)) return res.status(403).json({ success: false, message: 'Only the creator or an admin can delete this group' });
await groupStore.deleteGroup(req.params.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+243
View File
@@ -0,0 +1,243 @@
'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;
+60
View File
@@ -0,0 +1,60 @@
'use strict';
// routes/rmOvgSettings.js — admin-managed Item Code/Item Group → Ovg% rows
// (see services/rmOvgSettingsStore.js) plus the resolve lookup work-order.html
// uses to pre-fill a raw material row's Ovg% and recompute Qty Req./100 ml.
const express = require('express');
const router = express.Router();
const { verifyToken, verifyUserAdmin } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const store = () => require('../services/rmOvgSettingsStore');
router.get('/', verifyToken, verifyUserAdmin, async (req, res) => {
try {
const data = await store().listAll();
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.post('/', verifyToken, verifyUserAdmin, async (req, res) => {
try {
const { itemCode, itemGroup, ovgPercent } = req.body || {};
const saved = await store().create({ itemCode, itemGroup, ovgPercent, by: req.user.username });
res.json({ success: true, data: saved });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
router.put('/:id', verifyToken, verifyUserAdmin, async (req, res) => {
try {
const { itemCode, itemGroup, ovgPercent } = req.body || {};
const saved = await store().update(req.params.id, { itemCode, itemGroup, ovgPercent, by: req.user.username });
res.json({ success: true, data: saved });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
router.delete('/:id', verifyToken, verifyUserAdmin, async (req, res) => {
try {
await store().remove(req.params.id);
res.json({ success: true });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// GET /resolve?codes=RM001,RM002&company=... — { itemCode: ovgPercent } for
// every code that resolves a match (item-code-specific row wins over its
// item-group row). Any authenticated user may call this (needed by
// work-order.html for anyone creating/editing a Work Order, not just admins).
router.get('/resolve', verifyToken, async (req, res) => {
try {
const codes = (req.query.codes || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
if (!codes.length) return res.json({ success: true, data: {} });
const pool = await getPool(req.query.company || null);
const list = codes.map(c => `'${c.replace(/'/g, "''")}'`).join(',');
const result = await pool.request().query(`SELECT "ItemCode","ItmsGrpCod" FROM [dbo]."OITM" WHERE "ItemCode" IN (${list})`);
const groupByItemCode = {};
(result.recordset || []).forEach(r => { groupByItemCode[String(r.ItemCode).toUpperCase()] = r.ItmsGrpCod; });
const data = await store().resolveForItems(codes, groupByItemCode);
res.json({ success: true, data });
} catch (err) { res.json({ success: true, data: {}, warning: err.message }); }
});
module.exports = router;
+135
View File
@@ -0,0 +1,135 @@
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const store = () => require('../services/salaryStore');
router.post('/upload', verifyToken, async (req, res) => {
try {
const { period, sheets } = req.body;
if (!period || !sheets || typeof sheets !== 'object')
return res.status(400).json({ success: false, message: 'period and sheets required' });
await store().clearPeriod(period);
const results = [];
for (const [sheetName, rawRows] of Object.entries(sheets)) {
const parsed = parseSheetRows(rawRows);
if (!parsed.length) { results.push({ sheet: sheetName, rows: 0, skipped: true }); continue; }
await store().saveSheetRows(period, sheetName, parsed);
results.push({ sheet: sheetName, rows: parsed.length });
}
res.json({ success: true, data: results });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/periods', verifyToken, async (req, res) => {
try { res.json({ success: true, data: await store().getPeriods() }); }
catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/sheets', verifyToken, async (req, res) => {
try { res.json({ success: true, data: await store().getSheets(req.query.period || '') }); }
catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/rows', verifyToken, async (req, res) => {
try { res.json({ success: true, data: await store().getRows(req.query.period || '', req.query.sheet || '') }); }
catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/config', verifyToken, async (req, res) => {
try {
const [config, bbRatios] = await Promise.all([store().getConfig(), store().getBBRatios()]);
res.json({ success: true, config, bbRatios });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.post('/config', verifyToken, async (req, res) => {
try {
const { entries = [], bbRatios = [] } = req.body;
if (entries.length) await store().saveConfig(entries);
for (const b of bbRatios) await store().saveBBRatio(b.sheet_name, b.bb_pct, b.capd_pct);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/summary', verifyToken, async (req, res) => {
try { res.json({ success: true, data: await store().getSummary(req.query.period || '') }); }
catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/dept-mappings', verifyToken, async (req, res) => {
try {
const [mappings, depts] = await Promise.all([
store().getDeptMappings(),
req.query.period ? store().getDistinctDepts(req.query.period) : Promise.resolve([]),
]);
res.json({ success: true, mappings, depts });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.post('/dept-mapping', verifyToken, async (req, res) => {
try {
const { dept_name, cost_centre } = req.body;
if (!dept_name) return res.status(400).json({ success: false, message: 'dept_name required' });
await store().saveDeptMapping(dept_name, cost_centre || '');
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Parse raw 2D array from client-side xlsx into structured employee rows
function parseSheetRows(rawRows) {
if (!Array.isArray(rawRows) || rawRows.length === 0) return [];
let hIdx = -1;
for (let i = 0; i < Math.min(rawRows.length, 25); i++) {
const row = (rawRows[i] || []).map(c => String(c || '').toLowerCase().trim());
const hasNet = row.some(c => c.includes('net pay') || c === 'netpay');
const hasId = row.some(c => c === 'name' || c.includes('employee code') || c.includes('emp code'));
if (hasNet && hasId) { hIdx = i; break; }
if (hasNet && row.filter(Boolean).length >= 8) { hIdx = i; break; }
}
if (hIdx < 0) return [];
const headers = (rawRows[hIdx] || []).map(c => String(c || '').toLowerCase().trim());
const find = (...kws) => {
for (const kw of kws) {
const i = headers.findIndex(h => h.includes(kw));
if (i >= 0) return i;
}
return -1;
};
const cSr = find('sr.no', 'sr no', 'srno');
const cCode = find('employee code', 'emp code', 'empcode');
const cName = find('name');
// COST CENTER column (sheets 6-8 style) takes priority over generic dept/section
const cCC = find('cost center', 'cost centre', 'costcenter', 'cost_center');
const cDept = cCC >= 0 ? cCC : find('dept', 'deptt', 'section', 'sect');
const cGross = find('gross salary', 'total gross', 'gross sal', 'grosssalary');
const cNet = find('net pay', 'netpay', 'net_pay');
if (cNet < 0) return [];
const rows = [];
for (let i = hIdx + 1; i < rawRows.length; i++) {
const row = rawRows[i] || [];
const sr = String(row[cSr] || '').trim();
const name = cName >= 0 ? String(row[cName] || '').trim() : '';
const net = parseFloat(row[cNet]) || 0;
if (!name) continue;
const first = String(row[0] || '').toLowerCase();
if (first.includes('total') || name.toLowerCase().includes('total')) continue;
rows.push({
sr_no: sr,
employee_code: cCode >= 0 ? String(row[cCode] || '').trim() : '',
employee_name: name,
department: cDept >= 0 ? String(row[cDept] || '').trim() : '',
gross_salary: cGross >= 0 ? (parseFloat(row[cGross]) || 0) : 0,
net_pay: net,
});
}
return rows.filter(r => r.employee_name);
}
module.exports = router;
+4108
View File
File diff suppressed because it is too large Load Diff
+400
View File
@@ -0,0 +1,400 @@
// backend/routes/vendors.js — FIXED v3
// Fixes:
// 1. /lookup/sales-employees added (same OSLP table as customers)
// 2. mgrSalesPersonCode added to MANAGER_EDITABLE
// 3. mgrChain added to MANAGER_EDITABLE (was missing → chain never saved/sent to SAP)
// 4. allBillAddresses / allShipAddresses added to VENDOR_EDITABLE
// 5. doApproveVendor passes salesPersonCode, allBillAddresses, allShipAddresses to createVendor
// 6. getBankCodes now works (exported from sapServiceLayer)
'use strict';
const express = require('express');
const router = express.Router();
const { body, validationResult } = require('express-validator');
const { verifyToken, verifyAdmin, requireApprovalStep } = require('../middleware/auth');
const store = require('../services/hanaVendorStore');
const { resolve: resolveCompany } = require('../services/companyConfig');
function getSap() { return require('../services/sapServiceLayer'); }
// SQL for lookups
const sql = require('mssql');
let _hanaConn = null, _hanaConnecting = false;
async function getHanaConn() {
if (_hanaConn) return _hanaConn;
if (_hanaConnecting) {
for (let i = 0; i < 10; i++) { await new Promise(r => setTimeout(r, 500)); if (_hanaConn) return _hanaConn; }
throw new Error('SQL timeout');
}
_hanaConnecting = true;
try {
const config = {
server: process.env.SQL_HOST,
port: parseInt(process.env.SQL_PORT),
user: process.env.SQL_USER,
password: process.env.SQL_PASSWORD,
database: process.env.SQL_DATABASE,
options: {
encrypt: true,
trustServerCertificate: true,
},
};
_hanaConn = await sql.connect(config);
console.log('[VENDOR SQL] ✅ Connected');
} catch (err) { console.error('[VENDOR SQL] ❌', err.message); throw err; }
finally { _hanaConnecting = false; }
return _hanaConn;
}
async function hq(sqlQuery) {
const conn = await getHanaConn();
const result = await conn.request().query(sqlQuery);
return result.recordset || [];
}
const DB = () => `[dbo]`;
async function safeLookup(res, sql, mapFn) {
try { const rows = await hq(sql); return res.json({ success: true, data: rows.map(mapFn) }); }
catch (err) { console.warn('[VENDOR LOOKUP] fallback:', err.message); return res.json({ success: true, data: [], warning: err.message }); }
}
// Utils
function mapCurrency(label) {
return { 'Indian Rupee':'INR','US Dollar':'USD','Euro':'EUR','British Pound':'GBP','UAE Dirham':'AED' }[label] || 'INR';
}
function mapCountryCode(name) {
return { 'India':'IN','United States':'US','United Kingdom':'GB','UAE':'AE','Singapore':'SG','Germany':'DE','Japan':'JP','Australia':'AU' }[name] || 'IN';
}
function sanitizeMobile(raw) {
if (!raw) return '';
const digits = String(raw).replace(/\D/g, '');
if (digits.length === 12 && digits.startsWith('91')) return digits.slice(2);
if (digits.length === 13 && digits.startsWith('091')) return digits.slice(3);
return digits.slice(-10);
}
// ── EDITABLE FIELDS ───────────────────────────────────────────────────────────
const VENDOR_EDITABLE = [
'cardName','foreignName','typeOfBusiness','industry','products','paymentTerms',
'mobile','altContact','email','currency','contactFirst','contactLast','contactTitle',
'billStreet','billBlock','billCity','billZip','billState','billCountry',
// FIX: multiple addresses support
'allBillAddresses','allShipAddresses','sameAsBill',
'shipStreet','shipBlock','shipCity','shipZip','shipState','shipCountry',
'gstin','pan','tan','remarks','hasMsme','msmeNo','msmeType','msmeBType',
'fssaiNo','bankAccounts','attachments',
];
const MANAGER_EDITABLE = [
'mgrCardCodePrefix','mgrGroupCode','mgrGroup','mgrCurrency','mgrPayTerms',
'mgrPayTermsCode','mgrPurchaseAccount','mgrLanguage','mgrCreditLimit',
'mgrNotes','mgrTerritory',
'mgrMainGroup', // → U_Main_Group in SAP
'mgrChain', // FIX: was missing → chain never persisted or sent to SAP
'mgrSalesPersonCode', // FIX: new field for sales person
];
const ALL_EDITABLE = [...VENDOR_EDITABLE, ...MANAGER_EDITABLE];
function extractPatch(body, fields) {
const p = {};
fields.forEach(f => { if (body[f] !== undefined) p[f] = body[f]; });
return p;
}
// ══ LOOKUP ROUTES ════════════════════════════════════════════════════════════
// Vendor BP Groups
router.get('/lookup/bp-groups', verifyToken, async (req, res) => {
try {
const result = await getSap().sapRequest('GET',
`BusinessPartnerGroups?$filter=Type eq 'bbpgt_VendorGroup'&$select=Code,Name&$orderby=Name`
);
res.json({ success: true, data: (result?.value || []).map(r => ({ GroupCode: r.Code, GroupName: r.Name })) });
} catch (err) { res.json({ success: true, data: [], warning: err.message }); }
});
// Payment Terms
router.get('/lookup/payment-terms', verifyToken, (req, res) =>
safeLookup(res,
`SELECT "GroupNum","PymntGroup" FROM ${DB()}."OCTG" ORDER BY "PymntGroup"`,
r => ({ Code: r.GroupNum, Name: r.PymntGroup })
)
);
// AP Accounts
router.get('/lookup/ap-accounts', verifyToken, (req, res) =>
safeLookup(res,
`SELECT "AcctCode","AcctName" FROM ${DB()}."OACT"
WHERE ("FatherNum" = '2101000' OR "AcctCode" LIKE '211%')
AND "Finanse" = 'N'
ORDER BY "AcctCode"`,
r => ({ AcctCode: r.AcctCode, AcctName: r.AcctName })
)
);
// ── FIX 1: Sales Employees — was missing from vendor lookups ─────────────────
router.get('/lookup/sales-employees', verifyToken, (req, res) =>
safeLookup(res,
`SELECT "SlpCode","SlpName" FROM ${DB()}."OSLP"
WHERE "SlpCode" > 0 AND "Locked" = 'N'
ORDER BY "SlpName"`,
r => ({ SlpCode: r.SlpCode, SlpName: r.SlpName })
)
);
// ── Bank Codes from ODSC (Bank Master) ────────────────────────────────────────
// FIX: getBankCodes is now exported from sapServiceLayer — this will work
router.get('/lookup/banks', verifyToken, async (req, res) => {
const country = (req.query.country || 'IN').toUpperCase();
try {
const banks = await getSap().getBankCodes(country);
res.json({ success: true, data: banks });
} catch (err) {
// Fallback: try HANA ODSC table directly
try {
const rows = await hq(
`SELECT "BankCode","BankName","SwiftNo","CountryCode"
FROM ${DB()}."ODSC"
ORDER BY "BankName"`
);
res.json({ success: true, data: rows.map(r => ({
BankCode: r.BankCode, BankName: r.BankName,
SwiftNo: r.SwiftNo || '', CountryCode: r.CountryCode,
})) });
} catch (e2) {
res.json({ success: true, data: [], warning: e2.message });
}
}
});
// Main Group
router.get('/lookup/main-group', verifyToken, (req, res) =>
safeLookup(res,
`SELECT "Code","Name" FROM ${DB()}."@MAIN_GROUP" ORDER BY "Code"`,
r => ({ Code: r.Code, Name: r.Name || r.Code })
)
);
// ── FIX 2: Chain — query was correct but field wasn't in MANAGER_EDITABLE ───
router.get('/lookup/chain', verifyToken, (req, res) =>
safeLookup(res,
`SELECT "Code","Name" FROM ${DB()}."@CHAIN" ORDER BY "Code"`,
r => ({ Code: r.Code, Name: r.Name || r.Code })
)
);
// Next Vendor CardCode
router.get('/next-cardcode', verifyToken, async (req, res) => {
const prefix = (req.query.prefix || 'VENDA').trim();
try { res.json({ success: true, cardCode: await getSap().getNextVendorCardCode(prefix) }); }
catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ══ CRUD ROUTES ══════════════════════════════════════════════════════════════
router.post('/submit', [
body('cardName').notEmpty().trim(),
body('email').isEmail().normalizeEmail(),
body('mobile').notEmpty().trim(),
body('contactFirst').notEmpty().trim(),
body('contactLast').notEmpty().trim(),
body('billStreet').notEmpty().trim(),
body('billCity').notEmpty().trim(),
body('gstin').notEmpty().trim(),
body('pan').notEmpty().trim(),
], async (req, res) => {
const errs = validationResult(req);
if (!errs.isEmpty()) return res.status(400).json({ success: false, errors: errs.array() });
const b = req.body;
const companyDB = resolveCompany(b.company);
try {
const vendor = await store.insertVendor({
userId: b.userId||'', userType: b.userType||'', userDept: b.userDept||'',
vendorType: b.vendorType||'SUPPLIER', cardName: b.cardName, foreignName: b.foreignName||'',
typeOfBusiness: b.typeOfBusiness||'Company', industry: b.industry||'', products: b.products||'',
paymentTerms: b.paymentTerms||'30 Days', contactFirst: b.contactFirst, contactLast: b.contactLast,
contactTitle: b.contactTitle||'', mobile: sanitizeMobile(b.mobile),
altContact: b.altContact ? sanitizeMobile(b.altContact) : '', email: b.email,
billStreet: b.billStreet, billBlock: b.billBlock||'', billCity: b.billCity,
billZip: b.billZip||'', billState: b.billState||'', billCountry: b.billCountry||'India',
// Multiple addresses
allBillAddresses: Array.isArray(b.allBillAddresses) ? b.allBillAddresses : [],
allShipAddresses: Array.isArray(b.allShipAddresses) ? b.allShipAddresses : [],
sameAsBill: b.sameAsBill || false,
gstin: (b.gstin||'').toUpperCase(), pan: (b.pan||'').toUpperCase(), tan: (b.tan||'').toUpperCase(),
currency: b.currency||'Indian Rupee',
hasMsme: b.hasMsme||false, msmeNo: b.msmeNo||'', msmeType: b.msmeType||'', msmeBType: b.msmeBType||'',
fssaiNo: b.fssaiNo||'',
bankAccounts: Array.isArray(b.bankAccounts) ? b.bankAccounts : [],
attachments: b.attachments||{}, remarks: b.remarks||'',
mgrCardCodePrefix: 'VENDA', mgrPurchaseAccount: '2110005',
mgrCurrency: 'Indian Rupee', mgrLanguage: 'English (UK)',
company: companyDB,
}, companyDB);
res.json({ success: true, message: 'Vendor registration submitted', id: vendor.id });
} catch (err) {
console.error('[VENDOR] submit error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
router.get('/', verifyToken, async (req, res) => {
const st = (req.query.status || 'PENDING').toUpperCase();
const companyDB = resolveCompany(req.query.company);
try { res.json({ success: true, data: await store.listByStatus(st, companyDB) }); }
catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/:id', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.query.company);
try {
const v = await store.findById(req.params.id, companyDB);
if (!v) return res.status(404).json({ success: false, message: 'Not found' });
res.json({ success: true, data: v });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.patch('/:id/verify', verifyToken, requireApprovalStep('customer_vendor:verify', 'approve'), async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const v = await store.findById(req.params.id, companyDB);
if (!v) return res.status(404).json({ success: false, message: 'Not found' });
if (v.status !== 'PENDING') return res.status(400).json({ success: false, message: 'Only PENDING can be verified. Current: ' + v.status });
const patch = extractPatch(req.body, ALL_EDITABLE);
patch.status = req.body.approved ? 'VERIFIED' : 'REJECTED';
patch.verifiedAt = new Date().toISOString();
patch.verifiedBy = req.user.username;
await store.updateVendor(v.id, patch, companyDB);
console.log(`[VENDOR] ${v.id} (${v.cardName}) → ${patch.status} by ${req.user.username}`);
res.json({ success: true, message: `Vendor ${patch.status.toLowerCase()}` });
} catch (err) {
console.error('[VENDOR] verify error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
// ── Core approval logic ───────────────────────────────────────────────────────
async function doApproveVendor(v, req, patch, companyDB) {
const { createVendor, getNextVendorCardCode } = getSap();
const merged = { ...v, ...patch };
const cardCode = await getNextVendorCardCode(merged.mgrCardCodePrefix || 'VENDA', companyDB);
const groupCode = merged.mgrGroupCode && !isNaN(parseInt(merged.mgrGroupCode)) ? parseInt(merged.mgrGroupCode) : null;
const payTermsGrpCode = merged.mgrPayTermsCode && !isNaN(parseInt(merged.mgrPayTermsCode)) ? parseInt(merged.mgrPayTermsCode) : null;
// FIX: sales person now passed through
const salesPersonCode = merged.mgrSalesPersonCode && !isNaN(parseInt(merged.mgrSalesPersonCode)) ? parseInt(merged.mgrSalesPersonCode) : null;
console.log(`[VENDOR] APPROVE ${cardCode}: GroupCode=${groupCode} PayTermsGrpCode=${payTermsGrpCode} SalesPersonCode=${salesPersonCode}`);
console.log(`[VENDOR] UDFs: U_Main_Group=${merged.mgrMainGroup || '(none)'} U_Chain=${merged.mgrChain || '(none)'}`);
// Bank accounts: manager-selected bankCode takes priority
const bankAccounts = (merged.bankAccounts || []).map(b => ({
...b,
bankCode: b.bankCode || b.mgrBankCode || null,
}));
const result = await createVendor({
cardCode,
cardName: merged.cardName,
currency: mapCurrency(merged.mgrCurrency || merged.currency),
phone1: sanitizeMobile(merged.mobile),
email: merged.email,
creditLimit: parseFloat(merged.mgrCreditLimit) || 0,
remarks: merged.remarks,
typeOfBusiness: merged.typeOfBusiness,
groupCode,
payTermsGrpCode,
salesPersonCode, // FIX: now passed
contactFirst: merged.contactFirst,
contactLast: merged.contactLast,
contactMobile: sanitizeMobile(merged.mobile),
contactEmail: merged.email,
contactTitle: merged.contactTitle,
billStreet: merged.billStreet,
billBlock: merged.billBlock,
billCity: merged.billCity,
billZip: merged.billZip,
billState: merged.billState,
billCountry: mapCountryCode(merged.billCountry),
// FIX: multiple addresses now passed
allBillAddresses: merged.allBillAddresses || [],
allShipAddresses: merged.allShipAddresses || [],
mgrPurchaseAccount: merged.mgrPurchaseAccount || '2110005',
hasMsme: merged.hasMsme,
msmeNo: merged.msmeNo || '',
msmeType: merged.msmeType || '',
msmeBType: merged.msmeBType || '',
fssaiNo: merged.fssaiNo || '',
gstin: merged.gstin,
pan: merged.pan,
bankAccounts,
attachments: merged.attachments || {},
mgrMainGroup: merged.mgrMainGroup || '', // → U_Main_Group in SAP B1
mgrChain: merged.mgrChain || '', // FIX: now in MANAGER_EDITABLE → U_Chain in SAP B1
}, companyDB);
return {
cardCode,
cardName: merged.cardName,
attachmentEntry: result?.attachmentEntry || null,
};
}
router.patch('/:id/approve', verifyToken, requireApprovalStep('customer_vendor:approve', 'approve'), async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const v = await store.findById(req.params.id, companyDB);
if (!v) return res.status(404).json({ success: false, message: 'Not found' });
if (!req.body.approved) {
await store.updateVendor(v.id, { status: 'REJECTED', rejectedBy: req.user.username, rejectedAt: new Date().toISOString() }, companyDB);
return res.json({ success: true, message: 'Vendor rejected' });
}
if (v.status !== 'VERIFIED') return res.status(400).json({ success: false, message: `Must be VERIFIED. Current: ${v.status}` });
const patch = extractPatch(req.body, ALL_EDITABLE);
const { cardCode, cardName, attachmentEntry } = await doApproveVendor(v, req, patch, companyDB);
await store.updateVendor(v.id, {
...patch, status: 'APPROVED', sapCardCode: cardCode,
approvedAt: new Date().toISOString(), approvedBy: req.user.username,
sapAttachmentEntry: attachmentEntry,
}, companyDB);
console.log(`[VENDOR] ✅ ${cardName} → SAP B1 as ${cardCode}`);
res.json({ success: true, message: 'Vendor created in SAP B1!', cardCode, cardName });
} catch (err) {
console.error('[VENDOR] approve error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
router.patch('/:id/admin-approve', verifyToken, verifyAdmin, async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const v = await store.findById(req.params.id, companyDB);
if (!v) return res.status(404).json({ success: false, message: 'Not found' });
if (v.status === 'APPROVED') return res.status(400).json({ success: false, message: 'Already approved' });
if (v.status === 'REJECTED') return res.status(400).json({ success: false, message: 'Cannot approve rejected vendor' });
await store.updateVendor(v.id, { status: 'VERIFIED' }, companyDB);
const refreshed = await store.findById(v.id, companyDB);
const patch = extractPatch(req.body, ALL_EDITABLE);
const { cardCode, cardName, attachmentEntry } = await doApproveVendor(refreshed, req, patch, companyDB);
await store.updateVendor(v.id, {
...patch, status: 'APPROVED', sapCardCode: cardCode,
approvedAt: new Date().toISOString(), approvedBy: req.user.username,
sapAttachmentEntry: attachmentEntry,
}, companyDB);
console.log(`[VENDOR] ⚡ Admin pushed ${cardName} → SAP B1 as ${cardCode}`);
res.json({ success: true, message: 'Vendor pushed to SAP B1!', cardCode, cardName });
} catch (err) {
console.error('[VENDOR] admin-approve error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
router.patch('/:id/draft', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const v = await store.findById(req.params.id, companyDB);
if (!v) return res.status(404).json({ success: false, message: 'Not found' });
if (v.status === 'APPROVED' || v.status === 'REJECTED') return res.status(400).json({ success: false, message: 'Cannot edit ' + v.status + ' records' });
await store.updateVendor(v.id, extractPatch(req.body, ALL_EDITABLE), companyDB);
res.json({ success: true, message: 'Draft saved' });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
module.exports = router;
+87
View File
@@ -0,0 +1,87 @@
'use strict';
// routes/woHeaderProfiles.js — Work Order print/PDF "Header Details" as a
// list of named profiles (Blood Bag / CAPD / Accessories / …), each mapped
// to a set of SAP Item Groups, instead of one flat global config. See
// services/woHeaderProfileStore.js for the full design note.
//
// List/read is open to any authenticated user — work-order-print.html (any
// user who can print a Work Order) needs to fetch this to resolve which
// profile applies. Managing profiles (create/edit/delete/set-default) is
// gated by the 'work_order:approved_mgr_qa' approval step — the same step
// that already represents "QA Manager" for Work Order approval in this app
// — rather than requiring full Admin access. Admins always bypass via
// hasStepPerm's own role check.
const express = require('express');
const router = express.Router();
const { verifyToken, hasStepPerm } = require('../middleware/auth');
const store = () => require('../services/woHeaderProfileStore');
function requireQaManagerEdit(req, res, next) {
if (hasStepPerm(req.user, 'work_order:approved_mgr_qa', 'edit')) return next();
res.status(403).json({ success: false, message: 'You are not assigned "edit" on approval step: work_order:approved_mgr_qa' });
}
router.get('/', verifyToken, async (req, res) => {
try {
const data = await store().listProfiles();
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
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 }); }
});
router.post('/', verifyToken, requireQaManagerEdit, async (req, res) => {
try {
const b = req.body || {};
if (!(b.name || '').trim()) return res.status(400).json({ success: false, message: 'Name is required' });
const saved = await store().createProfile({
name: b.name.trim(), itemGroups: Array.isArray(b.itemGroups) ? b.itemGroups.map(String) : [],
companyName: b.companyName || '', companyAddress: b.companyAddress || '', formNo: b.formNo || '',
effectiveDate: b.effectiveDate || '', reviewDate: b.reviewDate || '', logo: b.logo || '',
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 }); }
});
router.put('/:id', verifyToken, requireQaManagerEdit, 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 b = req.body || {};
if (!(b.name || '').trim()) return res.status(400).json({ success: false, message: 'Name is required' });
const updated = await store().updateProfile(req.params.id, {
name: b.name.trim(), itemGroups: Array.isArray(b.itemGroups) ? b.itemGroups.map(String) : [],
companyName: b.companyName || '', companyAddress: b.companyAddress || '', formNo: b.formNo || '',
effectiveDate: b.effectiveDate || '', reviewDate: b.reviewDate || '', logo: b.logo || '',
});
res.json({ success: true, data: updated });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.post('/:id/set-default', verifyToken, requireQaManagerEdit, 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 updated = await store().setDefault(req.params.id);
res.json({ success: true, data: updated });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.delete('/:id', verifyToken, requireQaManagerEdit, async (req, res) => {
try {
const existing = await store().findById(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: 'Not found' });
await store().softDelete(req.params.id);
res.json({ success: true });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
module.exports = router;
+543
View File
@@ -0,0 +1,543 @@
'use strict';
// routes/workOrders.js — Production Work Orders (generated from Batch Intimation)
const express = require('express');
const router = express.Router();
const { verifyToken, requireApprovalStep, requireWorkflowPerm, hasStepPerm } = require('../middleware/auth');
const store = () => require('../services/workOrderStore');
const notify = () => require('../services/notifyStore');
// 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 badDate(v) { return v && !isValidMEDate(v); }
function normRaw(rows) {
return (rows || [])
.filter(r => (r.itemCode || '').trim() || (r.rawMaterial || '').trim())
.map(r => ({
itemCode: (r.itemCode || '').trim(),
rawMaterial: (r.rawMaterial || '').trim(),
spec: (r.spec || '').trim(),
stdQty: (r.stdQty || '').toString().trim(), // Qty Req. (std/per unit)
uom: (r.uom || '').trim(),
ovg: (r.ovg || '').toString().trim(),
qtyReq: (r.qtyReq || '').toString().trim(), // Qty Req. (total)
weighingBalanceId: (r.weighingBalanceId || '').trim(),
arNo: (r.arNo || '').trim(),
// Multi-solution support: which Solution this raw material belongs to,
// and that solution's own Batch Size (Ltr) — different solutions in the
// same product can have different batch sizes.
solCode: (r.solCode || '').trim(),
solName: (r.solName || '').trim(),
solBatchSize: (r.solBatchSize || '').toString().trim(),
solPerUnitLitres: r.solPerUnitLitres != null && r.solPerUnitLitres !== '' ? Number(r.solPerUnitLitres) : '',
solBSManual: !!r.solBSManual,
// Item Group Rules "RAW" override: shown as itself (not exploded from a
// Solution), qty calc = Std Qty/Unit × Total Units × (1+Ovg%) — see
// recalcMaterials() in work-order.html.
directRaw: !!r.directRaw,
}));
}
function normPack(rows) {
return (rows || [])
.filter(r => (r.itemCode || '').trim() || (r.packingMaterial || '').trim())
.map(r => ({
itemCode: (r.itemCode || '').trim(),
packingMaterial: (r.packingMaterial || '').trim(),
artworkNo: (r.artworkNo || '').trim(),
stdQtyPerUnit: (r.stdQtyPerUnit || '').toString().trim(),
// Stock UOM from SAP (display-only column) — was missing from this
// whitelist entirely, so it silently vanished on every save even
// though it displayed correctly right after loading the BOM.
uom: (r.uom || '').trim(),
// Std. Qty/Unit's own chosen display unit (mg/gm/Kg/ml/etc, or blank
// for a plain count) and the resulting Qty Req.(Units) unit label —
// also missing before, so picking a real unit never survived a save.
stdQtyUnit: (r.stdQtyUnit || '').trim(),
qtyUnitLabel: (r.qtyUnitLabel || '').trim(),
ovgPercent: (r.ovgPercent || '').toString().trim(),
qtyReqUnits: (r.qtyReqUnits || '').toString().trim(),
// AR No. is normally set via the separate per-row PATCH on Verify Work
// Order, but was missing here too — meaning a later edit+save of the
// WHOLE Work Order (e.g. a QA correction) would silently erase every
// AR No. already recorded, since this whitelist is what's actually
// persisted, not just what the client happens to send.
arNo: (r.arNo || '').trim(),
// Per-row Round Up/Exact override (work-order.html's round-pill on a
// "no unit picked" row) — same class of bug as the ones above: missing
// from this whitelist, so any edit+save of the Work Order silently
// reset every row back to the global default, even though the pill
// itself displayed the override correctly right up until that save.
roundUp: r.roundUp != null ? !!r.roundUp : null,
}));
}
function pickHeader(b) {
return {
reference: b.reference || '',
productName: b.productName || '',
productDesc: b.productDesc || '',
genericName: b.genericName || '',
productCode: b.productCode || '',
batchNumber: b.batchNumber || '',
batchSize: b.batchSize || '',
totalUnits: b.totalUnits || '',
mfgDate: b.mfgDate || null,
expDate: b.expDate || null,
packSize: b.packSize || '',
type: b.type || '',
market: b.market || '',
remarks: b.remarks || '',
rawMaterials: normRaw(b.rawMaterials),
packingMaterials: normPack(b.packingMaterials),
componentsOnly: !!b.componentsOnly,
};
}
router.get('/', verifyToken, requireWorkflowPerm('work_order', 'view'), async (req, res) => {
try {
const { mine, company, status } = req.query;
const data = await store().listWorkOrders({
mine: mine === '1' ? req.user.username : undefined, company, status,
});
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/:id', verifyToken, requireWorkflowPerm('work_order', 'view'), 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 (generate) a work order — this IS the "Prepared By QA" step, so only
// users assigned that approval step (or admin) may create one. Having the
// production-work-order MODULE just lets a user open/view the page; it does
// not by itself grant the right to originate a new work order.
router.post('/', verifyToken, requireApprovalStep('work_order:prepared_qa', 'add'), async (req, res) => {
try {
const b = req.body || {};
if (!(b.productName || b.productCode))
return res.status(400).json({ success: false, message: 'Product Name / Code is required' });
if (badDate(b.mfgDate) || badDate(b.expDate))
return res.status(400).json({ success: false, message: 'Invalid MFG/EXP date — use DD-MMM-YYYY or MMM/YYYY' });
// A given (Intimation, Product, Batch No.) combination may only ever
// produce ONE Work Order — its quantity is fully captured the first time.
// Client-side the picker hides/disables already-used batches, but this is
// the enforcing check (the client guard alone can be bypassed).
if (b.intimationId) {
const existing = await store().listWorkOrders({});
const dup = existing.find(w => !w.isDeleted
&& String(w.intimationId) === String(b.intimationId)
&& (w.productCode || '') === (b.productCode || '')
&& (w.batchNumber || '') === (b.batchNumber || ''));
if (dup)
return res.status(409).json({ success: false, message: `A Work Order (${dup.woNo}) has already been generated for this Intimation's batch "${b.batchNumber || b.productCode}" — its full quantity is already captured.` });
}
const saved = await store().insertWorkOrder({
...pickHeader(b),
intimationId: b.intimationId || null,
company: b.company || '',
createdBy: req.user.username, createdByName: req.user.name || req.user.username,
});
res.json({ success: true, data: saved });
if (saved.status === 'IN_PROGRESS') {
const nextKey = WORK_ORDER_STEP_KEYS[saved.stage];
notify().notify({
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
title: `Work Order ${saved.woNo} — awaiting ${saved.currentStep}`,
lines: [['Work Order', saved.woNo], ['Product', saved.productName || ''], ['Created By', saved.createdByName], ['Pending Step', saved.currentStep]],
url: `${process.env.APP_BASE_URL || ''}/work-order?id=${saved.id}`,
excludeUsernames: [req.user.username],
});
}
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Edit header/materials — normally only while In Progress (requires 'edit'
// perm on whichever step currently owns the record, same step that would
// act next). A REJECTED work order is ALSO editable, but as a combined
// edit+resubmit: the save both applies the changes AND restarts the full
// approval chain from step 1 (see resubmitAfterReject) — since none of the
// original approvers ever saw the edited content. Editing a rejected order
// is gated on the FIRST step's 'edit' permission (the same step it restarts
// at), not the step that happened to reject it.
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' });
if (badDate(req.body?.mfgDate) || badDate(req.body?.expDate))
return res.status(400).json({ success: false, message: 'Invalid MFG/EXP date — use DD-MMM-YYYY or MMM/YYYY' });
if (existing.status === 'REJECTED') {
const firstStepKey = WORK_ORDER_STEP_KEYS[0];
if (!hasStepPerm(req.user, `work_order:${firstStepKey}`, 'edit'))
return res.status(403).json({ success: false, message: `You are not assigned "edit" on approval step: work_order:${firstStepKey}` });
const updated = await store().resubmitAfterReject(req.params.id, pickHeader(req.body || {}), {
by: req.user.username, byName: req.user.name || req.user.username,
});
res.json({ success: true, data: updated });
const nextKey = WORK_ORDER_STEP_KEYS[updated.stage];
notify().notify({
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
title: `Work Order ${updated.woNo} — Resubmitted, awaiting ${updated.currentStep}`,
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Resubmitted By', req.user.name || req.user.username], ['Pending Step', updated.currentStep]],
url: `${process.env.APP_BASE_URL || ''}/work-order?id=${updated.id}`,
excludeUsernames: [req.user.username],
});
return;
}
if (existing.status !== 'IN_PROGRESS')
return res.status(409).json({ success: false, message: 'Work order is ' + existing.status.toLowerCase() + ' and cannot be edited' });
const curStepKey = WORK_ORDER_STEP_KEYS[existing.stage];
if (!curStepKey || !hasStepPerm(req.user, `work_order:${curStepKey}`, 'edit'))
return res.status(403).json({ success: false, message: `You are not assigned "edit" on approval step: work_order:${curStepKey || '?'}` });
const updated = await store().updateWorkOrder(req.params.id, pickHeader(req.body || {}));
res.json({ success: true, data: updated });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Workflow: approve (advance) or reject
// Index-aligned with services/workOrderStore.js STEPS (both are the 5-step
// QA/Production sign-off chain) and services/approvalStepsStore.js's
// workflow:'work_order' step keys.
const WORK_ORDER_STEP_KEYS = ['prepared_qa', 'checked_qc', 'checked_production', 'checked_mgr_production', 'approved_mgr_qa'];
router.patch('/:id/action', verifyToken, async (req, res) => {
try {
const action = (req.body.action || '').toLowerCase();
if (!['approve', 'reject'].includes(action))
return res.status(400).json({ success: false, message: 'action must be approve or reject' });
if (action === 'reject' && !(req.body.remarks || '').trim())
return res.status(400).json({ success: false, message: 'A reason is required to reject a Work Order' });
{
const wo = await store().findById(req.params.id);
if (!wo) return res.status(404).json({ success: false, message: 'Work order not found' });
const stepKey = WORK_ORDER_STEP_KEYS[wo.stage];
if (!stepKey || !hasStepPerm(req.user, `work_order:${stepKey}`, 'approve'))
return res.status(403).json({ success: false, message: `You are not assigned "approve" on approval step: work_order:${stepKey || '?'}` });
}
const updated = await store().workflowAction(req.params.id, {
action, by: req.user.username, byName: req.user.name || req.user.username,
remarks: req.body.remarks || '',
});
res.json({ success: true, data: updated });
const woUrl = `${process.env.APP_BASE_URL || ''}/work-order?id=${updated.id}`;
if (action === 'reject') {
notify().notify({
stepFullKey: 'work_order:prepared_qa',
title: `Work Order ${updated.woNo} — Rejected`,
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Rejected By', req.user.name || req.user.username], ['Remarks', req.body.remarks || '']],
url: woUrl,
excludeUsernames: [req.user.username],
});
} else if (updated.status === 'IN_PROGRESS') {
const nextKey = WORK_ORDER_STEP_KEYS[updated.stage];
notify().notify({
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
title: `Work Order ${updated.woNo} — awaiting ${updated.currentStep}`,
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Approved By', req.user.name || req.user.username], ['Pending Step', updated.currentStep]],
url: woUrl,
excludeUsernames: [req.user.username],
});
} else if (updated.status === 'APPROVED') {
notify().notify({
stepFullKey: 'work_order:prepared_qa',
title: `Work Order ${updated.woNo} — Fully Approved`,
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Final Approval By', req.user.name || req.user.username]],
url: woUrl,
excludeUsernames: [req.user.username],
});
}
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// Admin-only recall of a FULLY APPROVED Work Order back to QA for editing —
// not a normal in-flight rejection (no approval step is checked), just an
// override for a document that already finished its whole chain. Re-uses
// the exact same status ('REJECTED') the normal reject action sets, so the
// existing "Edit & Resubmit" flow (PUT /:id above) picks it up for free —
// once edited, it restarts the full 5-step chain from Prepared By QA.
router.post('/:id/send-to-qa', verifyToken, async (req, res) => {
try {
if (req.user.role !== 'admin' && req.user.role !== 'system_admin')
return res.status(403).json({ success: false, message: 'Only admin/system admin can send a fully approved Work Order back to QA' });
const updated = await store().sendBackToQaForEdit(req.params.id, {
by: req.user.username, byName: req.user.name || req.user.username,
remarks: req.body?.remarks || '',
});
res.json({ success: true, data: updated });
notify().notify({
stepFullKey: 'work_order:prepared_qa',
title: `Work Order ${updated.woNo} — Sent back to QA for edit`,
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Sent Back By', req.user.name || req.user.username], ['Remarks', req.body?.remarks || '']],
url: `${process.env.APP_BASE_URL || ''}/work-order?id=${updated.id}`,
excludeUsernames: [req.user.username],
});
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
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 curStepKey = WORK_ORDER_STEP_KEYS[existing.stage];
if (!hasStepPerm(req.user, `work_order:${curStepKey || 'prepared_qa'}`, 'delete'))
return res.status(403).json({ success: false, message: `You are not assigned "delete" on approval step: work_order:${curStepKey || 'prepared_qa'}` });
await store().softDelete(req.params.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── Per-row material fields (Weighing Balance ID / AR No.) ─────────────────
// Editable by whoever holds 'edit' on work_order:issue — independent of the
// work order's own approval status/edit-lock (this happens AFTER approval,
// at the moment materials are physically issued).
router.patch('/:id/row/:section/:index', verifyToken, async (req, res) => {
try {
const section = req.params.section;
if (!['raw', 'pack'].includes(section)) return res.status(400).json({ success: false, message: 'section must be "raw" or "pack"' });
if (!hasStepPerm(req.user, 'work_order:issue', 'edit'))
return res.status(403).json({ success: false, message: 'You are not assigned "edit" on approval step: work_order:issue' });
const patch = {};
if (req.body.weighingBalanceId !== undefined && section === 'raw') patch.weighingBalanceId = String(req.body.weighingBalanceId || '').trim();
if (req.body.arNo !== undefined) patch.arNo = String(req.body.arNo || '').trim();
if (!Object.keys(patch).length) return res.status(400).json({ success: false, message: 'Nothing to update' });
const updated = await store().patchMaterialRow(req.params.id, section, req.params.index, patch);
res.json({ success: true, data: updated });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// ── Per-row Issued/Received/Verified one-click stamps ───────────────────────
// Each is a SEPARATE portal-only sign-off on that ONE material row — not the
// whole work order — because different items can be issued/received/verified
// on different days by different people. Each is gated by 'approve' on its
// own approval step, and can only be stamped once per row.
const ROW_STAMP_STEPS = { issued: 'work_order:issue', received: 'work_order:receive', verified: 'work_order:verify' };
// Enforced order per row: Issued → Received → Verified. Each key names the
// PRECEDING stamp that must already exist before this one can be set.
const ROW_STAMP_PREREQ = { received: 'issued', verified: 'received' };
router.post('/:id/row/:section/:index/mark', verifyToken, async (req, res) => {
try {
const section = req.params.section;
if (!['raw', 'pack'].includes(section)) return res.status(400).json({ success: false, message: 'section must be "raw" or "pack"' });
const which = (req.body.which || '').toLowerCase();
const stepKey = ROW_STAMP_STEPS[which];
if (!stepKey) return res.status(400).json({ success: false, message: 'which must be issued, received, or verified' });
// Production Order Issuance (production_order:issuance) and Work Order
// row-stamping are different screens/responsibilities — no longer
// coupled. Marking a row here always requires its own explicit
// approval-step grant.
const allowed = hasStepPerm(req.user, stepKey, 'approve');
if (!allowed)
return res.status(403).json({ success: false, message: `You are not assigned "approve" on approval step: ${stepKey}` });
const wo = await store().findById(req.params.id);
if (!wo) return res.status(404).json({ success: false, message: 'Work order not found' });
const arr = (section === 'raw' ? wo.rawMaterials : wo.packingMaterials) || [];
const i = parseInt(req.params.index);
if (!(i >= 0) || i >= arr.length) return res.status(404).json({ success: false, message: 'Row not found' });
const row = arr[i];
const atField = `${which}At`;
// A stamp can only be set once — EXCEPT for a woVerifyOverride user (or
// admin), who may re-stamp ANY of the three (issued/received/verified)
// even after it's already signed, to correct who it's recorded as
// signed by and/or its date. That's the actual point of the override:
// catching up/correcting paperwork on any step, not just Verified, and
// not just rows nobody has touched yet.
const canOverrideStamp = req.user.role === 'admin' || req.user.woVerifyOverride;
if (row[atField] && !canOverrideStamp) return res.status(400).json({ success: false, message: `This row's "${which}" was already stamped by ${row[which + 'ByName'] || row[which + 'By']}` });
// Per-user item-group restriction (Admin → user → Issue Items) — same rule
// enforced on the real SAP issuance (routes/sap.js). Marking a row Issued
// must be blocked for item groups this user isn't allowed to issue, even
// if they hold Issue for Production generally.
if (which === 'issued') {
try {
const acting = await require('../services/hanaUsers').findById(req.user.id);
const allowedGroups = Array.isArray(acting && acting.issueItemGroups) ? acting.issueItemGroups.map(String) : [];
if (allowedGroups.length && row.itemCode) {
const { getPool } = require('../services/sqlPool');
// Must query the WO's OWN company database, not whatever the shared
// pool happens to default to (services/sqlPool.js's getPool() is
// per-database now — see [[displayed-sap-company-restriction]]) —
// this route gets no `company` param from the frontend at all, so
// the Work Order record's own stored company is the source of truth.
const pool = await getPool(wo.company || null);
const r = await pool.request().query(`SELECT "ItmsGrpCod" FROM [dbo].[OITM] WHERE "ItemCode" = '${String(row.itemCode).replace(/'/g, "''")}'`);
const grp = r.recordset && r.recordset[0] ? String(r.recordset[0].ItmsGrpCod) : null;
if (!grp || !allowedGroups.includes(grp))
return res.status(403).json({ success: false, message: `You are not permitted to issue this item (${row.itemCode}) — its item group is not in your allowed list.` });
}
} catch (e) { console.warn('[WO-ROW-MARK] item-group restriction check failed:', e.message); }
}
// A woVerifyOverride/admin user may sign a stage out of the normal
// Issued→Received→Verified order too (e.g. catching up Verified before
// Received ever gets marked in the portal) — everyone else must still
// follow it.
const prereq = ROW_STAMP_PREREQ[which];
if (prereq && !row[`${prereq}At`] && !canOverrideStamp)
return res.status(400).json({ success: false, message: `Mark "${prereq}" on this row before "${which}".` });
// Normal case: the stamp always records the ACTUAL logged-in user, right
// now — no client input is trusted for who/when. The one narrow
// exception: a user explicitly granted woVerifyOverride (Admin → Edit
// User), or anyone with the admin role (same bypass every approval-step
// check gives admin elsewhere — see hasStepPerm), can, on ANY of the
// three stamps (issued/received/verified), sign as a different user
// and/or backdate the sign date — for catching up paperwork signed on
// paper on an earlier date by someone else. Every other user is
// unaffected regardless of which stamp.
let signerUsername = req.user.username;
let signerName = req.user.name || req.user.username;
let atIso = new Date().toISOString();
if (canOverrideStamp) {
const actAs = String(req.body.actAsUsername || '').trim();
if (actAs) {
const actingUser = await require('../services/hanaUsers').findByUsername(actAs);
if (!actingUser) return res.status(400).json({ success: false, message: `User "${actAs}" not found` });
signerUsername = actingUser.username;
signerName = actingUser.fullName || actingUser.username;
}
if (req.body.signedAt) {
const d = new Date(req.body.signedAt);
if (isNaN(d.getTime())) return res.status(400).json({ success: false, message: 'Invalid sign date' });
if (d.getTime() > Date.now()) return res.status(400).json({ success: false, message: 'Sign date cannot be in the future' });
atIso = d.toISOString();
}
}
const patch = {
[`${which}By`]: signerUsername,
[`${which}ByName`]: signerName,
[atField]: atIso,
};
// Admin → System Settings → "Raw Material Qty Issued Source" picks which
// of two decoupled events is authoritative for EVERY material table's
// Qty Issued (Raw Material, Packing Material, Components alike):
// 'issue_for_production' (default) calculates it from the real SAP
// posting instead (see routes/sap.js's /issue-production, and the live
// qtyByItem lookup for Packing/Components); only under 'mark_issued'
// does THIS one-click paperwork stamp (the same action that sets Issued
// By/Date, above) set Qty Issued = the row's full Qty Req.
if (which === 'issued' && (section === 'raw' || section === 'pack') && require('../services/appSettingsStore').woRawQtyIssuedSource() === 'mark_issued') {
patch.qtyIssued = parseFloat(section === 'raw' ? row.qtyReq : row.qtyReqUnits) || 0;
}
const updated = await store().patchMaterialRow(req.params.id, section, i, patch);
res.json({ success: true, data: updated });
// Notify whoever holds the NEXT stamp in the Issued→Received→Verified
// chain for this row (no next step after Verified — nothing to notify).
const NEXT_STAMP_STEP = { issued: 'work_order:receive', received: 'work_order:verify' };
const nextStep = NEXT_STAMP_STEP[which];
if (nextStep) {
notify().notify({
stepFullKey: nextStep,
title: `Work Order ${wo.woNo} — item ${which}, awaiting ${which === 'issued' ? 'Received By' : 'Verified By'}`,
lines: [['Work Order', wo.woNo], ['Item', row.itemCode || row.rawMaterial || ''], [which === 'issued' ? 'Issued By' : 'Received By', req.user.name || req.user.username]],
url: `${process.env.APP_BASE_URL || ''}/verify-work-order`,
excludeUsernames: [req.user.username],
});
}
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// ── Issuance info for the printable Work Order ─────────────────────────────
// Pulls the linked Production Order(s): per-component issued quantity (live
// from SAP, summed by item code) and the Issuance / Receipt / Close signers
// (name + username + date, from each PO's workflow log). If no PO exists yet,
// or SAP is unreachable, the corresponding pieces come back empty so the print
// view simply leaves those cells blank.
async function computeIssuance(woId, co) {
const poStore = require('../services/productionOrderStore');
const sapSL = require('../services/sapServiceLayer');
const pos = (await poStore.listProductionOrders({ workOrderId: woId, company: co })) || [];
const active = pos.filter(p => !p.isDeleted);
const qtyByItem = {};
let issued = null, received = null, receivedManual = null, verified = null;
const pickLatest = (cur, e) => (!cur || new Date(e.at) >= new Date(cur.at)) ? e : cur;
for (const po of active) {
for (const e of (Array.isArray(po.workflowLog) ? po.workflowLog : [])) {
if (e.action === 'rejected') continue;
if (e.step === 'Issuance') issued = pickLatest(issued, e);
else if (e.step === 'Receipt from Production') received = pickLatest(received, e);
else if (e.step === 'Verified') verified = pickLatest(verified, e); // portal-only post-Issuance sign-off
}
// Portal-only "Received By" sign-off — ALWAYS overrides the SAP receipt
// step's signer above, wherever it exists (see productionOrderStore.receiveManual()).
if (po.receivedManualAt) {
receivedManual = pickLatest(receivedManual, { by: po.receivedManualBy, byName: po.receivedManualByName, at: po.receivedManualAt });
}
if (po.sapAbsEntry) {
try {
const so = await sapSL.sapRequest('GET', `ProductionOrders(${parseInt(po.sapAbsEntry)})`, null, co);
const lines = (so.ProductionOrderLines || []).filter(l => l.ItemType !== 'pit_Resource');
for (const l of lines) {
const code = (l.ItemNo || l.ItemCode || '').toString().trim();
if (!code) continue;
qtyByItem[code] = (qtyByItem[code] || 0) + (Number(l.IssuedQuantity) || 0);
}
} catch (_e) { /* SAP unreachable → leave issued qty blank */ }
}
}
const sig = (e) => e ? { name: e.byName || e.by || '', user: e.by || '', at: e.at || '' } : null;
return { hasPO: active.length > 0, qtyByItem, issuedBy: sig(issued), receivedBy: sig(receivedManual || received), verifiedBy: sig(verified) };
}
router.get('/:id/issuance-info', verifyToken, async (req, res) => {
try {
const data = await computeIssuance(parseInt(req.params.id), req.query.company || null);
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── Server-generated PDF of the Production Work Order (pdfmake) ─────────────
router.get('/:id/pdf', verifyToken, requireWorkflowPerm('work_order', 'view'), async (req, res) => {
const co = req.query.company || null;
try {
const wo = await store().findById(req.params.id);
if (!wo) return res.status(404).json({ success: false, message: 'Not found' });
const settings = require('../services/appSettingsStore').getAll();
const iss = await computeIssuance(parseInt(req.params.id), co);
// Gather signature images: the 5 approval signers + every material row's
// own Issued/Received/Verified signer (per-row sign-offs — see
// public/verify-work-order.html; each row is stamped independently).
const users = new Set();
(Array.isArray(wo.workflowLog) ? wo.workflowLog : []).forEach(l => { if (l.by) users.add(l.by); });
[...(wo.rawMaterials || []), ...(wo.packingMaterials || [])].forEach(r => {
['issuedBy', 'receivedBy', 'verifiedBy'].forEach(k => { if (r[k]) users.add(r[k]); });
});
const hu = require('../services/hanaUsers');
const sigs = {};
for (const u of users) { try { const s = await hu.getSignatureByUsername(u); if (s) sigs[u] = s; } catch (_e) {} }
// U_NewItemCode (OITM) for the header Product Code only — printed as
// "(code)" right after it, when SAP has a value (see public/work-order-print.html).
let newItemCode = '';
try {
const { getPool } = require('../services/sqlPool');
const pool = await getPool(co);
const code = String(wo.productCode || '').replace(/'/g, "''");
const r = await pool.request().query(`SELECT "U_NewItemCode" FROM [dbo]."OITM" WHERE "ItemCode"='${code}'`);
newItemCode = r.recordset?.[0]?.U_NewItemCode || '';
} catch (_e) {}
const qtyIssuedSource = require('../services/appSettingsStore').woRawQtyIssuedSource();
const doc = require('../services/workOrderPdf').generate({ wo, settings, iss, sigs, newItemCode, qtyIssuedSource });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="WO-${(wo.woNo || wo.id)}.pdf"`);
doc.pipe(res);
doc.end();
} catch (err) {
if (!res.headersSent) res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;