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
+268
View File
@@ -0,0 +1,268 @@
// services/sales/sap.js
// Everything the Sales Order module reads from / writes to SAP B1:
// • customers + addresses come LIVE from OCRD/CRD1 (msale kept a stale copy
// in dealer_master/dealer_address — no longer needed)
// • SAP-mode product list (OITM + price list)
// • posting the approved order as a SAP Sales Order (ORDR) via Service Layer,
// stamped with U_WEB_SO_NO = our order ID exactly like the old external
// sync job did, so existing SAP reports/PPC keep working
// • invoices, dispatch (LR/GR/AWB) and COA certificates for an order —
// read straight from OINV / batch links / ATC1 instead of being pushed
// into MySQL by external upload jobs
'use strict';
const fs = require('fs');
const path = require('path');
const { getPool } = require('../sqlPool');
const { sapRequest } = require('../sapServiceLayer');
const { sapSalesType } = require('./constants');
async function sq(company, text, inputs = {}) {
const pool = await getPool(company);
const r = pool.request();
Object.entries(inputs).forEach(([k, v]) => r.input(k, v));
return (await r.query(text)).recordset || [];
}
// ── Customers ───────────────────────────────────────────────────────────────
async function searchCustomers(company, term, { limit = 50, cardCodes = null } = {}) {
const t = `%${String(term || '').trim()}%`;
let filter = '';
if (Array.isArray(cardCodes)) {
if (!cardCodes.length) return [];
filter = ` AND c.CardCode IN (${cardCodes.map((_, i) => `@cc${i}`).join(',')})`;
}
const inputs = { t, lim: limit };
(cardCodes || []).forEach((c, i) => { inputs[`cc${i}`] = c; });
const rows = await sq(company, `SELECT TOP (@lim) c.CardCode, c.CardName, c.E_Mail, c.Cellular, c.CntctPrsn, c.SlpCode, s.SlpName, c.City, c.validFor, c.frozenFor
FROM OCRD c LEFT JOIN OSLP s ON s.SlpCode=c.SlpCode
WHERE c.CardType='C' AND (c.CardCode LIKE @t OR c.CardName LIKE @t)${filter} ORDER BY c.CardName`, inputs);
return rows.map(r => ({ cardCode: r.CardCode, cardName: r.CardName, email: r.E_Mail || '', phone: r.Cellular || '', contactPerson: r.CntctPrsn || '',
slpCode: r.SlpCode, slpName: r.SlpName || '', city: r.City || '', frozen: r.frozenFor === 'Y' }));
}
function formatAddress(a) {
return [a.street, a.block, [a.city, a.zipCode].filter(Boolean).join(' - '), a.stateName || a.state, a.countryName || a.country]
.map(x => String(x || '').trim()).filter(Boolean).join('\n');
}
async function getCustomer(company, cardCode) {
const rows = await sq(company, `SELECT c.CardCode, c.CardName, c.E_Mail, c.Cellular, c.CntctPrsn, c.SlpCode, s.SlpName, c.GroupNum, c.Territory,
t.descript AS TerritoryName, c.U_CustomerType, c.U_CustomerCategory, c.frozenFor, c.LicTradNum
FROM OCRD c LEFT JOIN OSLP s ON s.SlpCode=c.SlpCode LEFT JOIN OTER t ON t.territryID=c.Territory
WHERE c.CardCode=@cc AND c.CardType='C'`, { cc: cardCode });
if (!rows.length) return null;
const c = rows[0];
const addr = await sq(company, `SELECT a.Address, a.AdresType, a.Street, a.Block, a.City, a.ZipCode, a.State, st.Name AS StateName, a.Country, co.Name AS CountryName, a.GSTRegnNo
FROM CRD1 a LEFT JOIN OCST st ON st.Code=a.State AND st.Country=a.Country LEFT JOIN OCRY co ON co.Code=a.Country
WHERE a.CardCode=@cc ORDER BY a.AdresType, a.Address`, { cc: cardCode });
const addresses = addr.map(a => {
const o = { code: a.Address, type: a.AdresType === 'B' ? 'bill' : 'ship', street: a.Street || '', block: a.Block || '', city: a.City || '',
zipCode: a.ZipCode || '', state: a.State || '', stateName: a.StateName || '', country: a.Country || '', countryName: a.CountryName || '', gstin: a.GSTRegnNo || '' };
o.text = formatAddress(o);
return o;
});
return { cardCode: c.CardCode, cardName: c.CardName, email: c.E_Mail || '', phone: c.Cellular || '', contactPerson: c.CntctPrsn || '',
slpCode: c.SlpCode, slpName: c.SlpName || '', groupNum: c.GroupNum, territory: c.TerritoryName || '', customerType: c.U_CustomerType || '',
customerCategory: c.U_CustomerCategory || '', frozen: c.frozenFor === 'Y', gstin: c.LicTradNum || '',
billTo: addresses.filter(a => a.type === 'bill'), shipTo: addresses.filter(a => a.type === 'ship') };
}
async function customerNames(company, cardCodes) {
const codes = [...new Set(cardCodes.filter(Boolean))];
if (!codes.length) return {};
const out = {};
for (let i = 0; i < codes.length; i += 500) {
const chunk = codes.slice(i, i + 500); const inputs = {};
chunk.forEach((c, j) => { inputs[`c${j}`] = c; });
(await sq(company, `SELECT CardCode, CardName, E_Mail FROM OCRD WHERE CardCode IN (${chunk.map((_, j) => `@c${j}`).join(',')})`, inputs))
.forEach(r => { out[r.CardCode] = { name: r.CardName, email: r.E_Mail || '' }; });
}
return out;
}
// ── SAP-mode product list ───────────────────────────────────────────────────
async function listSapProducts(company, itemGroups, priceList, divisionId) {
const groups = (itemGroups || []).map(Number).filter(Boolean);
if (!groups.length) return [];
const inputs = { pl: parseInt(priceList) || 1 };
groups.forEach((g, i) => { inputs[`g${i}`] = g; });
const rows = await sq(company, `SELECT i.ItemCode, i.ItemName, i.FrgnName, i.SalPackUn, i.NumInSale, p.Price, h.ChapterID
FROM OITM i LEFT JOIN ITM1 p ON p.ItemCode=i.ItemCode AND p.PriceList=@pl LEFT JOIN OCHP h ON h.AbsEntry=i.ChapterID
WHERE i.SellItem='Y' AND i.frozenFor='N' AND i.ItmsGrpCod IN (${groups.map((_, i) => `@g${i}`).join(',')}) ORDER BY i.ItemCode`, inputs);
return rows.map(r => ({ key: `S:${r.ItemCode}`, id: null, catalog: 'domestic', divisionId: Number(divisionId), itemCode: r.ItemCode, displayCode: r.ItemCode,
description: r.ItemName || '', shortDesc: r.FrgnName || '', packSize: Number(r.SalPackUn) || 1, hsn: r.ChapterID || '', price: Number(r.Price) || 0,
isInstrument: false, active: true }));
}
// ── Posting the order to SAP ────────────────────────────────────────────────
// One SAP Sales Order per order — or, for a multi-consignee Direct order, one
// per consignee (each with its own ShipToCode and item allocation). Every SAP
// order carries U_WEB_SO_NO = order.id and NumAtCard = our reference number.
function ymd(d) { if (!d) return null; const x = new Date(d); return isNaN(x) ? null : x.toISOString().slice(0, 10); }
function buildSapPayloads(order, items, consignees, settings, division) {
const effPrice = it => (Number(it.specialPrice) > 0 ? Number(it.specialPrice) : Number(it.price));
const base = {
CardCode: order.cardCode,
DocDate: ymd(new Date()),
DocDueDate: ymd(order.deliveryDate) || ymd(new Date()),
NumAtCard: order.refNo,
PayToCode: order.billToCode || undefined,
Comments: `Web SO ${order.refNo}${order.custOrderNo ? ' / Cust PO ' + order.custOrderNo : ''}`.slice(0, 254),
U_WEB_SO_NO: order.id,
U_WebCreated: 'Y',
U_SalesType: sapSalesType(order.soType, order.orderType),
U_Saletype: '1.',
U_CustomerType: 'DOMESTIC',
};
if (settings.sapSeries) base.Series = parseInt(settings.sapSeries);
const line = (it, qty) => ({
ItemCode: it.itemCode, Quantity: Number(qty), UnitPrice: effPrice(it), TaxCode: order.taxCode || undefined,
WarehouseCode: (division && division.warehouse) || undefined,
});
const active = consignees.filter(c => c.active !== false && Array.isArray(c.items) && c.items.length);
if (order.isMultiConsignee && active.length) {
return active.map(c => {
const lines = c.items.map(ci => {
const it = items.find(i => i.itemCode === ci.itemCode);
return it && Number(ci.qty) > 0 ? line(it, ci.qty) : null;
}).filter(Boolean);
return { consigneeId: c.id, payload: { ...base, ShipToCode: c.shipToCode || order.shipToCode || undefined, DocumentLines: lines } };
}).filter(p => p.payload.DocumentLines.length);
}
return [{ consigneeId: null, payload: { ...base, ShipToCode: order.shipToCode || undefined, DocumentLines: items.map(it => line(it, it.qty)) } }];
}
async function postOrderToSap(company, payloads) {
const results = [];
for (const p of payloads) {
const r = await sapRequest('POST', 'Orders', p.payload, company);
results.push({ consigneeId: p.consigneeId, docEntry: r.DocEntry, docNum: r.DocNum, docDate: r.DocDate });
}
return results;
}
// SAP orders already carrying U_WEB_SO_NO for these IDs — used to avoid ever
// double-posting (e.g. someone keyed it in SAP manually, or a retry after a
// timeout that actually succeeded) and for the status sync.
async function sapOrdersByWebNo(company, ids) {
const list = [...new Set(ids.map(Number).filter(Boolean))];
if (!list.length) return [];
const out = [];
for (let i = 0; i < list.length; i += 500) {
const chunk = list.slice(i, i + 500); const inputs = {};
chunk.forEach((v, j) => { inputs[`i${j}`] = v; });
out.push(...await sq(company, `SELECT DocEntry, DocNum, DocDate, DocStatus, CANCELED, U_WEB_SO_NO, DocTotal FROM ORDR
WHERE U_WEB_SO_NO IN (${chunk.map((_, j) => `@i${j}`).join(',')})`, inputs));
}
return out.map(r => ({ docEntry: r.DocEntry, docNum: r.DocNum, docDate: r.DocDate, open: r.DocStatus === 'O', cancelled: r.CANCELED === 'Y', webNo: r.U_WEB_SO_NO, total: Number(r.DocTotal) }));
}
// Open (un-delivered) qty per item for posted orders — pending-order reports.
async function openQtyByWebNo(company, ids) {
const list = [...new Set(ids.map(Number).filter(Boolean))];
if (!list.length) return [];
const out = [];
for (let i = 0; i < list.length; i += 500) {
const chunk = list.slice(i, i + 500); const inputs = {};
chunk.forEach((v, j) => { inputs[`i${j}`] = v; });
out.push(...await sq(company, `SELECT o.U_WEB_SO_NO, o.DocNum, l.ItemCode, l.Dscription, l.Quantity, l.OpenQty, l.Price
FROM ORDR o JOIN RDR1 l ON l.DocEntry=o.DocEntry WHERE o.CANCELED='N' AND o.U_WEB_SO_NO IN (${chunk.map((_, j) => `@i${j}`).join(',')})`, inputs));
}
return out.map(r => ({ webNo: r.U_WEB_SO_NO, sapDocNum: r.DocNum, itemCode: r.ItemCode, description: r.Dscription, qty: Number(r.Quantity), openQty: Number(r.OpenQty), price: Number(r.Price) }));
}
// ── Invoices / dispatch / COA ───────────────────────────────────────────────
// Invoices raised against the order (OINV carries U_WEB_SO_NO, set by SAP
// copy-from; also matched through base-document links for safety).
async function invoicesForOrder(company, orderId) {
const rows = await sq(company, `SELECT v.DocEntry, v.DocNum, v.DocDate, v.DocTotal, v.CANCELED, v.U_RrGrAwb, v.U_LR_Date, v.U_TrnspDetails,
v.U_VehRegNo, v.U_EWAYNO, v.AtcEntry
FROM OINV v WHERE v.CANCELED='N' AND (v.U_WEB_SO_NO=@id OR v.DocEntry IN (
SELECT i.DocEntry FROM INV1 i JOIN DLN1 d ON d.DocEntry=i.BaseEntry AND d.LineNum=i.BaseLine AND i.BaseType=15
JOIN ORDR o ON o.DocEntry=d.BaseEntry AND d.BaseType=17 WHERE o.U_WEB_SO_NO=@id
UNION SELECT i.DocEntry FROM INV1 i JOIN ORDR o ON o.DocEntry=i.BaseEntry AND i.BaseType=17 WHERE o.U_WEB_SO_NO=@id))
ORDER BY v.DocDate, v.DocNum`, { id: parseInt(orderId) });
if (!rows.length) return [];
const lines = await sq(company, `SELECT i.DocEntry, i.ItemCode, i.Dscription, i.Quantity, i.Price, i.LineTotal FROM INV1 i
WHERE i.DocEntry IN (${rows.map(r => r.DocEntry).join(',')})`);
return rows.map(r => ({
docEntry: r.DocEntry, invoiceNo: r.DocNum, invoiceDate: r.DocDate, total: Number(r.DocTotal),
dispatch: { lrNo: r.U_RrGrAwb || '', lrDate: r.U_LR_Date || null, transporter: r.U_TrnspDetails || '', vehicle: r.U_VehRegNo || '', ewayBill: r.U_EWAYNO || '' },
hasAttachments: !!r.AtcEntry, atcEntry: r.AtcEntry || null,
lines: lines.filter(l => l.DocEntry === r.DocEntry).map(l => ({ itemCode: l.ItemCode, description: l.Dscription, qty: Number(l.Quantity), price: Number(l.Price), total: Number(l.LineTotal) })),
}));
}
// All (non-cancelled) A/R invoices for many web orders at once — invoice
// email detection. Matches OINV.U_WEB_SO_NO and, for safety, invoices copied
// from the order directly or via a delivery. Header fields only.
async function invoicesForWebNos(company, ids) {
const list = [...new Set(ids.map(Number).filter(Boolean))];
if (!list.length) return [];
const out = [];
for (let i = 0; i < list.length; i += 300) {
const chunk = list.slice(i, i + 300); const inputs = {};
chunk.forEach((v, j) => { inputs[`i${j}`] = v; });
const IN = chunk.map((_, j) => `@i${j}`).join(',');
out.push(...await sq(company, `SELECT x.WebNo, v.DocEntry, v.DocNum, v.DocDate, v.DocTotal, v.U_RrGrAwb, v.U_LR_Date, CAST(v.U_TrnspDetails AS nvarchar(400)) AS Transporter, v.U_VehRegNo, v.U_EWAYNO
FROM (SELECT DISTINCT WebNo, DocEntry FROM (
SELECT v1.U_WEB_SO_NO AS WebNo, v1.DocEntry FROM OINV v1 WHERE v1.U_WEB_SO_NO IN (${IN})
UNION SELECT o.U_WEB_SO_NO, i1.DocEntry FROM INV1 i1 JOIN ORDR o ON i1.BaseType=17 AND o.DocEntry=i1.BaseEntry WHERE o.U_WEB_SO_NO IN (${IN})
UNION SELECT o.U_WEB_SO_NO, i1.DocEntry FROM INV1 i1 JOIN DLN1 d ON i1.BaseType=15 AND d.DocEntry=i1.BaseEntry AND d.LineNum=i1.BaseLine
JOIN ORDR o ON d.BaseType=17 AND o.DocEntry=d.BaseEntry WHERE o.U_WEB_SO_NO IN (${IN})) u) x
JOIN OINV v ON v.DocEntry=x.DocEntry WHERE v.CANCELED='N'`, inputs));
}
return out.map(r => ({ webNo: r.WebNo, docEntry: r.DocEntry, invoiceNo: r.DocNum, invoiceDate: r.DocDate, total: Number(r.DocTotal),
dispatch: { lrNo: r.U_RrGrAwb || '', lrDate: r.U_LR_Date || null, transporter: r.Transporter || '', vehicle: r.U_VehRegNo || '', ewayBill: r.U_EWAYNO || '' } }));
}
async function invoiceLines(company, docEntry) {
return (await sq(company, `SELECT ItemCode, Dscription, Quantity, LineTotal FROM INV1 WHERE DocEntry=@d ORDER BY LineNum`, { d: parseInt(docEntry) }))
.map(l => ({ itemCode: l.ItemCode, description: l.Dscription, qty: Number(l.Quantity), total: Number(l.LineTotal) }));
}
// Batches shipped on an invoice (direct batch rows on the invoice, or on the
// delivery it was copied from), each with its COA file if one was attached in
// SAP — the COA attachment's FileName is the batch number (same rule the
// coa-upload-job uses: ATC1.FileName = OIGN.U_BTCHNO = batch).
async function batchesWithCoa(company, invoiceDocEntries) {
const ids = invoiceDocEntries.map(Number).filter(Boolean);
if (!ids.length) return [];
const rows = await sq(company, `SELECT DISTINCT x.InvEntry, b.ItemCode, b.DistNumber, b.MnfDate, b.ExpDate, a.AbsEntry AS AtcAbs, a.Line AS AtcLine,
a.trgtPath, a.FileName, a.FileExt
FROM (
SELECT L.DocEntry AS InvEntry, L.LogEntry FROM OITL L WHERE L.DocType=13 AND L.DocEntry IN (${ids.join(',')})
UNION SELECT i.DocEntry, L.LogEntry FROM INV1 i JOIN OITL L ON L.DocType=15 AND L.DocEntry=i.BaseEntry AND i.BaseType=15 WHERE i.DocEntry IN (${ids.join(',')})
) x JOIN ITL1 t ON t.LogEntry=x.LogEntry JOIN OBTN b ON b.ItemCode=t.ItemCode AND b.SysNumber=t.SysNumber
OUTER APPLY (SELECT TOP 1 a1.AbsEntry, a1.Line, CAST(a1.trgtPath AS nvarchar(400)) AS trgtPath, CAST(a1.FileName AS nvarchar(254)) AS FileName,
CAST(a1.FileExt AS nvarchar(30)) AS FileExt FROM ATC1 a1 WHERE CAST(a1.FileName AS nvarchar(254))=b.DistNumber ORDER BY a1.AbsEntry DESC, a1.Line DESC) a`);
return rows.map(r => ({ invoiceDocEntry: r.InvEntry, itemCode: r.ItemCode, batch: r.DistNumber, mfgDate: r.MnfDate, expDate: r.ExpDate,
coa: r.FileName ? { absEntry: r.AtcAbs, line: r.AtcLine, fileName: r.FileName, ext: r.FileExt || 'pdf', path: r.trgtPath || '' } : null }));
}
async function attachmentLines(company, absEntry) {
return (await sq(company, `SELECT AbsEntry, Line, trgtPath, FileName, FileExt FROM ATC1 WHERE AbsEntry=@a ORDER BY Line`, { a: parseInt(absEntry) }))
.map(r => ({ absEntry: r.AbsEntry, line: r.Line, path: r.trgtPath || '', fileName: r.FileName, ext: r.FileExt || '' }));
}
async function attachmentLine(company, absEntry, line) {
const r = await sq(company, `SELECT AbsEntry, Line, trgtPath, FileName, FileExt FROM ATC1 WHERE AbsEntry=@a AND Line=@l`, { a: parseInt(absEntry), l: parseInt(line) });
return r[0] ? { absEntry: r[0].AbsEntry, line: r[0].Line, path: r[0].trgtPath || '', fileName: r[0].FileName, ext: r[0].FileExt || '' } : null;
}
// Locate an attachment's bytes. ATC1.trgtPath is the SAP server's view
// (often a local "D:\COAUpload"), so also try the UNC share(s) from .env.
function readAttachment(att) {
const name = att.ext ? `${att.fileName}.${att.ext}` : att.fileName;
const bases = [att.path, process.env.SAP_ATTACHMENT_SERVER_PATH, process.env.SAP_ATTACHMENT_PATH,
...String(process.env.SALES_COA_PATHS || '').split(',')].map(s => String(s || '').trim()).filter(Boolean);
for (const b of [...new Set(bases)]) {
const full = path.join(b, name);
try { if (fs.existsSync(full)) return { buffer: fs.readFileSync(full), name, full }; } catch (_e) {}
}
throw new Error(`Attachment file not found: ${name}`);
}
module.exports = {
searchCustomers, getCustomer, customerNames, formatAddress, listSapProducts,
buildSapPayloads, postOrderToSap, sapOrdersByWebNo, openQtyByWebNo,
invoicesForOrder, invoicesForWebNos, invoiceLines, batchesWithCoa, attachmentLines, attachmentLine, readAttachment,
};