180 lines
8.4 KiB
JavaScript
180 lines
8.4 KiB
JavaScript
'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 };
|