1710 lines
104 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
|