// 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, };