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

This commit is contained in:
John
2026-10-05 18:45:17 +05:30
parent e725a6571b
commit eead8f5ffd
129 changed files with 14252 additions and 452 deletions
+305
View File
@@ -0,0 +1,305 @@
// services/sales/masters.js
// Settings, divisions, product catalog, Sales User Types (permission
// templates), per-employee sales profiles, sales-person ↔ customer mapping
// and customer portal accounts for the Sales Order module.
'use strict';
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { query, one, json, csv } = require('./db');
const { DEFAULT_SETTINGS } = require('./constants');
const { DEFAULT_COMPANY } = require('../companyConfig');
// ── Settings (cached 30s) ───────────────────────────────────────────────────
let _settings = null, _settingsAt = 0;
async function getSettings(force = false) {
if (!force && _settings && Date.now() - _settingsAt < 30000) return _settings;
const rows = await query(`SELECT SKEY, SVALUE FROM dbo.ZSO_SETTINGS`);
const s = { ...DEFAULT_SETTINGS };
rows.forEach(r => { s[r.SKEY] = json(r.SVALUE, r.SVALUE); });
if (!s.company) s.company = DEFAULT_COMPANY;
_settings = s; _settingsAt = Date.now();
return s;
}
async function saveSettings(patch, by) {
for (const [k, v] of Object.entries(patch || {})) {
if (!(k in DEFAULT_SETTINGS)) continue;
await query(`MERGE dbo.ZSO_SETTINGS AS t USING (SELECT ? AS K) s ON t.SKEY=s.K
WHEN MATCHED THEN UPDATE SET SVALUE=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY=?
WHEN NOT MATCHED THEN INSERT (SKEY,SVALUE,UPDATED_BY) VALUES (?,?,?);`, [k, JSON.stringify(v), by, k, JSON.stringify(v), by]);
}
return getSettings(true);
}
// Settings safe to show to every logged-in employee/customer (no API keys).
function publicSettings(s) { const { bankApiKey, ...rest } = s; return rest; }
// ── Divisions ───────────────────────────────────────────────────────────────
function divFromRow(r) { return { id: r.ID, name: r.NAME, code: r.CODE || '', itemGroups: r.ITEM_GROUPS || '', warehouse: r.WAREHOUSE || '', sort: r.SORT || 0, active: !!r.ACTIVE }; }
async function listDivisions(includeInactive = false) {
const rows = await query(`SELECT * FROM dbo.ZSO_DIVISIONS ${includeInactive ? '' : 'WHERE ACTIVE=1'} ORDER BY SORT, ID`);
return rows.map(divFromRow);
}
async function saveDivision(d) {
const id = parseInt(d.id);
if (!id) throw new Error('Division ID is required');
await query(`MERGE dbo.ZSO_DIVISIONS AS t USING (SELECT ? AS ID) s ON t.ID=s.ID
WHEN MATCHED THEN UPDATE SET NAME=?, CODE=?, ITEM_GROUPS=?, WAREHOUSE=?, SORT=?, ACTIVE=?
WHEN NOT MATCHED THEN INSERT (ID,NAME,CODE,ITEM_GROUPS,WAREHOUSE,SORT,ACTIVE) VALUES (?,?,?,?,?,?,?);`,
[id, d.name, d.code || '', d.itemGroups || '', d.warehouse || '', parseInt(d.sort) || 0, d.active !== false,
id, d.name, d.code || '', d.itemGroups || '', d.warehouse || '', parseInt(d.sort) || 0, d.active !== false]);
}
async function getDivision(id) { const r = await one(`SELECT * FROM dbo.ZSO_DIVISIONS WHERE ID=?`, [parseInt(id)]); return r ? divFromRow(r) : null; }
// ── Products ────────────────────────────────────────────────────────────────
// Two sources (Admin → Settings → Product source):
// catalog — ZSO_PRODUCTS, maintained in the Sales admin screen (msale's
// product + price_list tables, migrated)
// sap — live OITM items of the division's SAP item groups, priced from
// the configured SAP price list
// Either way callers get the same shape, keyed by `key` (P:<id> / S:<code>).
function prodFromRow(r) {
return { key: `P:${r.ID}`, id: r.ID, catalog: r.CATALOG, divisionId: r.DIVISION_ID, itemCode: r.ITEM_CODE, displayCode: r.DISPLAY_CODE || r.ITEM_CODE,
description: r.DESCRIPTION || '', shortDesc: r.SHORT_DESC || '', packSize: r.PACK_SIZE || 1, hsn: r.HSN || '', price: Number(r.UNIT_PRICE) || 0,
isInstrument: !!r.IS_INSTRUMENT, active: !!r.ACTIVE };
}
async function listCatalog({ catalog = 'domestic', divisionId, includeInactive = false } = {}) {
const w = ['CATALOG=?']; const p = [catalog];
if (divisionId) { w.push('DIVISION_ID=?'); p.push(parseInt(divisionId)); }
if (!includeInactive) w.push('ACTIVE=1');
const rows = await query(`SELECT * FROM dbo.ZSO_PRODUCTS WHERE ${w.join(' AND ')} ORDER BY DIVISION_ID, DISPLAY_CODE`, p);
return rows.map(prodFromRow);
}
async function saveProduct(d, by) {
const vals = [d.catalog || 'domestic', parseInt(d.divisionId), String(d.itemCode || '').trim(), d.displayCode || d.itemCode, d.description || '',
d.shortDesc || '', parseInt(d.packSize) || 1, d.hsn || '', Number(d.price) || 0, !!d.isInstrument, d.active !== false, by];
if (!vals[1] || !vals[2]) throw new Error('Division and SAP item code are required');
if (d.id) {
await query(`UPDATE dbo.ZSO_PRODUCTS SET CATALOG=?, DIVISION_ID=?, ITEM_CODE=?, DISPLAY_CODE=?, DESCRIPTION=?, SHORT_DESC=?, PACK_SIZE=?, HSN=?,
UNIT_PRICE=?, IS_INSTRUMENT=?, ACTIVE=?, UPDATED_BY=?, UPDATED_AT=SYSDATETIME() WHERE ID=?`, [...vals, parseInt(d.id)]);
return parseInt(d.id);
}
const r = await one(`INSERT INTO dbo.ZSO_PRODUCTS (CATALOG,DIVISION_ID,ITEM_CODE,DISPLAY_CODE,DESCRIPTION,SHORT_DESC,PACK_SIZE,HSN,UNIT_PRICE,IS_INSTRUMENT,ACTIVE,UPDATED_BY)
OUTPUT INSERTED.ID VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, vals);
return r.ID;
}
// Products orderable in a division, from whichever source is configured.
async function listOrderableProducts(divisionId, catalog = 'domestic') {
const s = await getSettings();
if (s.productSource === 'sap' && catalog === 'domestic') {
const div = await getDivision(divisionId);
return require('./sap').listSapProducts(s.company, csv(div && div.itemGroups), s.sapPriceList, divisionId);
}
return listCatalog({ catalog, divisionId });
}
// Resolve one product key → product (used to price order lines server-side;
// the client's price is never trusted).
async function resolveProduct(key, divisionId, catalog = 'domestic') {
if (String(key).startsWith('P:')) {
const r = await one(`SELECT * FROM dbo.ZSO_PRODUCTS WHERE ID=? AND ACTIVE=1`, [parseInt(String(key).slice(2))]);
return r ? prodFromRow(r) : null;
}
if (String(key).startsWith('S:')) {
const list = await listOrderableProducts(divisionId, catalog);
return list.find(p => p.key === key) || null;
}
return null;
}
// ── Sales User Types (permission templates) ─────────────────────────────────
function utFromRow(r) {
return { id: r.ID, name: r.NAME, scope: r.SCOPE, listStatuses: r.LIST_STATUSES || '', defaultSteps: json(r.DEFAULT_STEPS, []),
defaultModules: json(r.DEFAULT_MODULES, []), active: !!r.ACTIVE };
}
async function listUserTypes() { return (await query(`SELECT * FROM dbo.ZSO_USER_TYPES ORDER BY ID`)).map(utFromRow); }
async function getUserType(id) { const r = await one(`SELECT * FROM dbo.ZSO_USER_TYPES WHERE ID=?`, [parseInt(id)]); return r ? utFromRow(r) : null; }
async function saveUserType(d) {
const id = parseInt(d.id);
if (!id || !d.name) throw new Error('ID and name are required');
if (!['all', 'mapped', 'own'].includes(d.scope)) throw new Error('Scope must be all / mapped / own');
const steps = JSON.stringify(Array.isArray(d.defaultSteps) ? d.defaultSteps : []);
const mods = JSON.stringify(Array.isArray(d.defaultModules) ? d.defaultModules : []);
await query(`MERGE dbo.ZSO_USER_TYPES AS t USING (SELECT ? AS ID) s ON t.ID=s.ID
WHEN MATCHED THEN UPDATE SET NAME=?, SCOPE=?, LIST_STATUSES=?, DEFAULT_STEPS=?, DEFAULT_MODULES=?, ACTIVE=?
WHEN NOT MATCHED THEN INSERT (ID,NAME,SCOPE,LIST_STATUSES,DEFAULT_STEPS,DEFAULT_MODULES,ACTIVE) VALUES (?,?,?,?,?,?,?);`,
[id, d.name, d.scope, d.listStatuses || '', steps, mods, d.active !== false, id, d.name, d.scope, d.listStatuses || '', steps, mods, d.active !== false]);
}
// ── Employee sales profiles ─────────────────────────────────────────────────
function profFromRow(r) {
return { userId: r.USER_ID, userTypeId: r.USER_TYPE_ID, divisions: csv(r.DIVISIONS).map(Number), empCode: r.EMP_CODE || '',
territory: r.TERRITORY || '', ccEmails: r.CC_EMAILS || '' };
}
// Profile + resolved user type for an ERP user. ERP admins with no profile
// act as Superuser across every division.
const _profCache = new Map();
async function getProfile(user) {
const uid = parseInt(user && user.id);
const hit = _profCache.get(uid);
if (hit && Date.now() - hit.at < 20000) return hit.v;
const r = uid ? await one(`SELECT * FROM dbo.ZSO_USER_PROFILES WHERE USER_ID=?`, [uid]) : null;
let prof = r ? profFromRow(r) : null;
const ut = prof && prof.userTypeId ? await getUserType(prof.userTypeId) : null;
// All categories incl. disabled — disabling only stops NEW orders; existing
// ones in it must stay visible. (Creation checks division.active itself.)
const allDivs = (await listDivisions(true)).map(d => d.id);
const isAdmin = user && user.role === 'admin';
const v = {
userId: uid,
configured: !!prof,
userTypeId: prof ? prof.userTypeId : null,
userTypeName: ut ? ut.name : (isAdmin ? 'Administrator' : null),
scope: isAdmin ? 'all' : (ut ? ut.scope : 'all'),
listStatuses: ut ? csv(ut.listStatuses).map(Number) : [],
divisions: isAdmin ? allDivs : (prof && prof.divisions.length ? prof.divisions : []),
empCode: prof ? prof.empCode : '',
ccEmails: prof ? prof.ccEmails : '',
};
_profCache.set(uid, { at: Date.now(), v });
return v;
}
async function listProfiles() {
return (await query(`SELECT * FROM dbo.ZSO_USER_PROFILES`)).map(profFromRow);
}
// Save an employee's sales profile. With applyTemplate=true, the user type's
// default steps/modules are written onto the ERP user (replacing any existing
// sales_* steps and sales-* modules, leaving everything else untouched).
async function saveProfile(d, by) {
const uid = parseInt(d.userId);
if (!uid) throw new Error('userId is required');
const divs = (Array.isArray(d.divisions) ? d.divisions : csv(d.divisions)).map(Number).filter(Boolean).join(',');
await query(`MERGE dbo.ZSO_USER_PROFILES AS t USING (SELECT ? AS U) s ON t.USER_ID=s.U
WHEN MATCHED THEN UPDATE SET USER_TYPE_ID=?, DIVISIONS=?, EMP_CODE=?, TERRITORY=?, CC_EMAILS=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY=?
WHEN NOT MATCHED THEN INSERT (USER_ID,USER_TYPE_ID,DIVISIONS,EMP_CODE,TERRITORY,CC_EMAILS,UPDATED_BY) VALUES (?,?,?,?,?,?,?);`,
[uid, parseInt(d.userTypeId) || null, divs, d.empCode || '', d.territory || '', d.ccEmails || '', by,
uid, parseInt(d.userTypeId) || null, divs, d.empCode || '', d.territory || '', d.ccEmails || '', by]);
_profCache.delete(uid);
if (d.applyTemplate && d.userTypeId) await applyUserTypeTemplate(uid, parseInt(d.userTypeId));
}
async function applyUserTypeTemplate(userId, userTypeId) {
const users = require('../hanaUsers');
const u = await users.findById(userId);
const ut = await getUserType(userTypeId);
if (!u || !ut) throw new Error('User or user type not found');
const isSalesStep = s => /^sales_/.test(typeof s === 'string' ? s : (s && s.step) || '');
const steps = (u.approvalSteps || []).filter(s => !isSalesStep(s)).concat(ut.defaultSteps || []);
const mods = Array.isArray(u.modules) ? u.modules.filter(m => !/^sales-/.test(m)) : [];
await users.updateUser(userId, { approvalSteps: steps, modules: mods.concat(ut.defaultModules || []) });
}
// ── Sales persons & customer mapping (msale sales_person_master) ────────────
async function listSalesPersons() {
const rows = await query(`SELECT p.*, (SELECT COUNT(*) FROM dbo.ZSO_SP_CUSTOMER_MAP m WHERE m.SALES_PERSON_ID=p.ID AND m.ACTIVE=1) AS CUST_COUNT
FROM dbo.ZSO_SALES_PERSONS p ORDER BY p.ACTIVE DESC, p.NAME`);
return rows.map(r => ({ id: r.ID, name: r.NAME, email: r.EMAIL || '', designation: r.DESIGNATION || '', divisions: csv(r.DIVISIONS).map(Number),
userId: r.USER_ID, active: !!r.ACTIVE, customerCount: r.CUST_COUNT }));
}
async function saveSalesPerson(d) {
const divs = (Array.isArray(d.divisions) ? d.divisions : csv(d.divisions)).join(',');
if (!d.name) throw new Error('Name is required');
if (d.id) {
await query(`UPDATE dbo.ZSO_SALES_PERSONS SET NAME=?, EMAIL=?, DESIGNATION=?, DIVISIONS=?, USER_ID=?, ACTIVE=? WHERE ID=?`,
[d.name, d.email || '', d.designation || '', divs, parseInt(d.userId) || null, d.active !== false, parseInt(d.id)]);
return parseInt(d.id);
}
const r = await one(`INSERT INTO dbo.ZSO_SALES_PERSONS (NAME,EMAIL,DESIGNATION,DIVISIONS,USER_ID,ACTIVE) OUTPUT INSERTED.ID VALUES (?,?,?,?,?,1)`,
[d.name, d.email || '', d.designation || '', divs, parseInt(d.userId) || null]);
return r.ID;
}
async function listMappedCustomers(salesPersonId) {
return (await query(`SELECT ID, CARD_CODE FROM dbo.ZSO_SP_CUSTOMER_MAP WHERE SALES_PERSON_ID=? AND ACTIVE=1 ORDER BY CARD_CODE`, [parseInt(salesPersonId)]))
.map(r => ({ id: r.ID, cardCode: r.CARD_CODE }));
}
async function mapCustomers(salesPersonId, cardCodes, by) {
for (const cc of cardCodes) {
await query(`IF NOT EXISTS (SELECT 1 FROM dbo.ZSO_SP_CUSTOMER_MAP WHERE SALES_PERSON_ID=? AND CARD_CODE=? AND ACTIVE=1)
INSERT INTO dbo.ZSO_SP_CUSTOMER_MAP (SALES_PERSON_ID,CARD_CODE,ACTIVE,CREATED_BY) VALUES (?,?,1,?)`,
[parseInt(salesPersonId), cc, parseInt(salesPersonId), cc, by]);
}
}
async function unmapCustomer(mapId) { await query(`UPDATE dbo.ZSO_SP_CUSTOMER_MAP SET ACTIVE=0 WHERE ID=?`, [parseInt(mapId)]); }
// Customers visible to a 'mapped'-scope employee: those mapped to any sales
// person linked to this user (by USER_ID, or by matching email — msale keyed on email).
async function mappedCardCodesForUser(userId, email) {
const rows = await query(`SELECT DISTINCT m.CARD_CODE FROM dbo.ZSO_SP_CUSTOMER_MAP m JOIN dbo.ZSO_SALES_PERSONS p ON p.ID=m.SALES_PERSON_ID
WHERE m.ACTIVE=1 AND p.ACTIVE=1 AND (p.USER_ID=? OR (?<>'' AND LOWER(p.EMAIL)=LOWER(?)))`, [parseInt(userId) || 0, email || '', email || '']);
return rows.map(r => r.CARD_CODE);
}
// Sales-person emails for a customer in a division (order email CCs — msale get_zsm_email_by_dealer_id).
async function salesPersonEmailsFor(cardCode, divisionId) {
const rows = await query(`SELECT DISTINCT p.EMAIL, p.DIVISIONS FROM dbo.ZSO_SP_CUSTOMER_MAP m JOIN dbo.ZSO_SALES_PERSONS p ON p.ID=m.SALES_PERSON_ID
WHERE m.ACTIVE=1 AND p.ACTIVE=1 AND m.CARD_CODE=?`, [cardCode]);
return rows.filter(r => r.EMAIL && (!divisionId || csv(r.DIVISIONS).map(Number).includes(Number(divisionId)))).map(r => r.EMAIL);
}
// ── Customer portal accounts ────────────────────────────────────────────────
// Passwords migrated from msale are unsalted MD5 — stored as "md5:<hex>" and
// transparently upgraded to bcrypt on the customer's first successful login.
async function hashPassword(p) { return bcrypt.hash(String(p), 10); }
async function verifyPassword(plain, stored) {
if (!stored) return false;
if (stored.startsWith('md5:')) return crypto.createHash('md5').update(String(plain)).digest('hex') === stored.slice(4).toLowerCase();
return bcrypt.compare(String(plain), stored);
}
function custFromRow(r) {
return { cardCode: r.CARD_CODE, cardName: r.CARD_NAME || '', email: r.EMAIL || '', active: !!r.ACTIVE, locked: !!r.LOCKED,
loginAttempts: r.LOGIN_ATTEMPTS || 0, mustChangePwd: !!r.MUST_CHANGE_PWD, lastLogin: r.LAST_LOGIN, creditLimit: Number(r.CREDIT_LIMIT) || 0,
hasPassword: !!r.PASSWORD_HASH };
}
async function getCustomerAccount(cardCode) {
const r = await one(`SELECT * FROM dbo.ZSO_CUSTOMERS WHERE CARD_CODE=?`, [cardCode]);
return r ? { ...custFromRow(r), _hash: r.PASSWORD_HASH } : null;
}
async function listCustomerAccounts(search) {
const s = `%${String(search || '').trim()}%`;
const rows = await query(`SELECT TOP 500 * FROM dbo.ZSO_CUSTOMERS WHERE (?='%%' OR CARD_CODE LIKE ? OR CARD_NAME LIKE ?) ORDER BY CARD_CODE`, [s, s, s]);
return rows.map(custFromRow);
}
async function upsertCustomerAccount(d, by) {
const cc = String(d.cardCode || '').trim();
if (!cc) throw new Error('Customer code is required');
const existing = await one(`SELECT CARD_CODE FROM dbo.ZSO_CUSTOMERS WHERE CARD_CODE=?`, [cc]);
if (d.password && String(d.password).length < 8) throw new Error('Password must be at least 8 characters');
const hash = d.password ? await hashPassword(d.password) : null;
if (existing) {
await query(`UPDATE dbo.ZSO_CUSTOMERS SET CARD_NAME=COALESCE(?,CARD_NAME), EMAIL=?, ACTIVE=?, LOCKED=?, CREDIT_LIMIT=?,
PASSWORD_HASH=COALESCE(?,PASSWORD_HASH), MUST_CHANGE_PWD=CASE WHEN ? IS NULL THEN MUST_CHANGE_PWD ELSE 1 END,
LOGIN_ATTEMPTS=CASE WHEN ?=0 THEN 0 ELSE LOGIN_ATTEMPTS END, UPDATED_AT=SYSDATETIME(), UPDATED_BY=? WHERE CARD_CODE=?`,
[d.cardName || null, d.email || '', d.active !== false, !!d.locked, Number(d.creditLimit) || 0, hash, hash, d.locked ? 1 : 0, by, cc]);
return { created: false, passwordReset: !!hash };
} else {
if (!hash) throw new Error('An initial password is required for a new customer login');
await query(`INSERT INTO dbo.ZSO_CUSTOMERS (CARD_CODE,CARD_NAME,EMAIL,PASSWORD_HASH,ACTIVE,LOCKED,CREDIT_LIMIT,MUST_CHANGE_PWD,UPDATED_BY) VALUES (?,?,?,?,?,0,?,1,?)`,
[cc, d.cardName || '', d.email || '', hash, d.active !== false, Number(d.creditLimit) || 0, by]);
return { created: true, passwordReset: false };
}
}
const MAX_LOGIN_ATTEMPTS = 5;
async function customerLogin(cardCode, password) {
const acc = await getCustomerAccount(String(cardCode || '').trim());
if (!acc || !acc.active) return { ok: false, message: 'Invalid customer code or password' };
if (acc.locked) return { ok: false, message: 'This login is locked after too many failed attempts. Please contact MIPL to unlock it.' };
const ok = await verifyPassword(password, acc._hash);
if (!ok) {
const n = acc.loginAttempts + 1;
await query(`UPDATE dbo.ZSO_CUSTOMERS SET LOGIN_ATTEMPTS=?, LOCKED=? WHERE CARD_CODE=?`, [n, n >= MAX_LOGIN_ATTEMPTS, acc.cardCode]);
return { ok: false, message: n >= MAX_LOGIN_ATTEMPTS ? 'Too many failed attempts — this login is now locked. Contact MIPL.' : 'Invalid customer code or password' };
}
const upgrade = acc._hash.startsWith('md5:') ? await hashPassword(password) : null;
await query(`UPDATE dbo.ZSO_CUSTOMERS SET LOGIN_ATTEMPTS=0, LAST_LOGIN=SYSDATETIME(), PASSWORD_HASH=COALESCE(?,PASSWORD_HASH) WHERE CARD_CODE=?`, [upgrade, acc.cardCode]);
return { ok: true, account: acc };
}
async function changeCustomerPassword(cardCode, oldPwd, newPwd) {
const acc = await getCustomerAccount(cardCode);
if (!acc || !(await verifyPassword(oldPwd, acc._hash))) throw new Error('Current password is incorrect');
if (String(newPwd || '').length < 8) throw new Error('New password must be at least 8 characters');
await query(`UPDATE dbo.ZSO_CUSTOMERS SET PASSWORD_HASH=?, MUST_CHANGE_PWD=0, UPDATED_AT=SYSDATETIME() WHERE CARD_CODE=?`, [await hashPassword(newPwd), cardCode]);
}
module.exports = {
getSettings, saveSettings, publicSettings,
listDivisions, saveDivision, getDivision,
listCatalog, saveProduct, listOrderableProducts, resolveProduct,
listUserTypes, getUserType, saveUserType,
getProfile, listProfiles, saveProfile, applyUserTypeTemplate,
listSalesPersons, saveSalesPerson, listMappedCustomers, mapCustomers, unmapCustomer, mappedCardCodesForUser, salesPersonEmailsFor,
getCustomerAccount, listCustomerAccounts, upsertCustomerAccount, customerLogin, changeCustomerPassword, verifyPassword, hashPassword,
};