using SAPbobsCOM; using System; using System.Collections.Generic; using System.Web.Script.Serialization; namespace SapDiConsole { class Program { static Company oCompany; // Persistent request loop — one line of JSON in on stdin, one line of // JSON out on stdout, per request, kept alive across MANY requests // instead of the old one-shot "connect, do one thing, disconnect, // exit" model. Company.Connect() alone was measured at ~30s (SAP's // SLD/License round trip dominates it) — paying that cost on every // single Purchase Request was the real cause of "creating a PR takes // long". Node's services/diApiService.js now keeps ONE of these // processes alive per SAP company and reuses it; EnsureConnected() // below only reconnects if the session has actually dropped. static int Main(string[] args) { var js = new JavaScriptSerializer(); string line; while ((line = Console.In.ReadLine()) != null) { if (string.IsNullOrWhiteSpace(line)) continue; Dictionary p; try { p = js.Deserialize>(line); } catch (Exception ex) { Console.WriteLine(js.Serialize(new { success = false, error = "Bad request JSON: " + ex.Message })); continue; } string action = Get(p, "Action", "AddPurchaseRequest"); if (action == "Shutdown") { try { oCompany?.Disconnect(); } catch { } return 0; } try { EnsureConnected(p); object result; switch (action) { case "Ping": result = new { success = true, ping = true }; break; case "RemoveProductionOrderLine": result = RemoveProductionOrderLine(p); break; default: result = AddPurchaseRequest(p); break; } Console.WriteLine(js.Serialize(result)); } catch (Exception ex) { // Non-fatal per request — stay alive for the next one. // A connection-level failure will simply cause the next // EnsureConnected() to notice oCompany.Connected is false // and reconnect. Console.WriteLine(js.Serialize(new { success = false, error = ex.Message })); } } // Node closed stdin (process shutting down, or replacing this // pooled connection) — disconnect cleanly before exiting. try { oCompany?.Disconnect(); } catch { } return 0; } // Reuses the existing SAP session unless it's genuinely gone — // avoids paying Connect()'s ~30s SLD/License round trip on every // request, which is the whole point of this persistent process. static void EnsureConnected(Dictionary p) { if (oCompany != null && oCompany.Connected) return; Connect(p); } // ── Remove a component line from a Production Order ────────────── // Service Layer (OData PATCH) has no way to delete a ProductionOrderLines // row — verified live, omitting a line from the array is silently // ignored. DI API's ProductionOrders.Lines collection DOES support // SetCurrentLine + Delete() even on a Released order (ProductionOrders // has its OWN dedicated Lines interface — ProductionOrders_Lines, NOT // the generic Document_Lines used by regular marketing documents). // Used to actually delete a Deviation-superseded line (SAP-side; the // app's own audit trail — the deviation record itself — is untouched // either way). static object RemoveProductionOrderLine(Dictionary p) { int docEntry = int.Parse(Get(p, "DocEntry")); int lineNum = int.Parse(Get(p, "LineNum")); SAPbobsCOM.ProductionOrders po = (SAPbobsCOM.ProductionOrders)oCompany.GetBusinessObject(BoObjectTypes.oProductionOrders); if (!po.GetByKey(docEntry)) throw new Exception($"Production Order {docEntry} not found"); int idx = -1; for (int i = 0; i < po.Lines.Count; i++) { po.Lines.SetCurrentLine(i); if (po.Lines.LineNumber == lineNum) { idx = i; break; } } if (idx < 0) throw new Exception($"Line {lineNum} not found on Production Order {docEntry}"); po.Lines.SetCurrentLine(idx); po.Lines.Delete(); int res = po.Update(); if (res != 0) throw new Exception($"Update failed [{res}]: {oCompany.GetLastErrorDescription()}"); Console.Error.WriteLine($"[PROD-ORDER] Removed line {lineNum} from DocEntry {docEntry}"); return new { success = true, DocEntry = docEntry, RemovedLine = lineNum }; } // ── Standalone DI API connection (no SAP client needed) ────────── static void Connect(Dictionary p) { oCompany = new Company(); // Order matters in SAP DI API: Server → DbServerType → CompanyDB → credentials oCompany.Server = Get(p, "Server", "SAPSVR"); // DbServerType MUST match the SQL Server version or Connect() fails // SBO-user auth with -132 (the DB itself connects fine via sa first). // This install runs SQL Server 2022, but DI API 10.0 build 260 only // knows up to dst_MSSQL2019 = 15 — use that (2022 is wire-compatible). // Numeric cast (NOT the named enum) so values like 16 = dst_MSSQL2022 // compile even though this interop build only defines up to // dst_MSSQL2019 = 15. Value comes from Node (env SAP_B1_DB_SERVER_TYPE). // oCompany.DbServerType = (BoDataServerTypes)int.Parse(Get(p, "DbServerType", "15")); oCompany.DbServerType = BoDataServerTypes.dst_MSSQL2022; // No hardcoded fallback credentials — Node (services/diApiService.js) // always supplies these from .env via the stdin JSON payload; an // empty default here means a missing key fails loudly with a clear // SAP login error instead of silently connecting with a real // password that used to be baked into this binary. oCompany.CompanyDB = Get(p, "CompanyDB"); oCompany.UserName = Get(p, "UserName"); oCompany.Password = Get(p, "Password"); oCompany.DbUserName = Get(p, "DbUserName"); oCompany.DbPassword = Get(p, "DbPassword"); oCompany.UseTrusted = false; oCompany.language = 0; // SAP B1 10.0 standalone DI API auth goes through the SLD/License // service — without these, Connect() fails SBO auth with -132 even // when the DB connection and password are valid. host:30010 = license // service, host:40000 = SLD (System Landscape Directory). string lic = Get(p, "LicenseServer", ""); if (!string.IsNullOrEmpty(lic)) oCompany.LicenseServer = lic; string sld = Get(p, "SLDServer", ""); if (!string.IsNullOrEmpty(sld)) oCompany.SLDServer = sld; int ret = oCompany.Connect(); if (ret != 0) throw new Exception( $"Login failed [{ret}]: {oCompany.GetLastErrorDescription()}"); Console.Error.WriteLine($"[DI] Connected as {Get(p,"UserName")} to {Get(p,"CompanyDB")}"); } // ── Create Purchase Request ─────────────────────────────────────── static object AddPurchaseRequest(Dictionary p) { Documents pr = (Documents)oCompany.GetBusinessObject( BoObjectTypes.oPurchaseRequest); // Header string docDate = Get(p, "DocDate"); string dueDate = Get(p, "DocDueDate"); string reqDate = Get(p, "RequriedDate"); if (!string.IsNullOrEmpty(docDate)) pr.DocDate = DateTime.Parse(docDate); if (!string.IsNullOrEmpty(dueDate)) pr.DocDueDate = DateTime.Parse(dueDate); if (!string.IsNullOrEmpty(reqDate)) pr.RequriedDate = DateTime.Parse(reqDate); string comments = Get(p, "Comments"); string cardCode = Get(p, "CardCode"); string numAtCard = Get(p, "NumAtCard"); string email = Get(p, "RequesterEmail"); if (!string.IsNullOrEmpty(comments)) pr.Comments = comments; if (!string.IsNullOrEmpty(cardCode)) pr.CardCode = cardCode; if (!string.IsNullOrEmpty(numAtCard)) pr.NumAtCard = numAtCard; if (!string.IsNullOrEmpty(email)) pr.RequesterEmail = email; // UDFs SetUdf(pr, "U_ReqDqte", Get(p, "U_ReqDqte")); SetUdf(pr, "U_Depart", Get(p, "U_Depart")); SetUdf(pr, "U_Department", Get(p, "U_Department")); SetUdf(pr, "U_STS_REQD", Get(p, "U_STS_REQD")); // Lines var lines = p.ContainsKey("DocumentLines") ? p["DocumentLines"] as System.Collections.ArrayList : null; bool first = true; if (lines != null) { foreach (var lineObj in lines) { var line = lineObj as Dictionary; if (line == null) continue; if (!first) pr.Lines.Add(); pr.Lines.ItemCode = GetL(line, "ItemCode"); string desc = GetL(line, "ItemDescription"); if (!string.IsNullOrEmpty(desc)) pr.Lines.ItemDescription = desc; string qty = GetL(line, "Quantity"); if (!string.IsNullOrEmpty(qty)) pr.Lines.Quantity = double.Parse(qty); string price = GetL(line, "UnitPrice"); if (!string.IsNullOrEmpty(price)) pr.Lines.UnitPrice = double.Parse(price); string whs = GetL(line, "WarehouseCode"); if (!string.IsNullOrEmpty(whs)) pr.Lines.WarehouseCode = whs; string lineDate = GetL(line, "RequiredDate"); if (!string.IsNullOrEmpty(lineDate)) pr.Lines.RequiredDate = DateTime.Parse(lineDate); first = false; } } Console.Error.WriteLine("[PR] Adding Purchase Request..."); int res = pr.Add(); if (res != 0) throw new Exception( $"Add PR failed [{res}]: {oCompany.GetLastErrorDescription()}"); string docEntry = oCompany.GetNewObjectKey(); Console.Error.WriteLine($"[PR] SUCCESS : {docEntry}"); // ── Check approval status ───────────────────────────────────── bool approvalTriggered = false; string wddCode = null; string wddStatus = null; try { Recordset rs = (Recordset)oCompany.GetBusinessObject( BoObjectTypes.BoRecordset); rs.DoQuery( $"SELECT TOP 1 WddStatus, DraftKey FROM OPRQ WHERE DocEntry = {docEntry}"); if (!rs.EoF) { wddStatus = rs.Fields.Item("WddStatus").Value?.ToString(); wddCode = rs.Fields.Item("DraftKey").Value?.ToString(); approvalTriggered = wddStatus == "W" || wddStatus == "A"; Console.Error.WriteLine($"[APPROVAL] WddStatus={wddStatus} DraftKey={wddCode}"); } } catch { /* approval check is informational only */ } return new { success = true, DocEntry = int.Parse(docEntry), ApprovalTriggered = approvalTriggered, WddStatus = wddStatus, WddCode = wddCode }; } static void SetUdf(Documents doc, string field, string value) { try { if (!string.IsNullOrEmpty(value)) doc.UserFields.Fields.Item(field).Value = value; } catch { } } static string Get(Dictionary d, string k, string def = "") => d.ContainsKey(k) && d[k] != null ? d[k].ToString() : def; static string GetL(Dictionary d, string k) => d.ContainsKey(k) && d[k] != null ? d[k].ToString() : ""; } }