@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user