@@ -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 ', '');
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+109
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user