Files
sap-erp/server.js
T
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

1710 lines
104 KiB
JavaScript

// ════════════════════════════════════════════════════════════════
// SAP B1 UNIFIED PORTAL MULTI-LEVEL BOM APPROVAL SERVER
// ════════════════════════════════════════════════════════════════
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { resolve: resolveCompany, DEFAULT_COMPANY } = require('./services/companyConfig');
const { hasStepPerm, hasWorkflowPerm, requireApprovalStep, requireWorkflowPerm } = require('./middleware/auth');
const app = express();
const SECRET = process.env.JWT_SECRET || 'sap-portal-secret';
// ── MIDDLEWARE FIRST (body parser must come before routes) ───────
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// Audit trail — records every mutating /api request (must be after body parsers)
app.use(require('./middleware/auditLogger'));
app.use(cors({ origin: true, credentials: true }));
// auth-guard.js is the first <script> on every protected page. Serve it with
// the default SAP company (from .env, via companyConfig) injected as a global
// so pages read window.__DEFAULT_COMPANY__ instead of hardcoding the value —
// must be registered BEFORE express.static so this handler wins.
app.get('/auth-guard.js', (req, res) => {
const filePath = path.join(__dirname, 'public', 'auth-guard.js');
require('fs').readFile(filePath, 'utf8', (err, content) => {
if (err) return res.status(500).end();
res.type('application/javascript').send(
`window.__DEFAULT_COMPANY__=${JSON.stringify(DEFAULT_COMPANY)};\n${content}`
);
});
});
// Same window.__DEFAULT_COMPANY__ global, for PUBLIC pages (no login required,
// e.g. the customer self-registration form) that don't load auth-guard.js.
app.get('/company-config.js', (req, res) => {
res.type('application/javascript').send(`window.__DEFAULT_COMPANY__=${JSON.stringify(DEFAULT_COMPANY)};`);
});
app.use(express.static(path.join(__dirname, 'public')));
// Extension-less page routes (/work-order, /admin, etc. below) never get
// cached by the browser at all — a server restart never clears the
// browser's own cache, so an edited page could otherwise keep serving stale
// pre-fix HTML/JS indefinitely until someone manually clears site data.
// 'no-cache' alone (revalidate-before-use) turned out not to be enough in
// practice — repeatedly observed serving a stale page even right after a
// fix landed and the server restarted, on pages with no version query
// string to force a new cache entry (sidebar.js needed a manual ?v= bump
// for the exact same reason). 'no-store' is unambiguous: never cache this
// response at all, full stop. Setting this BEFORE res.sendFile() runs
// sticks: Express's send() only applies its own default Cache-Control when
// one isn't already present on the response.
app.use((req, res, next) => {
if (req.method === 'GET' && !path.extname(req.path)) res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
next();
});
// ════════════════════════════════════════════════════════════════
// APPROVAL LEVEL CONFIGURATION
// ════════════════════════════════════════════════════════════════
// BOM approval levels is now an admin-editable setting (services/
// appSettingsStore.js) rather than an .env var — read it live via this getter
// so a change in the Admin panel takes effect without a restart.
const appSettings = require('./services/appSettingsStore');
const APPROVAL_LEVELS = () => appSettings.bomApprovalLevels();
const mailer = require('./services/mailer');
const LEVEL_ROLES = { 1: 'manager', 2: 'sr_manager', 3: 'sap_adder', 4: 'sap_adder' };
const STATUS_FLOW = {
2: ['DRAFT', 'PENDING', 'L1_APPROVED', 'SAP_PUSHED', 'REJECTED'],
3: ['DRAFT', 'PENDING', 'L1_APPROVED', 'L2_APPROVED', 'SAP_PUSHED', 'REJECTED'],
4: ['DRAFT', 'PENDING', 'L1_APPROVED', 'L2_APPROVED', 'L3_APPROVED', 'SAP_PUSHED', 'REJECTED'],
};
function getNextStatus(currentStatus) {
const flow = STATUS_FLOW[APPROVAL_LEVELS()];
const idx = flow.indexOf(currentStatus);
if (idx === -1 || idx >= flow.length - 2) return null;
return flow[idx + 1];
}
// Which BOM approval-step (from the generic Approval Steps registry, workflow
// 'bom', keys level1..level4) is required to act on a given BOM status.
const STATUS_TO_BOM_LEVEL = { PENDING: 1, L1_APPROVED: 2, L2_APPROVED: 3, L3_APPROVED: 4 };
function canApproveAtStatus(user, status) {
const level = STATUS_TO_BOM_LEVEL[status];
if (!level) return false;
return hasStepPerm(user, `bom:level${level}`, 'approve');
}
function isFinalApproval(status) {
const finalMap = { 2: 'L1_APPROVED', 3: 'L2_APPROVED', 4: 'L3_APPROVED' };
return finalMap[APPROVAL_LEVELS()] === status;
}
// ────────────────────────────────────────────────────────────────
// STATIC PAGES
// ────────────────────────────────────────────────────────────────
app.get('/portal', (req, res) => res.sendFile(path.join(__dirname, 'public', 'portal.html')));
app.get('/bom', (req, res) => res.sendFile(path.join(__dirname, 'public', 'bom.html')));
app.get('/grpo', (req, res) => res.sendFile(path.join(__dirname, 'public', 'grpo.html')));
app.get('/purchase-request', (req, res) => res.sendFile(path.join(__dirname, 'public', 'purchase-request.html')));
app.get('/purchase-quotation', (req, res) => res.sendFile(path.join(__dirname, 'public', 'purchase-quotation.html')));
app.get('/purchase-order', (req, res) => res.sendFile(path.join(__dirname, 'public', 'purchase-order.html')));
app.get('/production', (req, res) => res.sendFile(path.join(__dirname, 'public', 'production.html')));
app.get('/issue-production',(req, res) => res.sendFile(path.join(__dirname, 'public', 'issue-production.html')));
app.get('/receipt-production',(req, res) => res.sendFile(path.join(__dirname, 'public', 'receipt-production.html')));
app.get('/close-production', (req, res) => res.sendFile(path.join(__dirname, 'public', 'close-production.html')));
app.get('/rejection-register', (req, res) => res.sendFile(path.join(__dirname, 'public', 'rejection-register.html')));
app.get('/rejection-analytics', (req, res) => res.sendFile(path.join(__dirname, 'public', 'rejection-analytics.html')));
app.get('/production-planning', (req, res) => res.sendFile(path.join(__dirname, 'public', 'production-planning.html')));
app.get('/receipts-history', (req, res) => res.sendFile(path.join(__dirname, 'public', 'receipts-history.html')));
app.get('/man-power', (req, res) => res.sendFile(path.join(__dirname, 'public', 'man-power.html')));
app.get('/oee', (req, res) => res.sendFile(path.join(__dirname, 'public', 'oee.html')));
app.get('/work-order-print', (req, res) => res.sendFile(path.join(__dirname, 'public', 'work-order-print.html')));
app.get('/audit-logs', (req, res) => res.sendFile(path.join(__dirname, 'public', 'audit-logs.html')));
app.get('/ppc-report', (req, res) => res.sendFile(path.join(__dirname, 'public', 'ppc-report.html')));
app.get('/pwo-source-audit', (req, res) => res.sendFile(path.join(__dirname, 'public', 'pwo-source-audit.html')));
app.get('/inventory-status-report', (req, res) => res.sendFile(path.join(__dirname, 'public', 'inventory-status-report.html')));
app.get('/batch-no-transaction', (req, res) => res.sendFile(path.join(__dirname, 'public', 'batch-no-transaction.html')));
app.get('/inventory-transfer', (req, res) => res.sendFile(path.join(__dirname, 'public', 'inventory-transfer.html')));
// Central auth appends a fixed "sso/login" suffix to this app's registered
// app_url when redirecting back after login (confirmed against the QMS
// portal's own working integration, which registers its app_url ending in
// "/login/" and receives the browser at ".../login/sso/login") — so the
// actual landing path is /sso/login, not /sso-login. Both are kept so a
// manually-typed /sso-login link (e.g. for testing) still works.
app.get('/sso/login', (req, res) => res.sendFile(path.join(__dirname, 'public', 'sso-login.html')));
app.get('/sso-login', (req, res) => res.sendFile(path.join(__dirname, 'public', 'sso-login.html')));
app.get('/inventory-posting-list', (req, res) => res.sendFile(path.join(__dirname, 'public', 'inventory-posting-list.html')));
app.get('/downtime-analysis', (req, res) => res.sendFile(path.join(__dirname, 'public', 'downtime-analysis.html')));
app.get('/deviations', (req, res) => res.sendFile(path.join(__dirname, 'public', 'deviations.html')));
app.get('/mail-logs', (req, res) => res.sendFile(path.join(__dirname, 'public', 'mail-logs.html')));
app.get('/guide', (req, res) => res.sendFile(path.join(__dirname, 'public', 'guide.html')));
app.get('/verify-production',(req, res) => res.sendFile(path.join(__dirname, 'public', 'verify-production.html')));
app.get('/verify-work-order',(req, res) => res.sendFile(path.join(__dirname, 'public', 'verify-work-order.html')));
app.get('/board-dashboard', (req, res) => res.sendFile(path.join(__dirname, 'public', 'board-dashboard.html')));
app.get('/production-dashboard', (req, res) => res.sendFile(path.join(__dirname, 'public', 'production-dashboard.html')));
app.get('/production-dashboard-legacy', (req, res) => res.sendFile(path.join(__dirname, 'public', 'production-dashboard-legacy.html')));
app.get('/requirements', (req, res) => res.sendFile(path.join(__dirname, 'public', 'requirements.html')));
app.get('/batch-issuance', (req, res) => res.sendFile(path.join(__dirname, 'public', 'batch-issuance.html')));
app.get('/work-order', (req, res) => res.sendFile(path.join(__dirname, 'public', 'work-order.html')));
app.get('/wo-header-profiles', (req, res) => res.sendFile(path.join(__dirname, 'public', 'wo-header-profiles.html')));
app.get('/budget', (req, res) => res.sendFile(path.join(__dirname, 'public', 'budget.html')));
app.get('/documents', (req, res) => res.sendFile(path.join(__dirname, 'public', 'documents.html')));
app.get('/sap-approvals', (req, res) => res.sendFile(path.join(__dirname, 'public', 'sap-approvals.html')));
app.get('/gstr1', (req, res) => res.sendFile(path.join(__dirname, 'public', 'gstr1.html')));
app.get('/gstr2', (req, res) => res.sendFile(path.join(__dirname, 'public', 'gstr2.html')));
app.get('/itc04', (req, res) => res.sendFile(path.join(__dirname, 'public', 'itc04.html')));
app.get('/project-form', (req, res) => res.sendFile(path.join(__dirname, 'public', 'project-form.html')));
app.get('/project-approvals', (req, res) => res.sendFile(path.join(__dirname, 'public', 'project-approvals.html')));
app.get('/costing-pl', (req, res) => res.sendFile(path.join(__dirname, 'public', 'costing-pl.html')));
app.get('/balance-sheet', (req, res) => res.sendFile(path.join(__dirname, 'public', 'balance-sheet.html')));
app.get('/cash-flow', (req, res) => res.sendFile(path.join(__dirname, 'public', 'cash-flow.html')));
app.get('/costing-comparison', (req, res) => res.sendFile(path.join(__dirname, 'public', 'costing-comparison.html')));
app.get('/cost-sheet', (req, res) => res.sendFile(path.join(__dirname, 'public', 'cost-sheet.html')));
app.get('/salary', (req, res) => res.sendFile(path.join(__dirname, 'public', 'salary.html')));
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.get('/items', (req, res) => res.sendFile(path.join(__dirname, 'public', 'Itemcreationform.html')));
app.get('/reports', (req, res) => res.sendFile(path.join(__dirname, 'public', 'reports.html')));
app.get('/general-ledger', (req, res) => res.sendFile(path.join(__dirname, 'public', 'general-ledger.html')));
app.get('/approvals', (req, res) => res.sendFile(path.join(__dirname, 'public', 'approvals.html')));
app.get('/register', (req, res) => res.sendFile(path.join(__dirname, 'public', 'register.html')));
app.get('/vendor-register', (req, res) => res.sendFile(path.join(__dirname, 'public', 'vendor-register.html')));
app.get('/admin', (req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
app.get('/item-group-classification', (req, res) => res.sendFile(path.join(__dirname, 'public', 'item-group-classification.html')));
app.get('/business-master', (req, res) => res.sendFile(path.join(__dirname, 'public', 'business-master.html')));
app.get('/profile', (req, res) => res.sendFile(path.join(__dirname, 'public', 'profile.html')));
// Sales Order module (replaces the msale portal) — employee pages + the
// separate customer portal (customers log in with their SAP customer code).
app.get('/sales-orders', (req, res) => res.sendFile(path.join(__dirname, 'public', 'sales-orders.html')));
app.get('/sales-samples', (req, res) => res.sendFile(path.join(__dirname, 'public', 'sales-samples.html')));
app.get('/sales-reports', (req, res) => res.sendFile(path.join(__dirname, 'public', 'sales-reports.html')));
app.get('/sales-admin', (req, res) => res.sendFile(path.join(__dirname, 'public', 'sales-admin.html')));
app.get('/customer-portal', (req, res) => res.sendFile(path.join(__dirname, 'public', 'customer-portal.html')));
app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
// ════════════════════════════════════════════════════════════════
// MIDDLEWARE JWT AUTH
// ════════════════════════════════════════════════════════════════
function verifyToken(req, res, next) {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false, message: 'No token provided' });
try {
req.user = jwt.verify(token, SECRET);
next();
} catch {
res.status(401).json({ success: false, message: 'Invalid or expired token' });
}
}
function requireRole(...roles) {
return (req, res, next) => {
if (req.user?.role === 'admin') return next();
if (!roles.includes(req.user?.role))
return res.status(403).json({ success: false, message: `Required role: ${roles.join(' or ')}` });
next();
};
}
// requireApprovalStep/requireWorkflowPerm imported from ./middleware/auth above.
// ════════════════════════════════════════════════════════════════
// LAZY-LOAD SERVICES
// ════════════════════════════════════════════════════════════════
let hanaStore = null;
let hanaUsers = null;
let sapSvc = null;
let bomStore = null;
let hanaVendorStore = null;
function getStore() { return hanaStore; }
function getUsers() { return hanaUsers; }
function getSap() { return sapSvc; }
function getBomStore() { return bomStore; }
// ════════════════════════════════════════════════════════════════
// SQL CONNECTION (shared pool used by server-level routes)
// ════════════════════════════════════════════════════════════════
const sql = require('mssql');
let sqlConn = null;
let sqlConnecting = false;
async function getHanaConn() {
if (sqlConn) return sqlConn;
if (sqlConnecting) {
for (let i = 0; i < 10; i++) {
await new Promise(r => setTimeout(r, 500));
if (sqlConn) return sqlConn;
}
throw new Error('SQL connection timeout');
}
sqlConnecting = true;
try {
const config = {
server: process.env.SQL_HOST,
port: parseInt(process.env.SQL_PORT),
user: process.env.SQL_USER,
password: process.env.SQL_PASSWORD,
database: process.env.SQL_DATABASE,
options: {
encrypt: true,
trustServerCertificate: true,
},
};
sqlConn = await sql.connect(config);
console.log('[SQL] Connected');
} catch (err) {
console.error('[SQL] Connection failed:', err.message);
throw err;
} finally { sqlConnecting = false; }
return sqlConn;
}
async function hanaQuery(sqlQuery) {
console.log('[SQL] SQL:', sqlQuery.slice(0, 120).replace(/\s+/g, ' '));
const conn = await getHanaConn();
const result = await conn.request().query(sqlQuery);
console.log(`[SQL] ${(result.recordset || []).length} rows`);
return result.recordset || [];
}
const DB = () => `[dbo]`;
async function safeLookup(res, sql, mapFn) {
try {
const rows = await hanaQuery(sql);
return res.json({ success: true, data: rows.map(mapFn) });
} catch (err) {
console.warn('[LOOKUP] fallback to empty:', err.message);
return res.json({ success: true, data: [], warning: err.message });
}
}
// ════════════════════════════════════════════════════════════════
// CONFIG /api/config
// ════════════════════════════════════════════════════════════════
app.get('/api/config', (req, res) => {
const levels = APPROVAL_LEVELS();
res.json({
success: true,
defaultCompany: DEFAULT_COMPANY,
approvalLevels: levels,
statusFlow: STATUS_FLOW[levels],
levelRoles: LEVEL_ROLES,
approvalMap: {
2: { PENDING: 'manager', L1_APPROVED: 'sap_adder' },
3: { PENDING: 'manager', L1_APPROVED: 'sr_manager', L2_APPROVED: 'sap_adder' },
4: { PENDING: 'manager', L1_APPROVED: 'sr_manager', L2_APPROVED: 'sap_adder', L3_APPROVED: 'sap_adder' },
}[levels],
levelLabels: {
2: { PENDING: 'Awaiting Manager Review', L1_APPROVED: 'Awaiting SAP Adder Approval' },
3: { PENDING: 'Awaiting Manager Review', L1_APPROVED: 'Awaiting Senior Manager Review', L2_APPROVED: 'Awaiting SAP Adder Approval' },
4: { PENDING: 'Awaiting Manager Review', L1_APPROVED: 'Awaiting Senior Manager Review', L2_APPROVED: 'Awaiting SAP Adder L1', L3_APPROVED: 'Awaiting SAP Adder L2' },
}[levels],
custApprovalLevels: parseInt(process.env.CUST_APPROVAL_LEVELS) || 2,
prodOrderTypesEnabled: appSettings.prodOrderTypes(),
poRequireStockAvailability: appSettings.poRequireStockAvailability(),
receiptRequireFullQty: appSettings.receiptRequireFullQty(),
receiptExcludeByProductFromTotal: appSettings.receiptExcludeByProductFromTotal(),
closeRequireFullQty: appSettings.closeRequireFullQty(),
poCloseRequireTracking: appSettings.poCloseRequireTracking(),
poRequireFullIssuance: appSettings.poRequireFullIssuance(),
woWeighingBalanceIds: appSettings.woWeighingBalanceIds(),
woCustomQtyUnits: appSettings.woCustomQtyUnits(),
woRawPotencyFactors: appSettings.woRawPotencyFactors(),
woRawQtyIssuedSource: appSettings.woRawQtyIssuedSource(),
woSolutionBatchMaxEditLtr: appSettings.woSolutionBatchMaxEditLtr(),
woSolutionBatchMaxEditLtrPD: appSettings.woSolutionBatchMaxEditLtrPD(),
woSolutionBatchRoundLtr: appSettings.woSolutionBatchRoundLtr(),
woSolutionBatchRoundLtrPD: appSettings.woSolutionBatchRoundLtrPD(),
receiptAutoclaveRejection: appSettings.receiptAutoclaveRejection(),
receiptAutoclaveItemGroups: appSettings.receiptAutoclaveItemGroups(),
manpowerTabs: appSettings.manpowerTabs(),
// Work Order print/PDF header
woCompanyName: appSettings.woCompanyName(),
woCompanyAddress: appSettings.woCompanyAddress(),
woFormNo: appSettings.woFormNo(),
woEffectiveDate: appSettings.woEffectiveDate(),
woReviewDate: appSettings.woReviewDate(),
woLogo: appSettings.woLogo(),
reqCalcPeriodAEnabled: appSettings.reqCalcPeriodAEnabled(),
reqCalcPeriodBEnabled: appSettings.reqCalcPeriodBEnabled(),
reqCalcPeriodALabel: appSettings.reqCalcPeriodALabel(),
reqCalcPeriodBLabel: appSettings.reqCalcPeriodBLabel(),
biAllowExceedPending: appSettings.biAllowExceedPending(),
biSfgWorkflowEnabled: appSettings.biSfgWorkflowEnabled(),
biMultiSolutionVolumesEnabled: appSettings.biMultiSolutionVolumesEnabled(),
woHideSfgFromPacking: appSettings.woHideSfgFromPacking(),
woShowWfiInRawMaterial: appSettings.woShowWfiInRawMaterial(),
woHideStdQtyUnitPicker: appSettings.woHideStdQtyUnitPicker(),
rmOvgOverrideEnabled: appSettings.rmOvgOverrideEnabled(),
oeeMachineNames: appSettings.oeeMachineNames(),
woQtyIssuedSource: appSettings.woRawQtyIssuedSource(),
woPackQtyRoundUp: appSettings.woPackQtyRoundUp(),
deviationScrapWarehouse: appSettings.deviationScrapWarehouse(),
deviationRequireQaApproval: appSettings.deviationRequireQaApproval(),
deviationDeferIssueUntilApproval: appSettings.deviationDeferIssueUntilApproval(),
deviationShowRemoveOldLineButton: appSettings.deviationShowRemoveOldLineButton(),
deviationDamageSeries: appSettings.deviationDamageSeries(),
deviationEnabledTypes: appSettings.deviationEnabledTypes(),
requirementStoreReviewEnabled: appSettings.requirementStoreReviewEnabled(),
requirementStoreReviewHardGate: appSettings.requirementStoreReviewHardGate(),
preWoStoreReviewEnabled: appSettings.preWoStoreReviewEnabled(),
rejectionShifts: appSettings.rejectionShifts(),
rejectionStages: appSettings.rejectionStages(),
});
});
// ════════════════════════════════════════════════════════════════
// GET /api/companies — LIVE list of SAP B1 company databases on the
// SQL Server (public — no auth; used by unauthenticated forms like
// register.html too). Discovered dynamically: a database is a valid
// SAP B1 company DB iff it has an OADM table (SAP's own admin table,
// present in every company DB, holding the display name in CompnyName).
// Short in-memory cache to avoid re-scanning every DB on every request.
// ════════════════════════════════════════════════════════════════
let _companiesCache = null, _companiesCacheAt = 0;
const COMPANIES_CACHE_MS = 5 * 60 * 1000;
app.get('/api/companies', async (req, res) => {
try {
if (_companiesCache && (Date.now() - _companiesCacheAt) < COMPANIES_CACHE_MS) {
return res.json({ success: true, data: _companiesCache });
}
const { getPool } = require('./services/sqlPool');
const pool = await getPool();
const dbsResult = await pool.request().query(`
SELECT name FROM sys.databases
WHERE name NOT IN ('master','tempdb','model','msdb') AND state = 0
ORDER BY name
`);
const companies = [];
// Chandan
// companies.push({ value: 'Test_MI-NewDB2', label: 'TEST' });
for (const row of dbsResult.recordset) {
const dbName = row.name;
const safeName = dbName.replace(/]/g, ']]');
try {
const check = await pool.request().query(`
IF EXISTS (SELECT 1 FROM [${safeName}].sys.tables WHERE name = 'OADM')
SELECT TOP 1 CompnyName FROM [${safeName}].dbo.OADM
`);
if (check.recordset.length) {
companies.push({ value: dbName, label: check.recordset[0].CompnyName || dbName });
}
} catch (_e) { /* not a SAP B1 company DB, or no access — skip */ }
}
_companiesCache = companies;
_companiesCacheAt = Date.now();
res.json({ success: true, data: companies });
} catch (err) {
console.error('[COMPANIES] scan failed:', err.message);
// Fail safe: at least offer the configured default so dropdowns aren't empty
res.json({ success: true, data: [{ value: DEFAULT_COMPANY, label: DEFAULT_COMPANY }], warning: err.message });
}
});
// ════════════════════════════════════════════════════════════════
// AUTH /api/auth
// ════════════════════════════════════════════════════════════════
const authRouter = express.Router();
// Builds the JWT payload for a user (shared by /login, /refresh, and
// /sap-credentials so a token's shape never drifts between issuers).
// `sapLogins` is the user's RAW per-company SAP login map (services/
// hanaUsers.getSapLoginMapRaw) — { [companyDB]: { user, pwdEnc } }, password
// already AES-encrypted, safe to embed in the JWT the same way the old single
// sapPwdEnc field always was. SAP B1 companies each have their own OUSR
// table, so the same SAP username can have a DIFFERENT password per company —
// this is why it's a map keyed by company, not one global pair.
function buildAuthPayload(user, sapLogins) {
return {
id: user.id, username: user.username, role: user.role, name: user.fullName,
modules: user.modules, sapUserId: user.sapUserId, approvalDept: user.approvalDept || null,
approvalSteps: user.approvalSteps || [],
allowedCompanies: user.allowedCompanies || [],
woVerifyOverride: !!user.woVerifyOverride,
canImpersonate: !!user.canImpersonate,
sapLogins: sapLogins || {},
};
}
// Strip encrypted passwords out of a raw sapLogins map for anything shipped
// as plain JSON (not inside the signed token) — e.g. the `user` object in a
// login/refresh response, so the UI can show "which companies have a login
// configured, under which username" without the ciphertext.
function safeSapLogins(sapLogins) {
const out = {};
Object.entries(sapLogins || {}).forEach(([c, v]) => { out[c] = { user: (v && v.user) || '', hasPwd: !!(v && v.pwdEnc) }; });
return out;
}
authRouter.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password)
return res.status(400).json({ success: false, message: 'Username and password required' });
try {
const userDb = getUsers();
if (!userDb) return res.status(503).json({ success: false, message: 'Auth service not ready' });
const user = await userDb.findByUsername(username);
if (!user) return res.status(401).json({ success: false, message: 'Invalid username or password' });
const ok = await userDb.verifyPassword(password, user.passwordHash);
if (!ok) return res.status(401).json({ success: false, message: 'Invalid username or password' });
await userDb.upgradeLegacyPassword(user.id, password, user.passwordHash); // msale-migrated MD5 → bcrypt
await userDb.touchLastLogin(user.id);
// Per-user, per-company SAP logins (SAP password AES-encrypted) ride in
// the JWT so downstream SAP calls act as this user — see the per-request
// SAP-context middleware below, which picks the entry for whichever
// company the actual request targets.
const sapLogins = await userDb.getSapLoginMapRaw(user.id);
const payload = buildAuthPayload(user, sapLogins);
const token = jwt.sign(payload, SECRET, { expiresIn: '12h' });
const { sapLogins: _sl, ...safeUser } = payload; // don't ship the (encrypted) map in the user object
res.json({ success: true, token, user: { ...safeUser, sapLogins: safeSapLogins(sapLogins) } });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ── POST /api/auth/sso-login — Mitra Industry central auth SSO ─────────────
// Central auth (a separate Django service) is the entry point: a user logs
// in there, picks this app from their dashboard, and central auth redirects
// the browser to /sso/login on this app with a short-lived access token in
// the URL. That token carries NO email itself (it's a bare SimpleJWT access
// token — just token_type/exp/iat/jti/user_id), so it can't be trusted
// locally; instead we call central auth's own API server-to-server to
// resolve it — same proven pattern as the QMS portal's working integration
// (D:\Claude_projects\qms\server\controllers\userController.js ssoLogin).
// Central auth returns the authenticated user's email among other fields —
// that's the ONLY identity taken from it. Unlike QMS, we do NOT auto-create
// accounts: SAP per-user credentials/approval steps/module access must
// already be deliberately provisioned by an admin, so an unmatched email is
// rejected with a clear message instead of silently creating a blank user.
const CENTRAL_AUTH_API_URL = process.env.CENTRAL_AUTH_API_URL || 'http://14.99.235.114:8016';
const CENTRAL_AUTH_APP_NAME = process.env.CENTRAL_AUTH_APP_NAME || 'SAP ERP';
authRouter.post('/sso-login', async (req, res) => {
const { token } = req.body || {};
if (!token) return res.status(400).json({ success: false, message: 'Missing SSO token' });
try {
const userDb = getUsers();
if (!userDb) return res.status(503).json({ success: false, message: 'Auth service not ready' });
const verifyUrl = `${CENTRAL_AUTH_API_URL}/accounts/auth-subapp/?app_name=${encodeURIComponent(CENTRAL_AUTH_APP_NAME)}`;
const resp = await fetch(verifyUrl, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) {
const body = await resp.text().catch(() => '');
console.warn('[AUTH] SSO verify failed:', resp.status, body.slice(0, 300));
return res.status(401).json({ success: false, message: 'Central auth could not verify this login — please try logging in again from the central auth dashboard.' });
}
const data = await resp.json();
const email = (data?.user?.email || '').trim();
if (!email) return res.status(401).json({ success: false, message: 'Central auth did not return an email for this account.' });
const user = await userDb.findByEmail(email);
if (!user) return res.status(403).json({ success: false, message: `No portal account found for ${email}. Ask your admin to create one with this exact email before using central auth login.` });
await userDb.touchLastLogin(user.id);
const sapLogins = await userDb.getSapLoginMapRaw(user.id);
const payload = buildAuthPayload(user, sapLogins);
const jwtToken = jwt.sign(payload, SECRET, { expiresIn: '12h' });
const { sapLogins: _sl, ...safeUser } = payload;
res.json({ success: true, token: jwtToken, user: { ...safeUser, sapLogins: safeSapLogins(sapLogins) } });
} catch (err) {
console.error('[AUTH] SSO login error:', err.message);
res.status(500).json({ success: false, message: 'Central auth login failed: ' + err.message });
}
});
authRouter.get('/me', (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
try {
const user = jwt.verify(token, SECRET);
res.json({ success: true, user });
} catch { res.status(401).json({ success: false }); }
});
// ── POST /auth/refresh — mint a fresh token from CURRENT DB state ──────────
// approvalSteps/modules/role are baked into the JWT at issuance; server-side
// permission checks (requireApprovalStep/requireWorkflowPerm) decode the
// token directly, with no DB lookup. So an admin granting a new Approval Step
// takes effect for API calls only once the caller holds a NEW token — this is
// what sidebar.js calls (replacing the cached token) whenever it notices the
// live profile differs from what's cached, so users don't have to log out.
authRouter.post('/refresh', async (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
try {
const decoded = jwt.verify(token, SECRET);
const userDb = getUsers();
const user = await userDb.findById(decoded.id);
if (!user) return res.status(404).json({ success: false, message: 'User not found' });
if (user.active === false) return res.status(401).json({ success: false, message: 'Account is inactive' });
const sapLogins = await userDb.getSapLoginMapRaw(user.id);
const payload = buildAuthPayload(user, sapLogins);
const freshToken = jwt.sign(payload, SECRET, { expiresIn: '12h' });
const { sapLogins: _sl, ...safeUser } = payload;
res.json({ success: true, token: freshToken, user: { ...safeUser, sapLogins: safeSapLogins(sapLogins) } });
} catch (err) { res.status(401).json({ success: false, message: err.message }); }
});
// Impersonate: mint a token for a DIFFERENT user, gated by that user having
// canImpersonate (or being admin) — not a normal login (no password), so the
// caller must already hold a valid token proving who THEY are. The new
// token carries the TARGET's full identity/permissions (the impersonator
// then sees/does exactly what that user would) plus impersonatedBy, so
// audit logging (middleware/auditLogger.js) and the UI (a persistent
// banner, public/sidebar.js) both know this isn't the target's own session.
// "Stop impersonating" needs no server call — the client just restores the
// original token it cached before switching.
authRouter.post('/impersonate', async (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
try {
const decoded = jwt.verify(token, SECRET);
if (decoded.impersonatedBy) return res.status(403).json({ success: false, message: 'Already impersonating — return to your own account first' });
const userDb = getUsers();
const actor = await userDb.findById(decoded.id);
if (!actor) return res.status(404).json({ success: false, message: 'User not found' });
if (actor.active === false) return res.status(401).json({ success: false, message: 'Account is inactive' });
if (!(actor.role === 'admin' || actor.canImpersonate))
return res.status(403).json({ success: false, message: 'Not permitted to impersonate' });
const targetUsername = String((req.body || {}).username || '').trim().toLowerCase();
if (!targetUsername) return res.status(400).json({ success: false, message: 'username is required' });
const target = await userDb.findByUsername(targetUsername);
if (!target) return res.status(404).json({ success: false, message: `User "${targetUsername}" not found` });
if (target.id === actor.id) return res.status(400).json({ success: false, message: 'You are already yourself' });
// A non-admin impersonator (canImpersonate-only) may never impersonate an
// admin — that would be a privilege-escalation path. A real admin has no
// such restriction.
if (target.role === 'admin' && actor.role !== 'admin')
return res.status(403).json({ success: false, message: 'You are not permitted to impersonate an admin' });
const sapLogins = await userDb.getSapLoginMapRaw(target.id);
const payload = buildAuthPayload(target, sapLogins);
payload.impersonatedBy = { id: actor.id, username: actor.username, name: actor.fullName || actor.username };
const impToken = jwt.sign(payload, SECRET, { expiresIn: '12h' });
const { sapLogins: _sl, ...safeUser } = payload;
res.json({ success: true, token: impToken, user: { ...safeUser, sapLogins: safeSapLogins(sapLogins) } });
} catch (err) { res.status(401).json({ success: false, message: err.message }); }
});
authRouter.get('/profile', async (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
try {
const decoded = jwt.verify(token, SECRET);
const userDb = getUsers();
const user = await userDb.findById(decoded.id);
if (!user) return res.status(404).json({ success: false, message: 'User not found' });
res.json({ success: true, user });
} catch (err) { res.status(401).json({ success: false, message: err.message }); }
});
authRouter.put('/profile', async (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
try {
const decoded = jwt.verify(token, SECRET);
const userDb = getUsers();
const { fullName, email, currentPassword, newPassword, signature } = req.body;
if (newPassword) {
const user = await userDb.findByUsername(decoded.username);
const ok = await userDb.verifyPassword(currentPassword || '', user.passwordHash);
if (!ok) return res.status(400).json({ success: false, message: 'Current password is incorrect' });
}
const patch = {};
if (fullName !== undefined) patch.fullName = fullName.trim();
if (email !== undefined) patch.email = email.trim();
if (newPassword) patch.password = newPassword;
if (signature !== undefined) patch.signature = signature; // base64 data URI; '' clears
await userDb.updateUser(decoded.id, patch);
res.json({ success: true, message: 'Profile updated successfully' });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── GET /auth/signature — own signature image (base64) for previewing ─────────
authRouter.get('/signature', async (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
try {
const decoded = jwt.verify(token, SECRET);
const sig = await getUsers().getSignature(decoded.id);
res.json({ success: true, signature: sig || '' });
} catch (err) { res.status(401).json({ success: false, message: err.message }); }
});
// ── GET /auth/sap-credentials — own SAP login status per company (never the
// password) — one entry per company this user has configured a login for.
authRouter.get('/sap-credentials', async (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
try {
const decoded = jwt.verify(token, SECRET);
const sapLogins = await getUsers().getSapLoginMapRaw(decoded.id);
res.json({ success: true, sapLogins: safeSapLogins(sapLogins) });
} catch (err) { res.status(401).json({ success: false, message: err.message }); }
});
// ── PUT /auth/sap-credentials — set own SAP login FOR ONE COMPANY (validated
// against SAP) — SAP B1 companies each have their own OUSR table, so the same
// SAP username can need a different password per company; every other
// company's already-saved login is left untouched. Returns a fresh token
// carrying the updated map so it takes effect now.
authRouter.put('/sap-credentials', async (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
let decoded;
try { decoded = jwt.verify(token, SECRET); }
catch (err) { return res.status(401).json({ success: false, message: err.message }); }
const company = (req.body?.company || '').trim() || require('./services/companyConfig').DEFAULT_COMPANY;
const sapUser = (req.body?.sapUser || '').trim();
const sapPassword = req.body?.sapPassword || '';
if (!sapUser || !sapPassword)
return res.status(400).json({ success: false, message: 'SAP User ID and Password are required' });
try {
await require('./services/sapServiceLayer').testSapLogin(company, sapUser, sapPassword);
} catch (e) {
return res.status(400).json({ success: false, message: `SAP login failed for company "${company}" — check your SAP User ID/Password. (${e.message})` });
}
try {
const userDb = getUsers();
await userDb.setSapLoginForCompany(decoded.id, company, sapUser, sapPassword);
const user = await userDb.findById(decoded.id);
const sapLogins = await userDb.getSapLoginMapRaw(decoded.id);
const payload = buildAuthPayload(user, sapLogins);
const freshToken = jwt.sign(payload, SECRET, { expiresIn: '12h' });
res.json({ success: true, token: freshToken, company, sapUser, hasSapLogin: true, sapLogins: safeSapLogins(sapLogins) });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── DELETE /auth/sap-credentials — remove own SAP login for ONE company ────────
authRouter.delete('/sap-credentials', async (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false });
let decoded;
try { decoded = jwt.verify(token, SECRET); }
catch (err) { return res.status(401).json({ success: false, message: err.message }); }
const company = (req.query?.company || '').trim() || require('./services/companyConfig').DEFAULT_COMPANY;
try {
const userDb = getUsers();
await userDb.clearSapLoginForCompany(decoded.id, company);
const user = await userDb.findById(decoded.id);
const sapLogins = await userDb.getSapLoginMapRaw(decoded.id);
const payload = buildAuthPayload(user, sapLogins);
const freshToken = jwt.sign(payload, SECRET, { expiresIn: '12h' });
res.json({ success: true, token: freshToken, sapLogins: safeSapLogins(sapLogins) });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── Per-request SAP identity ──────────────────────────────────────
// Runs the rest of each request inside a SAP-user context so every SAP call
// downstream acts as the logged-in user's own SAP login (not one shared
// account). Credentials ride in the signed JWT as a PER-COMPANY map (SAP
// passwords AES-encrypted) — SAP B1 companies each have their own OUSR
// table, so the same SAP username can need a different password in each one.
// This picks the entry for whichever company THIS request actually targets
// (req.query.company / req.body.company, same extraction routes/sap.js's
// cq() helper uses) — no per-request DB hit, still just decoding the token.
// Best-effort: a missing/invalid token or no matching company entry just
// yields no context (the route's own verifyToken still guards access).
const _cryptoUtil = require('./services/cryptoUtil');
const { runWithSapUser } = require('./services/sapServiceLayer');
app.use((req, res, next) => {
let ctx = null;
const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : (req.cookies?.portal_token || '');
if (token) {
try {
const p = jwt.verify(token, SECRET);
const companyDB = req.query?.company || req.body?.company || null;
const entry = companyDB && p.sapLogins ? p.sapLogins[companyDB] : null;
if (entry && entry.user && entry.pwdEnc) ctx = { sapUser: entry.user, sapPassword: _cryptoUtil.decrypt(entry.pwdEnc) };
else ctx = { blocked: true }; // authenticated but no SAP login set for this company
} catch { ctx = null; }
}
runWithSapUser(ctx, () => next());
});
app.use('/api/auth', authRouter);
// ════════════════════════════════════════════════════════════════
// USERS /api/users
// ════════════════════════════════════════════════════════════════
const usersRouter = express.Router();
usersRouter.get('/', verifyToken, requireRole('sap_adder','system_admin'), async (req, res) => {
try {
const users = await getUsers().listUsers();
res.json({ success: true, data: users });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Minimal username/name list — NOT the full admin user list above (no
// roles/modules/etc.) — for non-admin cases that need to pick another user
// by name: Verify Work Order's "sign as" override (woVerifyOverride) and
// the Impersonate picker (canImpersonate).
usersRouter.get('/names', verifyToken, async (req, res) => {
if (!(req.user.role === 'admin' || req.user.woVerifyOverride || req.user.canImpersonate))
return res.status(403).json({ success: false, message: 'Not permitted' });
try {
const users = await getUsers().listUsers();
res.json({ success: true, data: users.filter(u => u.active).map(u => ({ username: u.username, fullName: u.fullName || u.username })) });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Sub-admins may only hand out access they themselves hold: any module or
// approval step outside their own assignment is rejected. Grants the target
// ALREADY has are tolerated (editing a user must not force removals).
// Loaded fresh from the DB — not the JWT — so mid-session changes count.
async function subAdminGrantViolation(reqUser, body, existing) {
if (reqUser.role !== 'system_admin') return null;
const me = await getUsers().findById(reqUser.id);
if (!me) return 'Your account could not be loaded';
const stepKey = s => (typeof s === 'string' ? s : s?.step);
const myMods = new Set(me.modules || []);
const mySteps = new Set((me.approvalSteps || []).map(stepKey).filter(Boolean));
const oldMods = new Set(existing?.modules || []);
const oldSteps = new Set((existing?.approvalSteps || []).map(stepKey).filter(Boolean));
if (Array.isArray(body.modules)) {
const bad = body.modules.filter(m => !myMods.has(m) && !oldMods.has(m));
if (bad.length) return `You can only grant modules assigned to you (not allowed: ${bad.join(', ')})`;
}
if (Array.isArray(body.approvalSteps)) {
const bad = body.approvalSteps.map(stepKey).filter(k => k && !mySteps.has(k) && !oldSteps.has(k));
if (bad.length) return `You can only grant approval steps assigned to you (not allowed: ${bad.join(', ')})`;
}
return null;
}
usersRouter.post('/', verifyToken, requireRole('sap_adder','system_admin'), async (req, res) => {
const { username, password, fullName, email, role, modules, approvalSteps } = req.body;
if (!username || !password)
return res.status(400).json({ success: false, message: 'Username and password are required' });
if (!['manager', 'sr_manager', 'sap_adder', 'system_admin', 'board_member', 'admin', 'user', 'project_approver'].includes(role))
return res.status(400).json({ success: false, message: 'Invalid role' });
// Sub-admins manage ordinary users only — granting the full admin role
// would be privilege escalation, so that stays admin-only.
if (role === 'admin' && req.user.role !== 'admin')
return res.status(403).json({ success: false, message: 'Only an admin can create admin users' });
try {
const viol = await subAdminGrantViolation(req.user, req.body, null);
if (viol) return res.status(403).json({ success: false, message: viol });
} catch (e) { return res.status(500).json({ success: false, message: e.message }); }
try {
const id = await getUsers().createUser({ username, password, fullName, email, role, modules, approvalDept: req.body.approvalDept || null, approvalSteps, sapLoginUser: req.body.sapLoginUser, sapLoginPwd: req.body.sapLoginPwd, issueItemGroups: req.body.issueItemGroups, manualPoItemGroups: req.body.manualPoItemGroups, manpowerTabs: req.body.manpowerTabs, oeeTabs: req.body.oeeTabs, signature: req.body.signature, allowedCompanies: req.body.allowedCompanies, sapApprovalTypes: req.body.sapApprovalTypes, allowedDepartments: req.body.allowedDepartments, emailNotify: req.body.emailNotify, woVerifyOverride: req.body.woVerifyOverride, canImpersonate: req.body.canImpersonate });
res.json({ success: true, message: 'User created', id });
// Welcome email — best-effort, never affects the response above (the
// account is already created either way). Includes the password as-set
// by the admin so the new user can log in immediately without a
// separate handoff.
const notifyEnabled = appSettings.notifyEmailsEnabled();
// Admin/System Admin used to be hard-excluded from ALL portal email here
// and in services/mailer.js — replaced by the per-user "Send stage-change
// email notifications" checkbox (Admin → user), which now covers every
// role including these, so an admin can opt back in if they want to.
const optedOut = req.body.emailNotify === false;
console.log('[USERS] create email check —', 'username:', username, 'email:', JSON.stringify(email), 'role:', role, 'notifyEmailsEnabled:', notifyEnabled, 'optedOut:', optedOut);
if (email && notifyEnabled && !optedOut) {
try {
console.log('[USERS] sending welcome email to', email);
const sent = await mailer.sendMail({
to: email,
subject: 'Your SAP ERP Portal account has been created',
html: `<div style="font-family:Segoe UI,Arial,sans-serif;max-width:480px">
<h2 style="margin:0 0 12px;font-size:16px;color:#1a2b4a">Welcome to the SAP ERP Portal</h2>
<p style="font-size:13px;color:#333">An account has been created for you${fullName ? `, <strong>${fullName}</strong>` : ''}.</p>
<table style="margin:14px 0"><tbody>
<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">Username</td><td style="padding:3px 0;font-size:13px;font-weight:600">${username}</td></tr>
<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">Password</td><td style="padding:3px 0;font-size:13px;font-weight:600">${password}</td></tr>
<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">Role</td><td style="padding:3px 0;font-size:13px;font-weight:600">${role}</td></tr>
</tbody></table>
<p style="font-size:12px;color:#98a">Please log in and change your password. This is an automated message from the SAP ERP Portal.</p>
</div>`,
});
console.log('[USERS] welcome email mailer.sendMail() returned:', sent);
} catch (e) { console.warn('[USERS] welcome email failed (non-fatal):', e.message); }
} else if (optedOut) {
console.log('[USERS] welcome email SKIPPED — this user has opted out of email notifications');
}
} catch (err) {
const msg = err.message?.includes('unique') || err.message?.includes('duplicate')
? `Username "${username}" already exists` : err.message;
res.status(400).json({ success: false, message: msg });
}
});
usersRouter.patch('/:id', verifyToken, requireRole('sap_adder','system_admin'), async (req, res) => {
try {
const existing = await getUsers().findById(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: 'User not found' });
// Sub-admins can't touch admin accounts, nor promote anyone to admin.
if (req.user.role !== 'admin' && (existing.role === 'admin' || req.body.role === 'admin'))
return res.status(403).json({ success: false, message: 'Only an admin can modify admin users' });
// …and can only grant modules/approval steps they hold themselves.
const viol = await subAdminGrantViolation(req.user, req.body, existing);
if (viol) return res.status(403).json({ success: false, message: viol });
await getUsers().updateUser(req.params.id, { ...req.body, approvalDept: req.body.approvalDept !== undefined ? req.body.approvalDept : undefined });
res.json({ success: true, message: 'User updated' });
// Update-notification email — best-effort, never affects the response
// above. Sent to the (possibly just-changed) email on file.
const notifyEmail = req.body.email !== undefined ? req.body.email : existing.email;
const notifyEnabled = appSettings.notifyEmailsEnabled();
// Admin/System Admin used to be hard-excluded here (and in
// services/mailer.js) — replaced by the per-user "Send stage-change
// email notifications" checkbox, which now covers every role.
const notifyOptedOut = req.body.emailNotify !== undefined ? req.body.emailNotify === false : existing.emailNotify === false;
console.log('[USERS] update email check —',
'userId:', req.params.id,
'body.email:', JSON.stringify(req.body.email),
'existing.email:', JSON.stringify(existing.email),
'resolved notifyEmail:', JSON.stringify(notifyEmail),
'notifyEmailsEnabled:', notifyEnabled,
'optedOut:', notifyOptedOut);
if (notifyEmail && notifyOptedOut) {
console.log('[USERS] update email SKIPPED — this user has opted out of email notifications');
} else if (notifyEmail) {
if (!notifyEnabled) {
console.log('[USERS] update email SKIPPED — notifyEmailsEnabled() is false (Admin → System Settings → Stage-Change Email Notifications)');
} else {
try {
const rows = [];
if (req.body.role !== undefined) rows.push(['Role', req.body.role]);
if (req.body.active !== undefined) rows.push(['Status', req.body.active ? 'Active' : 'Inactive']);
if (req.body.password) rows.push(['New Password', req.body.password]);
const rowsHtml = rows.map(([k, v]) => `<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">${k}</td><td style="padding:3px 0;font-size:13px;font-weight:600">${v}</td></tr>`).join('');
console.log('[USERS] sending update email to', notifyEmail);
const sent = await mailer.sendMail({
to: notifyEmail,
subject: 'Your SAP ERP Portal account was updated',
html: `<div style="font-family:Segoe UI,Arial,sans-serif;max-width:480px">
<h2 style="margin:0 0 12px;font-size:16px;color:#1a2b4a">Account updated</h2>
<p style="font-size:13px;color:#333">Your account (<strong>${existing.username}</strong>) was just updated by an administrator.</p>
${rowsHtml ? `<table style="margin:14px 0"><tbody>${rowsHtml}</tbody></table>` : ''}
<p style="font-size:12px;color:#98a">Other permissions (module access, approval steps, etc.) may also have changed — contact your administrator for details. This is an automated message from the SAP ERP Portal.</p>
</div>`,
});
console.log('[USERS] update email mailer.sendMail() returned:', sent);
} catch (e) { console.warn('[USERS] update email failed (non-fatal):', e.message); }
}
} else {
console.log('[USERS] update email SKIPPED — no email on file for this user (neither in the PATCH body nor already stored)');
}
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
usersRouter.delete('/:id', verifyToken, requireRole('sap_adder','system_admin'), async (req, res) => {
try {
if (parseInt(req.params.id) === req.user.id)
return res.status(400).json({ success: false, message: 'Cannot delete your own account' });
// Sub-admins can't delete admin accounts.
const target = await getUsers().findById(req.params.id);
if (target && target.role === 'admin' && req.user.role !== 'admin')
return res.status(403).json({ success: false, message: 'Only an admin can delete admin users' });
await getUsers().deleteUser(req.params.id);
res.json({ success: true, message: 'User deleted' });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Signature image (base64) by username — used by the Work Order print view to
// draw each approval signer's signature. Any logged-in user may read (needed to
// render a document that others signed).
usersRouter.get('/signature/by-username/:username', verifyToken, async (req, res) => {
try {
const sig = await getUsers().getSignatureByUsername(req.params.username);
res.json({ success: true, signature: sig || '' });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// A specific user's signature by ID — for the admin editor's preview.
usersRouter.get('/:id/signature', verifyToken, requireRole('sap_adder','system_admin'), async (req, res) => {
try {
const sig = await getUsers().getSignature(req.params.id);
res.json({ success: true, signature: sig || '' });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
app.use('/api/users', usersRouter);
// ════════════════════════════════════════════════════════════════
// APPROVAL STEPS REGISTRY — grouped list for the User Management UI,
// plus admin-only custom step creation/deletion.
// ════════════════════════════════════════════════════════════════
const approvalStepsRouter = express.Router();
approvalStepsRouter.get('/', verifyToken, async (req, res) => {
try {
const data = await require('./services/approvalStepsStore').listGrouped();
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
approvalStepsRouter.post('/', verifyToken, requireRole('admin'), async (req, res) => {
try {
const { workflow, key, label, order } = req.body || {};
const saved = await require('./services/approvalStepsStore').createCustomStep({
workflow, key, label, order, createdBy: req.user.username,
});
res.json({ success: true, data: saved });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
approvalStepsRouter.delete('/:id', verifyToken, requireRole('admin'), async (req, res) => {
try {
await require('./services/approvalStepsStore').deleteCustomStep(req.params.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
app.use('/api/approval-steps', approvalStepsRouter);
// ════════════════════════════════════════════════════════════════
// BOM APPROVAL /api/bom-requests
// ════════════════════════════════════════════════════════════════
const TREE_TYPE_MAP = {
production: 'iProductionTree', sales: 'iSalesTree',
assembly: 'iAssemblyTree', template: 'iTemplateTree', disassembly: 'iDisassemblyTree',
};
const ISSUE_METHOD_MAP = {
Manual: 'im_Manual', Backflush: 'im_Backflush',
Stock: 'im_Backflush', 'Non-Stock': 'im_Manual', Phantom: 'im_Manual', Fixed: 'im_Backflush',
};
const bomRequestRouter = express.Router();
bomRequestRouter.post('/direct-create', verifyToken, requireRole('admin'), [
body('itemCode').notEmpty(), body('itemName').notEmpty(),
body('qty').isFloat({ gt: 0 }), body('components').isArray({ min: 1 }),
], async (req, res) => {
const errs = validationResult(req);
if (!errs.isEmpty()) return res.status(400).json({ success: false, errors: errs.array() });
try {
const store = getBomStore();
const b = req.body;
const request = await store.insertBomRequest({
type: 'CREATE', itemCode: b.itemCode.trim().toUpperCase(),
itemName: b.itemName.trim().toUpperCase(), qty: Number(b.qty) || 1,
bomType: b.bomType || 'Production', warehouse: b.warehouse || '',
distrRule: b.distrRule || '', project: b.project || '',
components: b.components || [], submittedBy: req.user.username,
submittedByName: req.user.name || req.user.username, status: 'PENDING', approvalLog: [],
company: b.company || '',
});
const sapResult = await pushBomToSap({ ...b, id: request.id, itemCode: b.itemCode.trim().toUpperCase(), itemName: b.itemName.trim().toUpperCase(), type: 'CREATE' });
const logEntry = { username: req.user.username, name: req.user.name || req.user.username, role: 'admin', action: 'approve', comment: 'Admin direct push', status: 'PENDING', timestamp: new Date().toISOString() };
await store.updateRequest(request.id, { status: 'SAP_PUSHED', approvalLog: [logEntry], sapPushedAt: new Date().toISOString(), sapPushedBy: req.user.username, sapResult });
res.json({ success: true, message: 'BOM created directly in SAP B1!', id: request.id, sapResult });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
bomRequestRouter.post('/direct-update', verifyToken, requireRole('admin'), [
body('treeCode').notEmpty(), body('components').isArray({ min: 1 }),
], async (req, res) => {
const errs = validationResult(req);
if (!errs.isEmpty()) return res.status(400).json({ success: false, errors: errs.array() });
try {
const store = getBomStore();
const b = req.body;
const request = await store.insertBomRequest({
type: 'UPDATE', itemCode: b.treeCode.trim().toUpperCase(), itemName: b.itemName || '',
qty: Number(b.qty) || 1, bomType: b.bomType || 'Production', warehouse: b.warehouse || '',
distrRule: '', project: '', components: b.components || [],
submittedBy: req.user.username, submittedByName: req.user.name || req.user.username,
status: 'PENDING', approvalLog: [], company: b.company || '',
});
const sapResult = await pushBomToSap({ ...b, id: request.id, itemCode: b.treeCode.trim().toUpperCase(), itemName: b.itemName || '', type: 'UPDATE' });
const logEntry = { username: req.user.username, name: req.user.name || req.user.username, role: 'admin', action: 'approve', comment: 'Admin direct push', status: 'PENDING', timestamp: new Date().toISOString() };
await store.updateRequest(request.id, { status: 'SAP_PUSHED', approvalLog: [logEntry], sapPushedAt: new Date().toISOString(), sapPushedBy: req.user.username, sapResult });
res.json({ success: true, message: 'BOM updated directly in SAP B1!', id: request.id, sapResult });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
bomRequestRouter.post('/create', verifyToken, [
body('itemCode').notEmpty(), body('itemName').notEmpty(),
body('qty').isFloat({ gt: 0 }), body('components').isArray({ min: 1 }),
], async (req, res) => {
const errs = validationResult(req);
if (!errs.isEmpty()) return res.status(400).json({ success: false, errors: errs.array() });
try {
const store = getBomStore();
if (!store) return res.status(503).json({ success: false, message: 'Service not ready' });
const b = req.body;
if (req.user.role === 'admin') {
try {
const sapResult = await pushBomToSap({ ...b, id: 'DIRECT-' + Date.now(), itemCode: b.itemCode.trim().toUpperCase(), itemName: b.itemName.trim().toUpperCase(), type: 'CREATE' });
const request = await store.insertBomRequest({
type: 'CREATE', itemCode: b.itemCode.trim().toUpperCase(), itemName: b.itemName.trim().toUpperCase(),
qty: Number(b.qty) || 1, bomType: b.bomType || 'Production', warehouse: b.warehouse || '',
distrRule: b.distrRule || '', project: b.project || '', components: b.components || [],
submittedBy: req.user.username, submittedByName: req.user.name || req.user.username,
status: 'SAP_PUSHED', approvalLog: [{ username: req.user.username, role: 'admin', action: 'approve', comment: 'Admin direct push', timestamp: new Date().toISOString() }],
company: b.company || '',
});
await store.updateRequest(request.id, { status: 'SAP_PUSHED', sapPushedAt: new Date().toISOString(), sapPushedBy: req.user.username, sapResult });
return res.json({ success: true, message: 'BOM created directly in SAP B1!', id: request.id, sapResult, status: 'SAP_PUSHED' });
} catch (err) { return res.status(500).json({ success: false, message: err.message }); }
}
const request = await store.insertBomRequest({
type: 'CREATE', itemCode: b.itemCode.trim().toUpperCase(), itemName: b.itemName.trim().toUpperCase(),
qty: Number(b.qty) || 1, bomType: b.bomType || 'Production', warehouse: b.warehouse || '',
distrRule: b.distrRule || '', project: b.project || '', components: b.components || [],
submittedBy: req.user.username, submittedByName: req.user.name || req.user.username,
status: 'PENDING', approvalLog: [], company: b.company || '',
});
res.json({ success: true, message: 'BOM Create request submitted for approval', id: request.id, status: 'PENDING' });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
bomRequestRouter.post('/update', verifyToken, [
body('treeCode').notEmpty(), body('components').isArray({ min: 1 }),
], async (req, res) => {
const errs = validationResult(req);
if (!errs.isEmpty()) return res.status(400).json({ success: false, errors: errs.array() });
try {
const store = getBomStore();
if (!store) return res.status(503).json({ success: false, message: 'Service not ready' });
const b = req.body;
if (req.user.role === 'admin') {
try {
const sapResult = await pushBomToSap({ ...b, itemCode: b.treeCode.trim().toUpperCase(), itemName: b.itemName || '', type: 'UPDATE' });
const request = await store.insertBomRequest({
type: 'UPDATE', itemCode: b.treeCode.trim().toUpperCase(), itemName: b.itemName || '',
qty: Number(b.qty) || 1, bomType: b.bomType || 'Production', warehouse: b.warehouse || '',
distrRule: '', project: '', components: b.components || [],
submittedBy: req.user.username, submittedByName: req.user.name || req.user.username,
status: 'SAP_PUSHED', approvalLog: [{ username: req.user.username, role: 'admin', action: 'approve', comment: 'Admin direct push', timestamp: new Date().toISOString() }],
company: b.company || '',
});
await store.updateRequest(request.id, { status: 'SAP_PUSHED', sapPushedAt: new Date().toISOString(), sapPushedBy: req.user.username, sapResult });
return res.json({ success: true, message: 'BOM updated directly in SAP B1!', id: request.id, sapResult, status: 'SAP_PUSHED' });
} catch (err) { return res.status(500).json({ success: false, message: err.message }); }
}
const request = await store.insertBomRequest({
type: 'UPDATE', itemCode: b.treeCode.trim().toUpperCase(), itemName: b.itemName || '',
qty: Number(b.qty) || 1, bomType: b.bomType || 'Production', warehouse: b.warehouse || '',
distrRule: '', project: '', components: b.components || [],
submittedBy: req.user.username, submittedByName: req.user.name || req.user.username,
status: 'PENDING', approvalLog: [], originalData: b.originalData || null, company: b.company || '',
});
res.json({ success: true, message: 'BOM Update request submitted for approval', id: request.id, status: 'PENDING' });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
bomRequestRouter.get('/', verifyToken, async (req, res) => {
try {
const store = getBomStore();
if (!store) return res.status(503).json({ success: false, message: 'Service not ready' });
const { status, type, mine, company } = req.query;
const requests = await store.listRequests({ status, type, mine: mine === 'true' ? req.user.username : null, company: company || null });
res.json({ success: true, data: requests });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
bomRequestRouter.get('/:id', verifyToken, async (req, res) => {
try {
const store = getBomStore();
const req2 = await store.findById(req.params.id);
if (!req2) return res.status(404).json({ success: false, message: 'Request not found' });
res.json({ success: true, data: req2 });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
bomRequestRouter.patch('/:id/action', verifyToken, async (req, res) => {
try {
const store = getBomStore();
const bomReq = await store.findById(req.params.id);
if (!bomReq) return res.status(404).json({ success: false, message: 'Request not found' });
const { action, comment } = req.body;
if (!['approve', 'reject'].includes(action))
return res.status(400).json({ success: false, message: 'Action must be approve or reject' });
if (!canApproveAtStatus(req.user, bomReq.status))
return res.status(403).json({ success: false, message: `You are not assigned the approval step required for status: ${bomReq.status}` });
if (req.user.role !== 'admin') {
const alreadyApproved = (bomReq.approvalLog || []).some(l => l.username === req.user.username && l.action === 'approve');
if (alreadyApproved)
return res.status(400).json({ success: false, message: 'You have already approved this request' });
}
const logEntry = { username: req.user.username, name: req.user.name || req.user.username, role: req.user.role, action, comment: comment || '', status: bomReq.status, timestamp: new Date().toISOString() };
if (action === 'reject') {
await store.updateRequest(bomReq.id, { status: 'REJECTED', approvalLog: [...(bomReq.approvalLog || []), logEntry], rejectedBy: req.user.username, rejectedAt: new Date().toISOString() });
return res.json({ success: true, message: 'BOM request rejected', status: 'REJECTED' });
}
const isAdminAction = req.user.role === 'admin';
const isFinal = isAdminAction || isFinalApproval(bomReq.status);
const nextStatus = isAdminAction ? 'SAP_PUSHED' : getNextStatus(bomReq.status);
if (!nextStatus) return res.status(400).json({ success: false, message: 'No next status available' });
let sapResult = null;
if (isFinal) {
try { sapResult = await pushBomToSap(bomReq); }
catch (sapErr) { return res.status(500).json({ success: false, message: 'SAP push failed: ' + sapErr.message }); }
}
await store.updateRequest(bomReq.id, {
status: isFinal ? 'SAP_PUSHED' : nextStatus,
approvalLog: [...(bomReq.approvalLog || []), logEntry],
...(isFinal ? { sapPushedAt: new Date().toISOString(), sapPushedBy: req.user.username, sapResult } : {}),
});
res.json({ success: true, message: isFinal ? `BOM ${bomReq.type === 'CREATE' ? 'created' : 'updated'} in SAP B1!` : `Approved moved to ${nextStatus}`, status: isFinal ? 'SAP_PUSHED' : nextStatus, sapResult });
} catch (err) {
console.error('[BOM-APPROVAL] error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
});
async function pushBomToSap(bomReq) {
const sap = getSap();
if (!sap) throw new Error('SAP service not available');
const components = bomReq.components || [];
if (bomReq.type === 'CREATE') {
const productTreeLines = components.map((c, idx) => {
const line = { ItemCode: c.itemCode?.trim().toUpperCase(), Quantity: Number(c.qty) || 1, IssueMethod: ISSUE_METHOD_MAP[c.issueMethod] || 'im_Manual', ItemType: c.itemType === 'pit_Resource' ? 'pit_Resource' : 'pit_Item', VisualOrder: idx, PriceList: -1 };
if (c.warehouse?.trim()) line.Warehouse = c.warehouse.trim();
else if (bomReq.warehouse?.trim()) line.Warehouse = bomReq.warehouse.trim();
if (Number(c.unitCost) > 0) { line.Price = Number(c.unitCost); line.Currency = 'INR'; }
if (c.note?.trim()) line.Comment = c.note.trim().slice(0, 100);
return line;
});
const payload = { TreeCode: bomReq.itemCode, TreeType: TREE_TYPE_MAP[bomReq.bomType?.toLowerCase()] || 'iProductionTree', Quantity: Number(bomReq.qty) || 1, ProductDescription: bomReq.itemName.slice(0, 100), PriceList: -1, ProductTreeLines: productTreeLines };
if (bomReq.warehouse?.trim()) payload.Warehouse = bomReq.warehouse.trim();
if (bomReq.distrRule?.trim()) payload.DistributionRule = bomReq.distrRule.trim();
if (bomReq.project?.trim()) payload.Project = bomReq.project.trim();
const co = bomReq.company || null;
const result = await sap.sapRequest('POST', 'ProductTrees', payload, co);
return { treeCode: result?.TreeCode || bomReq.itemCode, operation: 'CREATED' };
} else {
const code = bomReq.itemCode;
const co = bomReq.company || null;
const payload = { TreeType: TREE_TYPE_MAP[bomReq.bomType?.toLowerCase()] || 'iProductionTree', Quantity: Number(bomReq.qty) || 1, Warehouse: bomReq.warehouse || '', PriceList: -1 };
if (bomReq.itemName) payload.ProductDescription = bomReq.itemName.slice(0, 100);
payload.ProductTreeLines = components.map((c, idx) => {
const line = { ItemCode: c.itemCode.toUpperCase(), Quantity: Number(c.qty) || 1, IssueMethod: ISSUE_METHOD_MAP[c.issueMethod] || 'im_Manual', ItemType: c.itemType === 'pit_Resource' ? 'pit_Resource' : 'pit_Item', VisualOrder: c.visualOrder !== undefined ? c.visualOrder : idx, PriceList: -1 };
if (c.warehouse?.trim()) line.Warehouse = c.warehouse.trim();
else if (bomReq.warehouse?.trim()) line.Warehouse = bomReq.warehouse.trim();
return line;
});
await sap.sapRequest('PUT', `ProductTrees('${encodeURIComponent(code)}')`, payload, co);
return { treeCode: code, operation: 'UPDATED' };
}
}
bomRequestRouter.delete('/:id', verifyToken, async (req, res) => {
try {
const store = getBomStore();
const bomReq = await store.findById(req.params.id);
if (!bomReq) return res.status(404).json({ success: false, message: 'Request not found' });
if (bomReq.submittedBy !== req.user.username && req.user.role !== 'sap_adder' && req.user.role !== 'admin')
return res.status(403).json({ success: false, message: 'Can only cancel your own requests' });
if (bomReq.status !== 'PENDING')
return res.status(400).json({ success: false, message: `Cannot cancel request in status: ${bomReq.status}` });
await store.updateRequest(bomReq.id, { status: 'CANCELLED' });
res.json({ success: true, message: 'Request cancelled' });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
app.use('/api/bom-requests', bomRequestRouter);
// ════════════════════════════════════════════════════════════════
// SAP LOOKUPS + GRPO /api/sap ← single router from routes/sap.js
// This replaces ALL inline sapRouter.get() definitions.
// routes/sap.js handles: items, warehouses, gl-accounts, tax-codes,
// costing-codes, branches, customers, vendors, sales-employees,
// payment-terms, ar-accounts, ap-accounts, states, bom/list,
// bom/:treeCode, bp-groups, and POST /grpo
// ════════════════════════════════════════════════════════════════
app.use('/api/sap', require('./routes/sap'));
app.use('/api/gstr1', require('./routes/gstr1'));
app.use('/api/gstr2', require('./routes/gstr2'));
app.use('/api/itc04', require('./routes/itc04'));
app.use('/api/projects',require('./routes/projects'));
app.use('/api/costing', require('./routes/costing'));
app.use('/api/balance-sheet', require('./routes/balanceSheet'));
app.use('/api/cash-flow', require('./routes/cashFlow'));
app.use('/api/salary', require('./routes/salary'));
app.use('/api/costsheet', require('./routes/costsheet'));
app.use('/api/chat', require('./routes/chatbot'));
app.use('/api/reports', require('./routes/reports'));
app.use('/api/ppc', require('./routes/ppc'));
app.use('/api/pwo-source-audit', require('./routes/pwoSourceAudit'));
app.use('/api/inventory-status-report', require('./routes/inventoryStatusReport'));
app.use('/api/batch-no-transaction', require('./routes/batchNoTransaction'));
app.use('/api/inventory-transfer', require('./routes/inventoryTransfer'));
app.use('/api/inventory-posting-list', require('./routes/inventoryPostingList'));
app.use('/api/general-ledger', require('./routes/generalLedger'));
// ITEMS
app.use('/api/item-approvals', require('./routes/itemApproval'));
// ════════════════════════════════════════════════════════════════
// VENDORS /api/vendors
// ════════════════════════════════════════════════════════════════
app.use('/api/vendors', require('./routes/vendors'));
app.use('/api/business-master', require('./routes/businessMaster'));
app.use('/api/requirements', require('./routes/requirements'));
app.use('/api/requirement-calc', require('./routes/requirementCalc'));
app.use('/api/batch-intimations', require('./routes/batchIntimations'));
app.use('/api/work-orders', require('./routes/workOrders'));
app.use('/api/production-orders', require('./routes/productionOrders'));
app.use('/api/pre-production-orders', require('./routes/prePwo'));
app.use('/api/wo-header-profiles', require('./routes/woHeaderProfiles'));
app.use('/api/board', require('./routes/board'));
app.use('/api/production-dashboard', require('./routes/prodDashboard'));
app.use('/api/item-group-classification', require('./routes/itemGroupClassification'));
app.use('/api/rm-ovg-settings', require('./routes/rmOvgSettings'));
app.use('/api/rejection-register', require('./routes/rejectionRegister'));
app.use('/api/production-planning', require('./routes/productionPlanning'));
app.use('/api/password-reset', require('./routes/passwordReset'));
app.use('/api/notifications', require('./routes/notifications'));
app.use('/api/settings', require('./routes/appSettings'));
app.use('/api/manpower', require('./routes/manPower'));
app.use('/api/oee', require('./routes/oee'));
app.use('/api/downtime-analysis', require('./routes/downtimeAnalysis'));
app.use('/api/audit', require('./routes/audit'));
app.use('/api/mail-logs', require('./routes/mailLogs'));
app.use('/api/sales', require('./routes/sales'));
app.use('/api/sales-portal', require('./routes/salesPortal'));
app.use('/api/sales-ext', require('./routes/salesExt'));
// ── Warehouse → Transaction Type mapping (Receipt from Production) ──────────
const whTranTypeStore = () => require('./services/warehouseTranTypeStore');
app.get('/api/warehouse-trantype', verifyToken, async (req, res) => {
try { res.json({ success: true, data: await whTranTypeStore().getMap(req.query.company || '') }); }
catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
app.put('/api/warehouse-trantype', verifyToken, require('./middleware/auth').verifyUserAdmin, async (req, res) => {
try {
const { company, map } = req.body || {};
const r = await whTranTypeStore().setMap(company || '', map || {});
res.json({ success: true, ...r });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Vendor BP groups (vendor-specific, kept here for vendor-register page)
app.get('/api/vendors/lookup/bp-groups', verifyToken, async (req, res) => {
try {
const sap = getSap();
if (!sap) return res.json({ success: true, data: [] });
const result = await sap.sapRequest('GET',
`BusinessPartnerGroups?$filter=Type eq 'bbpgt_VendorGroup'&$select=Code,Name&$orderby=Name`
);
res.json({ success: true, data: (result?.value || []).map(r => ({ GroupCode: r.Code, GroupName: r.Name })) });
} catch (err) { res.json({ success: true, data: [], warning: err.message }); }
});
app.get('/api/vendors/lookup/ap-accounts', verifyToken, (req, res) =>
safeLookup(res,
`SELECT "AcctCode","AcctName" FROM ${DB()}."OACT" WHERE "FatherNum"='2101000' ORDER BY "AcctCode"`,
r => ({ AcctCode: r.AcctCode, AcctName: r.AcctName })
)
);
// ════════════════════════════════════════════════════════════════
// CUSTOMERS /api/customers
// ════════════════════════════════════════════════════════════════
const CUSTOMER_EDITABLE = [
'cardName','foreignName','typeOfBusiness','industry','mobile','email','website',
'contactFirst','contactLast','contactMobile','contactEmail','contactTitle',
'currency','gstin','pan','remarks','hasMsme','msmeNo','msmeType','msmeBType','attachments',
'billAddressName','billStreet','billBlock','billCity','billZip','billState','billCountry',
'shipAddressName','shipStreet','shipBlock','shipCity','shipZip','shipState','shipCountry',
'sameAsBill','allBillAddresses','allShipAddresses',
];
const MANAGER_EDITABLE = [
'mgrCardCodePrefix','mgrGroupCode','mgrGroup','mgrCurrency','mgrChain','mgrMainGroup',
'mgrBranch','mgrCountry','mgrCity','mgrZone','mgrArea','mgrSubarea','mgrCountryHead',
'mgrRsm','mgrAsm','mgrSo','mgrSr','mgrPromoter','mgrSalesEmployee','mgrSalesPersonCode',
'mgrSchemeType','mgrTerritory','mgrNotes','mgrCreditLimit','mgrPayTerms','mgrPayTermsCode',
'mgrArAccount','mgrArAccountName','mgrLanguage',
];
const ALL_EDITABLE = [...CUSTOMER_EDITABLE, ...MANAGER_EDITABLE];
function extractPatch(body, fields) {
const patch = {};
fields.forEach(f => { if (body[f] !== undefined) patch[f] = body[f]; });
return patch;
}
function mapCurrency(label) {
return { 'Indian Rupee':'INR','US Dollar':'USD','Euro':'EUR','British Pound':'GBP','UAE Dirham':'AED' }[label] || 'INR';
}
function mapCountryCode(name) {
return { 'India':'IN','United States':'US','United Kingdom':'GB','UAE':'AE','Singapore':'SG','Germany':'DE','Japan':'JP','Australia':'AU' }[name] || 'IN';
}
async function doCreateCustomerInSAP(store, sap, c, patch, req, companyDB) {
const merged = { ...c, ...patch };
const prefix = merged.mgrCardCodePrefix || 'CUSTA';
const cardCode = await sap.getNextCardCode(prefix, companyDB);
const payTermsGrpCode = merged.mgrPayTermsCode && !isNaN(parseInt(merged.mgrPayTermsCode)) ? parseInt(merged.mgrPayTermsCode) : null;
const salesPersonCode = merged.mgrSalesPersonCode && !isNaN(parseInt(merged.mgrSalesPersonCode)) ? parseInt(merged.mgrSalesPersonCode) : null;
const groupCode = merged.mgrGroupCode && !isNaN(parseInt(merged.mgrGroupCode)) ? parseInt(merged.mgrGroupCode) : null;
const result = await sap.createCustomer({
cardCode, cardName: merged.cardName, currency: mapCurrency(merged.mgrCurrency || merged.currency),
phone1: merged.mobile, email: merged.email, website: merged.website,
creditLimit: parseFloat(merged.mgrCreditLimit) || 0, remarks: merged.remarks,
typeOfBusiness: merged.typeOfBusiness, groupCode, payTermsGrpCode, salesPersonCode,
contactFirst: merged.contactFirst, contactLast: merged.contactLast,
contactMobile: merged.contactMobile || merged.mobile, contactEmail: merged.contactEmail || merged.email,
contactTitle: merged.contactTitle, billAddressName: merged.billAddressName,
billStreet: merged.billStreet, billBlock: merged.billBlock, billCity: merged.billCity,
billZip: merged.billZip, billState: merged.billState, billCountry: mapCountryCode(merged.billCountry),
shipAddressName: merged.shipAddressName, shipStreet: merged.shipStreet, shipBlock: merged.shipBlock,
shipCity: merged.shipCity, shipZip: merged.shipZip, shipState: merged.shipState,
shipCountry: mapCountryCode(merged.shipCountry),
allBillAddresses: merged.allBillAddresses || [], allShipAddresses: merged.allShipAddresses || [],
mgrMainGroup: merged.mgrMainGroup, mgrChain: merged.mgrChain, mgrArAccount: merged.mgrArAccount,
hasMsme: merged.hasMsme, msmeNo: merged.msmeNo || '', msmeType: merged.msmeType || '',
msmeBType: merged.msmeBType || '', gstin: merged.gstin, pan: merged.pan,
attachments: merged.attachments || {},
}, companyDB);
await store.updateCustomer(c.id, {
...patch, status: 'APPROVED', sapCardCode: cardCode,
approvedAt: new Date().toISOString(), approvedBy: req.user.username,
sapAttachmentEntry: result?.attachmentEntry || null,
}, companyDB);
return { cardCode, cardName: merged.cardName };
}
const custRouter = express.Router();
// Lookup routes BEFORE /:id
custRouter.get('/lookup/bp-groups', verifyToken, async (req, res) => {
try {
const sap = getSap();
if (!sap) return res.json({ success: true, data: [] });
const result = await sap.sapRequest('GET', `BusinessPartnerGroups?$filter=Type eq 'bbpgt_CustomerGroup'&$select=Code,Name&$orderby=Name`);
res.json({ success: true, data: (result?.value || []).map(r => ({ GroupCode: r.Code, GroupName: r.Name })) });
} catch (err) { res.json({ success: true, data: [], warning: err.message }); }
});
custRouter.get('/lookup/payment-terms', verifyToken, (req, res) =>
safeLookup(res, `SELECT "GroupNum","PymntGroup" FROM ${DB()}."OCTG" ORDER BY "PymntGroup"`, r => ({ Code: r.GroupNum, Name: r.PymntGroup }))
);
custRouter.get('/lookup/sales-employees', verifyToken, (req, res) =>
safeLookup(res, `SELECT "SlpCode","SlpName" FROM ${DB()}."OSLP" WHERE "SlpCode">0 AND "Locked"='N' ORDER BY "SlpName"`, r => ({ SlpCode: r.SlpCode, SlpName: r.SlpName }))
);
custRouter.get('/lookup/ar-accounts', verifyToken, (req, res) =>
safeLookup(res, `SELECT "AcctCode","AcctName" FROM ${DB()}."OACT" WHERE "FatherNum"='1101000' ORDER BY "AcctCode"`, r => ({ AcctCode: r.AcctCode, AcctName: r.AcctName }))
);
custRouter.get('/lookup/main-group', verifyToken, (req, res) =>
safeLookup(res, `SELECT "Code","Name" FROM ${DB()}."@MAIN_GROUP" ORDER BY "Code"`, r => ({ Code: r.Code, Name: r.Name || r.Code }))
);
custRouter.get('/lookup/chain', verifyToken, (req, res) =>
safeLookup(res, `SELECT "Code","Name" FROM ${DB()}."@CHAIN" ORDER BY "Code"`, r => ({ Code: r.Code, Name: r.Name || r.Code }))
);
custRouter.get('/next-cardcode', verifyToken, async (req, res) => {
const { prefix = 'CUSTA' } = req.query;
try {
const sap = getSap();
if (!sap) return res.status(503).json({ success: false, message: 'SAP service not ready' });
const cardCode = await sap.getNextCardCode(prefix);
res.json({ success: true, cardCode });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
custRouter.get('/lookup/states', verifyToken, async (req, res) => {
try {
const sap = getSap();
if (!sap) return res.json({ success: true, data: [] });
let allStates = [], url = `States?$filter=Country eq 'IN'&$select=Code,Name&$orderby=Name`;
while (url) {
const result = await sap.sapRequest('GET', url);
allStates = allStates.concat(result?.value || []);
const nextLink = result?.['@odata.nextLink'];
url = nextLink ? nextLink.replace(/^.*\/b1s\/v2\//, '') : null;
}
res.json({ success: true, data: allStates.map(r => ({ Code: r.Code, Name: r.Name })) });
} catch (err) { res.json({ success: true, data: [], warning: err.message }); }
});
custRouter.post('/submit', [
body('cardName').notEmpty().trim(), body('email').isEmail().normalizeEmail(),
body('mobile').notEmpty().trim(), body('contactFirst').notEmpty().trim(),
body('contactLast').notEmpty().trim(), body('billStreet').notEmpty().trim(),
body('billCity').notEmpty().trim(),
], async (req, res) => {
const errs = validationResult(req);
if (!errs.isEmpty()) return res.status(400).json({ success: false, errors: errs.array() });
const b = req.body;
const companyDB = resolveCompany(b.company);
try {
const store = getStore();
if (!store) return res.status(503).json({ success: false, message: 'Database not ready' });
const customer = await store.insertCustomer({
customerType: b.customerType || 'B2B', cardName: b.cardName, foreignName: b.foreignName || '',
typeOfBusiness: b.typeOfBusiness || 'Company', industry: b.industry || '',
mobile: b.mobile, email: b.email, website: b.website || '',
contactFirst: b.contactFirst, contactLast: b.contactLast,
contactMobile: b.contactMobile || b.mobile, contactEmail: b.contactEmail || b.email || '',
contactTitle: b.contactTitle || '',
billAddressName: b.billAddressName || b.cardName, billStreet: b.billStreet,
billBlock: b.billBlock || '', billCity: b.billCity, billZip: b.billZip || '',
billState: b.billState || '', billCountry: b.billCountry || 'India',
sameAsBill: b.sameAsBill || false,
shipAddressName: b.sameAsBill ? (b.billAddressName || b.cardName) : (b.shipAddressName || b.billAddressName || b.cardName),
shipStreet: b.sameAsBill ? b.billStreet : (b.shipStreet || b.billStreet),
shipBlock: b.sameAsBill ? b.billBlock : (b.shipBlock || b.billBlock || ''),
shipCity: b.sameAsBill ? b.billCity : (b.shipCity || b.billCity),
shipZip: b.sameAsBill ? b.billZip : (b.shipZip || b.billZip || ''),
shipState: b.sameAsBill ? b.billState : (b.shipState || b.billState || ''),
shipCountry: b.sameAsBill ? b.billCountry : (b.shipCountry || b.billCountry || 'India'),
allBillAddresses: Array.isArray(b.allBillAddresses) ? b.allBillAddresses : [],
allShipAddresses: Array.isArray(b.allShipAddresses) ? b.allShipAddresses : [],
currency: b.currency || 'Indian Rupee', gstin: b.gstin || '', pan: b.pan || '',
remarks: b.remarks || '', hasMsme: b.hasMsme || false, msmeNo: b.msmeNo || '',
msmeType: b.msmeType || '', msmeBType: b.msmeBType || '', attachments: b.attachments || {},
mgrCardCodePrefix: 'CUSTA', mgrArAccount: '1101001',
mgrArAccountName: 'SUNDRY DEBTORS GT', mgrCurrency: 'Indian Rupee', mgrLanguage: 'English (UK)',
company: companyDB,
}, companyDB);
res.json({ success: true, message: 'Submitted', id: customer.id });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
custRouter.post('/direct-approve', verifyToken, requireRole('admin'), async (req, res) => {
try {
const store = getStore(); const sap = getSap();
if (!store || !sap) return res.status(503).json({ success: false, message: 'Service not ready' });
const b = req.body;
const companyDB = resolveCompany(b.company);
const customer = await store.insertCustomer({
customerType: b.customerType || 'B2B', cardName: b.cardName, foreignName: b.foreignName || '',
typeOfBusiness: b.typeOfBusiness || 'Company', industry: b.industry || '',
mobile: b.mobile, email: b.email, website: b.website || '',
contactFirst: b.contactFirst, contactLast: b.contactLast,
contactMobile: b.contactMobile || b.mobile, contactEmail: b.contactEmail || b.email || '',
contactTitle: b.contactTitle || '',
billAddressName: b.billAddressName || b.cardName, billStreet: b.billStreet || '',
billBlock: b.billBlock || '', billCity: b.billCity || '', billZip: b.billZip || '',
billState: b.billState || '', billCountry: b.billCountry || 'India',
sameAsBill: b.sameAsBill || false,
shipAddressName: b.shipAddressName || b.billAddressName || b.cardName,
shipStreet: b.shipStreet || b.billStreet || '', shipBlock: b.shipBlock || b.billBlock || '',
shipCity: b.shipCity || b.billCity || '', shipZip: b.shipZip || b.billZip || '',
shipState: b.shipState || b.billState || '', shipCountry: b.shipCountry || b.billCountry || 'India',
allBillAddresses: Array.isArray(b.allBillAddresses) ? b.allBillAddresses : [],
allShipAddresses: Array.isArray(b.allShipAddresses) ? b.allShipAddresses : [],
currency: b.currency || 'Indian Rupee', gstin: b.gstin || '', pan: b.pan || '',
remarks: b.remarks || '', hasMsme: b.hasMsme || false, msmeNo: b.msmeNo || '',
msmeType: b.msmeType || '', msmeBType: b.msmeBType || '', attachments: b.attachments || {},
mgrCardCodePrefix: b.mgrCardCodePrefix || 'CUSTA', mgrArAccount: b.mgrArAccount || '1101001',
mgrArAccountName: b.mgrArAccountName || 'SUNDRY DEBTORS GT',
mgrCurrency: b.mgrCurrency || b.currency || 'Indian Rupee', mgrLanguage: b.mgrLanguage || 'English (UK)',
mgrGroupCode: b.mgrGroupCode || null, mgrGroup: b.mgrGroup || '',
mgrChain: b.mgrChain || '', mgrMainGroup: b.mgrMainGroup || '',
mgrSalesEmployee: b.mgrSalesEmployee || '', mgrSalesPersonCode: b.mgrSalesPersonCode || null,
mgrPayTerms: b.mgrPayTerms || '', mgrPayTermsCode: b.mgrPayTermsCode || null,
mgrCreditLimit: b.mgrCreditLimit || 0, status: 'VERIFIED',
company: companyDB,
}, companyDB);
const { cardCode, cardName } = await doCreateCustomerInSAP(store, sap, customer, b, req, companyDB);
res.json({ success: true, message: 'Customer created directly in SAP B1!', cardCode, cardName });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
custRouter.get('/', verifyToken, async (req, res) => {
const st = (req.query.status || 'PENDING').toUpperCase();
const companyDB = resolveCompany(req.query.company);
try {
const data = await getStore().listByStatus(st, companyDB);
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
custRouter.get('/:id', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.query.company);
try {
const c = await getStore().findById(req.params.id, companyDB);
if (!c) return res.status(404).json({ success: false, message: 'Not found' });
res.json({ success: true, data: c });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
custRouter.patch('/:id/verify', verifyToken, requireApprovalStep('customer_vendor:verify', 'approve'), async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const c = await getStore().findById(req.params.id, companyDB);
if (!c) return res.status(404).json({ success: false, message: 'Not found' });
if (c.status !== 'PENDING')
return res.status(400).json({ success: false, message: 'Only PENDING can be verified. Current: ' + c.status });
const patch = extractPatch(req.body, ALL_EDITABLE);
patch.status = req.body.approved ? 'VERIFIED' : 'REJECTED';
patch.verifiedAt = new Date().toISOString();
patch.verifiedBy = req.user.username;
await getStore().updateCustomer(c.id, patch, companyDB);
res.json({ success: true, message: `Customer ${patch.status.toLowerCase()}` });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
custRouter.patch('/:id/approve', verifyToken, requireApprovalStep('customer_vendor:approve', 'approve'), async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const store = getStore(); const sap = getSap();
const c = await store.findById(req.params.id, companyDB);
if (!c) return res.status(404).json({ success: false, message: 'Not found' });
if (!req.body.approved) {
await store.updateCustomer(c.id, { status: 'REJECTED', rejectedBy: req.user.username, rejectedAt: new Date().toISOString() }, companyDB);
return res.json({ success: true, message: 'Customer rejected' });
}
if (c.status !== 'VERIFIED')
return res.status(400).json({ success: false, message: `Must be VERIFIED. Current: ${c.status}` });
const patch = extractPatch(req.body, ALL_EDITABLE);
const { cardCode, cardName } = await doCreateCustomerInSAP(store, sap, c, patch, req, companyDB);
res.json({ success: true, message: 'Customer created in SAP B1!', cardCode, cardName });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
custRouter.patch('/:id/admin-approve', verifyToken, requireRole('admin'), async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const store = getStore(); const sap = getSap();
const c = await store.findById(req.params.id, companyDB);
if (!c) return res.status(404).json({ success: false, message: 'Not found' });
if (c.status === 'APPROVED') return res.status(400).json({ success: false, message: 'Already approved' });
if (c.status === 'REJECTED') return res.status(400).json({ success: false, message: 'Cannot approve rejected customer' });
await store.updateCustomer(c.id, { status: 'VERIFIED' }, companyDB);
const refreshed = await store.findById(c.id, companyDB);
const patch = extractPatch(req.body, ALL_EDITABLE);
const { cardCode, cardName } = await doCreateCustomerInSAP(store, sap, refreshed, patch, req, companyDB);
res.json({ success: true, message: 'Customer approved and pushed to SAP B1!', cardCode, cardName });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
custRouter.patch('/:id/draft', verifyToken, async (req, res) => {
const companyDB = resolveCompany(req.body.company);
try {
const c = await getStore().findById(req.params.id, companyDB);
if (!c) return res.status(404).json({ success: false, message: 'Not found' });
if (c.status === 'APPROVED' || c.status === 'REJECTED')
return res.status(400).json({ success: false, message: 'Cannot edit ' + c.status + ' records' });
const patch = extractPatch(req.body, ALL_EDITABLE);
await getStore().updateCustomer(c.id, patch, companyDB);
res.json({ success: true, message: 'Draft saved' });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
app.use('/api/customers', custRouter);
// ════════════════════════════════════════════════════════════════
// HEALTH & STATS
// ════════════════════════════════════════════════════════════════
app.get('/api/health', (req, res) =>
res.json({ status: 'ok', hana: global._hanaReady === true, approvalLevels: APPROVAL_LEVELS(), time: new Date().toISOString() })
);
app.get('/api/stats', verifyToken, async (req, res) => {
try {
const store = getBomStore();
if (!store) return res.json({ success: true, data: {} });
const stats = await store.getStats();
res.json({ success: true, data: stats });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ════════════════════════════════════════════════════════════════
// 404 + ERROR HANDLERS (must be LAST)
// ════════════════════════════════════════════════════════════════
app.use((req, res) => res.status(404).json({ success: false, message: 'Not found: ' + req.path }));
app.use((err, req, res, next) => {
console.error('[SERVER] Error:', err.message);
if (err.type === 'entity.too.large')
return res.status(413).json({ success: false, message: 'Request too large.' });
res.status(500).json({ success: false, message: err.message || 'Internal server error' });
});
// ════════════════════════════════════════════════════════════════
// START SERVER
// ════════════════════════════════════════════════════════════════
function getLocalIP() {
try {
const ifaces = require('os').networkInterfaces();
for (const name of Object.keys(ifaces))
for (const iface of ifaces[name])
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
} catch {}
return '0.0.0.0';
}
const PORT = process.env.PORT || 5000;
app.listen(PORT, '0.0.0.0', () => {
const ip = getLocalIP();
console.log(`\nMITRA ERP Portal Multi-Level BOM Approval`);
console.log(` Approval Levels : ${APPROVAL_LEVELS()}`);
console.log(` Local : http://localhost:${PORT}`);
console.log(` Network : http://${ip}:${PORT}`);
console.log(` Customer Form : http://${ip}:${PORT}/register`);
console.log(` BOM Portal : http://${ip}:${PORT}/bom`);
console.log(` Approvals : http://${ip}:${PORT}/approvals`);
console.log(` Admin : http://${ip}:${PORT}/admin`);
console.log(` Connecting to database...\n`);
runBootstrap();
});
async function runBootstrap() {
try {
hanaStore = require('./services/hanaStore');
hanaUsers = require('./services/hanaUsers');
sapSvc = require('./services/sapServiceLayer');
bomStore = require('./services/bomRequestStore');
hanaVendorStore = require('./services/hanaVendorStore');
const hanaItemStore = require('./services/hanaItemStore');
await hanaStore.bootstrap();
await require('./services/approvalStepsStore').bootstrap();
await hanaUsers.bootstrap();
await bomStore.bootstrap();
await hanaVendorStore.bootstrap();
await hanaItemStore.bootstrap();
await require('./services/projectStore').bootstrap();
await require('./services/costingStore').bootstrap();
await require('./services/costingStore').bootstrapSnapshots();
await require('./services/cashFlowStore').bootstrap();
await require('./services/salaryStore').bootstrap();
await require('./services/requirementStore').bootstrap();
await require('./services/reqCalcGroupStore').bootstrap();
await require('./services/batchIntimationStore').bootstrap();
await require('./services/workOrderStore').bootstrap();
await require('./services/productionOrderStore').bootstrap();
await require('./services/prePwoStore').bootstrap();
await require('./services/woHeaderProfileStore').bootstrap();
await require('./services/deviationStore').bootstrap();
await require('./services/itemGroupClassStore').bootstrap();
await require('./services/rmOvgSettingsStore').bootstrap();
await require('./services/rejectionRegisterStore').bootstrap();
await require('./services/productionPlanningStore').bootstrap();
await require('./services/productionPlanningItemDefaultsStore').bootstrap();
await require('./services/passwordResetStore').bootstrap();
await require('./services/autoclaveRejectionStore').bootstrap();
await require('./services/warehouseTranTypeStore').bootstrap();
await require('./services/oeeStore').bootstrap();
await require('./services/auditStore').bootstrap();
await require('./services/mailLogStore').bootstrap();
await require('./services/shortStockAlertStore').bootstrap();
await require('./services/sales/schema').bootstrap();
await appSettings.bootstrap();
console.log(require('./services/mailer').isConfigured()
? '[MAILER] SMTP configured — stage-change emails enabled'
: '[MAILER] SMTP not configured (.env SMTP_HOST) — stage-change emails will be skipped');
// Short in Stock — Auto Email Alerts: an independent background poll,
// not tied to any user request. Runs once shortly after boot (so a
// watch-list defined before a restart doesn't wait a full interval to
// first fire), then every 15 minutes. checkShortStock() itself no-ops
// instantly when the feature is off or has no rules defined, so this is
// cheap to just always have running.
setTimeout(() => require('./services/shortStockAlertStore').checkShortStock(), 30000);
setInterval(() => require('./services/shortStockAlertStore').checkShortStock(), 15 * 60 * 1000);
// Sales Orders ↔ SAP: link orders already present in SAP (status 7 → 8)
// and close fully-invoiced ones (8 → 9). Replaces msale's external sync job.
const salesSync = () => require('./services/sales/orders').syncWithSap()
.then(s => { if (s && (s.linked || s.closed || s.invoiceEmails)) console.log(`[SALES] SAP sync: ${s.linked} linked, ${s.closed} closed, ${s.invoiceEmails || 0} invoice email(s)`); })
.catch(e => console.warn('[SALES] SAP sync failed:', e.message));
setTimeout(salesSync, 60000);
setInterval(salesSync, 15 * 60 * 1000); // also drives the "invoice raised" emails
global._hanaReady = true;
console.log('Database ready — all features available\n');
} catch (err) {
console.error(`\nDatabase error: ${err.message}`);
console.error(' Retrying in 30s...\n');
setTimeout(runBootstrap, 30000);
}
}