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

This commit is contained in:
John
2026-10-05 18:45:17 +05:30
parent e725a6571b
commit eead8f5ffd
129 changed files with 14252 additions and 452 deletions
+284
View File
@@ -0,0 +1,284 @@
// routes/sales.js — Sales Order module, EMPLOYEE API (/api/sales)
// Replaces the msale portal's employee side. Every route needs a normal ERP
// login; what a user may see/do is decided by their Approval Steps
// (sales_order:*, sales_sample:*, sales_export_sample:*, sales_master:*) plus
// their Sales profile (user type → scope, divisions) — see services/sales/*.
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { verifyToken, hasStepPerm, hasWorkflowPerm } = require('../middleware/auth');
const masters = require('../services/sales/masters');
const orders = require('../services/sales/orders');
const samples = require('../services/sales/samples');
const reports = require('../services/sales/reports');
const sap = require('../services/sales/sap');
const { STEPS, statusLabel, sampleStatusLabel, exportSampleStatusLabel } = require('../services/sales/constants');
const { query } = require('../services/sales/db');
const router = express.Router();
router.use(verifyToken);
// Private document store — deliberately NOT under /uploads (served statically).
const DOC_DIR = path.join(__dirname, '..', 'storage', 'sales');
fs.mkdirSync(DOC_DIR, { recursive: true });
const ALLOWED_EXT = ['.pdf', '.jpg', '.jpeg', '.png', '.bmp'];
const upload = multer({
storage: multer.diskStorage({
destination: DOC_DIR,
filename: (req, file, cb) => cb(null, `${Date.now()}_${Math.random().toString(36).slice(2, 8)}${path.extname(file.originalname).toLowerCase()}`),
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => cb(ALLOWED_EXT.includes(path.extname(file.originalname).toLowerCase()) ? null : new Error('Only PDF / JPG / PNG / BMP files are allowed'), true),
});
// Build the actor once per request (+ the user's email, needed for the
// 'mapped' scope which msale keyed on the employee's email).
const _emailCache = new Map();
router.use(async (req, res, next) => {
try {
let email = _emailCache.get(req.user.id);
if (email === undefined) {
const u = await require('../services/hanaUsers').findById(req.user.id);
email = (u && u.email) || '';
_emailCache.set(req.user.id, email);
setTimeout(() => _emailCache.delete(req.user.id), 60000);
}
req.actor = { type: 'user', user: req.user, name: req.user.name || req.user.username, email };
next();
} catch (e) { next(e); }
});
const wrap = fn => async (req, res) => {
try { const out = await fn(req, res); if (out !== undefined && !res.headersSent) res.json({ success: true, data: out }); }
catch (e) { if (!res.headersSent) res.status(e.status || (/^\[SAP/.test(e.message) ? 502 : 500)).json({ success: false, message: e.message }); }
};
const isAdmin = req => req.user.role === 'admin';
const hasModule = (req, m) => isAdmin(req) || (Array.isArray(req.user.modules) && req.user.modules.includes(m));
function need(cond, msg = 'Not allowed') { if (!cond) { const e = new Error(msg); e.status = 403; throw e; } }
// ── Session info / lookups ──────────────────────────────────────────────────
router.get('/me', wrap(async (req) => {
const prof = await masters.getProfile(req.user);
const caps = {};
STEPS.forEach(s => { caps[`${s.workflow}:${s.key}`] = ['view', 'add', 'edit', 'approve', 'delete'].filter(p => hasStepPerm(req.user, `${s.workflow}:${s.key}`, p)); });
const settings = masters.publicSettings(await masters.getSettings());
// divisions = enabled only (for creating orders/samples); allDivisions also
// includes disabled ones so existing orders still show their category name.
return { profile: prof, caps, isAdmin: isAdmin(req), divisions: await masters.listDivisions(), allDivisions: await masters.listDivisions(true), settings,
statusLabels: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(s => [s, statusLabel(s)])),
directStatusLabels: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(s => [s, statusLabel(s, 'DIRECT')])),
sampleStatusLabels: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9].map(s => [s, sampleStatusLabel(s)])),
exportStatusLabels: Object.fromEntries([1, 2, 3, 4].map(s => [s, exportSampleStatusLabel(s)])) };
}));
router.get('/divisions', wrap(() => masters.listDivisions()));
// Customers from SAP, limited to the caller's mapped customers when their scope is 'mapped'.
router.get('/customers', wrap(async (req) => {
need(hasWorkflowPerm(req.user, 'sales_order', 'view') || hasWorkflowPerm(req.user, 'sales_sample', 'view') || hasWorkflowPerm(req.user, 'sales_master', 'view'));
const s = await masters.getSettings();
const { prof, cards } = await orders.scopeFor(req.actor);
return sap.searchCustomers(s.company, req.query.q, { limit: 50, cardCodes: prof.scope === 'mapped' ? cards : null });
}));
router.get('/customers/:cardCode', wrap(async (req) => {
need(hasWorkflowPerm(req.user, 'sales_order', 'view') || hasWorkflowPerm(req.user, 'sales_sample', 'view') || hasWorkflowPerm(req.user, 'sales_master', 'view'));
const s = await masters.getSettings();
const { prof, cards } = await orders.scopeFor(req.actor);
if (prof.scope === 'mapped') need(cards.includes(req.params.cardCode), 'This customer is not mapped to you');
const c = await sap.getCustomer(s.company, req.params.cardCode);
if (!c) { const e = new Error('Customer not found'); e.status = 404; throw e; }
c.wallet = hasStepPerm(req.user, 'sales_order:view', 'view') ? await orders.walletSummary(c.cardCode) : null;
return c;
}));
router.get('/products', wrap(async (req) => {
const catalog = req.query.catalog === 'export' ? 'export' : 'domestic';
if (catalog === 'export') return masters.listCatalog({ catalog: 'export' });
if (!req.query.divisionId) return [];
return masters.listOrderableProducts(parseInt(req.query.divisionId));
}));
// ── Orders ──────────────────────────────────────────────────────────────────
router.get('/orders', wrap(req => orders.listOrders(req.actor, req.query)));
router.get('/orders/:id', wrap(req => orders.getOrder(req.params.id, req.actor)));
router.post('/orders', wrap(req => orders.createOrder(req.actor, req.body))); // Direct order
router.put('/orders/:id/resubmit', wrap(req => orders.resubmit(req.actor, req.params.id, req.body)));
router.post('/orders/:id/action', wrap(req => orders.act(req.actor, req.params.id, req.body.action, req.body)));
router.post('/sync-sap', wrap(async (req) => {
need(hasStepPerm(req.user, 'sales_order:sap_post', 'approve'), 'You are not assigned "approve" on sales_order:sap_post');
return orders.syncWithSap();
}));
// Invoices / dispatch / COA for an order, live from SAP.
async function invoiceBundle(actor, id) {
const o = await orders.getOrderRaw(id);
if (!o || !(await orders.canSee(actor, o))) { const e = new Error('Order not found'); e.status = 404; throw e; }
if (!o.sapDocEntry && ![8, 9].includes(o.status)) return { invoices: [], batches: [] };
const invoices = await sap.invoicesForOrder(o.company, o.id);
const batches = await sap.batchesWithCoa(o.company, invoices.map(i => i.docEntry));
const pdfs = invoices.length ? await query(`SELECT INVOICE_NO, MAX(ID) ID FROM dbo.ZSO_INVOICE_FILES WHERE INVOICE_NO IN (${invoices.map(() => '?').join(',')}) GROUP BY INVOICE_NO`,
invoices.map(i => String(i.invoiceNo))) : [];
const pdfSet = new Set(pdfs.map(p => String(p.INVOICE_NO)));
for (const inv of invoices) {
inv.hasPdf = pdfSet.has(String(inv.invoiceNo));
inv.attachments = inv.atcEntry ? await sap.attachmentLines(o.company, inv.atcEntry) : [];
inv.batches = batches.filter(b => b.invoiceDocEntry === inv.docEntry);
delete inv.atcEntry;
}
return { order: o, invoices };
}
router.get('/orders/:id/invoices', wrap(async req => { const b = await invoiceBundle(req.actor, req.params.id); delete b.order; return b; }));
async function streamAttachment(req, res, actor) {
const b = await invoiceBundle(actor, req.query.orderId);
const abs = parseInt(req.params.abs), line = parseInt(req.params.line);
const allowed = b.invoices.some(i => i.attachments.some(a => a.absEntry === abs && a.line === line) || i.batches.some(x => x.coa && x.coa.absEntry === abs && x.coa.line === line));
need(allowed, 'This file does not belong to the order');
const att = await sap.attachmentLine(b.order.company, abs, line);
const f = sap.readAttachment(att);
res.setHeader('Content-Disposition', `inline; filename="${f.name.replace(/"/g, '')}"`);
res.type(path.extname(f.name) || 'application/octet-stream').send(f.buffer);
}
async function streamInvoicePdf(req, res, actor) {
const b = await invoiceBundle(actor, req.query.orderId);
need(b.invoices.some(i => String(i.invoiceNo) === String(req.params.invoiceNo)), 'Invoice does not belong to the order');
const r = (await query(`SELECT TOP 1 FILE_NAME FROM dbo.ZSO_INVOICE_FILES WHERE INVOICE_NO=? ORDER BY ID DESC`, [String(req.params.invoiceNo)]))[0];
if (!r) { const e = new Error('Invoice PDF not available yet'); e.status = 404; throw e; }
const full = path.join(DOC_DIR, 'invoices', path.basename(r.FILE_NAME));
if (!fs.existsSync(full)) { const e = new Error('Invoice PDF file missing'); e.status = 404; throw e; }
res.setHeader('Content-Disposition', `inline; filename="${path.basename(r.FILE_NAME)}"`);
res.type('pdf').send(fs.readFileSync(full));
}
router.get('/attachment/:abs/:line', wrap((req, res) => streamAttachment(req, res, req.actor)));
router.get('/invoice-pdf/:invoiceNo', wrap((req, res) => streamInvoicePdf(req, res, req.actor)));
// ── Documents ───────────────────────────────────────────────────────────────
async function canSeeEntity(actor, entity, id) {
if (entity === 'order') { const o = await orders.getOrderRaw(id); return !!o && orders.canSee(actor, o); }
if (entity === 'export_sample') { try { await samples.getExport(actor, id); return true; } catch (_e) { return false; } }
if (entity === 'sample') { try { await samples.getSample(actor, id); return true; } catch (_e) { return false; } }
return false;
}
router.post('/docs', upload.single('file'), wrap(async (req) => {
const { entity, entityId, docType, title } = req.body;
try {
need(req.file, 'No file uploaded');
need(['order', 'sample', 'export_sample'].includes(entity), 'Invalid entity');
need(await canSeeEntity(req.actor, entity, entityId), 'Not allowed');
return { id: await orders.addDoc(entity, entityId, docType || 'other', title, req.file.filename, req.file.originalname, req.actor.name) };
} catch (e) { if (req.file) fs.unlink(req.file.path, () => {}); throw e; }
}));
router.get('/docs/:id', wrap(async (req, res) => {
const d = await orders.getDoc(req.params.id);
need(d && await canSeeEntity(req.actor, d.ENTITY, d.ENTITY_ID), 'Not allowed');
const full = path.join(DOC_DIR, path.basename(d.FILE_NAME));
need(fs.existsSync(full), 'File missing');
res.setHeader('Content-Disposition', `inline; filename="${(d.ORIG_NAME || d.FILE_NAME).replace(/"/g, '')}"`);
res.sendFile(full);
}));
// ── Payments / wallet (Accounts) ────────────────────────────────────────────
router.get('/payments/pending', wrap(req => orders.pendingPayments(req.actor)));
router.post('/payments/:txnId/decide', wrap(req => orders.decideTopup(req.actor, req.params.txnId, req.body.decision, req.body.remarks)));
router.post('/payments/on-account', wrap(req => orders.addOnAccountPayment(req.actor, req.body)));
router.get('/wallet/:cardCode', wrap(async (req) => {
need(hasStepPerm(req.user, 'sales_order:payment_entry', 'view') || hasStepPerm(req.user, 'sales_order:payment_verify', 'view'), 'Not allowed');
return { summary: await orders.walletSummary(req.params.cardCode), ledger: await orders.ledger(req.params.cardCode, req.query) };
}));
router.put('/credit-limit/:cardCode', wrap(async (req) => {
need(hasStepPerm(req.user, 'sales_order:payment_entry', 'edit'), 'You are not assigned "edit" on sales_order:payment_entry');
const acc = await masters.getCustomerAccount(req.params.cardCode);
need(acc, 'This customer has no portal login yet — create one in Sales Admin → Customer Logins');
await query(`UPDATE dbo.ZSO_CUSTOMERS SET CREDIT_LIMIT=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY=? WHERE CARD_CODE=?`, [Number(req.body.creditLimit) || 0, req.actor.name, req.params.cardCode]);
return { ok: true };
}));
// ── Samples ─────────────────────────────────────────────────────────────────
router.get('/samples', wrap(req => samples.listSamples(req.actor, req.query)));
router.get('/samples/:id', wrap(req => samples.getSample(req.actor, req.params.id)));
router.post('/samples', wrap(req => samples.createSample(req.actor, req.body)));
router.post('/samples/:id/action', wrap(req => samples.sampleAct(req.actor, req.params.id, req.body.action, req.body)));
router.get('/export-samples', wrap(req => samples.listExport(req.actor, req.query)));
router.get('/export-samples/:id', wrap(req => samples.getExport(req.actor, req.params.id)));
router.post('/export-samples', wrap(req => samples.saveExport(req.actor, req.body)));
router.post('/export-samples/:id/action', wrap(req => samples.exportAct(req.actor, req.params.id, req.body.action, req.body)));
// ── Reports ─────────────────────────────────────────────────────────────────
const REPORTS = { dashboard: reports.dashboard, 'order-wise': reports.orderWise, 'item-wise': reports.itemWise, pending: reports.pending, 'customer-pending': reports.customerPending };
router.get('/reports/:name', wrap(async (req) => {
need(hasModule(req, 'sales-reports') || hasModule(req, 'sales-orders'), 'Sales Reports access is required');
need(hasStepPerm(req.user, 'sales_order:view', 'view'), 'You are not assigned "view" on Sales Orders');
const fn = REPORTS[req.params.name];
need(fn, 'Unknown report');
return fn(req.actor, req.query);
}));
// ── Admin / masters ─────────────────────────────────────────────────────────
const master = key => req => isAdmin(req) || hasStepPerm(req.user, `sales_master:${key}`, 'view');
router.get('/admin/settings', wrap(async (req) => { need(isAdmin(req), 'Admin only'); return masters.getSettings(true); }));
router.put('/admin/settings', wrap(async (req) => { need(isAdmin(req), 'Admin only'); return masters.saveSettings(req.body, req.actor.name); }));
router.get('/admin/divisions', wrap(async (req) => { need(isAdmin(req) || master('products')(req)); return masters.listDivisions(true); }));
router.post('/admin/divisions', wrap(async (req) => { need(isAdmin(req), 'Admin only'); await masters.saveDivision(req.body); return masters.listDivisions(true); }));
// Quick enable/disable of a product category (disabled = hidden for NEW orders/samples; existing orders unaffected).
router.put('/admin/divisions/:id/active', wrap(async (req) => {
need(isAdmin(req), 'Admin only');
const d = await masters.getDivision(req.params.id);
need(d, 'Category not found');
await masters.saveDivision({ ...d, active: !!req.body.active });
const open = (await query(`SELECT COUNT(*) N FROM dbo.ZSO_ORDERS WHERE DIVISION_ID=? AND STATUS BETWEEN 1 AND 8`, [d.id]))[0].N;
return { divisions: await masters.listDivisions(true), openOrders: open };
}));
router.get('/admin/products', wrap(async (req) => { need(master('products')(req)); return masters.listCatalog({ catalog: req.query.catalog || 'domestic', divisionId: req.query.divisionId, includeInactive: true }); }));
router.post('/admin/products', wrap(async (req) => {
need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:products', req.body.id ? 'edit' : 'add'), 'Not allowed');
return { id: await masters.saveProduct(req.body, req.actor.name) };
}));
router.get('/admin/user-types', wrap(async (req) => { need(isAdmin(req) || ['system_admin', 'sap_adder'].includes(req.user.role)); return masters.listUserTypes(); }));
router.post('/admin/user-types', wrap(async (req) => { need(isAdmin(req), 'Admin only'); await masters.saveUserType(req.body); return masters.listUserTypes(); }));
router.get('/admin/users', wrap(async (req) => {
need(isAdmin(req) || ['system_admin', 'sap_adder'].includes(req.user.role), 'User admin only');
const users = await require('../services/hanaUsers').listUsers();
const profs = Object.fromEntries((await masters.listProfiles()).map(p => [p.userId, p]));
return users.map(u => ({ id: u.id, username: u.username, fullName: u.fullName, email: u.email, role: u.role, active: u.active, profile: profs[u.id] || null,
salesSteps: (u.approvalSteps || []).filter(s => /^sales_/.test(typeof s === 'string' ? s : s.step)) }));
}));
router.put('/admin/users/:id/profile', wrap(async (req) => {
need(isAdmin(req) || ['system_admin', 'sap_adder'].includes(req.user.role), 'User admin only');
await masters.saveProfile({ ...req.body, userId: req.params.id }, req.actor.name);
return { ok: true };
}));
router.get('/admin/sales-persons', wrap(async (req) => { need(master('mapping')(req)); return masters.listSalesPersons(); }));
router.post('/admin/sales-persons', wrap(async (req) => { need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:mapping', 'edit'), 'Not allowed'); return { id: await masters.saveSalesPerson(req.body) }; }));
router.get('/admin/sales-persons/:id/customers', wrap(async (req) => {
need(master('mapping')(req));
const list = await masters.listMappedCustomers(req.params.id);
const names = await sap.customerNames((await masters.getSettings()).company, list.map(x => x.cardCode));
return list.map(x => ({ ...x, cardName: (names[x.cardCode] || {}).name || '' }));
}));
router.post('/admin/sales-persons/:id/customers', wrap(async (req) => {
need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:mapping', 'add'), 'Not allowed');
await masters.mapCustomers(req.params.id, (req.body.cardCodes || []).map(String).filter(Boolean), req.actor.name);
return { ok: true };
}));
router.delete('/admin/sp-map/:mapId', wrap(async (req) => { need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:mapping', 'delete'), 'Not allowed'); await masters.unmapCustomer(req.params.mapId); return { ok: true }; }));
router.get('/admin/customers', wrap(async (req) => { need(master('customers')(req)); return masters.listCustomerAccounts(req.query.q); }));
router.post('/admin/customers', wrap(async (req) => {
need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:customers', 'edit') || hasStepPerm(req.user, 'sales_master:customers', 'add'), 'Not allowed');
if (!req.body.cardName) { const c = await sap.getCustomer((await masters.getSettings()).company, req.body.cardCode); if (!c) { const e = new Error('Customer not found in SAP'); e.status = 400; throw e; } req.body.cardName = c.cardName; if (!req.body.email) req.body.email = c.email; }
const r = await masters.upsertCustomerAccount(req.body, req.actor.name);
// Welcome / reset email (Sales email Test mode → goes to the test address only).
let emailed = false;
if ((r.created || r.passwordReset) && req.body.active !== false && req.body.sendEmail !== false) {
let email = req.body.email;
if (!email) { try { const c = await sap.getCustomer((await masters.getSettings()).company, req.body.cardCode); email = c && c.email; } catch (_e) {} }
require('../services/sales/notify').customerLoginEvent(r.created ? 'created' : 'reset',
{ cardCode: req.body.cardCode, cardName: req.body.cardName, email: email || '', password: req.body.password });
emailed = true;
}
return { ok: true, ...r, emailed };
}));
module.exports = router;
module.exports.DOC_DIR = DOC_DIR;
module.exports.streamAttachment = streamAttachment;
module.exports.streamInvoicePdf = streamInvoicePdf;
module.exports.upload = upload;