285 lines
20 KiB
JavaScript
285 lines
20 KiB
JavaScript
// 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;
|