'use strict'; // SAP B1 DI API — calls compiled C# console exe (SapDiConsole.exe). // The exe uses standalone Company.Connect() — no SAP client needed. // Build: cd services/SapDiConsole && build.bat const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const { DEFAULT_COMPANY } = require('./companyConfig'); const EXE_PATH = path.join(__dirname, 'SapDiConsole', 'bin', 'Release', 'net472', 'SapDiConsole.exe'); const PS_SCRIPT = path.join(__dirname, 'sap-di-pr.ps1'); // fallback if exe not built yet function buildPayload(body, companyDB) { // Host of the SAP B1 SLD/License services (same box as the SQL server here). const licHost = process.env.SAP_B1_LICENSE_HOST || process.env.SQL_HOST || '192.9.205.132'; return { Server: process.env.SQL_SERVER_NAME || 'SAP', CompanyDB: companyDB || DEFAULT_COMPANY, // SAP B1 10.0 DI API authenticates via the SLD/License service — required // or Connect() returns -132 despite valid DB + SBO credentials. LicenseServer: process.env.SAP_B1_LICENSE_SERVER || `${licHost}:30010`, SLDServer: process.env.SAP_B1_SLD_SERVER || `${licHost}:40000`, // DbServerType must match the SQL Server version. Server is SQL 2022 and // the DI API (v10.00.331) exposes dst_MSSQL2022 = 17. NOTE: Program.cs // currently hardcodes dst_MSSQL2022, so this payload value is only used if // that line is switched back to the env-driven numeric cast. Override via env. DbServerType: process.env.SAP_B1_DB_SERVER_TYPE || '17', // DI API goes through the approval engine properly — no bypass issue — use manager directly UserName: (process.env.SAP_B1_USER || 'manager').trim(), Password: (process.env.SAP_B1_PASSWORD || '').trim(), DbUserName: process.env.SQL_USER || 'sa', DbPassword: process.env.SQL_PASSWORD || '', ...body, }; } // ════════════════════════════════════════════════════════════════ // PERSISTENT PROCESS POOL — one SapDiConsole.exe kept alive PER SAP // company, reused across requests, instead of spawning a fresh process // (and paying Company.Connect()'s ~30s SLD/License round trip) on every // single call. This was the confirmed root cause of "creating a Purchase // Request takes long" — measured live at ~33s per create before this. // Protocol: newline-delimited JSON both ways — write one request line to // the child's stdin, read one response line back from its stdout, in the // same order (Program.cs processes requests strictly one at a time, so // responses come back in submission order — no request ID needed). // ════════════════════════════════════════════════════════════════ const _pool = {}; // companyDB → { child, resolvers: [{resolve,reject}], buffer } function getProc(companyDB) { const db = companyDB || DEFAULT_COMPANY; const entry = _pool[db]; if (entry && !entry.child.killed && entry.child.exitCode === null) return entry; console.log(`[DI-API] Spawning persistent connection for ${db}`); const child = spawn(EXE_PATH, [], { stdio: ['pipe', 'pipe', 'pipe'] }); const fresh = { child, resolvers: [], buffer: '' }; child.stdout.on('data', (d) => { fresh.buffer += d.toString(); let idx; while ((idx = fresh.buffer.indexOf('\n')) >= 0) { const line = fresh.buffer.slice(0, idx).trim(); fresh.buffer = fresh.buffer.slice(idx + 1); if (!line) continue; const waiter = fresh.resolvers.shift(); if (!waiter) continue; // unexpected extra line — ignore rather than crash try { waiter.resolve(JSON.parse(line)); } catch (e) { waiter.reject(new Error('DI API returned invalid JSON: ' + e.message)); } } }); child.stderr.on('data', (d) => process.stdout.write(d)); // stream DI-API progress lines to Node console const fail = (err) => { const pending = fresh.resolvers.splice(0); pending.forEach((w) => w.reject(err)); if (_pool[db] === fresh) delete _pool[db]; // let the next call respawn }; child.on('exit', (code) => fail(new Error(`DI API process for ${db} exited unexpectedly (code ${code})`))); child.on('error', (err) => fail(new Error(`DI API process for ${db} failed to start: ${err.message}`))); _pool[db] = fresh; return fresh; } function sendToProc(companyDB, payload) { const entry = getProc(companyDB); return new Promise((resolve, reject) => { entry.resolvers.push({ resolve, reject }); entry.child.stdin.write(JSON.stringify(payload) + '\n'); }); } // Best-effort cleanup so a Node restart doesn't leave orphaned SAP sessions // (and license seats) held by SapDiConsole.exe processes. function shutdownPool() { Object.values(_pool).forEach((entry) => { try { entry.child.stdin.write(JSON.stringify({ Action: 'Shutdown' }) + '\n'); } catch (_e) {} setTimeout(() => { try { entry.child.kill(); } catch (_e) {} }, 2000); }); } process.on('exit', shutdownPool); process.on('SIGINT', () => { shutdownPool(); process.exit(0); }); process.on('SIGTERM', () => { shutdownPool(); process.exit(0); }); // One-shot fallback (no persistence) — only used when the compiled exe isn't // built at all, matching the previous behavior exactly for that case. function runProcessOnce(cmd, args, stdinData) { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', d => { stdout += d.toString(); }); child.stderr.on('data', d => { stderr += d.toString(); process.stdout.write(d); // stream DI-API progress lines to Node console }); child.on('close', code => { // stdout must contain only the JSON Write-Output line; stderr has log messages const json = stdout.trim().split('\n').reverse().find(l => l.trim().startsWith('{')); try { const result = JSON.parse(json); if (result.success) resolve(result); else reject(new Error(result.error || 'DI API error')); } catch { reject(new Error(stderr.trim() || stdout.trim() || `Process exited with code ${code}`)); } }); child.on('error', err => reject(new Error(`Spawn failed: ${err.message}`))); if (stdinData) { child.stdin.write(stdinData); child.stdin.end(); } }); } async function addPurchaseRequest(body, companyDB) { const payload = buildPayload(body, companyDB); if (fs.existsSync(EXE_PATH)) { console.log(`[DI-API] ${payload.UserName}@${payload.CompanyDB} (pooled connection)`); const result = await sendToProc(companyDB, payload); if (result.success) return result; throw new Error(result.error || 'DI API error'); } console.log(`[DI-API] Using PowerShell → ${payload.UserName}@${payload.CompanyDB}`); return runProcessOnce('powershell.exe', [ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', PS_SCRIPT, '-PayloadJson', JSON.stringify(payload), ], null); } // Deletes a component line from a Production Order — Service Layer (OData // PATCH) has no way to do this at all (verified live: omitting a line from // the array is silently ignored, the line stays). DI API's dedicated // ProductionOrders_Lines interface supports SetCurrentLine + Delete() even // on an already-Released order. Used for a Deviation-superseded line the // user wants physically gone from SAP (the app's own audit trail — the // deviation record — is untouched either way, it doesn't read SAP's line // list for its own history). async function removeProductionOrderLine(docEntry, lineNum, companyDB) { const payload = buildPayload({ Action: 'RemoveProductionOrderLine', DocEntry: docEntry, LineNum: lineNum }, companyDB); if (fs.existsSync(EXE_PATH)) { console.log(`[DI-API] RemoveProductionOrderLine DocEntry=${docEntry} Line=${lineNum} → ${payload.UserName}@${payload.CompanyDB} (pooled connection)`); const result = await sendToProc(companyDB, payload); if (result.success) return result; throw new Error(result.error || 'DI API error'); } throw new Error('SapDiConsole.exe not built — run services/SapDiConsole/build.bat'); } module.exports = { addPurchaseRequest, removeProductionOrderLine };