sale order
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s

This commit is contained in:
John
2026-10-05 18:45:17 +05:30
parent e725a6571b
commit eead8f5ffd
129 changed files with 14252 additions and 452 deletions
+6
View File
@@ -59,6 +59,12 @@ router.post('/login', async (req, res) => {
}
});
// NOTE: central-auth SSO login (POST /sso-login) lives in server.js's own
// inline authRouter, not here — this file (routes/auth.js) is NOT mounted by
// server.js (which defines its own, more complete /api/auth router with
// modules/approvalSteps/per-company sapLogins), so anything added here is
// dead code. See server.js's authRouter.post('/sso-login', ...).
// ── GET /auth/me ──────────────────────────────────────────────────────────────
router.get('/me', (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
+14 -4
View File
@@ -246,13 +246,23 @@ router.get('/:id/linked-work-orders', verifyToken, async (req, res) => {
router.get('/linked-work-orders/all', verifyToken, async (req, res) => {
try {
const wos = await workOrderStore().listWorkOrders({});
const map = {};
const map = {}; // non-rejected only — whole-row/whole-document lock (Edit/Delete gating)
const allMap = {}; // every non-deleted status, REJECTED included — used only to keep a
// batch's own Batch No. (and its row) from being changed/removed once
// ANY Work Order, even a rejected one awaiting edit+resubmit, already
// references it by that number. Without this, correcting other fields
// on a rejected batch's row (which IS meant to stay editable) could also
// let the Batch No. itself drift, silently breaking the (productCode,
// batchNumber) link back to that rejected Work Order.
wos.forEach(w => {
if (!w.intimationId || w.isDeleted || w.status === 'REJECTED') return;
if (!w.intimationId || w.isDeleted) return;
const k = String(w.intimationId);
(map[k] || (map[k] = [])).push({ id: w.id, woNo: w.woNo, productCode: w.productCode || '', batchNumber: w.batchNumber || '' });
const entry = { id: w.id, woNo: w.woNo, productCode: w.productCode || '', batchNumber: w.batchNumber || '' };
(allMap[k] || (allMap[k] = [])).push(entry);
if (w.status === 'REJECTED') return;
(map[k] || (map[k] = [])).push(entry);
});
res.json({ success: true, data: map });
res.json({ success: true, data: map, allData: allMap });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
+161
View File
@@ -0,0 +1,161 @@
// routes/batchNoTransaction.js
// Mirrors SAP B1's own standard "Batch Number Transactions Report" (Inventory
// → Inventory Reports → Batch Number Transactions Report): a master grid of
// batches (from OIBT, SAP's per-batch/per-warehouse stock table) and, for a
// selected batch, a detail grid of every stock-affecting document that ever
// touched it (from IBT1, SAP's batch-transaction-log table).
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const cq = (req) => req.query?.company || req.body?.company || null;
const sqlEsc = (v) => String(v).replace(/'/g, "''");
// GET / — the "Batches" master grid: one row per Item/Batch/Warehouse,
// exactly the fields SAP's own report shows (Item No, Item Description,
// Batch, Whse, Quantity, Expiration Date, Manufacturing Date, Project,
// Status, COA_Status, Sale Type — all native OIBT columns), plus Committed/
// OnOrder/Available as extra value-add columns (same formula as Inventory
// Status Report) since the data is already on hand.
router.get('/', verifyToken, async (req, res) => {
try {
const co = cq(req);
const pool = await getPool(co);
const itemFrom = (req.query.itemFrom || '').trim();
const itemTo = (req.query.itemTo || '').trim();
const batchNo = (req.query.batchNo || '').trim();
const itemGroup = (req.query.itemGroup || '').trim();
const includeZeroQty = req.query.includeZeroQty === '1';
const warehouses = (req.query.warehouses || '').split(',').map((w) => w.trim()).filter(Boolean);
const where = [];
if (itemFrom) where.push(`T0."ItemCode" >= '${sqlEsc(itemFrom)}'`);
if (itemTo) where.push(`T0."ItemCode" <= '${sqlEsc(itemTo)}'`);
if (batchNo) where.push(`T0."BatchNum" LIKE '%${sqlEsc(batchNo)}%'`);
if (itemGroup) where.push(`T2."ItmsGrpCod" = ${parseInt(itemGroup)}`);
if (warehouses.length) where.push(`T0."WhsCode" IN (${warehouses.map((w) => `'${sqlEsc(w)}'`).join(',')})`);
if (!includeZeroQty) where.push(`T0."Quantity" <> 0`);
const query = `
SELECT T0."ItemCode", T0."ItemName", T0."BatchNum", T0."WhsCode",
T0."Quantity", T0."IsCommited", T0."OnOrder",
T0."PrdDate", T0."ExpDate",
T0."U_Project", T0."U_Status", T0."U_COA_Status", T0."U_SaleType",
T2."InvntryUom" AS "Uom"
FROM [dbo].[OIBT] T0
LEFT JOIN [dbo].[OITM] T2 ON T2."ItemCode" = T0."ItemCode"
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY T0."ItemCode", T0."BatchNum", T0."WhsCode"`;
const result = await pool.request().query(query);
const data = result.recordset.map((r) => {
const qty = Number(r.Quantity) || 0;
const committed = Number(r.IsCommited) || 0;
const onOrder = Number(r.OnOrder) || 0;
return {
itemCode: r.ItemCode,
itemName: r.ItemName || '',
batchNo: r.BatchNum,
warehouse: r.WhsCode,
inStock: qty,
committed,
ordered: onOrder,
available: qty - committed + onOrder,
mfgDate: r.PrdDate,
expDate: r.ExpDate,
project: r.U_Project || '',
status: r.U_Status || '',
coaStatus: r.U_COA_Status || '',
saleType: r.U_SaleType || '',
uom: r.Uom || '',
};
});
res.json({ success: true, data });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// BaseType (IBT1) → the SAP document table/label that actually recorded the
// stock movement. Confirmed by cross-checking real BaseEntry values against
// each candidate table in this database (e.g. BaseType 18 entries' BaseEntry
// values are found in OPCH, with matching CardName/DocDate — not any other
// table), not guessed from memory of SAP's BoObjectTypes enum alone.
const DOC_TYPES = {
13: { label: 'A/R Invoice', table: 'OINV' },
14: { label: 'A/R Credit Memo', table: 'ORIN' },
15: { label: 'Delivery', table: 'ODLN' },
16: { label: 'Return', table: 'ORDN' },
17: { label: 'Sales Order', table: 'ORDR' },
18: { label: 'A/P Invoice', table: 'OPCH' },
19: { label: 'A/P Credit Memo', table: 'ORPC' },
20: { label: 'Goods Receipt PO', table: 'OPDN' },
21: { label: 'Goods Return', table: 'ORPD' },
22: { label: 'Purchase Order', table: 'OPOR' },
59: { label: 'Goods Receipt', table: 'OIGN' },
60: { label: 'Goods Issue', table: 'OIGE' },
67: { label: 'Inventory Transfer', table: 'OWTR' },
310000001: { label: 'Production Order', table: 'OWOR' },
};
// GET /transactions?itemCode=X&batchNo=Y — the "Transactions for Batch" grid.
// Reads IBT1, SAP's own batch-transaction log, one row per stock-affecting
// document line that ever touched this exact Item+Batch.
//
// Sign/direction rule (reverse-engineered against this database's real IBT1
// data, not assumed from documentation): Direction 0 and 1 ALWAYS store
// Quantity as a positive magnitude — 0 means the document increased batch
// stock (In), 1 means it decreased it (Out), so the sign has to be applied
// here. Direction 2 (used by AR/Sales-side docs — Invoice/Credit
// Memo/Return/Sales Order) stores Quantity ALREADY signed correctly, so it's
// used as-is. Verified against BaseType 60 (Goods Issue: always Direction 1,
// Quantity always ≥0) and BaseType 15 (Delivery: Direction 0/1/2 all appear,
// but only Direction 2 rows have negative Quantity stored).
router.get('/transactions', verifyToken, async (req, res) => {
try {
const co = cq(req);
const pool = await getPool(co);
const itemCode = (req.query.itemCode || '').trim();
const batchNo = (req.query.batchNo || '').trim();
if (!itemCode || !batchNo) return res.json({ success: true, data: [] });
const query = `
SELECT T0."ItemCode", T0."BatchNum", T0."WhsCode", T1."WhsName",
T0."LineNum", T0."BaseType", T0."BaseNum", T0."BaseLinNum",
T0."DocDate", T0."Quantity", T0."Direction", T0."CardName"
FROM [dbo].[IBT1] T0
LEFT JOIN [dbo].[OWHS] T1 ON T1."WhsCode" = T0."WhsCode"
WHERE T0."ItemCode" = '${sqlEsc(itemCode)}' AND T0."BatchNum" = '${sqlEsc(batchNo)}'
ORDER BY T0."DocDate", T0."LineNum"`;
const result = await pool.request().query(query);
const data = result.recordset.map((r, i) => {
const rawQty = Number(r.Quantity) || 0;
const dir = Number(r.Direction);
const qty = dir === 1 ? -Math.abs(rawQty) : dir === 0 ? Math.abs(rawQty) : rawQty;
const docType = DOC_TYPES[Number(r.BaseType)] || { label: `Doc Type ${r.BaseType}`, table: '' };
return {
no: i + 1,
docTypeLabel: docType.label,
docNum: r.BaseNum,
docRow: r.BaseLinNum,
date: r.DocDate,
warehouse: r.WhsCode,
warehouseName: r.WhsName || '',
bpName: r.CardName || '',
qty,
direction: qty >= 0 ? 'In' : 'Out',
itemCode: r.ItemCode,
batchNo: r.BatchNum,
};
});
res.json({ success: true, data });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+10 -1
View File
@@ -55,7 +55,16 @@ router.get('/', verifyToken, async (req, res) => {
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'] });
// Genuine total (same filter, no paging) — drives "Page X of Y" on the
// client instead of an open-ended "Page X".
let total = null;
try {
const countEndpoint = `BusinessPartners/$count${filters.length ? `?$filter=${encodeURIComponent(filters.join(' and '))}` : ''}`;
const c = await getSap().sapRequest('GET', countEndpoint, null, companyDB);
const n = Number(c);
if (!isNaN(n)) total = n;
} catch (e) { console.warn('[BUSINESS-MASTER] $count failed — pager will show no total:', e.message); }
res.json({ success: true, data: result.value || [], count: result['odata.count'], total });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
+70
View File
@@ -0,0 +1,70 @@
'use strict';
// routes/downtimeAnalysis.js — "Downtime Analysis" report (see
// services/downtimeAnalysisStore.js for the aggregation logic and
// services/appSettingsStore.js's downtimeAnalysisConfig for the admin-edited
// cause taxonomy/field mapping/Base Days). Gated purely by the
// 'downtime-analysis' sidebar module (Admin → Users) — same soft-gate
// pattern as the other report-style pages (Inventory Status Report, PWO
// Source Report, etc.): verifyToken only here, the module toggle controls
// who even sees the menu link.
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const store = require('./../services/downtimeAnalysisStore');
const cq = (req) => req.query?.company || req.body?.company || null;
router.get('/report', verifyToken, async (req, res) => {
try {
const data = await store.buildReport({
company: cq(req),
periodFrom: req.query.periodFrom,
periodTo: req.query.periodTo,
baseDays: req.query.baseDays,
});
res.json({ success: true, data });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
router.get('/by-tab', verifyToken, async (req, res) => {
try {
const data = await store.buildTabReport({
company: cq(req),
periodFrom: req.query.periodFrom,
periodTo: req.query.periodTo,
baseDays: req.query.baseDays,
});
res.json({ success: true, data });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
router.get('/detail-by-tab', verifyToken, async (req, res) => {
try {
const data = await store.buildDetailByTab({
company: cq(req),
periodFrom: req.query.periodFrom,
periodTo: req.query.periodTo,
});
res.json({ success: true, data });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
router.get('/monthly', verifyToken, async (req, res) => {
try {
const data = await store.buildMonthlyReport({
company: cq(req),
periodFrom: req.query.periodFrom,
periodTo: req.query.periodTo,
});
res.json({ success: true, data });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
module.exports = router;
+28 -25
View File
@@ -58,6 +58,11 @@ function validUQC(code) {
// ── SQL builders ──────────────────────────────────────────────────────────
// Deemed Export (DE) sales ledgers — invoices posting to any of these GL
// accounts are reported as B2B with invoice_type 'DE' and kept out of EXP.
// Updated 05-10-2026: added 4110202005, 4110202007 (matches EXCL_EI in reports.js)
const DE_LEDGERS = `'4110202001','4110202003','4110202005','4110202007'`;
function sqlB2B(from, to) {
return `
WITH TaxData AS (
@@ -127,7 +132,7 @@ WITH TaxData AS (
FROM TaxData GROUP BY DocEntry, TaxRate
)
SELECT
CASE WHEN EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN ('4110202001','4110202003')) THEN 'DE'
CASE WHEN EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN (${DE_LEDGERS})) THEN 'DE'
WHEN T_DOC12.CountryB<>'IN' THEN 'EXP'
WHEN T_ODOC.U_CustomerCategory='Indirect Export' THEN 'B2B'
WHEN T_DOC12.BpGSTN IS NULL AND T_DOC12.CountryS='IN' THEN 'B2CS'
@@ -154,7 +159,7 @@ LEFT JOIN OCST T_OCST_S ON T_OCST_S.Code=T_DOC12.StateS
WHERE T_ODOC.CANCELED='N'
AND T_ODOC.DocDate>='${from}' AND T_ODOC.DocDate<='${to}'
AND (
EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN ('4110202001','4110202003'))
EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN (${DE_LEDGERS}))
OR (
T_DOC12.CountryB='IN'
AND (T_DOC12.BpGSTN IS NOT NULL OR T_ODOC.U_CustomerCategory='Indirect Export')
@@ -434,7 +439,7 @@ WHERE T_ODOC.CANCELED='N'
AND T_ODOC.DocDate>='${from}' AND T_ODOC.DocDate<='${to}'
AND T_DOC12.ImpORExp='Y' AND T_DOC12.CountryB<>'IN'
AND T_DOC12.BpGSTN IS NULL AND T_OCST_S.GSTCode IS NULL
AND NOT EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN ('4110202001','4110202003'))
AND NOT EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN (${DE_LEDGERS}))
ORDER BY ITS.DocEntry, ITS.TaxRate`;
}
@@ -447,7 +452,9 @@ function sqlHsn(from, to) {
SUM(CASE WHEN staType = '-120' THEN TaxSum ELSE 0 END) AS iamt,
SUM(CASE WHEN staType = '-110' THEN TaxSum ELSE 0 END) AS camt,
SUM(CASE WHEN staType = '-100' THEN TaxSum ELSE 0 END) AS samt,
SUM(CASE WHEN staType = '10' THEN TaxSum ELSE 0 END) AS tcs
SUM(CASE WHEN staType = '10' THEN TaxSum ELSE 0 END) AS tcs,
-- line GST rate (IGST or CGST+SGST) — excludes TCS, unlike VatPrcnt
SUM(CASE WHEN staType IN ('-120','-110','-100') THEN TaxRate ELSE 0 END) AS rt
FROM INV4
GROUP BY DocEntry, LineNum
),
@@ -459,7 +466,9 @@ RIN_TAX AS (
SUM(CASE WHEN staType = '-120' THEN TaxSum ELSE 0 END) AS iamt,
SUM(CASE WHEN staType = '-110' THEN TaxSum ELSE 0 END) AS camt,
SUM(CASE WHEN staType = '-100' THEN TaxSum ELSE 0 END) AS samt,
SUM(CASE WHEN staType = '10' THEN TaxSum ELSE 0 END) AS tcs
SUM(CASE WHEN staType = '10' THEN TaxSum ELSE 0 END) AS tcs,
-- line GST rate (IGST or CGST+SGST) — excludes TCS, unlike VatPrcnt
SUM(CASE WHEN staType IN ('-120','-110','-100') THEN TaxRate ELSE 0 END) AS rt
FROM RIN4
GROUP BY DocEntry, LineNum
),
@@ -486,7 +495,8 @@ HSN_Lines AS (
ISNULL(T.iamt, 0) AS iamt,
ISNULL(T.camt, 0) AS camt,
ISNULL(T.samt, 0) AS samt,
ISNULL(T.tcs, 0) AS tcs
ISNULL(T.tcs, 0) AS tcs,
ISNULL(T.rt, 0) AS rt
FROM OINV
INNER JOIN INV1
ON OINV.DocEntry = INV1.DocEntry
@@ -525,7 +535,8 @@ HSN_Lines AS (
-ISNULL(T.iamt, 0) AS iamt,
-ISNULL(T.camt, 0) AS camt,
-ISNULL(T.samt, 0) AS samt,
-ISNULL(T.tcs, 0) AS tcs
-ISNULL(T.tcs, 0) AS tcs,
ISNULL(T.rt, 0) AS rt
FROM ORIN
INNER JOIN RIN1
ON ORIN.DocEntry = RIN1.DocEntry
@@ -557,18 +568,9 @@ HSN_Agg AS (
SUM(camt) AS camt_n,
SUM(samt) AS samt_n,
SUM(tcs) AS tcs_n,
CASE
WHEN SUM(txval) = 0 THEN 0
WHEN ROUND(
(SUM(iamt) + SUM(camt) + SUM(samt)) * 100.0 / SUM(txval),
0
) IN (0, 5, 12, 18, 28)
THEN ROUND(
(SUM(iamt) + SUM(camt) + SUM(samt)) * 100.0 / SUM(txval),
0
)
ELSE 0
END AS rate_of_tax_n
-- group by the line's own GST rate so 0% exports and 5%/18% supplies
-- under the same HSN are separate rows (a blended rate is invalid)
rt AS rate_of_tax_n
FROM HSN_Lines
GROUP BY
fp,
@@ -576,7 +578,8 @@ HSN_Agg AS (
hsn_sc,
gstr1Desc,
section,
uqc
uqc,
rt
)
SELECT
@@ -613,29 +616,29 @@ WITH DocStatus AS (
CASE WHEN T0.CANCELED='Y' OR CM.BaseEntry IS NOT NULL THEN 1 ELSE 0 END AS IsCanceled
FROM OINV T0
LEFT JOIN (SELECT DISTINCT R1.BaseEntry FROM RIN1 R1 INNER JOIN ORIN R0 ON R0.DocEntry=R1.DocEntry WHERE R1.BaseType=13 AND R0.CANCELED='N') CM ON CM.BaseEntry=T0.DocEntry
WHERE T0.DocDate>='${from}' AND T0.DocDate<'${to}'
WHERE T0.DocDate>='${from}' AND T0.DocDate<='${to}'
UNION ALL
SELECT D0.DocEntry, D0.DocNum, D0.Series, D0.DocDate, 'Delivery',
CASE WHEN D0.CANCELED='Y' THEN 1 ELSE 0 END
FROM ODLN D0 INNER JOIN NNM1 N1 ON N1.Series=D0.Series
WHERE N1.SeriesName LIKE '%FOC%' AND D0.DocDate>='${from}' AND D0.DocDate<'${to}'
WHERE N1.SeriesName LIKE '%FOC%' AND D0.DocDate>='${from}' AND D0.DocDate<='${to}'
UNION ALL
SELECT W0.DocEntry, W0.DocNum, W0.Series, W0.DocDate,
CASE WHEN N1.SeriesName LIKE '%JW%' THEN 'JW' WHEN N1.SeriesName LIKE '%IT%' AND N1.SeriesName NOT LIKE '%ITR%' THEN 'IT' END,
CASE WHEN W0.CANCELED='Y' THEN 1 ELSE 0 END
FROM OWTR W0 INNER JOIN NNM1 N1 ON N1.Series=W0.Series
WHERE (N1.SeriesName LIKE '%JW%' OR (N1.SeriesName LIKE '%IT%' AND N1.SeriesName NOT LIKE '%ITR%'))
AND W0.DocDate>='${from}' AND W0.DocDate<'${to}'
AND W0.DocDate>='${from}' AND W0.DocDate<='${to}'
UNION ALL
SELECT P0.DocEntry, P0.DocNum, P0.Series, P0.DocDate, 'AP Invoice (RCM)',
CASE WHEN P0.CANCELED='Y' THEN 1 ELSE 0 END
FROM OPCH P0 INNER JOIN NNM1 N1 ON N1.Series=P0.Series
WHERE N1.SeriesName LIKE '%rev%' AND P0.DocDate>='${from}' AND P0.DocDate<'${to}'
WHERE N1.SeriesName LIKE '%rev%' AND P0.DocDate>='${from}' AND P0.DocDate<='${to}'
UNION ALL
SELECT R0.DocEntry, R0.DocNum, R0.Series, R0.DocDate, 'AR Credit Memo',
CASE WHEN R0.CANCELED='Y' THEN 1 ELSE 0 END
FROM ORIN R0 INNER JOIN NNM1 N1 ON N1.Series=R0.Series
WHERE N1.SeriesName LIKE '%ARCN%' AND R0.DocDate>='${from}' AND R0.DocDate<'${to}'
WHERE N1.SeriesName LIKE '%ARCN%' AND R0.DocDate>='${from}' AND R0.DocDate<='${to}'
)
SELECT CONVERT(char(6),DocDate,112) AS fp, Series, DocumentType,
CASE WHEN DocumentType='AR Credit Memo' THEN CONCAT('CN-',MIN(DocNum))
+123
View File
@@ -0,0 +1,123 @@
// routes/inventoryPostingList.js
// Mirrors SAP B1's own "Inventory Posting List" report (Inventory →
// Inventory Reports → Inventory Posting List): every stock-moving
// transaction (OINM) for a range of items over a date range, with a running
// quantity Balance per item, filterable by Item Code range, Item Group,
// and Warehouse(s) — same selection-criteria shape as the Inventory Status
// Report.
//
// NOTE — "Split Display by Batch/Serial Numbers" (visible on SAP's own
// selection screen) is deliberately NOT implemented here: OINM has no
// reliable, verified link back to the batch/serial actually posted on each
// transaction in this database (the obvious ITL1→OBTN join came back empty
// against real data), and showing a guessed/wrong batch number would be
// worse than not showing one at all. Every other column is real, verified
// OINM data.
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const cq = (req) => req.query?.company || req.body?.company || null;
// Real SAP TransType codes seen against this database's own transaction
// history (verified live: 60/59/67/15/18/20/14/16/19/13/21 account for
// nearly every row) — full descriptive labels rather than guessed 2-letter
// SAP abbreviations, since those couldn't be verified. Anything outside this
// list (e.g. a UDO-driven custom type) falls back to "Type <code>" rather
// than asserting a label that might be wrong.
const TRANS_TYPE_LABELS = {
13: 'AR Invoice', 14: 'AR Credit Memo', 15: 'Delivery', 16: 'AR Return',
17: 'Reserve Invoice', 18: 'Goods Receipt PO', 19: 'Goods Return',
20: 'AP Invoice', 21: 'AP Credit Memo',
59: 'Inventory Receipt', 60: 'Inventory Issue', 67: 'Stock Transfer',
};
function transLabel(t) { return TRANS_TYPE_LABELS[t] || `Type ${t}`; }
router.get('/', verifyToken, async (req, res) => {
try {
const co = cq(req);
const pool = await getPool(co);
const sqlEsc = (v) => String(v).replace(/'/g, "''");
const itemFrom = (req.query.itemFrom || '').trim();
const itemTo = (req.query.itemTo || '').trim();
const itemGroup = (req.query.itemGroup || '').trim();
const warehouses = (req.query.warehouses || '').split(',').map((w) => w.trim()).filter(Boolean);
const dateFrom = (req.query.dateFrom || '').trim();
const dateTo = (req.query.dateTo || '').trim();
const hideNoQtyChange = req.query.hideNoQtyChange === '1';
if (!dateFrom || !dateTo) return res.status(400).json({ success: false, message: 'dateFrom and dateTo are required' });
// Resolve the matching item set first — same range+group filter as the
// Inventory Status Report, so the two stay consistent.
const itemWhere = [];
if (itemFrom) itemWhere.push(`"ItemCode" >= '${sqlEsc(itemFrom)}'`);
if (itemTo) itemWhere.push(`"ItemCode" <= '${sqlEsc(itemTo)}'`);
if (itemGroup) itemWhere.push(`"ItmsGrpCod" = ${parseInt(itemGroup)}`);
const itemRows = await pool.request().query(`SELECT "ItemCode" FROM [dbo].[OITM] ${itemWhere.length ? 'WHERE ' + itemWhere.join(' AND ') : ''}`);
const itemCodes = itemRows.recordset.map((r) => r.ItemCode);
if (!itemCodes.length) return res.json({ success: true, data: [] });
// Guard against an unbounded range (e.g. both From/To left blank) —
// matching against every item in SAP would make the OINM scan below
// enormous. 2000 items is already a generous ceiling for one report run.
if (itemCodes.length > 2000) return res.status(400).json({ success: false, message: `${itemCodes.length} items match this filter — narrow the Item Code range or Item Group first (max 2000 at a time).` });
const itemList = itemCodes.map((c) => `'${sqlEsc(c)}'`).join(',');
const whWhere = warehouses.length ? `AND "Warehouse" IN (${warehouses.map((w) => `'${sqlEsc(w)}'`).join(',')})` : '';
// Opening balance per item — every transaction strictly BEFORE the
// selected date range, so "Balance" in the results is a real absolute
// stock level, not just a delta within the window (matches what SAP's
// own report shows).
const openingRows = await pool.request().query(`
SELECT "ItemCode", SUM(ISNULL("InQty",0)) - SUM(ISNULL("OutQty",0)) AS "Opening"
FROM [dbo].[OINM]
WHERE "ItemCode" IN (${itemList}) ${whWhere} AND "DocDate" < '${sqlEsc(dateFrom)}'
GROUP BY "ItemCode"`);
const openingByItem = {};
openingRows.recordset.forEach((r) => { openingByItem[r.ItemCode] = Number(r.Opening) || 0; });
const qtyFilter = hideNoQtyChange ? `AND (ISNULL("InQty",0)<>0 OR ISNULL("OutQty",0)<>0)` : '';
const rows = await pool.request().query(`
SELECT T0."ItemCode", T0."Dscription", T0."DocDate", T0."CreateDate", T0."Warehouse",
T0."InQty", T0."OutQty", T0."CardCode", T0."CardName", T0."Ref1", T0."Ref2",
T0."TransType", T0."TransNum", T0."DocLineNum", T0."CalcPrice", T0."Price", T0."Comments"
FROM [dbo].[OINM] T0
WHERE T0."ItemCode" IN (${itemList}) ${whWhere}
AND T0."DocDate" >= '${sqlEsc(dateFrom)}' AND T0."DocDate" <= '${sqlEsc(dateTo)}'
${qtyFilter}
ORDER BY T0."ItemCode", T0."DocDate", T0."TransNum"`);
// Running balance computed here (not trusted from OINM.Balance, which is
// only populated on certain valuation checkpoints, not every row —
// verified live: most receipt/issue rows carry a NULL Balance) — a
// simple per-item cumulative sum seeded from the opening balance above.
const runningByItem = {};
const data = rows.recordset.map((r) => {
const itemCode = r.ItemCode;
if (!(itemCode in runningByItem)) runningByItem[itemCode] = openingByItem[itemCode] || 0;
const inQty = Number(r.InQty) || 0;
const outQty = Number(r.OutQty) || 0;
runningByItem[itemCode] += inQty - outQty;
return {
itemCode, itemName: r.Dscription || '',
docDate: r.DocDate, warehouse: r.Warehouse || '',
transType: r.TransType, docLabel: transLabel(r.TransType),
docRef: r.Ref1 || '', ref2: r.Ref2 || '',
docRow: (r.DocLineNum || 0) + 1,
bpName: r.CardName || '', cardCode: r.CardCode || '',
inQty, outQty,
price: (r.CalcPrice != null && r.CalcPrice !== 0) ? Number(r.CalcPrice) : Number(r.Price) || 0,
balance: runningByItem[itemCode],
remarks: r.Comments || '',
};
});
res.json({ success: true, data, openingBalances: openingByItem });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+81
View File
@@ -0,0 +1,81 @@
// routes/inventoryStatusReport.js
// Mirrors SAP B1's own "Inventory Status" report (Inventory → Inventory
// Reports → Inventory Status): per-item In Stock / Committed / Ordered /
// Available, aggregated across whichever warehouses the user selects,
// filterable by Item Code range, Preferred Vendor range, and Item Group.
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const cq = (req) => req.query?.company || req.body?.company || null;
// Available = In Stock − Committed + Ordered — same formula SAP's own
// Inventory Status report uses (verified live against a real item: In Stock
// 2,817.648 − Committed 103,891.201 + Ordered 3,900 = Available −97,173.553,
// matching SAP's screen exactly).
router.get('/', verifyToken, async (req, res) => {
try {
const co = cq(req);
const pool = await getPool(co);
const sqlEsc = (v) => String(v).replace(/'/g, "''");
const itemFrom = (req.query.itemFrom || '').trim();
const itemTo = (req.query.itemTo || '').trim();
const vendorFrom = (req.query.vendorFrom || '').trim();
const vendorTo = (req.query.vendorTo || '').trim();
const itemGroup = (req.query.itemGroup || '').trim();
const hideZeroStock = req.query.hideZeroStock === '1';
const warehouses = (req.query.warehouses || '').split(',').map((w) => w.trim()).filter(Boolean);
const where = [];
if (itemFrom) where.push(`T0."ItemCode" >= '${sqlEsc(itemFrom)}'`);
if (itemTo) where.push(`T0."ItemCode" <= '${sqlEsc(itemTo)}'`);
if (vendorFrom) where.push(`T0."CardCode" >= '${sqlEsc(vendorFrom)}'`);
if (vendorTo) where.push(`T0."CardCode" <= '${sqlEsc(vendorTo)}'`);
if (itemGroup) where.push(`T0."ItmsGrpCod" = ${parseInt(itemGroup)}`);
// No warehouses selected = every warehouse (matches leaving every
// checkbox unticked on SAP's own selection screen). Otherwise scope the
// OITW join to just the picked ones, in the JOIN condition — not a
// WHERE clause — so an item with stock in ONLY unselected warehouses
// still appears (with everything zeroed out), same as SAP's own report.
const whJoin = warehouses.length
? `AND T1."WhsCode" IN (${warehouses.map((w) => `'${sqlEsc(w)}'`).join(',')})`
: '';
const havingZero = hideZeroStock ? `HAVING SUM(ISNULL(T1."OnHand",0)) <> 0` : '';
const query = `
SELECT T0."ItemCode", T0."ItemName", T0."InvntryUom" AS "Uom",
SUM(ISNULL(T1."OnHand",0)) AS "InStock",
SUM(ISNULL(T1."IsCommited",0)) AS "Committed",
SUM(ISNULL(T1."OnOrder",0)) AS "Ordered"
FROM [dbo].[OITM] T0
LEFT JOIN [dbo].[OITW] T1 ON T1."ItemCode" = T0."ItemCode" ${whJoin}
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY T0."ItemCode", T0."ItemName", T0."InvntryUom"
${havingZero}
ORDER BY T0."ItemCode"`;
const result = await pool.request().query(query);
const data = result.recordset.map((r) => {
const inStock = Number(r.InStock) || 0;
const committed = Number(r.Committed) || 0;
const ordered = Number(r.Ordered) || 0;
return {
itemCode: r.ItemCode,
itemName: r.ItemName || '',
inStock, committed, ordered,
available: inStock - committed + ordered,
uom: r.Uom || '',
};
});
res.json({ success: true, data });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+102
View File
@@ -0,0 +1,102 @@
// routes/inventoryTransfer.js
// General-purpose "Inventory Transfer" — mirrors SAP B1's own Inventory
// Transfer window (Inventory → Inventory Transactions → Inventory Transfer):
// move stock from one warehouse to another, any item(s), not tied to a
// Production/Work Order. Posts a standard SAP StockTransfers document.
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const appSettings = require('../services/appSettingsStore');
const { getPool } = require('../services/sqlPool');
let _sapSvc = null;
function getSap(){ if(!_sapSvc) _sapSvc = require('../services/sapServiceLayer'); return _sapSvc; }
const cq = (req) => req.query?.company || req.body?.company || null;
// GET /api/inventory-transfer/series-info — resolves the admin-configured
// Series ID to its human-readable SAP name (e.g. 28615 → "IC2627"), for the
// page itself to display so a non-admin user understands which series their
// transfer will post under. Deliberately NOT behind the admin-only /settings
// endpoint — any logged-in user doing a transfer needs to see this.
router.get('/series-info', verifyToken, async (req, res) => {
const co = cq(req);
const series = appSettings.inventoryTransferSeries();
if (series == null) return res.json({ success: true, series: null, seriesName: null });
try {
const pool = await getPool(co);
const r = await pool.request().query(`SELECT "SeriesName" FROM [dbo].[NNM1] WHERE "ObjectCode"='67' AND "Series"=${parseInt(series)}`);
res.json({ success: true, series, seriesName: r.recordset[0]?.SeriesName || null });
} catch (err) {
res.json({ success: true, series, seriesName: null, warning: err.message });
}
});
router.post('/', verifyToken, async (req, res) => {
try{
const sap = getSap();
if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'});
const co = cq(req);
const body = req.body || {};
const fromWarehouse = (body.fromWarehouse||'').trim();
const toWarehouse = (body.toWarehouse||'').trim();
if(!fromWarehouse||!toWarehouse) return res.status(400).json({success:false,message:'From Warehouse and To Warehouse are required'});
if(fromWarehouse===toWarehouse) return res.status(400).json({success:false,message:'From Warehouse and To Warehouse must be different'});
const rawLines = Array.isArray(body.lines) ? body.lines : [];
const lines = rawLines
.filter(l=>l.itemCode && (parseFloat(l.quantity)||0)>0)
.map((l,idx)=>{
const line={
ItemCode: String(l.itemCode).trim(),
Quantity: parseFloat(l.quantity)||0,
WarehouseCode: toWarehouse,
FromWarehouseCode: fromWarehouse,
};
if(Array.isArray(l.batchNumbers)&&l.batchNumbers.length){
const bn=l.batchNumbers.filter(b=>b.BatchNumber&&(parseFloat(b.Quantity)||0)>0)
.map(b=>({BatchNumber:b.BatchNumber,Quantity:parseFloat(b.Quantity)||0,BaseLineNumber:idx}));
if(bn.length) line.BatchNumbers=bn;
}
return line;
});
if(!lines.length) return res.status(400).json({success:false,message:'At least one item line with a quantity > 0 is required'});
const payload={
FromWarehouse: fromWarehouse,
ToWarehouse: toWarehouse,
StockTransferLines: lines,
};
if(body.comments) payload.Comments=String(body.comments).trim();
// StockTransfers has no DocDueDate field (unlike InventoryGenEntries) —
// sending it fails with "-1000 Property 'DocDueDate' of 'StockTransfer'
// is invalid", confirmed live.
if(body.postingDate){ payload.DocDate=body.postingDate; payload.TaxDate=body.postingDate; }
// Admin-configurable (System Settings → Inventory Transfer → Document
// Series) — e.g. SAP's "IC2627" series, internal ID 28615, maintained in
// Admin so it never needs a code change if the series changes.
const series=appSettings.inventoryTransferSeries();
if(series!=null) payload.Series=series;
console.log('[INV-TRANSFER] Final payload:\n',JSON.stringify(payload,null,2));
const result = await sap.sapRequest('POST','StockTransfers',payload,co);
console.log('[INV-TRANSFER] ✅ DocEntry:',result?.DocEntry,'DocNum:',result?.DocNum);
require('../services/auditStore').record({
userId:req.user?.id, username:req.user?.username, role:req.user?.role,
method:req.method, action:'CREATE', entity:'InventoryTransfer', entityId:String(result?.DocEntry||''),
path:req.originalUrl, status:200, ok:true,
summary:`Inventory Transfer #${result?.DocNum||result?.DocEntry} posted: ${fromWarehouse} → ${toWarehouse}, ${lines.length} item(s).`,
details:{fromWarehouse,toWarehouse,lines}, ip:req.ip, company:co,
}).catch(()=>{});
res.json({success:true,data:result});
}catch(err){
const sapMsg=err.message||'Unknown SAP error';
console.error('[INV-TRANSFER] ❌',sapMsg);
res.status(400).json({success:false,message:sapMsg});
}
});
module.exports = router;
+16 -5
View File
@@ -15,6 +15,14 @@ function getSap() {
if (!_sapSvc) _sapSvc = require('../services/sapServiceLayer');
return _sapSvc;
}
const appSettings = () => require('../services/appSettingsStore');
// admin/sap_adder/system_admin may push directly and approve/reject others'
// submissions. Kept as one helper so the client (public/approvals.html's
// canManageItems()) and server always agree on who this is.
function isItemApprover(user) {
return user?.role === 'admin' || user?.role === 'sap_adder' || user?.role === 'system_admin';
}
const cq = (req) => req.query?.company || req.body?.company || null;
@@ -110,8 +118,11 @@ function buildSapPayload(data) {
}
// ── POST /api/item-approvals/submit ─────────────────────────────────────────
// admin/sap_adder → push directly to SAP (no approval queue)
// all other roles → save as PENDING for admin review
// admin/sap_adder/system_admin → push directly to SAP (no approval queue)
// all other roles → save as PENDING for admin review
// Admin → System Settings → "Item Approval Workflow": when OFF, the whole
// queue is bypassed and EVERYONE pushes directly (there's no one left who'd
// review a pending item) — same as an approver submitting, regardless of role.
router.post('/submit', verifyToken, async (req, res) => {
try {
const store = getStore();
@@ -130,7 +141,7 @@ router.post('/submit', verifyToken, async (req, res) => {
company: company || '',
};
if (req.user.role === 'admin' || req.user.role === 'sap_adder') {
if (isItemApprover(req.user) || !appSettings().itemApprovalEnabled()) {
const payload = await pushItemToSap(itemData, co);
const logEntry = {
username: req.user.username, name: req.user.name || req.user.username,
@@ -187,8 +198,8 @@ router.get('/:id', verifyToken, async (req, res) => {
// 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' });
if (!isItemApprover(req.user)) {
return res.status(403).json({ success: false, message: 'Only admin/SAP Adder/system admin can approve items' });
}
try {
const store = getStore();
+9 -1
View File
@@ -151,7 +151,15 @@ router.get('/', verifyToken, requireApprovalStep('man_power:entry', 'view'), asy
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 });
// Genuine total (same filter, no paging) — drives "Page X of Y" on the
// client instead of an open-ended "Page X".
let total = null;
try {
const c = await sap().sapRequest('GET', `Production_Data/$count${filter ? '?' + filter.slice(1) : ''}`, null, co);
const n = Number(c);
if (!isNaN(n)) total = n;
} catch (e) { console.warn('[MANPOWER] $count failed — pager will show no total:', e.message); }
res.json({ success: true, data: docs, total });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
+36 -6
View File
@@ -1,11 +1,13 @@
'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.
// Production Order, Batch Issuance, BOM Requests, Production Deviations,
// and — admin only — Password Reset requests), for the sidebar bell +
// dashboard "Pending Actions" widget. Every item is gated by the same
// approval-step/role check its own workflow route enforces, so a user only
// ever sees what they're actually permitted to act on. 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');
@@ -56,13 +58,24 @@ router.get('/pending', verifyToken, async (req, res) => {
// 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' };
// Consumable Orders (Release → Issue → Close only, no Receipt/Transfer to
// FG) are gated by their OWN dedicated steps, never the general
// production_order:* ones — mirrors services/productionOrderStore.js's
// own notifyStepFor()/CONSUMABLE_STEP_FOR (not exported, so duplicated
// here, same convention as WORK_ORDER_STEP_KEYS above). Without this, a
// user holding the general 'production_order:release' step (but NOT
// 'production_order:consumable_release') incorrectly saw every pending
// Consumable Order in the bell too, even though the Production Order
// page itself already correctly hides Consumable Orders from them.
const CONSUMABLE_STEP_FOR = { release: 'consumable_release', issuance: 'consumable_issue', close: 'consumable_close' };
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];
const generalKey = poStore.STEP_KEYS[p.stage];
const key = p.isConsumable ? (CONSUMABLE_STEP_FOR[generalKey] || null) : generalKey;
// 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.
@@ -120,6 +133,23 @@ router.get('/pending', verifyToken, async (req, res) => {
});
} catch (e) { console.warn('[notifications] bom scan failed:', e.message); }
// Production Deviations awaiting QA sign-off — pending for whoever holds
// 'approve' on production_order:deviation (see [[production-deviation-workflow]]).
// Only QA_STATUS='PENDING' counts as pending; N_A (QA not required for this
// one) / APPROVED / REJECTED are already resolved, nothing left to do.
try {
if (hasStepPerm(user, 'production_order:deviation', 'approve')) {
const devs = await require('../services/deviationStore').listDeviations({});
devs.filter(d => d.qaStatus === 'PENDING').forEach(d => {
items.push({
module: 'deviation', label: 'Deviation', title: d.sapDocNum ? `PWO #${d.sapDocNum}` : `Item ${d.itemCode}`,
detail: `${d.type.charAt(0) + d.type.slice(1).toLowerCase()} — ${d.itemCode}${d.newItemCode ? ' → ' + d.newItemCode : ''} — awaiting QA`,
link: d.sapAbsEntry ? `/production?open=${d.sapAbsEntry}` : '/deviations', id: d.id, cardHref: '/deviations',
});
});
}
} catch (e) { console.warn('[notifications] deviation scan failed:', e.message); }
if (user.role === 'admin' || user.role === 'sap_adder') {
try {
const pending = await require('../services/passwordResetStore').listRequests('PENDING');
+4 -2
View File
@@ -48,10 +48,12 @@ router.get('/', verifyToken, requireApprovalStep('oee:entry', 'view'), async (re
const skip = Number(req.query.skip) || 0;
const tab = (req.query.tab || '').trim() || null;
const month = (req.query.month || '').trim() || null;
const monthFrom = (req.query.monthFrom || '').trim() || null;
const monthTo = (req.query.monthTo || '').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 });
const { data, total } = await store.list({ company: cq(req), tab, month, monthFrom, monthTo, allowTabs: allow, top, skip });
res.json({ success: true, data, total });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
+1
View File
@@ -89,6 +89,7 @@ router.get('/for-verification', verifyToken, requireAnyStep(['work_order:verify'
itemCode: w.productCode, itemName: w.productName,
plannedQty: w.totalUnits, batchNumber: w.batchNumber,
intimationDocNo: w.intimationDocNo || '', createdBy: w.createdBy, createdByName: w.createdByName,
createdAt: w.createdAt,
status: w.status,
issuedComplete: stampComplete(w, 'issued'),
receivedComplete: stampComplete(w, 'received'),
+284
View File
@@ -0,0 +1,284 @@
// routes/sales.js — Sales Order module, EMPLOYEE API (/api/sales)
// Replaces the msale portal's employee side. Every route needs a normal ERP
// login; what a user may see/do is decided by their Approval Steps
// (sales_order:*, sales_sample:*, sales_export_sample:*, sales_master:*) plus
// their Sales profile (user type → scope, divisions) — see services/sales/*.
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { verifyToken, hasStepPerm, hasWorkflowPerm } = require('../middleware/auth');
const masters = require('../services/sales/masters');
const orders = require('../services/sales/orders');
const samples = require('../services/sales/samples');
const reports = require('../services/sales/reports');
const sap = require('../services/sales/sap');
const { STEPS, statusLabel, sampleStatusLabel, exportSampleStatusLabel } = require('../services/sales/constants');
const { query } = require('../services/sales/db');
const router = express.Router();
router.use(verifyToken);
// Private document store — deliberately NOT under /uploads (served statically).
const DOC_DIR = path.join(__dirname, '..', 'storage', 'sales');
fs.mkdirSync(DOC_DIR, { recursive: true });
const ALLOWED_EXT = ['.pdf', '.jpg', '.jpeg', '.png', '.bmp'];
const upload = multer({
storage: multer.diskStorage({
destination: DOC_DIR,
filename: (req, file, cb) => cb(null, `${Date.now()}_${Math.random().toString(36).slice(2, 8)}${path.extname(file.originalname).toLowerCase()}`),
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => cb(ALLOWED_EXT.includes(path.extname(file.originalname).toLowerCase()) ? null : new Error('Only PDF / JPG / PNG / BMP files are allowed'), true),
});
// Build the actor once per request (+ the user's email, needed for the
// 'mapped' scope which msale keyed on the employee's email).
const _emailCache = new Map();
router.use(async (req, res, next) => {
try {
let email = _emailCache.get(req.user.id);
if (email === undefined) {
const u = await require('../services/hanaUsers').findById(req.user.id);
email = (u && u.email) || '';
_emailCache.set(req.user.id, email);
setTimeout(() => _emailCache.delete(req.user.id), 60000);
}
req.actor = { type: 'user', user: req.user, name: req.user.name || req.user.username, email };
next();
} catch (e) { next(e); }
});
const wrap = fn => async (req, res) => {
try { const out = await fn(req, res); if (out !== undefined && !res.headersSent) res.json({ success: true, data: out }); }
catch (e) { if (!res.headersSent) res.status(e.status || (/^\[SAP/.test(e.message) ? 502 : 500)).json({ success: false, message: e.message }); }
};
const isAdmin = req => req.user.role === 'admin';
const hasModule = (req, m) => isAdmin(req) || (Array.isArray(req.user.modules) && req.user.modules.includes(m));
function need(cond, msg = 'Not allowed') { if (!cond) { const e = new Error(msg); e.status = 403; throw e; } }
// ── Session info / lookups ──────────────────────────────────────────────────
router.get('/me', wrap(async (req) => {
const prof = await masters.getProfile(req.user);
const caps = {};
STEPS.forEach(s => { caps[`${s.workflow}:${s.key}`] = ['view', 'add', 'edit', 'approve', 'delete'].filter(p => hasStepPerm(req.user, `${s.workflow}:${s.key}`, p)); });
const settings = masters.publicSettings(await masters.getSettings());
// divisions = enabled only (for creating orders/samples); allDivisions also
// includes disabled ones so existing orders still show their category name.
return { profile: prof, caps, isAdmin: isAdmin(req), divisions: await masters.listDivisions(), allDivisions: await masters.listDivisions(true), settings,
statusLabels: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(s => [s, statusLabel(s)])),
directStatusLabels: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(s => [s, statusLabel(s, 'DIRECT')])),
sampleStatusLabels: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9].map(s => [s, sampleStatusLabel(s)])),
exportStatusLabels: Object.fromEntries([1, 2, 3, 4].map(s => [s, exportSampleStatusLabel(s)])) };
}));
router.get('/divisions', wrap(() => masters.listDivisions()));
// Customers from SAP, limited to the caller's mapped customers when their scope is 'mapped'.
router.get('/customers', wrap(async (req) => {
need(hasWorkflowPerm(req.user, 'sales_order', 'view') || hasWorkflowPerm(req.user, 'sales_sample', 'view') || hasWorkflowPerm(req.user, 'sales_master', 'view'));
const s = await masters.getSettings();
const { prof, cards } = await orders.scopeFor(req.actor);
return sap.searchCustomers(s.company, req.query.q, { limit: 50, cardCodes: prof.scope === 'mapped' ? cards : null });
}));
router.get('/customers/:cardCode', wrap(async (req) => {
need(hasWorkflowPerm(req.user, 'sales_order', 'view') || hasWorkflowPerm(req.user, 'sales_sample', 'view') || hasWorkflowPerm(req.user, 'sales_master', 'view'));
const s = await masters.getSettings();
const { prof, cards } = await orders.scopeFor(req.actor);
if (prof.scope === 'mapped') need(cards.includes(req.params.cardCode), 'This customer is not mapped to you');
const c = await sap.getCustomer(s.company, req.params.cardCode);
if (!c) { const e = new Error('Customer not found'); e.status = 404; throw e; }
c.wallet = hasStepPerm(req.user, 'sales_order:view', 'view') ? await orders.walletSummary(c.cardCode) : null;
return c;
}));
router.get('/products', wrap(async (req) => {
const catalog = req.query.catalog === 'export' ? 'export' : 'domestic';
if (catalog === 'export') return masters.listCatalog({ catalog: 'export' });
if (!req.query.divisionId) return [];
return masters.listOrderableProducts(parseInt(req.query.divisionId));
}));
// ── Orders ──────────────────────────────────────────────────────────────────
router.get('/orders', wrap(req => orders.listOrders(req.actor, req.query)));
router.get('/orders/:id', wrap(req => orders.getOrder(req.params.id, req.actor)));
router.post('/orders', wrap(req => orders.createOrder(req.actor, req.body))); // Direct order
router.put('/orders/:id/resubmit', wrap(req => orders.resubmit(req.actor, req.params.id, req.body)));
router.post('/orders/:id/action', wrap(req => orders.act(req.actor, req.params.id, req.body.action, req.body)));
router.post('/sync-sap', wrap(async (req) => {
need(hasStepPerm(req.user, 'sales_order:sap_post', 'approve'), 'You are not assigned "approve" on sales_order:sap_post');
return orders.syncWithSap();
}));
// Invoices / dispatch / COA for an order, live from SAP.
async function invoiceBundle(actor, id) {
const o = await orders.getOrderRaw(id);
if (!o || !(await orders.canSee(actor, o))) { const e = new Error('Order not found'); e.status = 404; throw e; }
if (!o.sapDocEntry && ![8, 9].includes(o.status)) return { invoices: [], batches: [] };
const invoices = await sap.invoicesForOrder(o.company, o.id);
const batches = await sap.batchesWithCoa(o.company, invoices.map(i => i.docEntry));
const pdfs = invoices.length ? await query(`SELECT INVOICE_NO, MAX(ID) ID FROM dbo.ZSO_INVOICE_FILES WHERE INVOICE_NO IN (${invoices.map(() => '?').join(',')}) GROUP BY INVOICE_NO`,
invoices.map(i => String(i.invoiceNo))) : [];
const pdfSet = new Set(pdfs.map(p => String(p.INVOICE_NO)));
for (const inv of invoices) {
inv.hasPdf = pdfSet.has(String(inv.invoiceNo));
inv.attachments = inv.atcEntry ? await sap.attachmentLines(o.company, inv.atcEntry) : [];
inv.batches = batches.filter(b => b.invoiceDocEntry === inv.docEntry);
delete inv.atcEntry;
}
return { order: o, invoices };
}
router.get('/orders/:id/invoices', wrap(async req => { const b = await invoiceBundle(req.actor, req.params.id); delete b.order; return b; }));
async function streamAttachment(req, res, actor) {
const b = await invoiceBundle(actor, req.query.orderId);
const abs = parseInt(req.params.abs), line = parseInt(req.params.line);
const allowed = b.invoices.some(i => i.attachments.some(a => a.absEntry === abs && a.line === line) || i.batches.some(x => x.coa && x.coa.absEntry === abs && x.coa.line === line));
need(allowed, 'This file does not belong to the order');
const att = await sap.attachmentLine(b.order.company, abs, line);
const f = sap.readAttachment(att);
res.setHeader('Content-Disposition', `inline; filename="${f.name.replace(/"/g, '')}"`);
res.type(path.extname(f.name) || 'application/octet-stream').send(f.buffer);
}
async function streamInvoicePdf(req, res, actor) {
const b = await invoiceBundle(actor, req.query.orderId);
need(b.invoices.some(i => String(i.invoiceNo) === String(req.params.invoiceNo)), 'Invoice does not belong to the order');
const r = (await query(`SELECT TOP 1 FILE_NAME FROM dbo.ZSO_INVOICE_FILES WHERE INVOICE_NO=? ORDER BY ID DESC`, [String(req.params.invoiceNo)]))[0];
if (!r) { const e = new Error('Invoice PDF not available yet'); e.status = 404; throw e; }
const full = path.join(DOC_DIR, 'invoices', path.basename(r.FILE_NAME));
if (!fs.existsSync(full)) { const e = new Error('Invoice PDF file missing'); e.status = 404; throw e; }
res.setHeader('Content-Disposition', `inline; filename="${path.basename(r.FILE_NAME)}"`);
res.type('pdf').send(fs.readFileSync(full));
}
router.get('/attachment/:abs/:line', wrap((req, res) => streamAttachment(req, res, req.actor)));
router.get('/invoice-pdf/:invoiceNo', wrap((req, res) => streamInvoicePdf(req, res, req.actor)));
// ── Documents ───────────────────────────────────────────────────────────────
async function canSeeEntity(actor, entity, id) {
if (entity === 'order') { const o = await orders.getOrderRaw(id); return !!o && orders.canSee(actor, o); }
if (entity === 'export_sample') { try { await samples.getExport(actor, id); return true; } catch (_e) { return false; } }
if (entity === 'sample') { try { await samples.getSample(actor, id); return true; } catch (_e) { return false; } }
return false;
}
router.post('/docs', upload.single('file'), wrap(async (req) => {
const { entity, entityId, docType, title } = req.body;
try {
need(req.file, 'No file uploaded');
need(['order', 'sample', 'export_sample'].includes(entity), 'Invalid entity');
need(await canSeeEntity(req.actor, entity, entityId), 'Not allowed');
return { id: await orders.addDoc(entity, entityId, docType || 'other', title, req.file.filename, req.file.originalname, req.actor.name) };
} catch (e) { if (req.file) fs.unlink(req.file.path, () => {}); throw e; }
}));
router.get('/docs/:id', wrap(async (req, res) => {
const d = await orders.getDoc(req.params.id);
need(d && await canSeeEntity(req.actor, d.ENTITY, d.ENTITY_ID), 'Not allowed');
const full = path.join(DOC_DIR, path.basename(d.FILE_NAME));
need(fs.existsSync(full), 'File missing');
res.setHeader('Content-Disposition', `inline; filename="${(d.ORIG_NAME || d.FILE_NAME).replace(/"/g, '')}"`);
res.sendFile(full);
}));
// ── Payments / wallet (Accounts) ────────────────────────────────────────────
router.get('/payments/pending', wrap(req => orders.pendingPayments(req.actor)));
router.post('/payments/:txnId/decide', wrap(req => orders.decideTopup(req.actor, req.params.txnId, req.body.decision, req.body.remarks)));
router.post('/payments/on-account', wrap(req => orders.addOnAccountPayment(req.actor, req.body)));
router.get('/wallet/:cardCode', wrap(async (req) => {
need(hasStepPerm(req.user, 'sales_order:payment_entry', 'view') || hasStepPerm(req.user, 'sales_order:payment_verify', 'view'), 'Not allowed');
return { summary: await orders.walletSummary(req.params.cardCode), ledger: await orders.ledger(req.params.cardCode, req.query) };
}));
router.put('/credit-limit/:cardCode', wrap(async (req) => {
need(hasStepPerm(req.user, 'sales_order:payment_entry', 'edit'), 'You are not assigned "edit" on sales_order:payment_entry');
const acc = await masters.getCustomerAccount(req.params.cardCode);
need(acc, 'This customer has no portal login yet — create one in Sales Admin → Customer Logins');
await query(`UPDATE dbo.ZSO_CUSTOMERS SET CREDIT_LIMIT=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY=? WHERE CARD_CODE=?`, [Number(req.body.creditLimit) || 0, req.actor.name, req.params.cardCode]);
return { ok: true };
}));
// ── Samples ─────────────────────────────────────────────────────────────────
router.get('/samples', wrap(req => samples.listSamples(req.actor, req.query)));
router.get('/samples/:id', wrap(req => samples.getSample(req.actor, req.params.id)));
router.post('/samples', wrap(req => samples.createSample(req.actor, req.body)));
router.post('/samples/:id/action', wrap(req => samples.sampleAct(req.actor, req.params.id, req.body.action, req.body)));
router.get('/export-samples', wrap(req => samples.listExport(req.actor, req.query)));
router.get('/export-samples/:id', wrap(req => samples.getExport(req.actor, req.params.id)));
router.post('/export-samples', wrap(req => samples.saveExport(req.actor, req.body)));
router.post('/export-samples/:id/action', wrap(req => samples.exportAct(req.actor, req.params.id, req.body.action, req.body)));
// ── Reports ─────────────────────────────────────────────────────────────────
const REPORTS = { dashboard: reports.dashboard, 'order-wise': reports.orderWise, 'item-wise': reports.itemWise, pending: reports.pending, 'customer-pending': reports.customerPending };
router.get('/reports/:name', wrap(async (req) => {
need(hasModule(req, 'sales-reports') || hasModule(req, 'sales-orders'), 'Sales Reports access is required');
need(hasStepPerm(req.user, 'sales_order:view', 'view'), 'You are not assigned "view" on Sales Orders');
const fn = REPORTS[req.params.name];
need(fn, 'Unknown report');
return fn(req.actor, req.query);
}));
// ── Admin / masters ─────────────────────────────────────────────────────────
const master = key => req => isAdmin(req) || hasStepPerm(req.user, `sales_master:${key}`, 'view');
router.get('/admin/settings', wrap(async (req) => { need(isAdmin(req), 'Admin only'); return masters.getSettings(true); }));
router.put('/admin/settings', wrap(async (req) => { need(isAdmin(req), 'Admin only'); return masters.saveSettings(req.body, req.actor.name); }));
router.get('/admin/divisions', wrap(async (req) => { need(isAdmin(req) || master('products')(req)); return masters.listDivisions(true); }));
router.post('/admin/divisions', wrap(async (req) => { need(isAdmin(req), 'Admin only'); await masters.saveDivision(req.body); return masters.listDivisions(true); }));
// Quick enable/disable of a product category (disabled = hidden for NEW orders/samples; existing orders unaffected).
router.put('/admin/divisions/:id/active', wrap(async (req) => {
need(isAdmin(req), 'Admin only');
const d = await masters.getDivision(req.params.id);
need(d, 'Category not found');
await masters.saveDivision({ ...d, active: !!req.body.active });
const open = (await query(`SELECT COUNT(*) N FROM dbo.ZSO_ORDERS WHERE DIVISION_ID=? AND STATUS BETWEEN 1 AND 8`, [d.id]))[0].N;
return { divisions: await masters.listDivisions(true), openOrders: open };
}));
router.get('/admin/products', wrap(async (req) => { need(master('products')(req)); return masters.listCatalog({ catalog: req.query.catalog || 'domestic', divisionId: req.query.divisionId, includeInactive: true }); }));
router.post('/admin/products', wrap(async (req) => {
need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:products', req.body.id ? 'edit' : 'add'), 'Not allowed');
return { id: await masters.saveProduct(req.body, req.actor.name) };
}));
router.get('/admin/user-types', wrap(async (req) => { need(isAdmin(req) || ['system_admin', 'sap_adder'].includes(req.user.role)); return masters.listUserTypes(); }));
router.post('/admin/user-types', wrap(async (req) => { need(isAdmin(req), 'Admin only'); await masters.saveUserType(req.body); return masters.listUserTypes(); }));
router.get('/admin/users', wrap(async (req) => {
need(isAdmin(req) || ['system_admin', 'sap_adder'].includes(req.user.role), 'User admin only');
const users = await require('../services/hanaUsers').listUsers();
const profs = Object.fromEntries((await masters.listProfiles()).map(p => [p.userId, p]));
return users.map(u => ({ id: u.id, username: u.username, fullName: u.fullName, email: u.email, role: u.role, active: u.active, profile: profs[u.id] || null,
salesSteps: (u.approvalSteps || []).filter(s => /^sales_/.test(typeof s === 'string' ? s : s.step)) }));
}));
router.put('/admin/users/:id/profile', wrap(async (req) => {
need(isAdmin(req) || ['system_admin', 'sap_adder'].includes(req.user.role), 'User admin only');
await masters.saveProfile({ ...req.body, userId: req.params.id }, req.actor.name);
return { ok: true };
}));
router.get('/admin/sales-persons', wrap(async (req) => { need(master('mapping')(req)); return masters.listSalesPersons(); }));
router.post('/admin/sales-persons', wrap(async (req) => { need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:mapping', 'edit'), 'Not allowed'); return { id: await masters.saveSalesPerson(req.body) }; }));
router.get('/admin/sales-persons/:id/customers', wrap(async (req) => {
need(master('mapping')(req));
const list = await masters.listMappedCustomers(req.params.id);
const names = await sap.customerNames((await masters.getSettings()).company, list.map(x => x.cardCode));
return list.map(x => ({ ...x, cardName: (names[x.cardCode] || {}).name || '' }));
}));
router.post('/admin/sales-persons/:id/customers', wrap(async (req) => {
need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:mapping', 'add'), 'Not allowed');
await masters.mapCustomers(req.params.id, (req.body.cardCodes || []).map(String).filter(Boolean), req.actor.name);
return { ok: true };
}));
router.delete('/admin/sp-map/:mapId', wrap(async (req) => { need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:mapping', 'delete'), 'Not allowed'); await masters.unmapCustomer(req.params.mapId); return { ok: true }; }));
router.get('/admin/customers', wrap(async (req) => { need(master('customers')(req)); return masters.listCustomerAccounts(req.query.q); }));
router.post('/admin/customers', wrap(async (req) => {
need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:customers', 'edit') || hasStepPerm(req.user, 'sales_master:customers', 'add'), 'Not allowed');
if (!req.body.cardName) { const c = await sap.getCustomer((await masters.getSettings()).company, req.body.cardCode); if (!c) { const e = new Error('Customer not found in SAP'); e.status = 400; throw e; } req.body.cardName = c.cardName; if (!req.body.email) req.body.email = c.email; }
const r = await masters.upsertCustomerAccount(req.body, req.actor.name);
// Welcome / reset email (Sales email Test mode → goes to the test address only).
let emailed = false;
if ((r.created || r.passwordReset) && req.body.active !== false && req.body.sendEmail !== false) {
let email = req.body.email;
if (!email) { try { const c = await sap.getCustomer((await masters.getSettings()).company, req.body.cardCode); email = c && c.email; } catch (_e) {} }
require('../services/sales/notify').customerLoginEvent(r.created ? 'created' : 'reset',
{ cardCode: req.body.cardCode, cardName: req.body.cardName, email: email || '', password: req.body.password });
emailed = true;
}
return { ok: true, ...r, emailed };
}));
module.exports = router;
module.exports.DOC_DIR = DOC_DIR;
module.exports.streamAttachment = streamAttachment;
module.exports.streamInvoicePdf = streamInvoicePdf;
module.exports.upload = upload;
+63
View File
@@ -0,0 +1,63 @@
// routes/salesExt.js — machine-to-machine API for the Sales Order module (/api/sales-ext)
// msale received invoice PDFs from an external job (POST api/invoice_details/
// upload_invoice). That job must now be pointed here instead. Auth: header
// MS-API-KEY = .env SALES_EXT_API_KEY (the endpoint is disabled while unset).
// Invoices themselves, dispatch details and COA certificates are read live
// from SAP, so they need no push at all any more.
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { query } = require('../services/sales/db');
const masters = require('../services/sales/masters');
const { getPool } = require('../services/sqlPool');
const { DOC_DIR } = require('./sales');
const router = express.Router();
const INV_DIR = path.join(DOC_DIR, 'invoices');
fs.mkdirSync(INV_DIR, { recursive: true });
router.use((req, res, next) => {
const key = process.env.SALES_EXT_API_KEY || '';
const got = String(req.headers['ms-api-key'] || '');
const ok = key && got.length === key.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(key));
if (!ok) return res.status(401).json({ success: false, message: 'Invalid API key' });
req.auditActor = 'sales-ext-api';
next();
});
// Invoice numbers of web orders (last N days, default 120) with no PDF yet.
router.get('/invoices-without-pdf', async (req, res) => {
try {
const s = await masters.getSettings();
const days = Math.min(parseInt(req.query.days) || 120, 730);
const pool = await getPool(s.company);
const r = await pool.request().input('d', days).query(`SELECT DocNum, DocDate, CardCode, U_WEB_SO_NO FROM OINV
WHERE CANCELED='N' AND U_WEB_SO_NO IS NOT NULL AND DocDate>=DATEADD(day,-@d,CAST(GETDATE() AS date))`);
const have = new Set((await query(`SELECT DISTINCT INVOICE_NO FROM dbo.ZSO_INVOICE_FILES`)).map(x => String(x.INVOICE_NO)));
res.json({ success: true, data: r.recordset.filter(x => !have.has(String(x.DocNum))).map(x => ({ invoice_no: x.DocNum, invoice_date: x.DocDate, card_code: x.CardCode, web_so_no: x.U_WEB_SO_NO })) });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
// Body: [{invoice_no, invoice_file_name, invoice_file_data(base64)}] — also
// accepts msale's index-keyed object form ({"0":{...},"1":{...}}).
router.post('/invoice-pdf', async (req, res) => {
const list = Array.isArray(req.body) ? req.body : Object.values(req.body || {});
const result = { success: [], error: [] };
for (const v of list) {
try {
const no = String(v.invoice_no || '').trim();
if (!/^\d+$/.test(no) || !v.invoice_file_data) throw new Error('invoice_no and invoice_file_data are required');
const buf = Buffer.from(String(v.invoice_file_data), 'base64');
if (buf.slice(0, 4).toString() !== '%PDF') throw new Error('file is not a PDF');
const name = `INV_${no}_${Date.now()}.pdf`;
fs.writeFileSync(path.join(INV_DIR, name), buf);
await query(`INSERT INTO dbo.ZSO_INVOICE_FILES (INVOICE_NO, FILE_NAME) VALUES (?,?)`, [no, name]);
result.success.push({ invoice_no: no, invoice_file_name: v.invoice_file_name || name });
} catch (e) { result.error.push({ invoice_no: v && v.invoice_no, message: e.message }); }
}
res.json({ status: 'ok', ...result });
});
module.exports = router;
+123
View File
@@ -0,0 +1,123 @@
// routes/salesPortal.js — Sales Order module, CUSTOMER portal API (/api/sales-portal)
// Customers (SAP business partners — msale's dealer_master) log in with their
// SAP customer code + password. Their token is signed with a DIFFERENT secret
// from employee tokens, so no employee endpoint anywhere in the ERP can ever
// accept a customer token (they'd fail verifyToken), and vice versa.
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const jwt = require('jsonwebtoken');
const masters = require('../services/sales/masters');
const orders = require('../services/sales/orders');
const sap = require('../services/sales/sap');
const salesRoutes = require('./sales');
const router = express.Router();
const CUSTOMER_SECRET = `${process.env.JWT_SECRET || 'sap-portal-secret'}::sales-customer`;
const wrap = fn => async (req, res) => {
try { const out = await fn(req, res); if (out !== undefined && !res.headersSent) res.json({ success: true, data: out }); }
catch (e) { if (!res.headersSent) res.status(e.status || 500).json({ success: false, message: e.message }); }
};
// Simple in-memory throttle on top of the per-account lockout: max 20 login
// attempts per IP per 10 minutes.
const _hits = new Map();
function throttled(ip) {
const now = Date.now(); const arr = (_hits.get(ip) || []).filter(t => now - t < 600000);
arr.push(now); _hits.set(ip, arr);
return arr.length > 20;
}
router.post('/login', wrap(async (req, res) => {
if (throttled(req.ip)) { res.status(429).json({ success: false, message: 'Too many login attempts — please wait a few minutes.' }); return; }
const { cardCode, password } = req.body || {};
if (!cardCode || !password) { res.status(400).json({ success: false, message: 'Customer code and password are required' }); return; }
const r = await masters.customerLogin(cardCode, password);
if (!r.ok) { res.status(401).json({ success: false, message: r.message }); return; }
const a = r.account;
const token = jwt.sign({ type: 'customer', cardCode: a.cardCode, name: a.cardName }, CUSTOMER_SECRET, { expiresIn: '12h' });
return { token, customer: { cardCode: a.cardCode, cardName: a.cardName, mustChangePwd: a.mustChangePwd } };
}));
// Auth for everything below.
router.use(async (req, res, next) => {
const h = req.headers.authorization || '';
const token = h.startsWith('Bearer ') ? h.slice(7) : '';
if (!token) return res.status(401).json({ success: false, message: 'Please log in' });
try {
const p = jwt.verify(token, CUSTOMER_SECRET);
if (p.type !== 'customer') throw new Error('bad token');
const acc = await masters.getCustomerAccount(p.cardCode);
if (!acc || !acc.active || acc.locked) return res.status(401).json({ success: false, message: 'Your login is inactive or locked' });
req.actor = { type: 'customer', cardCode: p.cardCode, name: p.name || p.cardCode };
req.auditActor = `customer:${p.cardCode}`;
next();
} catch (_e) { res.status(401).json({ success: false, message: 'Session expired — please log in again' }); }
});
router.get('/me', wrap(async (req) => {
const s = await masters.getSettings();
const acc = await masters.getCustomerAccount(req.actor.cardCode);
const c = await sap.getCustomer(s.company, req.actor.cardCode);
return { account: { cardCode: acc.cardCode, cardName: acc.cardName, email: acc.email, mustChangePwd: acc.mustChangePwd, lastLogin: acc.lastLogin },
customer: c, wallet: await orders.walletSummary(req.actor.cardCode), divisions: await masters.listDivisions(), allDivisions: await masters.listDivisions(true), settings: masters.publicSettings(s) };
}));
router.post('/change-password', wrap(async (req) => {
await masters.changeCustomerPassword(req.actor.cardCode, req.body.oldPassword, req.body.newPassword);
return { ok: true };
}));
router.get('/products', wrap(async (req) => (req.query.divisionId ? masters.listOrderableProducts(parseInt(req.query.divisionId)) : [])));
// Suggested "Your order ref. no." for the next order (the customer may override it).
router.get('/next-order-no', wrap(async req => ({ custOrderNo: await orders.nextCustOrderNo(req.actor.cardCode) })));
router.get('/orders', wrap(req => orders.listOrders(req.actor, req.query)));
router.get('/orders/:id', wrap(req => orders.getOrder(req.params.id, req.actor)));
router.post('/orders', wrap(req => orders.createOrder(req.actor, req.body)));
router.put('/orders/:id/resubmit', wrap(req => orders.resubmit(req.actor, req.params.id, req.body)));
router.post('/orders/:id/cancel', wrap(req => orders.customerCancel(req.actor, req.params.id, req.body.remarks)));
router.post('/orders/:id/pay', wrap(req => orders.submitPayment(req.actor, req.params.id, req.body)));
router.get('/ledger', wrap(async (req) => ({ summary: await orders.walletSummary(req.actor.cardCode), ledger: await orders.ledger(req.actor.cardCode, req.query) })));
router.get('/orders/:id/invoices', wrap(async (req) => {
const o = await orders.getOrderRaw(req.params.id);
if (!o || o.cardCode !== req.actor.cardCode) { const e = new Error('Order not found'); e.status = 404; throw e; }
if (![8, 9].includes(o.status)) return { invoices: [] };
const invoices = await sap.invoicesForOrder(o.company, o.id);
const batches = await sap.batchesWithCoa(o.company, invoices.map(i => i.docEntry));
const { query } = require('../services/sales/db');
const pdfs = invoices.length ? await query(`SELECT DISTINCT INVOICE_NO FROM dbo.ZSO_INVOICE_FILES WHERE INVOICE_NO IN (${invoices.map(() => '?').join(',')})`, invoices.map(i => String(i.invoiceNo))) : [];
const pdfSet = new Set(pdfs.map(p => String(p.INVOICE_NO)));
for (const inv of invoices) {
inv.hasPdf = pdfSet.has(String(inv.invoiceNo));
inv.attachments = inv.atcEntry ? await sap.attachmentLines(o.company, inv.atcEntry) : [];
inv.batches = batches.filter(b => b.invoiceDocEntry === inv.docEntry);
delete inv.atcEntry;
}
return { invoices };
}));
router.get('/attachment/:abs/:line', wrap((req, res) => salesRoutes.streamAttachment(req, res, req.actor)));
router.get('/invoice-pdf/:invoiceNo', wrap((req, res) => salesRoutes.streamInvoicePdf(req, res, req.actor)));
// Documents — customers may attach to their OWN orders only (PO copy, payment proof).
router.post('/docs', salesRoutes.upload.single('file'), wrap(async (req) => {
try {
if (!req.file) throw Object.assign(new Error('No file uploaded'), { status: 400 });
const o = await orders.getOrderRaw(req.body.entityId);
if (!o || o.cardCode !== req.actor.cardCode || req.body.entity !== 'order') throw Object.assign(new Error('Not allowed'), { status: 403 });
const docType = ['po_copy', 'payment', 'other'].includes(req.body.docType) ? req.body.docType : 'other';
return { id: await orders.addDoc('order', o.id, docType, req.body.title, req.file.filename, req.file.originalname, req.actor.name) };
} catch (e) { if (req.file) fs.unlink(req.file.path, () => {}); throw e; }
}));
router.get('/docs/:id', wrap(async (req, res) => {
const d = await orders.getDoc(req.params.id);
const o = d && d.ENTITY === 'order' ? await orders.getOrderRaw(d.ENTITY_ID) : null;
if (!o || o.cardCode !== req.actor.cardCode) throw Object.assign(new Error('Not allowed'), { status: 403 });
const full = path.join(salesRoutes.DOC_DIR, path.basename(d.FILE_NAME));
if (!fs.existsSync(full)) throw Object.assign(new Error('File missing'), { status: 404 });
res.setHeader('Content-Disposition', `inline; filename="${(d.ORIG_NAME || d.FILE_NAME).replace(/"/g, '')}"`);
res.sendFile(full);
}));
module.exports = router;
+743 -115
View File
File diff suppressed because it is too large Load Diff
+109 -14
View File
@@ -23,6 +23,38 @@ const DATE_RES = [
function isValidMEDate(v) { const t = String(v || '').trim(); return !t || DATE_RES.some(re => re.test(t)); }
function badDate(v) { return v && !isValidMEDate(v); }
// This Work Order's main product's SAP Item Group (ItmsGrpCod) — resolved
// fresh per notify() call (rare enough that caching isn't worth it) so
// notifyStore's Item-Group email routing (Admin → System Settings → "Notify
// by Item Group") can reach the right inbox. Never throws — a lookup failure
// just means no group-routed recipients get added, the normal step/module
// recipients still fire regardless.
async function itemGroupOf(itemCode, company) {
if (!itemCode) return null;
try {
const { getPool } = require('../services/sqlPool');
const pool = await getPool(company || null);
const r = await pool.request().query(`SELECT "ItmsGrpCod" FROM [dbo].[OITM] WHERE "ItemCode" = '${String(itemCode).replace(/'/g, "''")}'`);
return r.recordset && r.recordset[0] ? String(r.recordset[0].ItmsGrpCod) : null;
} catch (e) { console.warn('[WO] itemGroupOf lookup failed:', e.message); return null; }
}
// Batched version — one round trip for a whole list's worth of distinct
// product codes, instead of one query per row. Returns {itemCode: groupCode}.
async function itemGroupsFor(itemCodes, company) {
const codes = [...new Set((itemCodes || []).filter(Boolean))];
if (!codes.length) return {};
try {
const { getPool } = require('../services/sqlPool');
const pool = await getPool(company || null);
const list = codes.map(c => `'${String(c).replace(/'/g, "''")}'`).join(',');
const r = await pool.request().query(`SELECT "ItemCode","ItmsGrpCod" FROM [dbo].[OITM] WHERE "ItemCode" IN (${list})`);
const map = {};
(r.recordset || []).forEach(row => { map[row.ItemCode] = String(row.ItmsGrpCod); });
return map;
} catch (e) { console.warn('[WO] itemGroupsFor lookup failed:', e.message); return {}; }
}
function normRaw(rows) {
return (rows || [])
.filter(r => (r.itemCode || '').trim() || (r.rawMaterial || '').trim())
@@ -108,9 +140,28 @@ function pickHeader(b) {
router.get('/', verifyToken, requireWorkflowPerm('work_order', 'view'), async (req, res) => {
try {
const { mine, company, status } = req.query;
const data = await store().listWorkOrders({
let data = await store().listWorkOrders({
mine: mine === '1' ? req.user.username : undefined, company, status,
});
// Item Group visibility restriction (Admin → user → Issue Items allowed
// groups) — same 'issueItemGroups' list already enforced at actual
// issuance time, reused here purely for list visibility: a user
// restricted to specific Item Groups shouldn't see OTHER groups' Work
// Orders in the list at all. Empty list (default) = unrestricted.
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 && data.length) {
const byCompany = {};
data.forEach(w => { (byCompany[w.company || ''] = byCompany[w.company || ''] || []).push(w.productCode); });
const groupMaps = {};
await Promise.all(Object.keys(byCompany).map(async co => {
groupMaps[co] = await itemGroupsFor(byCompany[co], co || null);
}));
const allowSet = new Set(allowedGroups);
data = data.filter(w => allowSet.has((groupMaps[w.company || ''] || {})[w.productCode]));
}
} catch (e) { console.warn('[WO] item-group visibility filter failed (non-fatal):', e.message); }
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
@@ -158,6 +209,7 @@ router.post('/', verifyToken, requireApprovalStep('work_order:prepared_qa', 'add
const nextKey = WORK_ORDER_STEP_KEYS[saved.stage];
notify().notify({
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
itemGroupCode: await itemGroupOf(saved.productCode, saved.company),
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}`,
@@ -193,6 +245,7 @@ router.put('/:id', verifyToken, async (req, res) => {
const nextKey = WORK_ORDER_STEP_KEYS[updated.stage];
notify().notify({
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
itemGroupCode: await itemGroupOf(updated.productCode, updated.company),
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}`,
@@ -240,6 +293,7 @@ router.patch('/:id/action', verifyToken, async (req, res) => {
if (action === 'reject') {
notify().notify({
stepFullKey: 'work_order:prepared_qa',
itemGroupCode: await itemGroupOf(updated.productCode, updated.company),
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,
@@ -249,6 +303,7 @@ router.patch('/:id/action', verifyToken, async (req, res) => {
const nextKey = WORK_ORDER_STEP_KEYS[updated.stage];
notify().notify({
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
itemGroupCode: await itemGroupOf(updated.productCode, updated.company),
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,
@@ -257,6 +312,7 @@ router.patch('/:id/action', verifyToken, async (req, res) => {
} else if (updated.status === 'APPROVED') {
notify().notify({
stepFullKey: 'work_order:prepared_qa',
itemGroupCode: await itemGroupOf(updated.productCode, updated.company),
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,
@@ -266,16 +322,20 @@ router.patch('/:id/action', verifyToken, async (req, res) => {
} 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.
// Recall of a FULLY APPROVED Work Order back to QA for editing — not a
// normal in-flight rejection (no per-stage approval step is checked), just
// an override for a document that already finished its whole chain.
// Admin/system_admin always bypass; a regular user may also be granted this
// specifically via 'approve' on the dedicated work_order:send_to_qa step
// (Admin → Edit User → Approval Steps) — previously this action had NO
// assignable step at all. 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' });
if (req.user.role !== 'admin' && req.user.role !== 'system_admin' && !hasStepPerm(req.user, 'work_order:send_to_qa', 'approve'))
return res.status(403).json({ success: false, message: 'You are not assigned to approval step: work_order:send_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 || '',
@@ -283,6 +343,7 @@ router.post('/:id/send-to-qa', verifyToken, async (req, res) => {
res.json({ success: true, data: updated });
notify().notify({
stepFullKey: 'work_order:prepared_qa',
itemGroupCode: await itemGroupOf(updated.productCode, updated.company),
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}`,
@@ -382,13 +443,47 @@ router.post('/:id/row/:section/:index/mark', verifyToken, async (req, res) => {
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); }
// Mirrors the client's own gate (public/verify-work-order.html's
// issCells()) — the 'mark_issued' bypass ONLY applies to Raw Material:
// under that mode THIS stamp is what writes Qty Issued for a raw row
// in the first place (checking it first would be circular). Packing
// Material's Qty Issued always comes from the live SAP posting
// regardless of this setting (never written by this stamp), so it
// must always be checked for real — otherwise a Work Order with no
// Production Order/issuance posted yet would let Packing/Components
// rows be marked Issued with nothing actually issued. An override
// user may still catch up paperwork regardless.
const rawRowBypass = section === 'raw' && require('../services/appSettingsStore').woRawQtyIssuedSource() === 'mark_issued';
if (!canOverrideStamp) {
// Even under the raw-material bypass, nothing is issuable before a
// Production Order actually exists for this Work Order — there's no
// production event yet for the stamp to be recording. This is the
// actual fix for a fresh WO with no PO yet still allowing every
// raw-material row to be marked Issued.
let iss = null;
try { iss = await computeIssuance(wo.id, wo.company); } catch (e) { console.warn('[WO-ROW-MARK] issuance lookup failed:', e.message); }
if (!iss || !iss.hasPO)
return res.status(400).json({ success: false, message: 'Cannot mark "Issued" — no Production Order has been created for this Work Order yet.' });
if (!rawRowBypass) {
const reqQty = parseFloat(section === 'raw' ? row.qtyReq : row.qtyReqUnits) || 0;
if (reqQty > 0) {
const issuedQty = section === 'raw'
? (parseFloat(row.qtyIssued) || 0)
: ((iss.qtyByItem && iss.qtyByItem[String(row.itemCode || '').trim()]) || 0);
if (issuedQty + 0.001 < reqQty)
return res.status(400).json({ success: false, message: `Cannot mark "Issued" — only ${issuedQty} of ${reqQty} required has actually been issued for this item.` });
}
}
}
}
// 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.
// Issued→Received→Verified order is enforced for EVERYONE, including a
// woVerifyOverride/admin user — no one may sign a stage before the prior
// one has genuinely happened. That override only ever applies to
// RE-SIGNING an already-completed stamp (see the canOverrideStamp check
// above — correcting who it's recorded as signed by and/or its date),
// never to skipping straight past a stage that hasn't happened yet.
const prereq = ROW_STAMP_PREREQ[which];
if (prereq && !row[`${prereq}At`] && !canOverrideStamp)
if (prereq && !row[`${prereq}At`])
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