// 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;