first commit
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s

This commit is contained in:
John
2026-09-23 17:31:02 +05:30
commit 69b4e68baf
51657 changed files with 3864077 additions and 0 deletions
Binary file not shown.
+300
View File
@@ -0,0 +1,300 @@
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<string, object> p;
try { p = js.Deserialize<Dictionary<string, object>>(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<string, object> 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<string, object> 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<string, object> 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<string, object> 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<string, object>;
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<string, object> d, string k, string def = "")
=> d.ContainsKey(k) && d[k] != null ? d[k].ToString() : def;
static string GetL(Dictionary<string, object> d, string k)
=> d.ContainsKey(k) && d[k] != null ? d[k].ToString() : "";
}
}
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net472</TargetFramework>
<AssemblyName>SapDiConsole</AssemblyName>
<RootNamespace>SapDiConsole</RootNamespace>
<Nullable>disable</Nullable>
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
</PropertyGroup>
<ItemGroup>
<Reference Include="Interop.SAPbobsCOM">
<HintPath>Interop.SAPbobsCOM.dll</HintPath>
<Private>true</Private>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
<Reference Include="System.Web.Extensions" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>
+26
View File
@@ -0,0 +1,26 @@
@echo off
echo Building SAP DI Console...
rem This project references a pre-generated, machine-neutral (MSIL) interop
rem assembly: Interop.SAPbobsCOM.dll (checked in next to this script). Because
rem that's a plain managed <Reference> (not a <COMReference>), the ordinary
rem .NET SDK build works - no Visual Studio / Framework MSBuild required.
rem
rem If the SAP B1 DI API is upgraded, regenerate the interop once:
rem "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\TlbImp.exe" ^
rem "C:\Program Files (x86)\SAP\SAP Business One DI API\DI API 100\SAPbobsCOM100.dll" ^
rem /out:Interop.SAPbobsCOM.dll /namespace:SAPbobsCOM /machine:Agnostic /silent
dotnet build SapDiConsole.csproj -c Release
if %ERRORLEVEL% EQU 0 (
echo.
echo Build successful.
echo Exe: bin\Release\net472\SapDiConsole.exe
) else (
echo.
echo Build failed. Make sure:
echo 1. .NET SDK is installed ^(dotnet --version^)
echo 2. Interop.SAPbobsCOM.dll exists in this folder ^(regenerate with TlbImp - see comment above^)
echo 3. SAP B1 DI API is installed and its COM type library is registered
)
pause
@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")]
@@ -0,0 +1,22 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyTitleAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Generated by the MSBuild WriteCodeFragment class.
@@ -0,0 +1 @@
bd88c037973a1a968556ea51ee8cddc9d960f7dcdfb631602883b428ec252d76
@@ -0,0 +1,8 @@
is_global = true
build_property.RootNamespace = SapDiConsole
build_property.ProjectDir = D:\Claude_projects\sap-erp\services\SapDiConsole\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
build_property.EffectiveAnalysisLevelStyle =
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>
@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]
@@ -0,0 +1,22 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyTitleAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Generated by the MSBuild WriteCodeFragment class.
@@ -0,0 +1 @@
bd88c037973a1a968556ea51ee8cddc9d960f7dcdfb631602883b428ec252d76
@@ -0,0 +1,15 @@
is_global = true
build_property.TargetFramework = net8.0
build_property.TargetPlatformMinVersion =
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = SapDiConsole
build_property.ProjectDir = D:\Claude_projects\sap-erp\services\SapDiConsole\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.EffectiveAnalysisLevelStyle = 8.0
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1,8 @@
// <auto-generated/>
global using global::System;
global using global::System.Collections.Generic;
global using global::System.IO;
global using global::System.Linq;
global using global::System.Net.Http;
global using global::System.Threading;
global using global::System.Threading.Tasks;
@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")]
@@ -0,0 +1,22 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyTitleAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Generated by the MSBuild WriteCodeFragment class.
@@ -0,0 +1 @@
fec65bade770bf15c6935812f142fd68aace5ba0d4b263fc890ff8d31a508443
@@ -0,0 +1,8 @@
is_global = true
build_property.RootNamespace = SapDiConsole
build_property.ProjectDir = D:\Claude_projects\sap-erp\services\SapDiConsole\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
build_property.EffectiveAnalysisLevelStyle =
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1 @@
b506486fafa6af369e4745f88ac8f983a1c6b332351b19c4b09d46b52c07c2c9
@@ -0,0 +1,13 @@
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net472\SapDiConsole.csproj.AssemblyReference.cache
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net472\SapDiConsole.GeneratedMSBuildEditorConfig.editorconfig
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net472\SapDiConsole.AssemblyInfoInputs.cache
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net472\SapDiConsole.AssemblyInfo.cs
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net472\SapDiConsole.csproj.CoreCompileInputs.cache
D:\Claude_projects\sap-erp\services\SapDiConsole\bin\Release\net472\SapDiConsole.exe.config
D:\Claude_projects\sap-erp\services\SapDiConsole\bin\Release\net472\SapDiConsole.exe
D:\Claude_projects\sap-erp\services\SapDiConsole\bin\Release\net472\SapDiConsole.pdb
D:\Claude_projects\sap-erp\services\SapDiConsole\bin\Release\net472\Interop.SAPbobsCOM.dll
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net472\SapDiConsole.exe
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net472\SapDiConsole.pdb
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net472\SapDiCon.6D353C83.Up2Date
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net472\SapDiConsole.exe.config
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>
@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]
@@ -0,0 +1,23 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyTitleAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Generated by the MSBuild WriteCodeFragment class.
@@ -0,0 +1 @@
fec65bade770bf15c6935812f142fd68aace5ba0d4b263fc890ff8d31a508443
@@ -0,0 +1,15 @@
is_global = true
build_property.TargetFramework = net8.0
build_property.TargetPlatformMinVersion =
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = SapDiConsole
build_property.ProjectDir = D:\Claude_projects\sap-erp\services\SapDiConsole\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.EffectiveAnalysisLevelStyle = 8.0
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1,8 @@
// <auto-generated/>
global using global::System;
global using global::System.Collections.Generic;
global using global::System.IO;
global using global::System.Linq;
global using global::System.Net.Http;
global using global::System.Threading;
global using global::System.Threading.Tasks;
@@ -0,0 +1 @@
58b78b16bb97398da9570216ea95dfc59fe5c3e1da3c503b3b387457166f73f0
@@ -0,0 +1,5 @@
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net8.0\SapDiConsole.csproj.AssemblyReference.cache
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net8.0\SapDiConsole.GeneratedMSBuildEditorConfig.editorconfig
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net8.0\SapDiConsole.AssemblyInfoInputs.cache
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net8.0\SapDiConsole.AssemblyInfo.cs
D:\Claude_projects\sap-erp\services\SapDiConsole\obj\Release\net8.0\SapDiConsole.csproj.CoreCompileInputs.cache
@@ -0,0 +1,62 @@
{
"format": 1,
"restore": {
"D:\\Claude_projects\\sap-erp\\services\\SapDiConsole\\SapDiConsole.csproj": {}
},
"projects": {
"D:\\Claude_projects\\sap-erp\\services\\SapDiConsole\\SapDiConsole.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\Claude_projects\\sap-erp\\services\\SapDiConsole\\SapDiConsole.csproj",
"projectName": "SapDiConsole",
"projectPath": "D:\\Claude_projects\\sap-erp\\services\\SapDiConsole\\SapDiConsole.csproj",
"packagesPath": "C:\\Users\\admin\\.nuget\\packages\\",
"outputPath": "D:\\Claude_projects\\sap-erp\\services\\SapDiConsole\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\admin\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
"originalTargetFrameworks": [
"net472"
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net472": {
"targetAlias": "net472",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "9.0.300"
},
"frameworks": {
"net472": {
"targetAlias": "net472",
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.302\\RuntimeIdentifierGraph.json"
}
},
"runtimes": {
"win-x86": {
"#import": []
}
}
}
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\admin\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.14.0</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="C:\Users\admin\.nuget\packages\" />
<SourceRoot Include="C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages\" />
</ItemGroup>
</Project>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" />
@@ -0,0 +1,69 @@
{
"version": 3,
"targets": {
".NETFramework,Version=v4.7.2": {},
".NETFramework,Version=v4.7.2/win-x86": {}
},
"libraries": {},
"projectFileDependencyGroups": {
".NETFramework,Version=v4.7.2": []
},
"packageFolders": {
"C:\\Users\\admin\\.nuget\\packages\\": {},
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {}
},
"project": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\Claude_projects\\sap-erp\\services\\SapDiConsole\\SapDiConsole.csproj",
"projectName": "SapDiConsole",
"projectPath": "D:\\Claude_projects\\sap-erp\\services\\SapDiConsole\\SapDiConsole.csproj",
"packagesPath": "C:\\Users\\admin\\.nuget\\packages\\",
"outputPath": "D:\\Claude_projects\\sap-erp\\services\\SapDiConsole\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\admin\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
"originalTargetFrameworks": [
"net472"
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net472": {
"targetAlias": "net472",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "9.0.300"
},
"frameworks": {
"net472": {
"targetAlias": "net472",
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.302\\RuntimeIdentifierGraph.json"
}
},
"runtimes": {
"win-x86": {
"#import": []
}
}
}
}
@@ -0,0 +1,8 @@
{
"version": 2,
"dgSpecHash": "x0a3ttMP/vQ=",
"success": true,
"projectFilePath": "D:\\Claude_projects\\sap-erp\\services\\SapDiConsole\\SapDiConsole.csproj",
"expectedPackageFiles": [],
"logs": []
}
+550
View File
@@ -0,0 +1,550 @@
// services/appSettingsStore.js
// Application-wide settings that used to live in .env, now editable from the
// Admin panel and persisted in the app's own database (APP_SQL_*). Values are
// stored as strings (one row per key) and cached in memory; typed getters
// parse them at the point of use. bootstrap() seeds any missing key with its
// built-in default and loads the cache, so synchronous getters are safe once
// startup has run (and fall back to defaults even if called earlier).
'use strict';
const sql = require('mssql');
const TABLE = `[dbo].[ZAPP_SETTINGS]`;
// The full set of admin-editable keys, each with its built-in default (these
// are exactly the values that previously lived in .env). Anything not listed
// here is rejected by setMany() so the settings surface stays closed.
const DEFAULTS = {
bomApprovalLevels: '3',
workOrderSkipQc: 'true',
prodOrderTypes: 'bopotStandard',
// Item Master Data — Create Item's auto-numbering: per-prefix (RM/PK/ICO)
// floor for the NEXT suggested code, so an admin can force the sequence
// past a range that has a mistaken/out-of-order entry in it, without
// waiting for someone to naturally catch up to that number. JSON map,
// e.g. {"RM":1500,"PK":800,"ICO":15800} — the next code is
// max(actual highest existing code + 1, this floor) per prefix; a prefix
// with no entry here (or 0) is unrestricted (natural max+1, as before).
itemCodeSeriesFloor: '{}',
// Production Order creation — block (rather than just warn) when a real
// Item component's Planned Quantity exceeds what's actually on hand in its
// warehouse (OITW.OnHand), across every creation path (manual, from Work
// Order, from-component "+PWO"). Resources and Consumable Orders'
// Service items are never stock-checked (not inventory items). Default
// OFF — matches behavior before this existed (SAP itself allows creating
// a Production Order with insufficient stock; issuance is what actually
// needs it in hand).
poRequireStockAvailability: 'false',
receiptRequireFullQty: 'false',
// When receiptRequireFullQty is on, should a by-product/process-loss line
// (e.g. ICO10791 — a negative-Planned-Qty component, its own BaseLine, NOT
// another warehouse split of the FG) count toward the FG's remaining
// planned quantity? Default true = excluded (the correct behavior — a
// process-loss quantity is tracked separately, in its own unit, and was
// never part of what SAP expects the FG's own receipt to total). Off = old
// behavior, sums every DocumentLine including by-product ones.
receiptExcludeByProductFromTotal: 'true',
closeRequireFullQty: 'true', // Close PO only when Completed Qty = Planned Qty (rejected-receipt orders exempt)
// Close PO only if this app's own local stage tracking confirms it
// actually passed through Issuance → Receipt → Transfer to FG in order.
// The "Out of sequence" block on Close already enforces this whenever a
// local tracking record exists — but if that record is MISSING (e.g. the
// registration call right after SAP creation silently failed, or the
// order was never created through this portal), the sequence can't be
// verified at all today, and Close proceeds anyway. ON (default): refuse
// to close any Production Order with no local tracking record. OFF: keep
// today's looser behavior (needed if you have genuine legacy/external SAP
// orders this portal never tracked, that still need to be closeable).
poCloseRequireTracking: 'true',
// Block Receipt from Production AND Close until every REAL component
// (ItemType != pit_Resource) on the Production Order shows
// IssuedQuantity >= PlannedQuantity in SAP. Today, a Production Order
// moves to the "Receipt" stage after the FIRST issue action, even a
// partial one issuing just one of many components (by design — "you
// don't have to issue every component at once") — this setting adds a
// SEPARATE, stricter gate on top for sites that don't want Receipt/Close
// possible until issuance is fully complete. Default ON.
poRequireFullIssuance: 'true',
// Comma-separated list of allowed Weighing Balance IDs — when non-empty,
// the "Weighing Balance ID" field (Work Order creation's raw material
// rows, and Verify Work Order's per-row inline edit at issuance) becomes a
// dropdown restricted to this list instead of free text. Empty (default)
// = free text, unchanged from before this setting existed.
// Rejection Register's batch lookup: besides this portal's own Work Order
// record and SAP's batch master (OBTN), also search SAP Production
// Orders' own Remarks/Comments field for a match (OWOR.Comments — where
// this portal, and evidently direct SAP entry too, writes the Batch No.
// since Production Orders have no dedicated field for it). Off by
// default — it's a broader/looser match than the other two (matches on
// free text ANYONE could have typed into Remarks for any reason), so an
// admin opts in only if that batch-number convention is actually
// followed at their site.
rejectionLookupProdOrderRemarks: 'false',
// Comma-separated master list of Shift options for the Rejection
// Register's entry wizard (Which batch → Shift radio buttons). Admin-
// editable instead of hardcoded, same pattern as Production Order Types.
rejectionShifts: 'A (06:00–14:00),B (14:00–22:00),C (22:00–06:00)',
// Master list of manufacturing Stages the Rejection Register can count
// against (EBB, Sheet Welding, Moulding, …) — JSON array of
// {v,label,active}. Managed from the Rejection Analytics → Root Cause
// Setup screen itself (routes/rejectionRegister.js's GET/PUT /stages,
// gated by rejection_register:causes_setup — deliberately NOT admin-only,
// since the QA lead who manages causes should manage stages too without
// needing System Settings access) rather than here — this default only
// matters on a brand-new install (empty: no sample/placeholder stages are
// seeded, an admin adds their own real ones). "Deleting" a stage there
// sets active:false rather than removing it from this list — existing
// Rejection Causes/entries still reference its key, so a hard removal
// would leave their stage label unresolvable (see [[rejection-register-soft-delete]]).
rejectionStages: JSON.stringify([]),
woWeighingBalanceIds: '',
// Extra plain (no-conversion) options appended to the "Std. Qty/Unit"
// dropdown on Work Order's Packing Material rows, alongside the built-in
// "No."/"Units" — e.g. "Box, Roll, Set". Behave exactly like "Units": no
// auto-scaling/conversion math, just a distinct label. Comma-separated;
// empty (default) = just the two built-in options.
woCustomQtyUnits: '',
woSolutionBatchMaxEditLtr: '200', // New Work Order: BLOOD BAG Solution Batch Size editable only when < this many Ltr (else locked to the auto value)
woSolutionBatchMaxEditLtrPD: '200', // …same for PD (CAPD) finished goods
woSolutionBatchRoundLtr: '10', // Auto Solution Batch Size rounded to the nearest this many Ltr — BLOOD BAG
woSolutionBatchRoundLtrPD: '50', // …nearest this many Ltr — PD (CAPD)
// New Work Order BOM scan: when a top-level SFG (semi-finished good — an
// item that itself has its own BOM) would otherwise land in the Packing
// Material table alongside real packing items, drop it from there. Only
// takes effect when the table is genuinely "Packing Material" (a Solution/
// raw-material section exists) — when there's no raw material and the
// table is the combined "Component" table instead, SFG items stay put.
// Off by default — no behavior change until an admin opts in.
woHideSfgFromPacking: 'false',
// Raw Material BOM explosion (explodeToLeaves): a Solution sub-assembly's
// recipe is walked down to real leaf ingredients, but WFI (SFG00090 — an
// SFG that has its own SAP BOM, e.g. for its own separate PWO/backflush
// handling) would otherwise get silently recursed THROUGH instead of
// shown as its own Raw Material row, since it isn't a plain leaf item.
// ON = stop at SFG00090 and list it directly as a Raw Material line (its
// own sub-ingredients are not shown). OFF = old behavior, keep exploding
// into it like any other non-leaf BOM node. Scoped to this ONE item code
// only, not a generic "show every SFG" toggle.
woShowWfiInRawMaterial: 'true',
// Packing Material/Components' "Std. Qty/Unit" cell has a manual unit
// picker (mg/gm/Kg/pcs/etc, next to the qty input) alongside the new
// read-only UOM column (SAP's actual stock UOM). ON = hide that picker —
// the qty input alone fills the cell, UOM shown separately is enough.
woHideStdQtyUnitPicker: 'false',
// Whole-feature switch for the admin-managed Item Code/Item Group → Ovg%
// override (services/rmOvgSettingsStore.js): when ON, a raw material row
// whose item code (or its item group) has a configured row gets its
// Ovg. cell pre-filled from that setting, and its "Qty Req./100 ml" is
// recomputed as (BOM value) − (BOM value × Ovg%/100) instead of the plain
// BOM figure — with NO further Ovg% multiplication downstream in Qty Req.
// (the reduction is already baked into the /100ml figure). OFF = today's
// behavior everywhere, completely unchanged.
rmOvgOverrideEnabled: 'false',
// OEE — machine names PER OEE type/tab (e.g. EBB Production has its own
// machine list, separate from PD Production's). JSON object, tab key →
// comma-separated names, e.g. {"ebb":"EBB-01, EBB-02","pd":"PD-Line-1"}.
// A tab with a non-empty list here gets its "Machine Name" field turned
// into a dropdown restricted to that tab's own list; a tab with no entry
// stays free text (see public/oee.html).
oeeMachineNames: '{}',
receiptAutoclaveRejection: 'true',
receiptAutoclaveItemGroups: '101,102,143,144,142',
manpowerTabs: 'bb,packing,autoclave,capd,moulding,sheet,lamination,stent,equipment,needle',
// Work Order print/PDF header — shown on the printable Production Work Order.
woCompanyName: 'MITRA INDUSTRIES PRIVATE LIMITED',
woCompanyAddress: '14/4, Delhi Mathura Road, Faridabad, Haryana',
woFormNo: 'MIPL-QS-029-01K',
woEffectiveDate: '24-03-2025',
woReviewDate: '23-03-2027',
woLogo: '', // base64 data URI of the logo image (optional)
// Production module stage-change email notifications (services/notifyStore.js).
// Global on/off switch — SMTP itself is configured via .env (SMTP_HOST/…),
// not here, since this app has no other admin-editable server credentials.
notifyEmailsEnabled: 'true',
// Company Growth (Board) Dashboard — which SAP Item Groups are allowed to
// appear in the "Sales by Product Group" card and its filter, out of every
// group the SAP company actually has. Comma-separated codes (plain group
// codes, plus the synthetic '103bb'/'103capd' FG EQUIPMENT split codes —
// see routes/board.js). Empty = no restriction (every group shows).
boardProductGroups: '',
// PPC Report — which SAP Item Groups are allowed to appear in its "Item
// Group" filter, out of every group the SAP company actually has (site
// wants just the FG groups — BB, CAPD, Equipment, etc. — not raw material/
// packing groups too). Comma-separated group codes. Empty = no restriction
// (every group shows) — same convention as boardProductGroups above.
ppcItemGroups: '',
// PPC Report — dedicated API key for EXTERNAL (non-portal-login) access to
// GET /api/ppc/invoice-report and /api/ppc/item-groups, via header
// "X-API-Key: <this value>". Empty (default) = external access is off —
// only real portal logins (JWT) can call those routes. A separate
// credential from any user's own login, so it can be rotated/revoked here
// without touching anyone's account. See routes/ppc.js's verifyApiKeyOrToken().
ppcApiKey: '',
// PPC Report — "Order Type" filter options, sent verbatim as SAP's free-
// text U_SalesType UDF (routes/ppc.js). Not a fixed enum in SAP — whoever
// enters Sales Orders can type any value there, so this list was
// previously hardcoded in ppc-report.html and needed a code change every
// time a new one showed up in SAP (e.g. "Institute Ind"). Comma-separated,
// seeded with the values already in use.
ppcOrderTypes: 'Trade,Institute,Institute Ind',
// Requirement Calculator (under Requirements) — which comparison periods
// are enabled. At least one must always stay enabled (enforced in
// setMany() below); disabling one collapses Average Sale/Month to just the
// other period's qty ÷ its own month span, instead of (A+B)/months.
reqCalcPeriodAEnabled: 'true',
reqCalcPeriodBEnabled: 'true',
// Display labels for the two periods (default "Period A"/"Period B") — an
// admin can rename them to something meaningful, e.g. "Last Year" / "This Year".
reqCalcPeriodALabel: 'Period A',
reqCalcPeriodBLabel: 'Period B',
// Batch Issuance Intimation — off by default (blocked): a product's total
// batch qty (Total Units) can never exceed its requirement's pending qty.
// When on, an admin allows exceeding it (e.g. rounding/safety buffer).
biAllowExceedPending: 'false',
// Batch Issuance — SFG (Semi-Finished Good) workflow. Off by default. When
// ON, the "New Intimation" screen gets a second, separate "SFG" tab that
// lets a user search and add items DIRECTLY (no Requirement selection at
// all) — restricted server-side to items whose SAP Item Group is tagged
// 'SFG' in Item Group Rules (services/itemGroupClassStore.js). The
// resulting Batch Intimation has no refNo/requirementId, same as any
// other Intimation otherwise — Work Order generation from it is unchanged
// (already pulls the full BOM regardless of source).
biSfgWorkflowEnabled: 'false',
// Batch Issuance Intimation — Multi-Solution Volumes. Off by default (today's
// behavior, unchanged): a batch's single "Batch Volume (Ltr)" field, when
// present, is broadcast to EVERY Solution group on the Work Order generated
// from it — correct for a one-solution product, WRONG for a multi-solution
// one (e.g. a dual-chamber bag with its own anticoagulant + additive
// solutions), which silently forces every solution to the same batch size.
// When ON: a batch can instead list PER-SOLUTION name+volume pairs
// (public/batch-issuance.html); Work Order generation matches each by name
// against the BOM's own Solution groups and applies only that group's own
// volume — an unmatched group keeps its plain BOM-calculated ratio instead
// of being overridden. A batch with the old single value and NO per-solution
// list still applies it, but ONLY when the product's BOM has exactly one
// Solution group — multi-solution products with no per-solution data fall
// back to pure BOM math for every group (no more silent broadcast).
biMultiSolutionVolumesEnabled: 'false',
// Production Deviations (Shortage / Substitution / Damage, raised after
// Issue for Production) — see services/deviationStore.js. Scrap Warehouse
// is where a Damage/Loss deviation's SAP write-off (Stock Transfer) lands
// when the user chooses to post one. QA Approval, when on, leaves a
// deviation "Pending QA Review" after its SAP posting until someone with
// the deviation step's 'approve' perm signs off — the posting itself is
// never gated by this, only the paper-trail closure.
deviationScrapWarehouse: '',
deviationRequireQaApproval: 'true',
// When on, raising a Shortage/Substitution/Damage deviation does NOT post
// the SAP goods movement immediately — it only adds/updates the affected
// component on the Production Order (Shortage bumps PlannedQuantity,
// Substitution adds the new line). The line stays LOCKED (Issue for
// Production refuses to issue it) until QA approves the deviation — the
// concerned store user then issues it manually via Issue for Production.
// Damage/Loss has no "Issue for Production" equivalent, so it gets its own
// "Post to SAP" button on the deviation once approved.
deviationDeferIssueUntilApproval: 'false',
// Controls how a Substitution's replaced (now-superseded, zeroed-out) old
// item line actually gets deleted from SAP via DI API. ON (default) = show
// the "Remove from SAP" button, someone must press it (manual, reviewed
// per case). OFF = no button shown at all — the app deletes it
// automatically right after the substitution is applied, whenever it's
// safe to (the same live IssuedQuantity==0 check either way — an
// already-issued line is NEVER auto- or manually-removable).
deviationShowRemoveOldLineButton: 'true',
// SAP B1 document numbering Series (the internal Series ID from
// Administration → System Initialization → Document Numbering, NOT the
// series name) that a Damage/Loss deviation's Stock Transfer write-off —
// and its reversal, if QA later rejects it — should be posted under.
// Empty = don't send Series at all, SAP picks its own default series.
deviationDamageSeries: '',
// Comma-separated subset of SHORTAGE/SUBSTITUTION/DAMAGE — which Deviation
// types an admin has chosen to make available at all. Default = all three
// (today's behavior, unchanged unless an admin opts to narrow it). At
// least one must always stay enabled (enforced in setMany()) — a
// Deviation type with nothing enabled would leave "Raise Deviation" with
// no valid choice.
deviationEnabledTypes: 'SHORTAGE,SUBSTITUTION,DAMAGE',
// Whole-feature switch for the Requirement Production↔Store review loop
// (Production shares a Requirement with Store, Store cross-checks it
// against SAP's own MRP Wizard output and edits it, reverts to
// Production) — OFF by default since it may not be needed at every site;
// when OFF, Requirements behave exactly as before this feature existed
// (straight to Batch Intimation, no extra step visible anywhere).
requirementStoreReviewEnabled: 'false',
// Only meaningful when the switch above is ON: whether a Batch Intimation
// can be raised from a Requirement that HASN'T completed the Store review
// round trip yet. ON = hard block (must reach REVIEW_STAGE 2 first). OFF =
// the review loop is available but optional — Production can still skip
// straight to Batch Intimation without waiting on Store.
requirementStoreReviewHardGate: 'false',
// Pre-PWO Store Review (Production Order creation) — OFF by default. When
// ON, "Create Production Order from Work Order" no longer writes to SAP
// immediately: it first creates a portal-only "Pre-PWO" staging record
// (same component/BOM table as today, editable). Production must "Share
// with Store", who can add/remove/substitute component lines outright and
// revert it (single round trip, no further back-and-forth). HARD GATE —
// whenever this setting is ON, a Work-Order-sourced Production Order
// cannot reach SAP until Store has actually completed the review (no
// bypass, enforced server-side in routes/sap.js's production-order
// create route, not just the UI). Once pushed, the Pre-PWO locks
// (read-only) and the real SAP Production Order + existing 6-stage
// lifecycle continues exactly as before, unchanged.
preWoStoreReviewEnabled: 'false',
// JSON map of stepFullKey (or a synthetic module key, e.g.
// 'module:production-batch-issuance') -> comma-separated extra recipient
// emails, ALWAYS notified for that step/module in addition to whoever is
// actually assigned it. Lets an admin cc a supervisor/QA inbox that isn't
// itself an approval-step holder.
notifyExtraEmails: '{}',
// Work Order Raw Material table — per-item SOP display factor for the
// "Qty Req./100 ml" figure ONLY. Some raw materials are labeled by their
// supplied strength (e.g. "SODIUM LACTATE 60% USP") — SAP's BOM quantity
// already reflects the as-purchased, diluted-strength weight needed, so
// the calculated Qty Req./100 ml and final Qty Req. are both correct as-is.
// Some sites' SOP paperwork still wants the /100ml figure shown scaled by
// the strength percentage anyway (purely cosmetic — see conversation with
// chandan.singh@mitraindustries.com, 2026-09-01, item RM00003). JSON map of
// itemCode -> percent (0-100], e.g. {"RM00003":60}. Applied ONLY to the
// displayed Qty Req./100 ml value (create/edit form, View, Print, PDF) —
// never to the stored stdQty used for the Qty Req. calc, never to Qty Req.
// itself, and never to what gets saved/posted. Missing/absent itemCode = 100
// (no adjustment, today's behavior).
woRawPotencyFactors: '{}',
// Work Order — where every material table's "Qty Issued" column value
// comes from (Raw Material, Packing Material, and the combined
// Components table alike). Key name kept as "woRawQtyIssuedSource" for
// backward compatibility with sites that already set it — scope was
// widened to cover Packing Material/Components too without a migration.
// 'issue_for_production' (default) — calculated from the real SAP
// posting. Raw Material rows are never SAP Production Order lines
// themselves (they're the exploded recipe of a Solution/SFG item,
// which IS the PO line), so their value is prorated: (this issue's
// Quantity ÷ that Solution item's PlannedQuantity) × the row's own
// full Qty Req., persisted cumulatively across multiple issue passes.
// Packing Material/Components rows ARE real PO lines, so their value
// is simply read live off the PO by item code — unaffected by this
// setting either way.
// 'mark_issued' — the manual one-click "Mark Issued" paperwork stamp on
// a row (same action that sets Issued By/Date) sets Qty Issued = that
// row's full Qty Req., regardless of what was actually posted in SAP
// (or whether anything was posted at all) — now for EVERY material
// table, not just Raw Material.
woRawQtyIssuedSource: 'issue_for_production',
// Packing Material/Components rows with NO unit picked in the Std Qty/Unit
// dropdown are treated as a discrete physical count (e.g. "2 caps") and
// Qty Req. rounds UP (Math.ceil) so a real fractional requirement never
// rounds down to "0 needed" — see recalcMaterials() in work-order.html.
// Some items genuinely ARE fractional in SAP even without a picked unit
// (e.g. a roll-based consumable tracked in NOS where SAP's own Planned
// Qty is 0.1 or 0.12, not a whole number) — rounding those up to 1
// overstates the requirement vs SAP. 'true' (default) keeps the existing
// round-up-to-whole-number behavior; 'false' switches to plain 3-decimal
// rounding (matches SAP's own fractional Planned Qty) for every such row.
woPackQtyRoundUp: 'true',
};
let _cache = { ...DEFAULTS };
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[APP-SETTINGS] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
SETTING_KEY NVARCHAR(100) PRIMARY KEY,
SETTING_VALUE NVARCHAR(MAX),
UPDATED_BY NVARCHAR(50),
UPDATED_AT DATETIME2
)
`).catch(e => { if (isAlreadyExists(e)) { console.log('[APP-SETTINGS] Table exists — OK'); } else throw e; });
// Seed any key that doesn't have a stored value yet (idempotent).
for (const [key, val] of Object.entries(DEFAULTS)) {
await exec(
`IF NOT EXISTS (SELECT 1 FROM ${TABLE} WHERE SETTING_KEY=?)
INSERT INTO ${TABLE} (SETTING_KEY, SETTING_VALUE, UPDATED_AT) VALUES (?,?,SYSUTCDATETIME())`,
[key, key, val]
).catch(e => console.warn('[APP-SETTINGS] seed failed for', key, e.message));
}
await reload();
console.log('[APP-SETTINGS] ✅ Ready');
}
async function reload() {
const rows = await exec(`SELECT SETTING_KEY, SETTING_VALUE FROM ${TABLE}`);
const next = { ...DEFAULTS };
rows.forEach(r => { if (r.SETTING_KEY in DEFAULTS) next[r.SETTING_KEY] = r.SETTING_VALUE; });
_cache = next;
}
function raw(key) { return _cache[key] != null ? _cache[key] : DEFAULTS[key]; }
// All raw string values (defaults merged with stored) — for the admin editor.
function getAll() { return { ...DEFAULTS, ..._cache }; }
// Update one or more keys (only known keys are accepted). Reloads the cache.
async function setMany(obj, updatedBy) {
// At least one Requirement Calculator period must stay enabled — the
// calculator has nothing to compute an average from otherwise.
const nextA = 'reqCalcPeriodAEnabled' in (obj || {}) ? String(obj.reqCalcPeriodAEnabled).toLowerCase() === 'true' : reqCalcPeriodAEnabled();
const nextB = 'reqCalcPeriodBEnabled' in (obj || {}) ? String(obj.reqCalcPeriodBEnabled).toLowerCase() === 'true' : reqCalcPeriodBEnabled();
if (!nextA && !nextB) throw new Error('At least one of Period A / Period B must stay enabled for the Requirement Calculator');
// At least one Deviation type must stay enabled — otherwise "Raise
// Deviation" would have no valid choice left at all.
if ('deviationEnabledTypes' in (obj || {})) {
const nextTypes = String(obj.deviationEnabledTypes || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
if (!nextTypes.length) throw new Error('At least one Deviation type (Shortage / Substitution / Damage-Loss) must stay enabled');
}
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23);
for (const [key, value] of Object.entries(obj || {})) {
if (!(key in DEFAULTS)) continue; // ignore unknown keys
const val = value == null ? '' : String(value).trim();
await exec(`
MERGE ${TABLE} AS t
USING (SELECT ? AS K, ? AS V) AS s ON t.SETTING_KEY = s.K
WHEN MATCHED THEN UPDATE SET SETTING_VALUE = s.V, UPDATED_BY = ?, UPDATED_AT = ?
WHEN NOT MATCHED THEN INSERT (SETTING_KEY, SETTING_VALUE, UPDATED_BY, UPDATED_AT) VALUES (s.K, s.V, ?, ?);
`, [key, val, updatedBy || null, now, updatedBy || null, now]);
}
await reload();
return getAll();
}
// ── Typed getters (parse at point of use) ───────────────────────
function bomApprovalLevels() { const n = parseInt(raw('bomApprovalLevels')); return (n >= 2 && n <= 4) ? n : 3; }
function workOrderSkipQc() { return String(raw('workOrderSkipQc')).toLowerCase() === 'true'; }
function prodOrderTypes() { return String(raw('prodOrderTypes') || 'bopotStandard').split(',').map(s => s.trim()).filter(Boolean); }
function itemCodeSeriesFloor() {
try {
const obj = JSON.parse(raw('itemCodeSeriesFloor') || '{}');
if (!obj || typeof obj !== 'object') return {};
const out = {};
Object.keys(obj).forEach(k => { const n = parseInt(obj[k]); if (!isNaN(n) && n > 0) out[String(k).toUpperCase()] = n; });
return out;
} catch { return {}; }
}
function poRequireStockAvailability() { return String(raw('poRequireStockAvailability')).toLowerCase() === 'true'; }
function receiptRequireFullQty() { return String(raw('receiptRequireFullQty')).toLowerCase() === 'true'; }
function receiptExcludeByProductFromTotal() { return String(raw('receiptExcludeByProductFromTotal')).toLowerCase() !== 'false'; }
function closeRequireFullQty() { return String(raw('closeRequireFullQty')).toLowerCase() === 'true'; }
function poCloseRequireTracking(){ return String(raw('poCloseRequireTracking')).toLowerCase() === 'true'; }
function poRequireFullIssuance(){ return String(raw('poRequireFullIssuance')).toLowerCase() === 'true'; }
function woWeighingBalanceIds(){ return String(raw('woWeighingBalanceIds') || '').split(',').map(s => s.trim()).filter(Boolean); }
function woCustomQtyUnits(){ return String(raw('woCustomQtyUnits') || '').split(',').map(s => s.trim()).filter(Boolean); }
function woSolutionBatchMaxEditLtr() { return parseFloat(raw('woSolutionBatchMaxEditLtr')) || 200; }
function woSolutionBatchMaxEditLtrPD() { return parseFloat(raw('woSolutionBatchMaxEditLtrPD')) || 200; }
function woSolutionBatchRoundLtr() { return parseFloat(raw('woSolutionBatchRoundLtr')) || 10; }
function woSolutionBatchRoundLtrPD() { return parseFloat(raw('woSolutionBatchRoundLtrPD')) || 50; }
function receiptAutoclaveRejection() { return String(raw('receiptAutoclaveRejection')).toLowerCase() === 'true'; }
function receiptAutoclaveItemGroups() { return String(raw('receiptAutoclaveItemGroups') || '').split(',').map(s => s.trim()).filter(Boolean); }
function manpowerTabs() { return String(raw('manpowerTabs') || '').split(',').map(s => s.trim()).filter(Boolean); }
// Work Order print header (raw string getters).
function woCompanyName() { return String(raw('woCompanyName') || ''); }
function woCompanyAddress() { return String(raw('woCompanyAddress') || ''); }
function woFormNo() { return String(raw('woFormNo') || ''); }
function woEffectiveDate() { return String(raw('woEffectiveDate') || ''); }
function woReviewDate() { return String(raw('woReviewDate') || ''); }
function woLogo() { return String(raw('woLogo') || ''); }
function notifyEmailsEnabled() { return String(raw('notifyEmailsEnabled')).toLowerCase() === 'true'; }
function boardProductGroups() { return String(raw('boardProductGroups') || '').split(',').map(s => s.trim()).filter(Boolean); }
function ppcItemGroups() { return String(raw('ppcItemGroups') || '').split(',').map(s => s.trim()).filter(Boolean); }
function ppcApiKey() { return String(raw('ppcApiKey') || '').trim(); }
function ppcOrderTypes() { return String(raw('ppcOrderTypes') || '').split(',').map(s => s.trim()).filter(Boolean); }
function notifyExtraEmails() { try { return JSON.parse(raw('notifyExtraEmails') || '{}') || {}; } catch { return {}; } }
function reqCalcPeriodAEnabled() { return String(raw('reqCalcPeriodAEnabled')).toLowerCase() === 'true'; }
function reqCalcPeriodBEnabled() { return String(raw('reqCalcPeriodBEnabled')).toLowerCase() === 'true'; }
function reqCalcPeriodALabel() { return String(raw('reqCalcPeriodALabel') || 'Period A').trim() || 'Period A'; }
function reqCalcPeriodBLabel() { return String(raw('reqCalcPeriodBLabel') || 'Period B').trim() || 'Period B'; }
function biAllowExceedPending() { return String(raw('biAllowExceedPending')).toLowerCase() === 'true'; }
function biSfgWorkflowEnabled() { return String(raw('biSfgWorkflowEnabled')).toLowerCase() === 'true'; }
function biMultiSolutionVolumesEnabled() { return String(raw('biMultiSolutionVolumesEnabled')).toLowerCase() === 'true'; }
function woHideSfgFromPacking() { return String(raw('woHideSfgFromPacking')).toLowerCase() === 'true'; }
function woShowWfiInRawMaterial() { return String(raw('woShowWfiInRawMaterial')).toLowerCase() === 'true'; }
function woHideStdQtyUnitPicker() { return String(raw('woHideStdQtyUnitPicker')).toLowerCase() === 'true'; }
function rmOvgOverrideEnabled() { return String(raw('rmOvgOverrideEnabled')).toLowerCase() === 'true'; }
// { tabKey: "comma, separated, names" } — malformed/missing = {}.
function oeeMachineNames() { try { const v = JSON.parse(raw('oeeMachineNames') || '{}'); return (v && typeof v === 'object') ? v : {}; } catch { return {}; } }
function deviationScrapWarehouse() { return String(raw('deviationScrapWarehouse') || '').trim(); }
function deviationRequireQaApproval() { return String(raw('deviationRequireQaApproval')).toLowerCase() === 'true'; }
function deviationDeferIssueUntilApproval() { return String(raw('deviationDeferIssueUntilApproval')).toLowerCase() === 'true'; }
function deviationShowRemoveOldLineButton() { return String(raw('deviationShowRemoveOldLineButton')).toLowerCase() === 'true'; }
// Returns the numeric Series ID, or null if unset/invalid — callers only
// add the field to a SAP payload when this isn't null, so an empty setting
// cleanly leaves SAP's own default series in effect.
function deviationDamageSeries() {
const v = String(raw('deviationDamageSeries') || '').trim();
if (!v) return null;
const n = parseInt(v, 10);
return Number.isNaN(n) ? null : n;
}
function deviationEnabledTypes() {
const v = String(raw('deviationEnabledTypes') || '').trim();
const set = new Set(v.split(',').map(s => s.trim().toUpperCase()).filter(Boolean));
return ['SHORTAGE', 'SUBSTITUTION', 'DAMAGE'].filter(t => set.has(t));
}
function requirementStoreReviewEnabled() { return String(raw('requirementStoreReviewEnabled')).toLowerCase() === 'true'; }
function requirementStoreReviewHardGate() { return String(raw('requirementStoreReviewHardGate')).toLowerCase() === 'true'; }
function preWoStoreReviewEnabled() { return String(raw('preWoStoreReviewEnabled')).toLowerCase() === 'true'; }
function woRawPotencyFactors() { try { return JSON.parse(raw('woRawPotencyFactors') || '{}') || {}; } catch { return {}; } }
function woRawQtyIssuedSource() { const v = String(raw('woRawQtyIssuedSource') || '').trim(); return v === 'mark_issued' ? 'mark_issued' : 'issue_for_production'; }
function woPackQtyRoundUp() { return String(raw('woPackQtyRoundUp')).toLowerCase() !== 'false'; }
function rejectionLookupProdOrderRemarks() { return String(raw('rejectionLookupProdOrderRemarks')).toLowerCase() === 'true'; }
function rejectionShifts() { return String(raw('rejectionShifts') || '').split(',').map(s => s.trim()).filter(Boolean); }
// Always returns EVERY stage (active and soft-deleted alike) — callers that
// need only the pickable ones filter with .filter(s=>s.active!==false)
// themselves (the entry wizard's Stage select, Analytics' filter chips, a
// new cause's Stage select); callers doing label lookups for historical
// data (Batch Log, existing causes) want the full list so a since-deleted
// stage's name still resolves instead of falling back to its raw key.
function rejectionStages() {
try {
const arr = JSON.parse(raw('rejectionStages') || '[]');
if (!Array.isArray(arr)) return [];
return arr.map(s => ({ v: String(s?.v || '').trim(), label: String(s?.label || '').trim(), active: s?.active !== false }))
.filter(s => s.v && s.label);
} catch { return []; }
}
module.exports = {
bootstrap, reload, getAll, setMany, DEFAULTS,
bomApprovalLevels, workOrderSkipQc, prodOrderTypes,
itemCodeSeriesFloor, poRequireStockAvailability, receiptRequireFullQty, receiptExcludeByProductFromTotal, closeRequireFullQty, poCloseRequireTracking, poRequireFullIssuance, woWeighingBalanceIds, woCustomQtyUnits, woSolutionBatchMaxEditLtr, woSolutionBatchMaxEditLtrPD,
woSolutionBatchRoundLtr, woSolutionBatchRoundLtrPD, receiptAutoclaveRejection, receiptAutoclaveItemGroups, manpowerTabs,
woCompanyName, woCompanyAddress, woFormNo, woEffectiveDate, woReviewDate, woLogo,
notifyEmailsEnabled, notifyExtraEmails, boardProductGroups, ppcItemGroups, ppcApiKey, ppcOrderTypes,
reqCalcPeriodAEnabled, reqCalcPeriodBEnabled, reqCalcPeriodALabel, reqCalcPeriodBLabel,
biAllowExceedPending, biSfgWorkflowEnabled, biMultiSolutionVolumesEnabled, woHideSfgFromPacking, woShowWfiInRawMaterial, woHideStdQtyUnitPicker, rmOvgOverrideEnabled, oeeMachineNames,
deviationScrapWarehouse, deviationRequireQaApproval, deviationDeferIssueUntilApproval, deviationShowRemoveOldLineButton, deviationDamageSeries, deviationEnabledTypes,
requirementStoreReviewEnabled, requirementStoreReviewHardGate,
preWoStoreReviewEnabled,
woRawPotencyFactors, woRawQtyIssuedSource, woPackQtyRoundUp,
rejectionLookupProdOrderRemarks, rejectionShifts, rejectionStages,
};
+80
View File
@@ -0,0 +1,80 @@
// services/appSqlPool.js
// Shared MSSQL connection pool for the PORTAL'S OWN tables (ZWORK_ORDERS,
// ZCUST_USERS, ZAPPROVAL_STEPS, pl_account_map, cf_items, bs_config, etc.) —
// deliberately a SEPARATE server/database from the SAP B1 SQL Server
// (services/sqlPool.js), so nothing this app creates ever touches the SAP
// database. Same shape/API as sqlPool.js (getPool/query), just pointed at
// APP_SQL_* env vars instead of SQL_*.
'use strict';
const sql = require('mssql');
let _pool = null;
let _connecting = false;
let _waiters = [];
function buildConfig() {
return {
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT) || 1433,
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
connectionTimeout: 30000,
requestTimeout: 60000,
pool: { max: 10, min: 0, idleTimeoutMillis: 30000 },
options: {
encrypt: true,
trustServerCertificate: true,
enableArithAbort: true,
cryptoCredentialsDetails: { minVersion: 'TLSv1' },
},
};
}
async function getPool() {
if (_pool && _pool.connected) return _pool;
if (_connecting) {
return new Promise((resolve, reject) => _waiters.push({ resolve, reject }));
}
_connecting = true;
try {
const pool = new sql.ConnectionPool(buildConfig());
pool.on('error', err => {
console.error('[APP-SQL-POOL] Pool error — resetting:', err.message);
_pool = null;
});
await pool.connect();
_pool = pool;
console.log(`[APP-SQL-POOL] ✅ Connected ${process.env.APP_SQL_DATABASE} @ ${process.env.APP_SQL_HOST}`);
_waiters.forEach(w => w.resolve(_pool));
return _pool;
} catch (err) {
_pool = null;
console.error('[APP-SQL-POOL] ❌ Connection failed:', err.message);
_waiters.forEach(w => w.reject(err));
throw err;
} finally {
_connecting = false;
_waiters = [];
}
}
async function query(sqlText, params = []) {
const pool = await getPool();
const req = pool.request();
let i = 0;
const text = sqlText.replace(/\?/g, () => {
const name = `p${i}`;
req.input(name, params[i]);
i++;
return `@${name}`;
});
const result = await req.query(text);
return result.recordset || [];
}
module.exports = { getPool, query };
+286
View File
@@ -0,0 +1,286 @@
// services/approvalStepsStore.js
// Generic Approval Step registry: a catalogue of steps grouped by workflow,
// used to grant users fine-grained "which step can this person act on"
// permissions (services/hanaUsers.js stores the per-user assignment array).
// Built-in workflows are seeded on bootstrap (idempotent); admins can also
// add custom steps to a NEW workflow name via the API.
'use strict';
const { getPool } = require('./appSqlPool');
const TABLE = `[dbo].[ZAPPROVAL_STEPS]`;
// Built-in steps, seeded once. STEP_KEY must be unique within a workflow.
const BUILTIN_STEPS = [
// Work Order — 5-step QA/Production sign-off chain
{ workflow: 'work_order', key: 'prepared_qa', label: 'Prepared By QA', order: 1 },
{ workflow: 'work_order', key: 'checked_qc', label: 'Checked By QC', order: 2 },
{ workflow: 'work_order', key: 'checked_production', label: 'Checked By Store In-Charge', order: 3 },
{ workflow: 'work_order', key: 'checked_mgr_production', label: 'Checked By (Manager Production)', order: 4 },
{ workflow: 'work_order', key: 'approved_mgr_qa', label: 'Approved By (Manager QA)', order: 5 },
// Corrective, NOT part of the sequential 5-step chain above (same pattern as
// production_order's return_components): a portal-only sign-off performed
// AFTER a linked Production Order has been Issued in SAP — there is no such
// process in SAP itself. The verifier's signature is captured on the printed
// Work Order's "Verified By" column. Distinct from production_order:verify
// (kept, unused, for possible future use) — this is the one actually wired
// to the "Verify Work Order" screen (public/verify-work-order.html).
{ workflow: 'work_order', key: 'verify', label: 'Verify Work Order (post-Issuance sign-off)', order: 6 },
// Same idea as 'verify' above — a portal-only manual sign-off, independent
// of whoever performed the actual "Receipt from Production" SAP step. Lets
// a designated person (e.g. store keeper physically receiving the batch)
// record "Received By" for the printed Work Order, overriding the
// SAP-step-derived value. Managed from the same "Verify Work Order" screen.
{ workflow: 'work_order', key: 'receive', label: 'Received By (post-Issuance sign-off)', order: 7 },
// Per-row (per material line) sign-off — NOT order-wide. 'edit' lets the
// issuer fill in that row's Weighing Balance ID / AR No. at the moment of
// physically issuing it; 'approve' lets them stamp "Issued By/Date" on that
// one row. See public/verify-work-order.html — each row has its own
// Issued/Received/Verified stamps since different items can be issued,
// received and verified on different days by different people.
{ workflow: 'work_order', key: 'issue', label: 'Issued By (per-row post-Issuance sign-off)', order: 8 },
// BOM — up to 4 levels (BOM_APPROVAL_LEVELS controls how many are actually used)
{ workflow: 'bom', key: 'level1', label: 'Level 1 (Manager)', order: 1 },
{ workflow: 'bom', key: 'level2', label: 'Level 2 (Sr. Manager)', order: 2 },
{ workflow: 'bom', key: 'level3', label: 'Level 3 (SAP Adder)', order: 3 },
{ workflow: 'bom', key: 'level4', label: 'Level 4 (SAP Adder — Final)', order: 4 },
// Customer / Vendor registration — shared shape for both
{ workflow: 'customer_vendor', key: 'verify', label: 'Verify (Pending → Verified)', order: 1 },
{ workflow: 'customer_vendor', key: 'approve', label: 'Final Approval (Verified → SAP B1)', order: 2 },
// Project approvals — sequential department chain
{ workflow: 'project', key: 'purchase', label: 'Purchase', order: 1 },
{ workflow: 'project', key: 'engineering', label: 'Engineering', order: 2 },
{ workflow: 'project', key: 'qa', label: 'QA', order: 3 },
{ workflow: 'project', key: 'qc', label: 'QC', order: 4 },
{ workflow: 'project', key: 'legal', label: 'Legal', order: 5 },
{ workflow: 'project', key: 'owner', label: 'Project Owner', order: 6 },
{ workflow: 'project', key: 'plant', label: 'Plant Manager', order: 7 },
{ workflow: 'project', key: 'finance', label: 'Finance & Accounts', order: 8 },
// Production Order — created FROM a Work Order, then a 6-step SAP B1
// production lifecycle. Each step's 'approve' perm gates performing that
// step; 'add' on 'create'/'manual_create' gates who can originate an order.
// Split into two distinct steps so an admin can allow one without the
// other — e.g. force everyone through the Work Order-linked flow only,
// with no standalone/manual entry at all.
{ workflow: 'production_order', key: 'create', label: 'Create (from Work Order)', order: 1 },
{ workflow: 'production_order', key: 'manual_create', label: 'Create (Manual Entry, no Work Order)', order: 2 },
{ workflow: 'production_order', key: 'release', label: 'Release', order: 3 },
{ workflow: 'production_order', key: 'issuance', label: 'Issuance', order: 4 },
{ workflow: 'production_order', key: 'receipt', label: 'Receipt from Production', order: 5 },
{ workflow: 'production_order', key: 'transfer_fg', label: 'Transfer to Finished Goods', order: 6 },
{ workflow: 'production_order', key: 'close', label: 'Close', order: 7 },
// Corrective side-action, NOT part of the sequential stage chain above:
// after Issuance, defective component quantities can be returned to stock
// from the Receipt from Production page (SAP's "Return Components").
{ workflow: 'production_order', key: 'return_components', label: 'Return Components (defective, back to stock)', order: 8 },
// Portal-only sign-off (NOT a SAP transaction): after Issuance, a separate
// person verifies the issued production order. Their signature is captured on
// the printed Work Order's "Verified By" column. 'view' shows the Verify
// screen; 'approve' performs the verification.
{ workflow: 'production_order', key: 'verify', label: 'Verify (post-Issuance sign-off)', order: 9 },
// Corrective side-action, NOT part of the sequential stage chain: after
// Issuance, production may hit a Shortage (top up qty), need a
// Substitution (issue a different item instead), or a Damage/Loss
// (write off a damaged qty, optionally to a scrap warehouse) — without
// stopping the run. 'add' gates who can raise one; 'approve' gates the
// QA post-hoc sign-off (only relevant when Admin → System Settings →
// "Deviation — Require QA Approval" is on; the SAP posting itself always
// happens immediately on raise, regardless of that setting).
{ workflow: 'production_order', key: 'deviation', label: 'Deviation (Shortage / Substitution / Damage)', order: 10 },
// Pre-PWO Store Review (optional, Admin → System Settings → "Pre-PWO —
// Store Review Before SAP" — a whole-feature on/off switch, since it may
// not be needed at every site). When enabled, "Create (from Work Order)"
// first stages a portal-only Pre-PWO instead of writing to SAP; Production
// can then optionally share it with Store — who may add/remove/substitute
// component lines outright — before reverting it back (a single round
// trip). Actually creating the staging record AND pushing the final
// Pre-PWO to SAP both stay gated on the existing 'create' step above
// (same authority as today, just relocated); these two steps only gate
// the NEW capability of the Store detour itself. 'add' on 'prepwo_share'
// gates who can share; 'add' on 'prepwo_review' gates who can revert.
{ workflow: 'production_order', key: 'prepwo_share', label: 'Pre-PWO — Share with Store (before SAP)', order: 11 },
{ workflow: 'production_order', key: 'prepwo_review', label: 'Pre-PWO — Store Review & Revert', order: 12 },
// The "+ PWO" shortcut on a Released order's component table: raises a
// separate, standalone Production Order for a component that is itself an
// SFG with its own BOM, pre-filled with that line's exact required qty.
// Deliberately its own step (not reusing 'manual_create') so an admin can
// hand out this one narrow shortcut without granting full freeform Manual
// Entry creation, or vice versa.
{ workflow: 'production_order', key: 'create_from_component', label: '"+ PWO" — Create from a Component (SFG sub-assembly)', order: 13 },
// "Consumable Order" — a standalone manual Production Order restricted to
// Service items (SAP Item Group 132) only, e.g. for consumable/tooling
// items that need their own PWO but aren't part of a normal finished-goods
// BOM chain. Its own steps (not 'manual_create'/the general per-stage
// steps) so an admin can hand out each narrow capability independently —
// 'add' here = create one; Release/Issue/Close each get their OWN
// dedicated step below so a user can hold any subset (e.g. Issue only)
// without gaining the others or bleeding into the general
// production_order:release/issuance/close steps used by normal PWOs (and
// vice versa). No creator bypass — even the order's own creator needs the
// matching step to progress it further. Consumable Orders never go through
// Receipt (confirmed workflow is Release → Issue → Close only), so there
// is deliberately no consumable Receipt step at all.
{ workflow: 'production_order', key: 'consumable_create', label: 'Consumable Order — Add (Manual Entry, Service items only, Group 132)', order: 14 },
{ workflow: 'production_order', key: 'consumable_release', label: 'Consumable Order — Release', order: 15 },
{ workflow: 'production_order', key: 'consumable_issue', label: 'Consumable Order — Issue', order: 16 },
{ workflow: 'production_order', key: 'consumable_close', label: 'Consumable Order — Close', order: 17 },
// Man Power — single entry step; its view/add/edit perms gate the whole
// Man Power module CRUD (routes/manpower.js). 'edit' also gates Cancel.
{ workflow: 'man_power', key: 'entry', label: 'Man Power Data Entry', order: 1 },
// OEE — single entry step; its view/add/edit perms gate the whole Overall
// Equipment Efficiency module CRUD (routes/oee.js). 'edit' also gates Cancel.
{ workflow: 'oee', key: 'entry', label: 'OEE Data Entry', order: 1 },
// Requirement — general CRUD gate (single entry step, same pattern as
// man_power:entry / oee:entry): 'view' gates seeing the Requirements page
// (list + Calculator) at all; 'add' gates creating a Requirement (and the
// Calculator's "send to New Requirement"); 'edit' gates editing one;
// 'delete' gates deleting one. Previously this was all-or-nothing via the
// 'production-requirements' module checkbox alone — see backfillRequirementManagePerm()
// in services/hanaUsers.js, which copies existing module access onto this
// step on deploy so nobody regresses.
{ workflow: 'requirement', key: 'manage', label: 'Create / Edit / Delete Requirements & Calculator', order: 1 },
// Requirement — Store Review Workflow (optional, Admin → System Settings →
// "Requirement — Store Review Workflow" — a whole-feature on/off switch
// separate from these permission steps, since it may not be needed at
// every site). A single round trip: Production shares a Requirement with
// Store, Store cross-checks it against SAP's own MRP Wizard output and
// edits the line items, then reverts it back to Production — who then
// raises the Batch Intimation as normal. 'add' on 'production_review'
// gates who can share; 'add' on 'store_review' gates who can revert.
{ workflow: 'requirement', key: 'production_review', label: 'Production — Share with Store', order: 2 },
{ workflow: 'requirement', key: 'store_review', label: 'Store — Review & Revert', order: 3 },
// Rejection Register — a line/shift user counts in-process rejections
// against a batch for one stage (EBB/Sheet Welding/Moulding/…), records
// rework, and submits; QA may then edit a submitted entry (the wizard
// itself is a one-way "submit" once done). Root-cause assignment (which
// physical/process cause each rejection cause rolls up to) is its own
// step so it can be handed to a QA lead separately from day-to-day QA
// editing. 'entry' with 'view' also gates seeing the Analytics screen —
// there's no separate analytics-only step, since anyone submitting/editing
// rejections needs to see the same numbers.
{ workflow: 'rejection_register', key: 'entry', label: 'Rejection Register — Entry (count & submit)', order: 1 },
{ workflow: 'rejection_register', key: 'qa_edit', label: 'Rejection Register — QA Edit After Submit', order: 2 },
{ workflow: 'rejection_register', key: 'causes_setup', label: 'Rejection Register — Root Cause Setup', order: 3 },
// Production Planning — entry step (view/add/edit/delete gate the plain
// CRUD list, routes/productionPlanning.js) plus a separate approve step:
// whoever holds 'approve' on production_planning:approve can sign off a
// plan, stamping Approved By/Date on it (used in the Excel export).
{ workflow: 'production_planning', key: 'entry', label: 'Production Planning Data Entry', order: 1 },
{ workflow: 'production_planning', key: 'approve', label: 'Production Planning Approval', order: 2 },
];
const WORKFLOW_LABELS = {
work_order: 'Work Order',
bom: 'BOM Approvals',
customer_vendor: 'Customer / Vendor Registration',
project: 'Project Approvals',
production_order: 'Production Order',
man_power: 'Man Power',
oee: 'Overall Equipment Efficiency',
requirement: 'Requirements',
rejection_register: 'Rejection Register',
production_planning: 'Production Planning',
};
async function exec(sqlQuery, params = []) {
const pool = await getPool();
const req = pool.request();
let i = 0;
const text = sqlQuery.replace(/\?/g, () => { const n = `p${i}`; req.input(n, params[i]); i++; return `@${n}`; });
const result = await req.query(text);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[APPROVAL-STEPS] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
WORKFLOW NVARCHAR(50) NOT NULL,
STEP_KEY NVARCHAR(50) NOT NULL,
STEP_LABEL NVARCHAR(150) NOT NULL,
STEP_ORDER INT DEFAULT 1,
IS_CUSTOM BIT DEFAULT 0,
CREATED_BY NVARCHAR(50),
CREATED_AT DATETIME2,
CONSTRAINT UQ_APPROVAL_STEP UNIQUE (WORKFLOW, STEP_KEY)
)
`).catch(e => { if (!isAlreadyExists(e)) throw e; });
// Seed built-ins (idempotent — skip any that already exist)
for (const s of BUILTIN_STEPS) {
await exec(
`IF NOT EXISTS (SELECT 1 FROM ${TABLE} WHERE WORKFLOW=? AND STEP_KEY=?)
INSERT INTO ${TABLE} (WORKFLOW, STEP_KEY, STEP_LABEL, STEP_ORDER, IS_CUSTOM, CREATED_AT)
VALUES (?,?,?,?,0,SYSUTCDATETIME())`,
[s.workflow, s.key, s.workflow, s.key, s.label, s.order]
).catch(e => console.warn('[APPROVAL-STEPS] seed failed for', s.workflow, s.key, e.message));
}
// One-time relabel: "Checked By Production" → "Checked By Store In-Charge".
// The seed loop above only INSERTs when a row is missing, so an
// already-seeded row from before this rename would otherwise keep
// showing the old label forever in the admin permission checkboxes. Only
// touches the built-in row (IS_CUSTOM=0) and only if it still has the old
// text — never overwrites an admin's own custom step with this key.
await exec(
`UPDATE ${TABLE} SET STEP_LABEL=? WHERE WORKFLOW='work_order' AND STEP_KEY='checked_production' AND IS_CUSTOM=0 AND STEP_LABEL=?`,
['Checked By Store In-Charge', 'Checked By Production']
).catch(e => console.warn('[APPROVAL-STEPS] checked_production relabel failed:', e.message));
console.log('[APPROVAL-STEPS] ✅ Ready');
}
function fromRow(row) {
return {
id: row.ID,
workflow: row.WORKFLOW,
key: row.STEP_KEY,
label: row.STEP_LABEL,
order: row.STEP_ORDER,
isCustom: !!row.IS_CUSTOM,
fullKey: `${row.WORKFLOW}:${row.STEP_KEY}`,
};
}
async function listAll() {
const rows = await exec(`SELECT * FROM ${TABLE} ORDER BY WORKFLOW, STEP_ORDER, ID`);
return rows.map(fromRow);
}
async function listGrouped() {
const all = await listAll();
const workflows = [...new Set(all.map(s => s.workflow))];
return workflows.map(w => ({
workflow: w,
label: WORKFLOW_LABELS[w] || w,
steps: all.filter(s => s.workflow === w),
}));
}
// Create a custom step. `workflow` may be an existing one or a brand-new
// custom workflow name (admin-defined, for future use cases).
async function createCustomStep({ workflow, key, label, order, createdBy }) {
const wf = String(workflow || '').trim().toLowerCase().replace(/[^a-z0-9_]/g, '_');
const k = String(key || '').trim().toLowerCase().replace(/[^a-z0-9_]/g, '_');
if (!wf || !k || !label) throw new Error('workflow, key and label are required');
await exec(
`INSERT INTO ${TABLE} (WORKFLOW, STEP_KEY, STEP_LABEL, STEP_ORDER, IS_CUSTOM, CREATED_BY, CREATED_AT)
VALUES (?,?,?,?,1,?,SYSUTCDATETIME())`,
[wf, k, label, parseInt(order) || 99, createdBy || null]
);
return { workflow: wf, key: k, label, fullKey: `${wf}:${k}` };
}
async function deleteCustomStep(id) {
await exec(`DELETE FROM ${TABLE} WHERE ID = ? AND IS_CUSTOM = 1`, [parseInt(id)]);
}
module.exports = {
bootstrap, listAll, listGrouped, createCustomStep, deleteCustomStep,
WORKFLOW_LABELS, BUILTIN_STEPS,
};
+114
View File
@@ -0,0 +1,114 @@
'use strict';
// services/auditStore.js — application-wide Audit Trail. Every mutating API
// request (create / update / delete / approve / login …) is recorded here by
// middleware/auditLogger.js, so admins (or users granted the 'audit' module)
// can review who changed what and when. Stored in the portal's own app DB.
// NOTE: the timestamp column is EVENT_AT, not "AT" — "AT" is a SQL Server
// reserved keyword and misbehaves unbracketed. All column names are bracketed.
const { getPool } = require('./appSqlPool');
const TABLE = `[dbo].[ZAUDIT_LOG]`;
async function exec(sqlQuery, params = []) {
const pool = await getPool();
const req = pool.request();
let i = 0;
const text = sqlQuery.replace(/\?/g, () => { const n = `p${i}`; req.input(n, params[i]); i++; return `@${n}`; });
const result = await req.query(text);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[AUDIT] Checking table', TABLE, '…');
await exec(`
CREATE TABLE ${TABLE} (
[ID] BIGINT IDENTITY(1,1) PRIMARY KEY,
[EVENT_AT] DATETIME2 NOT NULL,
[USER_ID] INT,
[USERNAME] NVARCHAR(80),
[ROLE] NVARCHAR(30),
[METHOD] NVARCHAR(10),
[ACTION] NVARCHAR(30),
[ENTITY] NVARCHAR(80),
[ENTITY_ID] NVARCHAR(80),
[SUBACTION] NVARCHAR(80),
[PATH] NVARCHAR(400),
[STATUS] INT,
[OK] BIT,
[SUMMARY] NVARCHAR(400),
[DETAILS] NVARCHAR(MAX),
[IP] NVARCHAR(60),
[COMPANY] NVARCHAR(80)
)
`).catch(e => { if (isAlreadyExists(e)) { console.log('[AUDIT] Table exists — OK'); } else throw e; });
// Migration: an earlier build used a reserved-word column "AT" (NOT NULL,
// indexed) which breaks new inserts. Add EVENT_AT, backfill from AT, then
// drop the legacy index + column (fallback: just make AT nullable).
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZAUDIT_LOG' AND COLUMN_NAME='EVENT_AT') ALTER TABLE ${TABLE} ADD [EVENT_AT] DATETIME2`).catch(() => {});
await exec(`IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZAUDIT_LOG' AND COLUMN_NAME='AT') UPDATE ${TABLE} SET [EVENT_AT] = [AT] WHERE [EVENT_AT] IS NULL`).catch(() => {});
await exec(`IF EXISTS (SELECT 1 FROM sys.indexes WHERE name='IX_ZAUDIT_AT' AND object_id=OBJECT_ID('dbo.ZAUDIT_LOG')) DROP INDEX IX_ZAUDIT_AT ON ${TABLE}`).catch(() => {});
await exec(`IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZAUDIT_LOG' AND COLUMN_NAME='AT') ALTER TABLE ${TABLE} DROP COLUMN [AT]`)
.catch(() => exec(`ALTER TABLE ${TABLE} ALTER COLUMN [AT] DATETIME2 NULL`).catch(() => {}));
await exec(`CREATE INDEX IX_ZAUDIT_AT ON ${TABLE} ([EVENT_AT] DESC)`).catch(() => {});
await exec(`CREATE INDEX IX_ZAUDIT_ENTITY ON ${TABLE} ([ENTITY], [EVENT_AT] DESC)`).catch(() => {});
console.log('[AUDIT] ✅ Ready');
}
async function record(e) {
try {
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23);
await exec(
`INSERT INTO ${TABLE} ([EVENT_AT],[USER_ID],[USERNAME],[ROLE],[METHOD],[ACTION],[ENTITY],[ENTITY_ID],[SUBACTION],[PATH],[STATUS],[OK],[SUMMARY],[DETAILS],[IP],[COMPANY])
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[now, e.userId || null, e.username || '', e.role || '', e.method || '', e.action || '',
e.entity || '', e.entityId || null, e.sub || null, e.path || '',
e.status || 0, e.ok ? 1 : 0, (e.summary || '').slice(0, 400),
e.details != null ? JSON.stringify(e.details).slice(0, 100000) : null,
e.ip || '', e.company || '']
);
} catch (err) { console.warn('[AUDIT] record failed (non-fatal):', err.message); }
}
function fromRow(r) {
const at = r.EVENT_AT || r.AT || null; // AT = legacy rows before the rename
return {
id: Number(r.ID), at: at ? new Date(at).toISOString() : null,
userId: r.USER_ID || null, username: r.USERNAME || '', role: r.ROLE || '',
method: r.METHOD || '', action: r.ACTION || '', entity: r.ENTITY || '',
entityId: r.ENTITY_ID || '', sub: r.SUBACTION || '', path: r.PATH || '',
status: r.STATUS || 0, ok: !!r.OK, summary: r.SUMMARY || '',
details: r.DETAILS ? safe(r.DETAILS) : null, ip: r.IP || '', company: r.COMPANY || '',
};
}
function safe(v) { try { return JSON.parse(v); } catch { return v; } }
async function list({ from, to, username, entity, action, status, q, top = 50, skip = 0 } = {}) {
const where = []; const params = [];
if (from) { where.push(`[EVENT_AT] >= ?`); params.push(String(from).slice(0, 10) + ' 00:00:00'); }
if (to) { where.push(`[EVENT_AT] <= ?`); params.push(String(to).slice(0, 10) + ' 23:59:59'); }
if (username) { where.push(`[USERNAME] = ?`); params.push(username); }
if (entity) { where.push(`[ENTITY] = ?`); params.push(entity); }
if (action) { where.push(`[ACTION] = ?`); params.push(action); }
if (status === 'ok') where.push(`[OK] = 1`);
if (status === 'fail') where.push(`[OK] = 0`);
if (q) { where.push(`([SUMMARY] LIKE ? OR [PATH] LIKE ? OR [USERNAME] LIKE ? OR [ENTITY_ID] LIKE ?)`); const like = `%${q}%`; params.push(like, like, like, like); }
const w = where.length ? `WHERE ${where.join(' AND ')}` : '';
const t = Math.min(200, parseInt(top) || 50);
const s = Math.max(0, parseInt(skip) || 0);
const rows = await exec(`SELECT * FROM ${TABLE} ${w} ORDER BY [ID] DESC OFFSET ${s} ROWS FETCH NEXT ${t} ROWS ONLY`, params);
const cnt = await exec(`SELECT COUNT(*) AS N FROM ${TABLE} ${w}`, params);
return { data: rows.map(fromRow), total: Number(cnt[0] && cnt[0].N) || 0 };
}
async function facets() {
const ent = await exec(`SELECT DISTINCT [ENTITY] AS V FROM ${TABLE} WHERE [ENTITY] <> '' ORDER BY [ENTITY]`);
const act = await exec(`SELECT DISTINCT [ACTION] AS V FROM ${TABLE} WHERE [ACTION] <> '' ORDER BY [ACTION]`);
const usr = await exec(`SELECT DISTINCT [USERNAME] AS V FROM ${TABLE} WHERE [USERNAME] <> '' ORDER BY [USERNAME]`);
return { entities: ent.map(r => r.V), actions: act.map(r => r.V), users: usr.map(r => r.V) };
}
module.exports = { bootstrap, record, list, facets };
+90
View File
@@ -0,0 +1,90 @@
// services/autoclaveRejectionStore.js
// Stores the "Autoclave Rejection" counts captured on a Receipt from
// Production for FG items (see routes/sap.js POST /receipt-production and
// public/receipt-production.html). This data is PURELY informational — it is
// NOT posted to SAP and has no effect on any inventory/production quantity.
// It lives in the app's own database (APP_SQL_*), keyed by the SAP receipt
// document, and is only ever written AFTER the SAP receipt posts successfully.
'use strict';
const sql = require('mssql');
const TABLE = `[dbo].[ZFG_AUTOCLAVE_REJECTIONS]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[AUTOCLAVE-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
ABS_ENTRY INT,
DOC_ENTRY INT,
DOC_NUM NVARCHAR(30),
ITEM_CODE NVARCHAR(60),
BATCH_NUMBER NVARCHAR(60),
ROWS_JSON NVARCHAR(MAX),
TOTAL_REJECTION NVARCHAR(60),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
COMPANY NVARCHAR(60)
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[AUTOCLAVE-STORE] Table exists — OK'); }
else throw e;
});
console.log('[AUTOCLAVE-STORE] ✅ Ready');
}
function toTs(iso) { return iso ? iso.replace('T', ' ').replace('Z', '').substring(0, 23) : null; }
// rows: array of numbers (rejection qty per manually-added row).
async function saveRejection({ absEntry, docEntry, docNum, itemCode, batchNumber, rows, company, createdBy, createdByName }) {
const clean = (Array.isArray(rows) ? rows : [])
.map(r => Number(r))
.filter(n => !isNaN(n) && n > 0);
const total = clean.reduce((s, n) => s + n, 0);
await exec(`
INSERT INTO ${TABLE} (
ABS_ENTRY, DOC_ENTRY, DOC_NUM, ITEM_CODE, BATCH_NUMBER, ROWS_JSON,
TOTAL_REJECTION, CREATED_BY, CREATED_NAME, CREATED_AT, COMPANY
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
`, [
absEntry != null ? parseInt(absEntry) : null,
docEntry != null ? parseInt(docEntry) : null,
docNum != null ? String(docNum) : null,
itemCode || '', batchNumber || '', JSON.stringify(clean),
String(total), createdBy || '', createdByName || createdBy || '',
toTs(new Date().toISOString()), company || '',
]);
return { total, count: clean.length };
}
module.exports = { bootstrap, saveRejection };
+351
View File
@@ -0,0 +1,351 @@
'use strict';
// bs_config (this store's own config table) lives on the app's own database.
// fetchGroupBalance/fetchReservesSurplus/etc. below all receive their pool
// as a parameter from the caller (routes/balanceSheet.js, which imports the
// SAP pool directly for its OACT/JDT1 queries), so they're unaffected here.
const { getPool } = require('./appSqlPool');
// Balance sheet group structure (fixed, not user-configurable)
const BS_ITEMS = [
// key, label, section (L=Liabilities, A=Assets), subsection, source
{ key:'share_capital', label:'Share Capital', sec:'L', sub:'SF', src:'fixed' },
{ key:'reserves_surplus', label:'Reserves & Surplus', sec:'L', sub:'SF', src:'reserves_formula' },
{ key:'profit_loss', label:'Profit & Loss', sec:'L', sub:'SF', src:'pl_net' },
{ key:'lt_borrowings', label:'Long-term Borrowings', sec:'L', sub:'NCL', src:'ledger' },
{ key:'other_lt_liab', label:'Other Long-term Liabilities', sec:'L', sub:'NCL', src:'ledger' },
{ key:'lt_provisions', label:'Long-term Provisions', sec:'L', sub:'NCL', src:'ledger' },
{ key:'st_borrowings', label:'Short-term Borrowings', sec:'L', sub:'CL', src:'ledger' },
{ key:'trade_payables', label:'Trade Payables', sec:'L', sub:'CL', src:'ledger' },
{ key:'other_current_liab', label:'Other Current Liabilities', sec:'L', sub:'CL', src:'ledger' },
{ key:'st_provisions', label:'Short-term Provisions', sec:'L', sub:'CL', src:'provisions' },
{ key:'fixed_assets', label:'Fixed Assets (Tangible & Intangible)', sec:'A', sub:'NCA', src:'ledger' },
{ key:'non_current_invest', label:'Non-current Investments', sec:'A', sub:'NCA', src:'ledger' },
{ key:'lt_loans_advances', label:'Long-term Loans & Advances', sec:'A', sub:'NCA', src:'ledger' },
{ key:'other_non_current', label:'Other Non-current Assets', sec:'A', sub:'NCA', src:'ledger' },
{ key:'current_invest', label:'Current Investments', sec:'A', sub:'CA', src:'ledger' },
{ key:'inventories', label:'Inventories', sec:'A', sub:'CA', src:'closing_stock' },
{ key:'trade_receivables', label:'Trade Receivables', sec:'A', sub:'CA', src:'ledger' },
{ key:'cash_equivalents', label:'Cash and Cash Equivalents', sec:'A', sub:'CA', src:'ledger' },
{ key:'st_loans_advances', label:'Short-term Loans & Advances', sec:'A', sub:'CA', src:'ledger' },
{ key:'other_current_assets', label:'Other Current Assets', sec:'A', sub:'CA', src:'ledger' },
];
// Default account codes per group
// Default opening account codes for reserves_surplus formula
const DEFAULT_OPENING_ACCOUNTS = {
reserves_surplus: ['5100001001'],
};
const DEFAULT_ACCOUNTS = {
reserves_surplus: ['2120020001'],
trade_payables: ['2140000000'],
other_current_liab: ['2200000000'],
st_provisions: ['2210000000'],
fixed_assets: ['1100000000'],
trade_receivables: ['1250000000'],
cash_equivalents: ['1220000000'],
st_loans_advances: ['1260000000'],
other_current_assets: ['1280000000','1290000000','1330000000'],
};
const DEFAULT_FIXED = { share_capital: 100 }; // in Lakh
async function bootstrap() {
const pool = await getPool();
await pool.request().query(`
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='bs_config' AND xtype='U')
CREATE TABLE bs_config (
group_key NVARCHAR(50) NOT NULL PRIMARY KEY,
acct_codes NVARCHAR(MAX) NOT NULL DEFAULT '[]',
opening_acct_codes NVARCHAR(MAX) NOT NULL DEFAULT '[]',
fixed_value DECIMAL(18,4) NULL,
notes NVARCHAR(500) NULL,
updated_at DATETIME DEFAULT GETDATE()
)
`);
// Add opening_acct_codes column if upgrading from older schema
await pool.request().query(`
IF NOT EXISTS (
SELECT 1 FROM sys.columns
WHERE name='opening_acct_codes' AND object_id=OBJECT_ID('bs_config')
)
ALTER TABLE bs_config ADD opening_acct_codes NVARCHAR(MAX) NOT NULL DEFAULT '[]'
`);
// Seed defaults if empty
const check = await pool.request().query(`SELECT COUNT(*) AS cnt FROM bs_config`);
if (check.recordset[0].cnt === 0) {
for (const item of BS_ITEMS) {
const codes = JSON.stringify(DEFAULT_ACCOUNTS[item.key] || []);
const ocodes = JSON.stringify(DEFAULT_OPENING_ACCOUNTS[item.key] || []);
const fv = DEFAULT_FIXED[item.key] != null ? DEFAULT_FIXED[item.key] : null;
await pool.request()
.input('k', item.key).input('c', codes).input('o', ocodes).input('f', fv)
.query(`INSERT INTO bs_config (group_key,acct_codes,opening_acct_codes,fixed_value) VALUES (@k,@c,@o,@f)`);
}
} else {
// Migration: fill in opening_acct_codes defaults for items that still have '[]'
for (const [key, defaults] of Object.entries(DEFAULT_OPENING_ACCOUNTS)) {
await pool.request()
.input('k', key)
.input('o', JSON.stringify(defaults))
.query(`
UPDATE bs_config
SET opening_acct_codes = @o, updated_at = GETDATE()
WHERE group_key = @k
AND (opening_acct_codes IS NULL OR opening_acct_codes = '[]')
`);
}
// Migration: fill in main acct_codes defaults if still '[]'
for (const [key, defaults] of Object.entries(DEFAULT_ACCOUNTS)) {
if (!defaults.length) continue;
await pool.request()
.input('k', key)
.input('c', JSON.stringify(defaults))
.query(`
UPDATE bs_config
SET acct_codes = @c, updated_at = GETDATE()
WHERE group_key = @k
AND (acct_codes IS NULL OR acct_codes = '[]')
`);
}
}
}
async function getConfig() {
const pool = await getPool();
const r = await pool.request().query(`SELECT group_key,acct_codes,opening_acct_codes,fixed_value,notes FROM bs_config`);
const map = {};
r.recordset.forEach(row => {
map[row.group_key] = {
acct_codes: JSON.parse(row.acct_codes || '[]'),
opening_acct_codes: JSON.parse(row.opening_acct_codes || '[]'),
fixed_value: row.fixed_value != null ? parseFloat(row.fixed_value) : null,
notes: row.notes || '',
};
});
return map;
}
async function saveConfig(groupKey, acctCodes, openingAcctCodes, fixedValue, notes) {
const pool = await getPool();
const codes = JSON.stringify(Array.isArray(acctCodes) ? acctCodes : []);
const ocodes = JSON.stringify(Array.isArray(openingAcctCodes) ? openingAcctCodes : []);
const fv = fixedValue != null && fixedValue !== '' ? parseFloat(fixedValue) : null;
await pool.request()
.input('k', groupKey).input('c', codes).input('o', ocodes).input('f', fv).input('n', notes || null)
.query(`
MERGE bs_config AS T
USING (SELECT @k AS group_key) AS S ON T.group_key = S.group_key
WHEN MATCHED THEN UPDATE SET acct_codes=@c, opening_acct_codes=@o, fixed_value=@f, notes=@n, updated_at=GETDATE()
WHEN NOT MATCHED THEN INSERT (group_key,acct_codes,opening_acct_codes,fixed_value,notes) VALUES (@k,@c,@o,@f,@n);
`);
}
// Derive a 4-char prefix from a 10-digit parent account code
// e.g. '2140000000' → trim trailing zeros '214' (length<4) → fallback to slice(0,4) = '2140'
function acctPrefix(code) {
const trimmed = String(code).replace(/0+$/, '');
return trimmed.length >= 4 ? trimmed : String(code).slice(0, 4);
}
// Fetch D-C balance for OACT accounts matching any configured prefix.
// from = period start (period balance, matching trial balance) OR null (cumulative up to asOf).
async function fetchGroupBalance(pool, acctCodes, from, asOf) {
if (!acctCodes || !acctCodes.length) return 0;
const req = pool.request().input('asOf', asOf);
if (from) req.input('from', from);
const conditions = acctCodes.map((c, i) => {
req.input(`p${i}`, acctPrefix(c) + '%');
return `T0.[AcctCode] LIKE @p${i}`;
}).join(' OR ');
const dateFilter = from
? `T1.[RefDate] BETWEEN @from AND @asOf`
: `T1.[RefDate] <= @asOf`;
const r = await req.query(`
SELECT ISNULL(SUM(
CASE WHEN ${dateFilter}
THEN ISNULL(T1.[Debit],0) - ISNULL(T1.[Credit],0)
ELSE 0 END
), 0) AS balance
FROM OACT T0
LEFT JOIN JDT1 T1 ON T0.[AcctCode] = T1.[Account]
WHERE ${conditions}
`);
return parseFloat(r.recordset[0]?.balance || 0);
}
// Compute netTax using EXACT same formula as Monthly Accounts P&L Summary frontend.
// stockDetail: { open: { conv }, close: { conv } } — matches renderSummary's convChange.
function calcNetTaxFromAccounts(rows, openingStock, closingStock, stockDetail) {
const S = { Revenue:0, Purchase:0, Employee:0, Factory:0, Admin:0, SND:0, Finance:0, OtherIncome:0 };
rows.forEach(r => {
const sheet = r.sheet || '';
if (S[sheet] === undefined) return;
const prov = r.override_val != null ? Number(r.override_val) : (r.provision != null ? Number(r.provision) : 0);
const net = sheet === 'Revenue'
? (Number(r.period_credit)||0) - (Number(r.period_debit)||0) + prov
: (Number(r.period_debit)||0) - (Number(r.period_credit)||0) + prov;
S[sheet] += net;
});
const matCost = (Number(openingStock)||0) + S.Purchase - (Number(closingStock)||0);
// convChange mirrors renderSummary: openConv - closeConv (change in conversion/WIP cost)
const openConv = Number(stockDetail?.open?.conv) || 0;
const closeConv = Number(stockDetail?.close?.conv) || 0;
const convChange = openConv - closeConv;
const grossP = S.Revenue - (matCost + convChange);
const totalExp = S.Employee + S.Factory + S.Admin + S.SND + S.Finance;
const netOps = grossP - totalExp;
const oiT = -S.OtherIncome;
return netOps + oiT;
}
// Fetch net profit from snapshot: parses the saved JSON using the same formula.
// `pool` (kept for call-site compatibility) is the SAP pool the caller
// already has for its other queries — pl_snapshots itself now lives on the
// app DB, so this reads it via costingStore.getSnapshot() instead.
async function fetchNetProfitFromSnapshot(pool, snapshotId) {
try {
const cs = require('./costingStore');
const snap = await cs.getSnapshot(parseInt(snapshotId));
if (!snap) return 0;
const parsed = JSON.parse(snap.data_json);
const accounts = parsed.accounts || parsed;
const openingStock = parsed.opening_stock != null ? parsed.opening_stock : 0;
const closingStock = parsed.closing_stock != null ? parsed.closing_stock : 0;
const stockDetail = parsed.stock_detail || null;
return calcNetTaxFromAccounts(accounts, openingStock, closingStock, stockDetail);
} catch(e) {
console.error('[fetchNetProfitFromSnapshot]', e.message);
return 0;
}
}
// Fetch P&L net profit from live database using the same formula as Monthly Accounts
async function fetchNetProfit(pool, from, to) {
try {
const cs = require('./costingStore');
const [tb, mapping, overrides] = await Promise.all([
cs.fetchTrialBalance(from, to),
cs.getMapping(),
cs.getOverrides(from, to),
]);
const mapByCode = {}; mapping.forEach(m => { mapByCode[m.acct_code] = m; });
const ovByCode = {}; overrides.forEach(o => { ovByCode[o.acct_code] = o; });
// Build account rows in the same format as the snapshot/frontend
const rows = tb.map(row => ({
sheet: mapByCode[row.acct_code]?.sheet ?? (row.group_mask === 4 ? 'Revenue' : 'Other'),
period_debit: Number(row.period_debit) || 0,
period_credit: Number(row.period_credit) || 0,
override_val: ovByCode[row.acct_code]?.override_val ?? null,
}));
const stockOpen = Number(ovByCode['__STOCK_OPEN__']?.override_val) || 0;
const stockClose = Number(ovByCode['__STOCK_CLOSE__']?.override_val) || 0;
const stockDetail = {
open: { conv: Number(ovByCode['__OPEN_CONV__']?.override_val) || 0 },
close: { conv: Number(ovByCode['__CLOSE_CONV__']?.override_val) || 0 },
};
return calcNetTaxFromAccounts(rows, stockOpen, stockClose, stockDetail);
} catch(_) { return 0; }
}
// Return date string one day before the given date (for opening balance query)
function dayBefore(dateStr) {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
// Gross opening stock = RM + WIP + FG + Trading (no conv deduction), falls back to __STOCK_OPEN__
function getGrossOpeningStock(overrides) {
const get = key => parseFloat(overrides.find(o => o.acct_code === key)?.override_val || 0) || 0;
const gross = get('__OPEN_RM__') + get('__OPEN_WIP__') + get('__OPEN_FG__') + get('__OPEN_TRADE__');
return gross > 0 ? gross : get('__STOCK_OPEN__');
}
// ── Reserves & Surplus formula:
// Total Opening = opening_balance(5100001001) − gross opening stock
// Reserves&Surplus = −(balance(2120020001, asOf) + Total Opening)
async function fetchReservesSurplus(pool, mainAcctCodes, openingAcctCodes, from, to, asOf) {
try {
const cs = require('./costingStore');
// 1. Main account cumulative balance up to BS date
const mainBalance = await fetchGroupBalance(pool, mainAcctCodes, null, asOf);
// 2. Opening account cumulative balance strictly before the period start
const openingBalance = openingAcctCodes.length
? await fetchGroupBalance(pool, openingAcctCodes, null, dayBefore(from))
: 0;
// 3. Gross opening stock: RM + WIP + FG + Trading (no conv deduction)
const overrides = await cs.getOverrides(from, to);
const openingStock = getGrossOpeningStock(overrides);
// Total Opening = opening_balance(5100001001) − gross opening stock
const totalOpening = openingBalance - openingStock;
// Reserves & Surplus = −(mainBalance + Total Opening)
return -(mainBalance + totalOpening);
} catch(e) {
console.error('[fetchReservesSurplus]', e.message);
return 0;
}
}
// Same as above but returns intermediate values for debugging
async function fetchReservesSurplusDebug(pool, mainAcctCodes, openingAcctCodes, from, to, asOf) {
const cs = require('./costingStore');
const mainBalance = await fetchGroupBalance(pool, mainAcctCodes, null, asOf);
const openingBalance = openingAcctCodes.length
? await fetchGroupBalance(pool, openingAcctCodes, null, dayBefore(from))
: 0;
const overrides = await cs.getOverrides(from, to);
const openingStock = getGrossOpeningStock(overrides);
const totalOpening = openingBalance - openingStock;
const result = -(mainBalance + totalOpening);
return {
result,
mainAcctCodes,
openingAcctCodes,
mainBalance, // D-C for 2120020001 up to asOf
openingBalance, // D-C for 5100001001 up to dayBefore(from)
openingStock, // gross opening stock (RM+WIP+FG+Trading)
totalOpening, // openingBalance - openingStock
formula: `-(${mainBalance} + (${openingBalance} - ${openingStock})) = ${result}`,
dayBeforeFrom: dayBefore(from),
};
}
// Fetch closing stock from pl_overrides — gross total (RM + WIP + FG + Trading, no conv deduction)
async function fetchClosingStock(pool, from, to) {
try {
const cs = require('./costingStore');
const overrides = await cs.getOverrides(from, to);
const get = key => parseFloat(overrides.find(o => o.acct_code === key)?.override_val || 0) || 0;
const rm = get('__CLOSE_RM__');
const wip = get('__CLOSE_WIP__');
const fg = get('__CLOSE_FG__');
const trade = get('__CLOSE_TRADE__');
const gross = rm + wip + fg + trade;
// Fall back to __STOCK_CLOSE__ if individual components not yet saved
if (gross === 0) return get('__STOCK_CLOSE__');
return gross;
} catch(_) { return 0; }
}
// Fetch total provisions (sum of all non-stock pl_overrides)
async function fetchTotalProvisions(pool, from, to) {
try {
const cs = require('./costingStore');
const overrides = await cs.getOverrides(from, to);
return overrides
.filter(o => !o.acct_code.startsWith('__'))
.reduce((s, o) => s + (parseFloat(o.override_val) || 0), 0);
} catch(_) { return 0; }
}
module.exports = { bootstrap, BS_ITEMS, getConfig, saveConfig, fetchGroupBalance, fetchReservesSurplus, fetchReservesSurplusDebug, fetchNetProfit, fetchNetProfitFromSnapshot, fetchClosingStock, fetchTotalProvisions };
+232
View File
@@ -0,0 +1,232 @@
// services/batchIntimationStore.js
// Batch Issuance Intimation records in SQL: ZBATCH_INTIMATIONS
// Doc No format: BII-{MM}-{YY}-{NNNN}. References a Requirement's Ref No.
const sql = require('mssql');
const TABLE = `[dbo].[ZBATCH_INTIMATIONS]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
const config = {
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
};
_conn = await sql.connect(config);
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (match, offset, string) => {
const paramIndex = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${paramIndex}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[BII-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
DOC_NO NVARCHAR(30) NOT NULL,
REF_NO NVARCHAR(30),
REQUIREMENT_ID INT,
DOC_DATE DATE,
ISSUE_DATE DATE,
PRODUCTS NVARCHAR(MAX),
REMARKS NVARCHAR(MAX),
STATUS NVARCHAR(30) DEFAULT 'SENT_TO_QA',
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_AT DATETIME2,
IS_DELETED BIT DEFAULT 0,
COMPANY NVARCHAR(60)
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[BII-STORE] Table exists — OK'); }
else throw e;
});
console.log('[BII-STORE] ✅ Ready');
}
function toTs(isoStr) {
if (!isoStr) return null;
return isoStr.replace('T', ' ').replace('Z', '').substring(0, 23);
}
function safeJson(val, fallback) {
if (!val) return fallback;
try { return JSON.parse(val); } catch { return fallback; }
}
function fromRow(row) {
if (!row) return null;
return {
id: row.ID,
docNo: row.DOC_NO || '',
refNo: row.REF_NO || '',
requirementId: row.REQUIREMENT_ID || null,
date: row.DOC_DATE ? new Date(row.DOC_DATE).toISOString().slice(0, 10) : null,
issueDate: row.ISSUE_DATE ? new Date(row.ISSUE_DATE).toISOString().slice(0, 10) : null,
products: safeJson(row.PRODUCTS, []),
remarks: row.REMARKS || '',
status: row.STATUS || 'SENT_TO_QA',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
isDeleted: !!row.IS_DELETED,
company: row.COMPANY || '',
};
}
async function generateDocNo(company) {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const yy = String(now.getFullYear()).slice(-2);
const prefix = `BII-${mm}-${yy}-`;
const rows = await exec(
`SELECT DOC_NO FROM ${TABLE} WHERE DOC_NO LIKE ? ${company ? 'AND COMPANY = ?' : ''}`,
company ? [prefix + '%', company] : [prefix + '%']
);
let max = 0;
rows.forEach(r => {
const n = parseInt((r.DOC_NO || '').slice(prefix.length), 10);
if (!isNaN(n) && n > max) max = n;
});
return prefix + String(max + 1).padStart(4, '0');
}
async function insertIntimation(r) {
const now = toTs(new Date().toISOString());
const docNo = await generateDocNo(r.company);
// INSERT and SELECT SCOPE_IDENTITY() must be one batch — as two separate
// exec() calls, a pooled connection can route the second one to a
// DIFFERENT physical connection than the one that just inserted, where
// SCOPE_IDENTITY() correctly returns NULL (see workOrderStore.js's
// insertWorkOrder() for the full write-up of this bug class).
const idRows = await exec(`
INSERT INTO ${TABLE} (
DOC_NO, REF_NO, REQUIREMENT_ID, DOC_DATE, ISSUE_DATE, PRODUCTS, REMARKS,
STATUS, CREATED_BY, CREATED_NAME, CREATED_AT, COMPANY
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
docNo,
r.refNo || '',
r.requirementId ? parseInt(r.requirementId) : null,
r.date || null,
r.issueDate || null,
JSON.stringify(r.products || []),
r.remarks || '',
'SENT_TO_QA',
r.createdBy,
r.createdByName || r.createdBy,
now,
r.company || '',
]);
return { id: idRows[0].ID, docNo, ...r, status: 'SENT_TO_QA', createdAt: new Date().toISOString() };
}
async function listIntimations({ mine, company, status, refNo, includeDeleted } = {}) {
const all = await exec(`SELECT * FROM ${TABLE} ORDER BY CREATED_AT DESC`);
return all.map(fromRow).filter(r => {
if (!includeDeleted && r.isDeleted) return false;
if (status && status !== 'ALL' && r.status !== status.toUpperCase()) return false;
if (refNo && r.refNo !== refNo) return false;
if (mine && r.createdBy !== mine) return false;
if (company && r.company && r.company !== company) return false;
return true;
});
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0]) : null;
}
async function updateIntimation(id, patch) {
const now = toTs(new Date().toISOString());
await exec(`
UPDATE ${TABLE}
SET DOC_DATE = ?, ISSUE_DATE = ?, PRODUCTS = ?, REMARKS = ?, UPDATED_AT = ?
WHERE ID = ? AND (IS_DELETED = 0 OR IS_DELETED IS NULL)
`, [
patch.date || null,
patch.issueDate || null,
JSON.stringify(patch.products || []),
patch.remarks || '',
now,
parseInt(id),
]);
return findById(id);
}
async function softDelete(id) {
await exec(`UPDATE ${TABLE} SET IS_DELETED = 1, UPDATED_AT = ? WHERE ID = ?`,
[toTs(new Date().toISOString()), parseInt(id)]);
}
// Sum of intimated Batch Size per requirement, per item.
// Returns { [requirementId||refNo]: { docs: <count>, byItem: { itemCode: qty } } }
// `excludeId` lets an edit exclude its own current record from the totals.
async function intimatedByRequirement({ company, excludeId } = {}) {
const list = await listIntimations({ company });
const map = {};
list.forEach(bi => {
if (excludeId && String(bi.id) === String(excludeId)) return;
const keys = [];
if (bi.requirementId != null) keys.push('rid:' + bi.requirementId);
if (bi.refNo) keys.push('ref:' + bi.refNo);
keys.forEach(k => {
if (!map[k]) map[k] = { docs: 0, byItem: {} };
map[k].docs += 1;
(bi.products || []).forEach(p => {
const qty = (p.batches || []).reduce((s, b) => s + (Number(b.batchSize) || 0), 0);
map[k].byItem[p.itemCode] = (map[k].byItem[p.itemCode] || 0) + qty;
});
});
});
return map;
}
// Does this batch number already appear in ANY other (non-deleted) saved
// Intimation's products/batches? Used by the "Batch No. Required" uniqueness
// check — `excludeId` lets an edit ignore its own current record.
async function findBatchNoUsage(batchNo, excludeId) {
const needle = (batchNo || '').trim().toUpperCase();
if (!needle) return null;
const list = await listIntimations({});
for (const bi of list) {
if (excludeId && String(bi.id) === String(excludeId)) continue;
for (const p of (bi.products || [])) {
for (const b of (p.batches || [])) {
if ((b.batchNo || '').trim().toUpperCase() === needle) {
return { docNo: bi.docNo, itemCode: p.itemCode };
}
}
}
}
return null;
}
module.exports = {
bootstrap, generateDocNo, insertIntimation,
listIntimations, findById, updateIntimation, softDelete,
intimatedByRequirement, findBatchNoUsage,
};
+223
View File
@@ -0,0 +1,223 @@
// services/bomRequestStore.js
// Stores BOM Create/Update approval requests in SQL: ZBOM_REQUESTS
const sql = require('mssql');
const { DEFAULT_COMPANY } = require('./companyConfig');
const DB_SCHEMA = DEFAULT_COMPANY;
const TABLE = `[dbo].[ZBOM_REQUESTS]`;
const SEQ = `[dbo].[ZBOM_REQUESTS_SEQ]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
const config = {
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: {
encrypt: true,
trustServerCertificate: true,
},
};
_conn = await sql.connect(config);
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => {
request.input(`param${index}`, param);
});
const replacedSql = sqlQuery.replace(/\?/g, (match, offset, string) => {
const paramIndex = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${paramIndex}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[BOM-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
TYPE NVARCHAR(10) NOT NULL,
ITEM_CODE NVARCHAR(50) NOT NULL,
ITEM_NAME NVARCHAR(200),
QTY DECIMAL(18,4) DEFAULT 1,
BOM_TYPE NVARCHAR(30) DEFAULT 'Production',
WAREHOUSE NVARCHAR(20),
DISTR_RULE NVARCHAR(50),
PROJECT NVARCHAR(50),
COMPONENTS NVARCHAR(MAX),
ORIGINAL_DATA NVARCHAR(MAX),
STATUS NVARCHAR(30) DEFAULT 'PENDING',
SUBMITTED_BY NVARCHAR(50),
SUBMITTED_NAME NVARCHAR(100),
SUBMITTED_AT DATETIME2,
APPROVAL_LOG NVARCHAR(MAX),
REJECTED_BY NVARCHAR(50),
REJECTED_AT DATETIME2,
SAP_PUSHED_AT DATETIME2,
SAP_PUSHED_BY NVARCHAR(50),
SAP_RESULT NVARCHAR(MAX),
COMPANY NVARCHAR(60)
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[BOM-STORE] Table exists — OK'); }
else throw e;
});
// Migration: add COMPANY column if missing
await exec(`
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME='ZBOM_REQUESTS' AND COLUMN_NAME='COMPANY'
)
ALTER TABLE [dbo].[ZBOM_REQUESTS] ADD [COMPANY] NVARCHAR(50) DEFAULT '${DB_SCHEMA}'
`).catch(e => {
console.log('[BOM-STORE] COMPANY column migration:', e.message);
});
console.log('[BOM-STORE] ✅ Ready');
}
function toTs(isoStr) {
if (!isoStr) return null;
return isoStr.replace('T', ' ').replace('Z', '').substring(0, 23);
}
function fromRow(row) {
if (!row) return null;
return {
id: row.ID,
type: row.TYPE,
itemCode: row.ITEM_CODE || '',
itemName: row.ITEM_NAME || '',
qty: Number(row.QTY) || 1,
bomType: row.BOM_TYPE || 'Production',
warehouse: row.WAREHOUSE || '',
distrRule: row.DISTR_RULE || '',
project: row.PROJECT || '',
components: safeJson(row.COMPONENTS, []),
originalData: safeJson(row.ORIGINAL_DATA, null),
status: row.STATUS || 'PENDING',
submittedBy: row.SUBMITTED_BY || '',
submittedByName: row.SUBMITTED_NAME || '',
submittedAt: row.SUBMITTED_AT ? new Date(row.SUBMITTED_AT).toISOString() : null,
approvalLog: safeJson(row.APPROVAL_LOG, []),
rejectedBy: row.REJECTED_BY || null,
rejectedAt: row.REJECTED_AT ? new Date(row.REJECTED_AT).toISOString() : null,
sapPushedAt: row.SAP_PUSHED_AT ? new Date(row.SAP_PUSHED_AT).toISOString() : null,
sapPushedBy: row.SAP_PUSHED_BY || null,
sapResult: safeJson(row.SAP_RESULT, null),
company: row.COMPANY || '',
};
}
function safeJson(val, fallback) {
if (!val) return fallback;
try { return JSON.parse(val); } catch { return fallback; }
}
async function insertBomRequest(b) {
const now = toTs(new Date().toISOString());
// INSERT and SELECT SCOPE_IDENTITY() must be one batch — as two separate
// exec() calls, a pooled connection can route the second one to a
// DIFFERENT physical connection than the one that just inserted, where
// SCOPE_IDENTITY() correctly returns NULL (see workOrderStore.js's
// insertWorkOrder() for the full write-up of this bug class).
const idRows = await exec(`
INSERT INTO ${TABLE} (
TYPE, ITEM_CODE, ITEM_NAME, QTY, BOM_TYPE, WAREHOUSE, DISTR_RULE, PROJECT,
COMPONENTS, ORIGINAL_DATA, STATUS, SUBMITTED_BY, SUBMITTED_NAME, SUBMITTED_AT,
APPROVAL_LOG, COMPANY
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
b.type, b.itemCode, b.itemName || '', Number(b.qty) || 1,
b.bomType || 'Production', b.warehouse || '', b.distrRule || '', b.project || '',
JSON.stringify(b.components || []),
b.originalData ? JSON.stringify(b.originalData) : null,
'PENDING', b.submittedBy, b.submittedByName || b.submittedBy, now,
JSON.stringify([]),
b.company || '',
]);
const id = idRows[0].ID;
return { id, ...b, status: 'PENDING', submittedAt: new Date().toISOString(), approvalLog: [] };
}
async function listRequests({ status, type, mine, company } = {}) {
// Fetch all then filter in JS for reliability
const all = await exec(`SELECT * FROM ${TABLE} ORDER BY SUBMITTED_AT DESC`);
return all.map(fromRow).filter(r => {
if (status && status !== 'ALL' && r.status !== status.toUpperCase()) return false;
if (type && r.type !== type.toUpperCase()) return false;
if (mine && r.submittedBy !== mine) return false;
if (company && r.company && r.company !== company) return false;
return true;
});
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0]) : null;
}
async function updateRequest(id, patch) {
const FIELD_MAP = {
status: 'STATUS',
approvalLog: 'APPROVAL_LOG',
rejectedBy: 'REJECTED_BY',
rejectedAt: 'REJECTED_AT',
sapPushedAt: 'SAP_PUSHED_AT',
sapPushedBy: 'SAP_PUSHED_BY',
sapResult: 'SAP_RESULT',
};
function prepareValue(key, val) {
if (key === 'approvalLog' || key === 'sapResult') return JSON.stringify(val || []);
if (key === 'rejectedAt' || key === 'sapPushedAt') return toTs(val);
return val;
}
const sets = []; const vals = [];
for (const [jsKey, colName] of Object.entries(FIELD_MAP)) {
if (patch[jsKey] !== undefined) {
sets.push(`${colName} = ?`);
vals.push(prepareValue(jsKey, patch[jsKey]));
}
}
if (!sets.length) return;
vals.push(parseInt(id));
await exec(`UPDATE ${TABLE} SET ${sets.join(', ')} WHERE ID = ?`, vals);
}
async function getStats() {
const rows = await exec(`
SELECT STATUS, TYPE, COUNT(*) AS CNT
FROM ${TABLE}
GROUP BY STATUS, TYPE
ORDER BY STATUS, TYPE
`);
const stats = { byStatus: {}, byType: { CREATE: 0, UPDATE: 0 }, total: 0 };
rows.forEach(r => {
const s = r.STATUS || r.status || r.S;
const t = r.TYPE || r.type || r.T;
const c = Number(r.CNT || r.cnt || r.C) || 0;
stats.byStatus[s] = (stats.byStatus[s] || 0) + c;
stats.byType[t] = (stats.byType[t] || 0) + c;
stats.total += c;
});
return stats;
}
module.exports = { bootstrap, insertBomRequest, listRequests, findById, updateRequest, getStats };
+159
View File
@@ -0,0 +1,159 @@
'use strict';
// cf_items (this store's own config table) lives on the app's own database —
// only fetchItemValue/fetchItemClosingBalance below touch real SAP data
// (OACT/JDT1), and those receive their pool as a parameter from the caller
// (routes/cashFlow.js, which imports the SAP pool directly), so they're
// unaffected by this import.
const { getPool } = require('./appSqlPool');
async function bootstrap() {
const pool = await getPool();
await pool.request().query(`
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='cf_items' AND xtype='U')
CREATE TABLE cf_items (
id INT IDENTITY(1,1) PRIMARY KEY,
section NVARCHAR(20) NOT NULL DEFAULT 'Assets',
item_type NVARCHAR(20) NOT NULL DEFAULT 'working',
sort_order INT NOT NULL DEFAULT 999,
label NVARCHAR(200) NOT NULL,
acct_codes NVARCHAR(MAX) NOT NULL DEFAULT '[]',
notes NVARCHAR(500) NULL,
updated_at DATETIME DEFAULT GETDATE()
)
`);
// Migration: add item_type if upgrading
await pool.request().query(`
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE name='item_type' AND object_id=OBJECT_ID('cf_items'))
ALTER TABLE cf_items ADD item_type NVARCHAR(20) NOT NULL DEFAULT 'working'
`);
// Migration: rename 'capital' item_type items to section='Capital' if they are in Assets section
await pool.request().query(`
UPDATE cf_items SET section='Capital' WHERE section='Assets' AND item_type='capital'
`);
}
async function getItems() {
const pool = await getPool();
const r = await pool.request().query(
`SELECT id, section, item_type, sort_order, label, acct_codes, notes
FROM cf_items ORDER BY
CASE section WHEN 'Assets' THEN 1 WHEN 'Liabilities' THEN 2 WHEN 'Capital' THEN 3 WHEN 'Cash' THEN 4 ELSE 5 END,
sort_order, id`
);
return r.recordset.map(row => ({
id: row.id,
section: row.section,
item_type: row.item_type || 'working',
sort_order: row.sort_order,
label: row.label,
acct_codes: JSON.parse(row.acct_codes || '[]'),
notes: row.notes || '',
}));
}
async function saveItem({ id, section, item_type, label, acct_codes, sort_order, notes }) {
const pool = await getPool();
const codes = JSON.stringify(Array.isArray(acct_codes) ? acct_codes : []);
const ord = sort_order != null ? parseInt(sort_order) : 999;
const type = item_type || 'working';
if (id) {
await pool.request()
.input('id', id).input('sec', section).input('typ', type)
.input('lbl', label).input('c', codes).input('ord', ord).input('n', notes || null)
.query(`UPDATE cf_items SET section=@sec, item_type=@typ, label=@lbl, acct_codes=@c, sort_order=@ord, notes=@n, updated_at=GETDATE() WHERE id=@id`);
} else {
await pool.request()
.input('sec', section).input('typ', type)
.input('lbl', label).input('c', codes).input('ord', ord).input('n', notes || null)
.query(`INSERT INTO cf_items (section, item_type, label, acct_codes, sort_order, notes) VALUES (@sec, @typ, @lbl, @c, @ord, @n)`);
}
}
async function deleteItem(id) {
const pool = await getPool();
await pool.request().input('id', id).query(`DELETE FROM cf_items WHERE id=@id`);
}
// Derive 4-char prefix for LIKE matching (same logic as balanceSheetStore)
function acctPrefix(code) {
const trimmed = String(code).replace(/0+$/, '');
return trimmed.length >= 4 ? trimmed : String(code).slice(0, 4);
}
// Period net change from live DB (Debit − Credit for the date range)
async function fetchItemValue(pool, acctCodes, from, to) {
if (!acctCodes || !acctCodes.length) return 0;
const req = pool.request().input('from', from).input('to', to);
const conditions = acctCodes.map((c, i) => {
req.input(`p${i}`, acctPrefix(c) + '%');
return `T0.[AcctCode] LIKE @p${i}`;
}).join(' OR ');
const r = await req.query(`
SELECT ISNULL(SUM(
CASE WHEN T1.[RefDate] BETWEEN @from AND @to
THEN ISNULL(T1.[Debit],0) - ISNULL(T1.[Credit],0)
ELSE 0 END
), 0) AS net_change
FROM OACT T0
LEFT JOIN JDT1 T1 ON T0.[AcctCode] = T1.[Account]
WHERE ${conditions}
`);
return parseFloat(r.recordset[0]?.net_change || 0);
}
// Closing balance from live DB — cumulative Debit − Credit up to asOf (= 'to' date)
async function fetchItemClosingBalance(pool, acctCodes, asOf) {
if (!acctCodes || !acctCodes.length) return 0;
const req = pool.request().input('asOf', asOf);
const conditions = acctCodes.map((c, i) => {
req.input(`p${i}`, acctPrefix(c) + '%');
return `T0.[AcctCode] LIKE @p${i}`;
}).join(' OR ');
const r = await req.query(`
SELECT ISNULL(SUM(
CASE WHEN T1.[RefDate] <= @asOf
THEN ISNULL(T1.[Debit],0) - ISNULL(T1.[Credit],0)
ELSE 0 END
), 0) AS closing_balance
FROM OACT T0
LEFT JOIN JDT1 T1 ON T0.[AcctCode] = T1.[Account]
WHERE ${conditions}
`);
return parseFloat(r.recordset[0]?.closing_balance || 0);
}
// Period net change from snapshot accounts array
function fetchItemValueFromSnapshot(accounts, acctCodes) {
if (!acctCodes || !acctCodes.length) return 0;
const prefixes = acctCodes.map(c => acctPrefix(c));
return accounts
.filter(a => prefixes.some(p => String(a.acct_code).startsWith(p)))
.reduce((s, a) => s + ((Number(a.period_debit) || 0) - (Number(a.period_credit) || 0)), 0);
}
// Closing balance from snapshot: opening_balance + period_debit − period_credit
function fetchItemClosingBalanceFromSnapshot(accounts, acctCodes) {
if (!acctCodes || !acctCodes.length) return 0;
const prefixes = acctCodes.map(c => acctPrefix(c));
return accounts
.filter(a => prefixes.some(p => String(a.acct_code).startsWith(p)))
.reduce((s, a) =>
s + (Number(a.opening_balance) || 0) + (Number(a.period_debit) || 0) - (Number(a.period_credit) || 0), 0);
}
// Opening balance from snapshot: the opening_balance field (before period start)
function fetchOpeningBalanceFromSnapshot(accounts, acctCodes) {
if (!acctCodes || !acctCodes.length) return 0;
const prefixes = acctCodes.map(c => acctPrefix(c));
return accounts
.filter(a => prefixes.some(p => String(a.acct_code).startsWith(p)))
.reduce((s, a) => s + (Number(a.opening_balance) || 0), 0);
}
module.exports = {
bootstrap, getItems, saveItem, deleteItem,
fetchItemValue, fetchItemClosingBalance,
fetchItemValueFromSnapshot, fetchItemClosingBalanceFromSnapshot,
fetchOpeningBalanceFromSnapshot,
acctPrefix,
};
+19
View File
@@ -0,0 +1,19 @@
'use strict';
// Single source of truth for the default SAP B1 company/schema — read from
// .env (SAP_B1_COMPANY). Every service/route/page must resolve it through
// this module rather than hardcoding the company code.
const DEFAULT_COMPANY = process.env.SAP_B1_COMPANY;
if (!DEFAULT_COMPANY) {
console.error('[companyConfig] FATAL: SAP_B1_COMPANY is not set in .env — cannot start.');
process.exit(1);
}
const COMPANIES = {
[DEFAULT_COMPANY]: { display: 'MIPL', short: 'MIPL' },
};
function isValid(c) { return !!COMPANIES[c]; }
function resolve(c) { return isValid(c) ? c : DEFAULT_COMPANY; }
module.exports = { COMPANIES, DEFAULT_COMPANY, isValid, resolve };
+291
View File
@@ -0,0 +1,291 @@
'use strict';
// This file straddles two databases: fetchTrialBalance/fetchAllTrialBalance
// read real SAP ledger data (OACT/JDT1/OJDT) via sapPool; everything else
// (pl_account_map, pl_overrides, pl_snapshots — this app's own config/cache
// tables) lives on the app's own database via getPool (appSqlPool).
// getAllOverrides is the one spot that used to JOIN an app table with OACT
// in a single query — now split into two queries + a JS-side merge, since
// the two tables can no longer live on the same server.
const { getPool } = require('./appSqlPool');
const { getPool: getSapPool } = require('./sqlPool');
const SHEETS = ['Revenue', 'Purchase', 'Employee', 'Factory', 'Admin', 'SND', 'Finance', 'Other'];
async function bootstrap() {
const pool = await getPool();
await pool.request().query(`
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='pl_account_map' AND xtype='U')
CREATE TABLE pl_account_map (
acct_code NVARCHAR(50) NOT NULL PRIMARY KEY,
sheet NVARCHAR(30) NOT NULL DEFAULT 'Other',
sort_order INT NOT NULL DEFAULT 999,
head NVARCHAR(100) NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT GETDATE()
)
`);
await pool.request().query(`
IF NOT EXISTS (
SELECT 1 FROM sys.columns
WHERE name='head' AND object_id=OBJECT_ID('pl_account_map')
)
ALTER TABLE pl_account_map ADD head NVARCHAR(100) NOT NULL DEFAULT ''
`);
await pool.request().query(`
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='pl_overrides' AND xtype='U')
CREATE TABLE pl_overrides (
id INT IDENTITY(1,1) PRIMARY KEY,
acct_code NVARCHAR(50) NOT NULL,
period_from NVARCHAR(10) NOT NULL,
period_to NVARCHAR(10) NOT NULL,
override_val DECIMAL(18,2) NULL,
note NVARCHAR(500) NULL,
updated_by NVARCHAR(100) NULL,
updated_at DATETIME DEFAULT GETDATE()
)
`);
await pool.request().query(`
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name='UQ_pl_overrides_code_period' AND object_id=OBJECT_ID('pl_overrides')
)
ALTER TABLE pl_overrides
ADD CONSTRAINT UQ_pl_overrides_code_period UNIQUE (acct_code, period_from, period_to)
`);
}
async function fetchTrialBalance(fromDate, toDate) {
const pool = await getSapPool();
const r = await pool.request()
.input('fromDate', fromDate)
.input('toDate', toDate)
.query(`
WITH base AS (
SELECT
T0.[AcctCode] AS acct_code,
T0.[AcctName] AS acct_name,
T0.[GroupMask] AS group_mask,
SUM(CASE WHEN T1.[RefDate] < @fromDate
THEN ISNULL(T1.[Debit],0) - ISNULL(T1.[Credit],0) ELSE 0 END) AS opening_balance,
SUM(CASE WHEN T1.[RefDate] BETWEEN @fromDate AND @toDate
THEN ISNULL(T1.[Debit],0) ELSE 0 END) AS period_debit,
SUM(CASE WHEN T1.[RefDate] BETWEEN @fromDate AND @toDate
THEN ISNULL(T1.[Credit],0) ELSE 0 END) AS period_credit
FROM OACT T0
LEFT JOIN JDT1 T1 ON T0.[AcctCode] = T1.[Account]
LEFT JOIN OJDT T2 ON T1.[TransId] = T2.[TransId]
WHERE T0.[GroupMask] IN (4, 5)
GROUP BY T0.[AcctCode], T0.[AcctName], T0.[GroupMask]
HAVING SUM(ISNULL(T1.[Debit],0)) <> 0 OR SUM(ISNULL(T1.[Credit],0)) <> 0
)
SELECT
acct_code, acct_name, group_mask,
opening_balance,
period_debit,
period_credit,
opening_balance + period_debit - period_credit AS closing_balance
FROM base
ORDER BY group_mask, acct_code
`);
return r.recordset || [];
}
async function fetchAllTrialBalance(fromDate, toDate) {
const pool = await getSapPool();
const r = await pool.request()
.input('fromDate', fromDate)
.input('toDate', toDate)
.query(`
WITH base AS (
SELECT
T0.[AcctCode] AS acct_code,
T0.[AcctName] AS acct_name,
T0.[GroupMask] AS group_mask,
SUM(CASE WHEN T1.[RefDate] < @fromDate
THEN ISNULL(T1.[Debit],0) - ISNULL(T1.[Credit],0) ELSE 0 END) AS opening_balance,
SUM(CASE WHEN T1.[RefDate] BETWEEN @fromDate AND @toDate
THEN ISNULL(T1.[Debit],0) ELSE 0 END) AS period_debit,
SUM(CASE WHEN T1.[RefDate] BETWEEN @fromDate AND @toDate
THEN ISNULL(T1.[Credit],0) ELSE 0 END) AS period_credit
FROM OACT T0
LEFT JOIN JDT1 T1 ON T0.[AcctCode] = T1.[Account]
LEFT JOIN OJDT T2 ON T1.[TransId] = T2.[TransId]
GROUP BY T0.[AcctCode], T0.[AcctName], T0.[GroupMask]
HAVING SUM(ISNULL(T1.[Debit],0)) <> 0 OR SUM(ISNULL(T1.[Credit],0)) <> 0
)
SELECT
acct_code, acct_name, group_mask,
opening_balance,
period_debit,
period_credit,
opening_balance + period_debit - period_credit AS closing_balance
FROM base
ORDER BY group_mask, acct_code
`);
return r.recordset || [];
}
async function getMapping() {
const pool = await getPool();
const r = await pool.request().query(
`SELECT acct_code, sheet, sort_order, head FROM pl_account_map ORDER BY sort_order, acct_code`
);
return r.recordset || [];
}
async function saveMapping(entries) {
const pool = await getPool();
for (const e of entries) {
await pool.request()
.input('code', e.acct_code)
.input('sheet', e.sheet || 'Other')
.input('ord', e.sort_order != null ? e.sort_order : 999)
.input('head', e.head || '')
.query(`
IF EXISTS (SELECT 1 FROM pl_account_map WHERE acct_code=@code)
UPDATE pl_account_map
SET sheet=@sheet, sort_order=@ord, head=@head, updated_at=GETDATE()
WHERE acct_code=@code
ELSE
INSERT INTO pl_account_map (acct_code, sheet, sort_order, head)
VALUES (@code, @sheet, @ord, @head)
`);
}
}
async function getOverrides(fromDate, toDate) {
const pool = await getPool();
const r = await pool.request()
.input('from', fromDate)
.input('to', toDate)
.query(`
SELECT acct_code, override_val, note
FROM pl_overrides
WHERE period_from=@from AND period_to=@to
`);
return r.recordset || [];
}
// All non-null, non-stock provisions across every period, with account names —
// used for the month-wise Provision Summary tab. pl_overrides/pl_account_map
// live on the app DB, AcctName comes from OACT on the SAP DB — different
// servers now, so this is two queries + a JS-side merge instead of one JOIN.
async function getAllOverrides() {
const pool = await getPool();
const r = await pool.request().query(`
SELECT po.acct_code, po.period_from, po.period_to, po.override_val, po.note,
po.updated_by, po.updated_at,
pam.sheet AS sheet, pam.head AS head
FROM pl_overrides po
LEFT JOIN pl_account_map pam ON pam.acct_code = po.acct_code
WHERE po.override_val IS NOT NULL
AND po.acct_code NOT LIKE '[_][_]%'
ORDER BY po.period_from, po.acct_code
`);
const rows = r.recordset || [];
const codes = [...new Set(rows.map(row => row.acct_code))];
const nameByCode = {};
if (codes.length) {
const sapPool = await getSapPool();
const req = sapPool.request();
const inList = codes.map((c, i) => { req.input(`c${i}`, c); return `@c${i}`; }).join(',');
const oa = await req.query(`SELECT AcctCode, AcctName FROM OACT WHERE AcctCode IN (${inList})`);
(oa.recordset || []).forEach(row => { nameByCode[row.AcctCode] = row.AcctName; });
}
return rows.map(row => ({ ...row, acct_name: nameByCode[row.acct_code] || null }));
}
async function deleteOverride(acctCode, fromDate, toDate) {
const pool = await getPool();
await pool.request()
.input('code', acctCode)
.input('from', fromDate)
.input('to', toDate)
.query(`DELETE FROM pl_overrides WHERE acct_code=@code AND period_from=@from AND period_to=@to`);
}
async function saveOverride(acctCode, fromDate, toDate, overrideVal, note, updatedBy) {
const pool = await getPool();
const val = (overrideVal !== null && overrideVal !== undefined && overrideVal !== '')
? parseFloat(overrideVal) : null;
await pool.request()
.input('code', acctCode)
.input('from', fromDate)
.input('to', toDate)
.input('val', val)
.input('note', note || null)
.input('by', updatedBy || null)
.query(`
IF EXISTS (
SELECT 1 FROM pl_overrides
WHERE acct_code=@code AND period_from=@from AND period_to=@to
)
UPDATE pl_overrides
SET override_val=@val, note=@note, updated_by=@by, updated_at=GETDATE()
WHERE acct_code=@code AND period_from=@from AND period_to=@to
ELSE
INSERT INTO pl_overrides (acct_code, period_from, period_to, override_val, note, updated_by)
VALUES (@code, @from, @to, @val, @note, @by)
`);
}
// ── Snapshots ─────────────────────────────────────────────────────────────────
async function bootstrapSnapshots() {
const pool = await getPool();
await pool.request().query(`
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='pl_snapshots' AND xtype='U')
CREATE TABLE pl_snapshots (
id INT IDENTITY(1,1) PRIMARY KEY,
period_from NVARCHAR(10) NOT NULL,
period_to NVARCHAR(10) NOT NULL,
snap_name NVARCHAR(200) NOT NULL DEFAULT '',
data_json NVARCHAR(MAX) NOT NULL,
saved_by NVARCHAR(100) NULL,
saved_at DATETIME DEFAULT GETDATE()
)
`);
}
async function saveSnapshot(periodFrom, periodTo, snapName, dataJson, savedBy) {
const pool = await getPool();
await pool.request()
.input('from', periodFrom)
.input('to', periodTo)
.input('name', snapName || '')
.input('data', dataJson)
.input('by', savedBy || null)
.query(`
INSERT INTO pl_snapshots (period_from, period_to, snap_name, data_json, saved_by)
VALUES (@from, @to, @name, @data, @by)
`);
}
async function getSnapshots() {
const pool = await getPool();
const r = await pool.request().query(`
SELECT id, period_from, period_to, snap_name, saved_by, saved_at
FROM pl_snapshots ORDER BY saved_at DESC
`);
return r.recordset || [];
}
async function getSnapshot(id) {
const pool = await getPool();
const r = await pool.request()
.input('id', id)
.query(`SELECT data_json FROM pl_snapshots WHERE id=@id`);
return r.recordset[0] || null;
}
async function deleteSnapshot(id) {
const pool = await getPool();
await pool.request().input('id', id).query(`DELETE FROM pl_snapshots WHERE id=@id`);
}
module.exports = {
bootstrap, fetchTrialBalance, fetchAllTrialBalance, getMapping, saveMapping, getOverrides, getAllOverrides, saveOverride, deleteOverride, SHEETS,
bootstrapSnapshots, saveSnapshot, getSnapshots, getSnapshot, deleteSnapshot
};
+39
View File
@@ -0,0 +1,39 @@
// services/cryptoUtil.js
// Symmetric encryption for secrets we must be able to READ back (unlike
// portal passwords, which are one-way hashed) — specifically each user's SAP
// Service-Layer password, which has to be replayed to SAP at login time.
// AES-256-GCM with a key derived from JWT_SECRET. Output is a self-describing
// string "v1:<iv>:<tag>:<ciphertext>" (all base64), so decrypt needs no extra
// state. NOT for passwords you only ever compare — use hashing for those.
'use strict';
const crypto = require('crypto');
function key() {
const secret = process.env.JWT_SECRET || 'sap-portal-secret';
return crypto.createHash('sha256').update('sapcred:' + secret).digest(); // 32 bytes
}
function encrypt(plain) {
if (plain == null || plain === '') return '';
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
const enc = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1:${iv.toString('base64')}:${tag.toString('base64')}:${enc.toString('base64')}`;
}
function decrypt(blob) {
if (!blob) return '';
try {
const parts = String(blob).split(':');
if (parts.length !== 4 || parts[0] !== 'v1') return '';
const iv = Buffer.from(parts[1], 'base64');
const tag = Buffer.from(parts[2], 'base64');
const data = Buffer.from(parts[3], 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
} catch { return ''; }
}
module.exports = { encrypt, decrypt };
+321
View File
@@ -0,0 +1,321 @@
// services/deviationStore.js
// Production Deviations: after Issue for Production, a component may run
// short (Shortage — top up qty of the same item), need swapping for a
// different item entirely (Substitution), or something already in-process
// gets damaged (Damage/Loss — optionally written off to a scrap warehouse).
// The SAP posting (if any) happens immediately when raised, so production is
// never blocked; this table is the documented "why" — reason, who raised it,
// and (when Admin → System Settings → "Deviation — Require QA Approval" is
// on) a post-hoc QA sign-off that closes the paper trail without ever
// gating the actual goods movement.
'use strict';
const sql = require('mssql');
const TABLE = `[dbo].[ZPRODUCTION_DEVIATIONS]`;
const TYPES = ['SHORTAGE', 'SUBSTITUTION', 'DAMAGE'];
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[DEVIATION-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
PRODUCTION_ORDER_ID INT,
SAP_ABS_ENTRY INT,
SAP_DOC_NUM NVARCHAR(30),
TYPE NVARCHAR(20) NOT NULL,
ITEM_CODE NVARCHAR(60),
ITEM_NAME NVARCHAR(200),
NEW_ITEM_CODE NVARCHAR(60),
NEW_ITEM_NAME NVARCHAR(200),
QUANTITY DECIMAL(19,6),
WAREHOUSE NVARCHAR(20),
DEST_WAREHOUSE NVARCHAR(20),
BATCH_NUMBER NVARCHAR(MAX),
LINE_NUM INT,
POST_INTENDED BIT DEFAULT 0,
POSTED_TO_SAP BIT DEFAULT 0,
SAP_DOC_ENTRY INT,
SAP_DOC_NUM_POSTED NVARCHAR(30),
SAP_DOC_OBJECT NVARCHAR(60),
REVERSED BIT DEFAULT 0,
REVERSAL_DOC_ENTRY INT,
REVERSAL_DOC_NUM NVARCHAR(30),
REVERSAL_DOC_OBJECT NVARCHAR(60),
REVERSAL_AT DATETIME2,
REVERSAL_ERROR NVARCHAR(MAX),
REASON NVARCHAR(MAX),
RAISED_BY NVARCHAR(50),
RAISED_NAME NVARCHAR(100),
RAISED_AT DATETIME2,
QA_REQUIRED BIT DEFAULT 0,
QA_STATUS NVARCHAR(20) DEFAULT 'N_A',
QA_BY NVARCHAR(50),
QA_NAME NVARCHAR(100),
QA_AT DATETIME2,
QA_REMARKS NVARCHAR(MAX),
COMPANY NVARCHAR(60),
IS_DELETED BIT DEFAULT 0
)
`).catch(e => { if (isAlreadyExists(e)) { console.log('[DEVIATION-STORE] Table exists — OK'); } else throw e; });
// Migration: BATCH_NUMBER added after the table already existed in some
// environments — holds a JSON array of {BatchNumber,Quantity} (a
// Shortage/Substitution issue or a Damage/Loss transfer can draw from
// MORE THAN ONE batch, same as Issue for Production, when a single batch
// doesn't have enough quantity in stock).
await exec(`
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_DEVIATIONS' AND COLUMN_NAME='BATCH_NUMBER')
ALTER TABLE ${TABLE} ADD BATCH_NUMBER NVARCHAR(MAX)
`).catch(e => console.warn('[DEVIATION-STORE] BATCH_NUMBER migration failed (non-fatal):', e.message));
// Widen it in case it was created earlier as the old NVARCHAR(60) — safe,
// non-destructive (widening never loses data).
await exec(`ALTER TABLE ${TABLE} ALTER COLUMN BATCH_NUMBER NVARCHAR(MAX)`)
.catch(e => console.warn('[DEVIATION-STORE] BATCH_NUMBER widen failed (non-fatal):', e.message));
// Migration: LINE_NUM + REVERSAL_* columns added after the table already
// existed in some environments — LINE_NUM records the affected component's
// own BaseLine (Shortage only, needed to reverse via the same "Return
// Components" shape); REVERSAL_* records what happened when QA rejects an
// already-posted deviation (the goods movement is reversed automatically —
// see routes/sap.js PATCH /deviations/:id/approve).
const addCol = (col, ddl) => exec(`
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_DEVIATIONS' AND COLUMN_NAME='${col}')
ALTER TABLE ${TABLE} ADD ${ddl}
`).catch(e => console.warn(`[DEVIATION-STORE] ${col} migration failed (non-fatal):`, e.message));
await addCol('LINE_NUM', 'LINE_NUM INT');
await addCol('REVERSED', 'REVERSED BIT DEFAULT 0');
await addCol('REVERSAL_DOC_ENTRY', 'REVERSAL_DOC_ENTRY INT');
await addCol('REVERSAL_DOC_NUM', 'REVERSAL_DOC_NUM NVARCHAR(30)');
await addCol('REVERSAL_DOC_OBJECT', 'REVERSAL_DOC_OBJECT NVARCHAR(60)');
await addCol('REVERSAL_AT', 'REVERSAL_AT DATETIME2');
await addCol('REVERSAL_ERROR', 'REVERSAL_ERROR NVARCHAR(MAX)');
// POST_INTENDED — only meaningful for a Damage/Loss deviation raised while
// "Defer Issue Until Approval" is on: remembers that the user DID check
// "post an actual SAP write-off" at raise time, even though (in deferred
// mode) nothing was posted yet — the deviation's own POSTED_TO_SAP stays 0
// until the concerned user manually posts it once QA approves. Without
// this the intent would be lost the moment the immediate posting is
// skipped, and there'd be no way to tell "informational only" apart from
// "queued to post" once it's sitting there un-posted.
await addCol('POST_INTENDED', 'POST_INTENDED BIT DEFAULT 0');
// LINE_APPLIED — for Shortage/Substitution: whether the Production Order
// line change (bump PlannedQuantity / add the component) has actually been
// made yet. Immediate (non-deferred) postings apply it right away; deferred
// ones do NOT touch the Production Order at raise time at all — only once
// QA approves (see POST /deviations vs PATCH /deviations/:id/approve in
// routes/sap.js). A still-PENDING deviation must never show up on the
// Production Order, full stop.
await addCol('LINE_APPLIED', 'LINE_APPLIED BIT DEFAULT 0');
await addCol('LINE_APPLY_ERROR', 'LINE_APPLY_ERROR NVARCHAR(MAX)');
// One-time backfill for rows created under the OLD deferred design (which
// DID patch the Production Order at raise time, just skipped the goods
// issue) — those already touched SAP even though this new column defaults
// to 0. Without this, rejecting one of those (if still PENDING) would
// wrongly skip reverting a change that genuinely happened. Scoped to a
// fixed cutoff (this feature's ship date) so it never misclassifies a
// brand-new deferred Shortage deviation raised under the CORRECT new
// behavior (which also has LINE_NUM set immediately but LINE_APPLIED
// correctly still 0 until approval).
// NOTE: ALTER TABLE ... ADD col DEFAULT 0 only applies that default to NEW
// inserts — SQL Server leaves already-existing rows NULL (not 0), unless
// the column is also NOT NULL. Must match NULL here too, or this silently
// backfills nothing.
await exec(`
UPDATE ${TABLE} SET LINE_APPLIED = 1
WHERE (LINE_APPLIED = 0 OR LINE_APPLIED IS NULL) AND ISNULL(POSTED_TO_SAP,0) = 0 AND LINE_NUM IS NOT NULL AND TYPE IN ('SHORTAGE','SUBSTITUTION')
AND RAISED_AT < '2026-08-06T08:30:00'
`).catch(e => console.warn('[DEVIATION-STORE] LINE_APPLIED backfill failed (non-fatal):', e.message));
// Any remaining NULLs (rows outside the backfill's scope — e.g. already
// POSTED_TO_SAP, or DAMAGE) just need to become a real 0, not stay NULL.
await exec(`UPDATE ${TABLE} SET LINE_APPLIED = 0 WHERE LINE_APPLIED IS NULL`)
.catch(e => console.warn('[DEVIATION-STORE] LINE_APPLIED NULL cleanup failed (non-fatal):', e.message));
// OLD_LINE_NUM — Substitution only: the REPLACED item's own component
// LineNumber (the "Affected Component" the user picked). Substituting must
// shrink that item's remaining (unissued) quantity by the deviation's own
// qty at the same time the new item is added/bumped — otherwise the store
// could still issue the item that's actually unavailable. Recorded so
// rejection can add that amount back.
await addCol('OLD_LINE_NUM', 'OLD_LINE_NUM INT');
// OLD_LINE_REMOVED — Substitution only: whether the replaced item's own
// (now-superseded) line has been physically DELETED from the Production
// Order in SAP via DI API (Service Layer can't do this at all — verified
// live). The app's own audit trail (this deviation record) is shown
// regardless of this flag; it only reflects whether SAP itself still has
// the zeroed-out line sitting there or not.
await addCol('OLD_LINE_REMOVED', 'OLD_LINE_REMOVED BIT DEFAULT 0');
console.log('[DEVIATION-STORE] ✅ Ready');
}
function toTs(d) { return d.toISOString().replace('T', ' ').replace('Z', '').substring(0, 23); }
function safeJson(val, fallback) { if (!val) return fallback; try { return JSON.parse(val); } catch { return fallback; } }
function fromRow(row) {
if (!row) return null;
return {
id: row.ID,
productionOrderId: row.PRODUCTION_ORDER_ID,
sapAbsEntry: row.SAP_ABS_ENTRY,
sapDocNum: row.SAP_DOC_NUM,
type: row.TYPE,
itemCode: row.ITEM_CODE || '',
itemName: row.ITEM_NAME || '',
newItemCode: row.NEW_ITEM_CODE || '',
newItemName: row.NEW_ITEM_NAME || '',
quantity: row.QUANTITY != null ? Number(row.QUANTITY) : 0,
warehouse: row.WAREHOUSE || '',
destWarehouse: row.DEST_WAREHOUSE || '',
batchNumbers: safeJson(row.BATCH_NUMBER, []), // [{BatchNumber,Quantity}] — can be more than one, same as Issue for Production
lineNum: row.LINE_NUM,
oldLineNum: row.OLD_LINE_NUM,
oldLineRemoved: !!row.OLD_LINE_REMOVED,
lineApplied: !!row.LINE_APPLIED,
lineApplyError: row.LINE_APPLY_ERROR || '',
postIntended: !!row.POST_INTENDED,
postedToSap: !!row.POSTED_TO_SAP,
sapDocEntry: row.SAP_DOC_ENTRY,
sapDocNumPosted: row.SAP_DOC_NUM_POSTED,
sapDocObject: row.SAP_DOC_OBJECT || '',
reversed: !!row.REVERSED,
reversalDocEntry: row.REVERSAL_DOC_ENTRY,
reversalDocNum: row.REVERSAL_DOC_NUM || '',
reversalDocObject: row.REVERSAL_DOC_OBJECT || '',
reversalAt: row.REVERSAL_AT ? new Date(row.REVERSAL_AT).toISOString() : null,
reversalError: row.REVERSAL_ERROR || '',
reason: row.REASON || '',
raisedBy: row.RAISED_BY || '',
raisedName: row.RAISED_NAME || '',
raisedAt: row.RAISED_AT ? new Date(row.RAISED_AT).toISOString() : null,
qaRequired: !!row.QA_REQUIRED,
qaStatus: row.QA_STATUS || 'N_A',
qaBy: row.QA_BY || '',
qaName: row.QA_NAME || '',
qaAt: row.QA_AT ? new Date(row.QA_AT).toISOString() : null,
qaRemarks: row.QA_REMARKS || '',
company: row.COMPANY || '',
isDeleted: !!row.IS_DELETED,
};
}
async function createDeviation(d) {
const now = toTs(new Date());
const qaRequired = !!d.qaRequired;
const rows = await exec(`
INSERT INTO ${TABLE} (
PRODUCTION_ORDER_ID, SAP_ABS_ENTRY, SAP_DOC_NUM, TYPE, ITEM_CODE, ITEM_NAME,
NEW_ITEM_CODE, NEW_ITEM_NAME, QUANTITY, WAREHOUSE, DEST_WAREHOUSE, BATCH_NUMBER, LINE_NUM, OLD_LINE_NUM,
LINE_APPLIED, POST_INTENDED, POSTED_TO_SAP, SAP_DOC_ENTRY, SAP_DOC_NUM_POSTED, SAP_DOC_OBJECT, REASON,
RAISED_BY, RAISED_NAME, RAISED_AT, QA_REQUIRED, QA_STATUS, COMPANY
)
OUTPUT INSERTED.*
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`, [
d.productionOrderId || null, d.sapAbsEntry || null, d.sapDocNum || '', d.type,
d.itemCode || '', d.itemName || '', d.newItemCode || '', d.newItemName || '',
d.quantity || 0, d.warehouse || '', d.destWarehouse || '', JSON.stringify(d.batchNumbers || []),
d.lineNum != null && d.lineNum !== '' ? parseInt(d.lineNum) : null,
d.oldLineNum != null && d.oldLineNum !== '' ? parseInt(d.oldLineNum) : null,
d.lineApplied ? 1 : 0,
d.postIntended ? 1 : 0,
d.postedToSap ? 1 : 0, d.sapDocEntry || null, d.sapDocNumPosted || '', d.sapDocObject || '',
d.reason || '', d.raisedBy || '', d.raisedName || '', now,
qaRequired ? 1 : 0, qaRequired ? 'PENDING' : 'N_A', d.company || '',
]);
return fromRow(rows[0]);
}
async function listDeviations({ productionOrderId, sapAbsEntry, company, includeDeleted } = {}) {
const rows = await exec(`SELECT * FROM ${TABLE} ORDER BY RAISED_AT DESC`);
return rows.map(fromRow).filter(r => {
if (!includeDeleted && r.isDeleted) return false;
if (productionOrderId != null && String(r.productionOrderId) !== String(productionOrderId)) return false;
if (sapAbsEntry != null && String(r.sapAbsEntry) !== String(sapAbsEntry)) return false;
if (company && r.company && r.company !== company) return false;
return true;
});
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0]) : null;
}
async function approveDeviation(id, { by, byName, remarks, approve }) {
const now = toTs(new Date());
await exec(`
UPDATE ${TABLE} SET QA_STATUS = ?, QA_BY = ?, QA_NAME = ?, QA_AT = ?, QA_REMARKS = ?
WHERE ID = ?
`, [approve ? 'APPROVED' : 'REJECTED', by || '', byName || by || '', now, remarks || '', parseInt(id)]);
return findById(id);
}
async function recordReversal(id, { reversed, docEntry, docNum, docObject, error }) {
const now = toTs(new Date());
await exec(`
UPDATE ${TABLE} SET REVERSED = ?, REVERSAL_DOC_ENTRY = ?, REVERSAL_DOC_NUM = ?,
REVERSAL_DOC_OBJECT = ?, REVERSAL_AT = ?, REVERSAL_ERROR = ?
WHERE ID = ?
`, [reversed ? 1 : 0, docEntry || null, docNum || '', docObject || '', now, error || '', parseInt(id)]);
return findById(id);
}
// Marks a previously-deferred (not posted at raise time) deviation as now
// posted — used by the manual "Post to SAP" action (currently Damage/Loss
// only; Shortage/Substitution are picked up by Issue for Production instead
// and never call this).
async function markPosted(id, { docEntry, docNum, docObject }) {
await exec(`
UPDATE ${TABLE} SET POSTED_TO_SAP = 1, SAP_DOC_ENTRY = ?, SAP_DOC_NUM_POSTED = ?, SAP_DOC_OBJECT = ?
WHERE ID = ?
`, [docEntry || null, docNum || '', docObject || '', parseInt(id)]);
return findById(id);
}
// Records that the Shortage/Substitution component change has now actually
// been applied to the Production Order (called once, right after QA
// approves — see PATCH /deviations/:id/approve). `lineNum` is the FINAL
// LineNumber it landed on (Substitution may reuse an already-existing line
// for the same item rather than always creating a new one).
async function markLineApplied(id, { lineNum }) {
await exec(`UPDATE ${TABLE} SET LINE_APPLIED = 1, LINE_APPLY_ERROR = '', LINE_NUM = ? WHERE ID = ?`,
[lineNum != null ? parseInt(lineNum) : null, parseInt(id)]);
return findById(id);
}
async function markLineApplyError(id, error) {
await exec(`UPDATE ${TABLE} SET LINE_APPLY_ERROR = ? WHERE ID = ?`, [error || '', parseInt(id)]);
return findById(id);
}
// Records that the replaced item's own (now-superseded) line has been
// physically deleted from the Production Order in SAP via DI API.
async function markOldLineRemoved(id) {
await exec(`UPDATE ${TABLE} SET OLD_LINE_REMOVED = 1 WHERE ID = ?`, [parseInt(id)]);
return findById(id);
}
module.exports = { bootstrap, TYPES, createDeviation, listDeviations, findById, approveDeviation, recordReversal, markPosted, markLineApplied, markLineApplyError, markOldLineRemoved };
+179
View File
@@ -0,0 +1,179 @@
'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 };
+178
View File
@@ -0,0 +1,178 @@
// backend/services/hanaItemStore.js
// SQL persistence for item creation approval requests.
'use strict';
const { getPool } = require('./appSqlPool');
const { DEFAULT_COMPANY } = require('./companyConfig');
const tbl = () => `[dbo].[ZITEM_PORTAL]`;
async function exec(sqlQuery, params = []) {
const pool = await getPool();
const request = pool.request();
let idx = 0;
const replaced = sqlQuery.replace(/\?/g, () => {
const name = `p${idx}`;
request.input(name, params[idx]);
idx++;
return `@${name}`;
});
const result = await request.query(replaced);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[ITEM STORE] Checking table...');
let tableExisted = false;
await exec(`
CREATE TABLE ${tbl()} (
[ID] INT IDENTITY(1,1) PRIMARY KEY,
[STATUS] NVARCHAR(30) DEFAULT 'PENDING',
[ITEM_CODE] NVARCHAR(50),
[ITEM_NAME] NVARCHAR(200),
[ITEM_DATA] NVARCHAR(MAX),
[SUBMITTED_BY] NVARCHAR(60),
[SUBMITTED_BY_NAME] NVARCHAR(100),
[SUBMITTED_AT] DATETIME2 DEFAULT GETDATE(),
[ADMIN_NOTES] NVARCHAR(MAX),
[ADMIN_EDITED_DATA] NVARCHAR(MAX),
[REVIEWED_BY] NVARCHAR(60),
[REVIEWED_AT] DATETIME2,
[APPROVED_BY] NVARCHAR(60),
[APPROVED_AT] DATETIME2,
[REJECTED_BY] NVARCHAR(60),
[REJECTED_AT] DATETIME2,
[SAP_ITEM_CODE] NVARCHAR(50),
[APPROVAL_LOG] NVARCHAR(MAX),
[COMPANY] NVARCHAR(50) DEFAULT '${DEFAULT_COMPANY}'
)
`).catch(e => {
if (!isAlreadyExists(e)) throw e;
tableExisted = true;
console.log('[ITEM STORE] Table already exists — checking for missing columns...');
});
if (tableExisted) {
const cols = [
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZITEM_PORTAL' AND COLUMN_NAME='COMPANY') ALTER TABLE ${tbl()} ADD [COMPANY] NVARCHAR(50) DEFAULT '${DEFAULT_COMPANY}'`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZITEM_PORTAL' AND COLUMN_NAME='ADMIN_EDITED_DATA') ALTER TABLE ${tbl()} ADD [ADMIN_EDITED_DATA] NVARCHAR(MAX)`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZITEM_PORTAL' AND COLUMN_NAME='APPROVAL_LOG') ALTER TABLE ${tbl()} ADD [APPROVAL_LOG] NVARCHAR(MAX)`,
];
for (const sql of cols) await exec(sql).catch(() => {});
console.log('[ITEM STORE] ✅ Column migration check complete');
}
console.log('[ITEM STORE] ✅ Table ready');
}
function rowToItem(row) {
if (!row) return null;
let itemData = {}, adminEditedData = null, approvalLog = [];
try { itemData = JSON.parse(row.ITEM_DATA || '{}'); } catch (_) {}
try { adminEditedData = row.ADMIN_EDITED_DATA ? JSON.parse(row.ADMIN_EDITED_DATA) : null; } catch (_) {}
try { approvalLog = JSON.parse(row.APPROVAL_LOG || '[]'); } catch (_) {}
return {
id: row.ID,
status: row.STATUS,
itemCode: row.ITEM_CODE || '',
itemName: row.ITEM_NAME || '',
itemData,
adminEditedData,
submittedBy: row.SUBMITTED_BY || '',
submittedByName: row.SUBMITTED_BY_NAME || '',
submittedAt: row.SUBMITTED_AT ? new Date(row.SUBMITTED_AT).toISOString() : null,
adminNotes: row.ADMIN_NOTES || '',
reviewedBy: row.REVIEWED_BY || null,
reviewedAt: row.REVIEWED_AT ? new Date(row.REVIEWED_AT).toISOString() : null,
approvedBy: row.APPROVED_BY || null,
approvedAt: row.APPROVED_AT ? new Date(row.APPROVED_AT).toISOString() : null,
rejectedBy: row.REJECTED_BY || null,
rejectedAt: row.REJECTED_AT ? new Date(row.REJECTED_AT).toISOString() : null,
sapItemCode: row.SAP_ITEM_CODE || null,
approvalLog,
company: row.COMPANY || '',
};
}
function ts(isoStr) {
if (!isoStr) return null;
return isoStr.replace('T', ' ').replace('Z', '').substring(0, 23);
}
async function insertItem(data) {
const rows = await exec(`
INSERT INTO ${tbl()} (
[STATUS],[ITEM_CODE],[ITEM_NAME],[ITEM_DATA],
[SUBMITTED_BY],[SUBMITTED_BY_NAME],[SUBMITTED_AT],
[APPROVAL_LOG],[COMPANY]
) OUTPUT INSERTED.ID VALUES (?,?,?,?,?,?,GETDATE(),?,?)
`, [
data.status || 'PENDING',
(data.itemCode || '').substring(0, 50),
(data.itemName || '').substring(0, 200),
JSON.stringify(data.itemData || {}),
(data.submittedBy || '').substring(0, 60),
(data.submittedByName || '').substring(0, 100),
JSON.stringify(data.approvalLog || []),
(data.company || '').substring(0, 50),
]);
const id = rows[0].ID;
console.log(`[ITEM STORE] ✅ Inserted ID=${id}`);
return { id };
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${tbl()} WHERE [ID]=?`, [parseInt(id)]);
return rowToItem(rows[0]);
}
async function listByStatus(status, companyDB, submittedBy = null) {
const conditions = [];
const params = [];
if (status !== 'ALL') { conditions.push(`[STATUS]=?`); params.push(status.toUpperCase()); }
if (submittedBy) { conditions.push(`[SUBMITTED_BY]=?`); params.push(submittedBy); }
if (companyDB && companyDB !== 'ALL') { conditions.push(`[COMPANY]=?`); params.push(companyDB); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const rows = await exec(`SELECT * FROM ${tbl()} ${where} ORDER BY [SUBMITTED_AT] DESC`, params);
return rows.map(rowToItem);
}
async function updateItem(id, patch) {
const COL_MAP = {
status: 'STATUS',
itemCode: 'ITEM_CODE',
itemName: 'ITEM_NAME',
itemData: 'ITEM_DATA',
adminNotes: 'ADMIN_NOTES',
adminEditedData: 'ADMIN_EDITED_DATA',
approvalLog: 'APPROVAL_LOG',
reviewedBy: 'REVIEWED_BY',
reviewedAt: 'REVIEWED_AT',
approvedBy: 'APPROVED_BY',
approvedAt: 'APPROVED_AT',
rejectedBy: 'REJECTED_BY',
rejectedAt: 'REJECTED_AT',
sapItemCode: 'SAP_ITEM_CODE',
company: 'COMPANY',
};
const JSON_COLS = new Set(['ITEM_DATA', 'ADMIN_EDITED_DATA', 'APPROVAL_LOG']);
const TS_COLS = new Set(['REVIEWED_AT', 'APPROVED_AT', 'REJECTED_AT']);
const setClauses = [], vals = [];
Object.entries(patch).forEach(([k, v]) => {
const col = COL_MAP[k];
if (!col) return;
setClauses.push(`[${col}]=?`);
if (JSON_COLS.has(col)) vals.push(v === null ? null : JSON.stringify(v));
else if (TS_COLS.has(col)) vals.push(v ? ts(v) : null);
else vals.push(v === null || v === undefined ? null : String(v));
});
if (!setClauses.length) return;
vals.push(parseInt(id));
await exec(`UPDATE ${tbl()} SET ${setClauses.join(',')} WHERE [ID]=?`, vals);
console.log(`[ITEM STORE] ✅ Updated ID=${id}`);
}
module.exports = { bootstrap, insertItem, findById, listByStatus, updateItem };
+559
View File
@@ -0,0 +1,559 @@
// backend/services/hanaStore.js
// ─────────────────────────────────────────────────────────────────────────────
// Persistent storage for the Customer Registration Portal using SAP SQL Server.
// All customer submissions are stored in the JIVO_OIL_HANADB database in a
// custom table: ZCUST_PORTAL
//
// TABLE STRUCTURE (auto-created on first run):
// ZCUST_PORTAL (
// ID INT IDENTITY(1,1) PRIMARY KEY, -- auto-increment
// CARD_NAME NVARCHAR(200),
// FOREIGN_NAME NVARCHAR(200),
// CUSTOMER_TYPE NVARCHAR(10), -- B2B / B2C
// TYPE_OF_BUSINESS NVARCHAR(50),
// INDUSTRY NVARCHAR(100),
// MOBILE NVARCHAR(30),
// EMAIL NVARCHAR(150),
// WEBSITE NVARCHAR(200),
// CONTACT_FIRST NVARCHAR(100),
// CONTACT_LAST NVARCHAR(100),
// CONTACT_TITLE NVARCHAR(100),
// CONTACT_MOBILE NVARCHAR(30),
// CONTACT_EMAIL NVARCHAR(150),
// GSTIN NVARCHAR(20),
// PAN NVARCHAR(15),
// CURRENCY NVARCHAR(50),
// REMARKS NCLOB,
// -- MSME
// HAS_MSME TINYINT DEFAULT 0,
// MSME_NO NVARCHAR(30),
// MSME_TYPE NVARCHAR(20),
// MSME_BTYPE NVARCHAR(20),
// -- Status & workflow
// STATUS NVARCHAR(20) DEFAULT 'PENDING',
// SUBMITTED_AT TIMESTAMP,
// VERIFIED_AT TIMESTAMP,
// APPROVED_AT TIMESTAMP,
// APPROVED_BY NVARCHAR(100),
// SAP_CARD_CODE NVARCHAR(50),
// SAP_ATT_ENTRY INTEGER,
// -- Billing address (primary)
// BILL_ADDR_NAME NVARCHAR(100),
// BILL_STREET NVARCHAR(200),
// BILL_BLOCK NVARCHAR(200),
// BILL_CITY NVARCHAR(100),
// BILL_ZIP NVARCHAR(20),
// BILL_STATE NVARCHAR(10),
// BILL_COUNTRY NVARCHAR(100),
// -- Shipping address (primary)
// SHIP_ADDR_NAME NVARCHAR(100),
// SHIP_STREET NVARCHAR(200),
// SHIP_BLOCK NVARCHAR(200),
// SHIP_CITY NVARCHAR(100),
// SHIP_ZIP NVARCHAR(20),
// SHIP_STATE NVARCHAR(10),
// SHIP_COUNTRY NVARCHAR(100),
// SAME_AS_BILL TINYINT DEFAULT 0,
// -- Multi-address JSON blobs
// ALL_BILL_ADDRS NCLOB, -- JSON array of billing address objects
// ALL_SHIP_ADDRS NCLOB, -- JSON array of shipping address objects
// -- Attachments (base64 JSON blob — cleared after SAP upload)
// ATTACHMENTS NCLOB,
// -- Manager fields
// MGR_PREFIX NVARCHAR(20) DEFAULT 'CUSTA',
// MGR_GROUP_CODE NVARCHAR(20),
// MGR_GROUP NVARCHAR(100),
// MGR_CURRENCY NVARCHAR(50),
// MGR_CHAIN NVARCHAR(50),
// MGR_MAIN_GROUP NVARCHAR(50),
// MGR_BRANCH NVARCHAR(50),
// MGR_COUNTRY NVARCHAR(10),
// MGR_CITY NVARCHAR(100),
// MGR_ZONE NVARCHAR(100),
// MGR_AREA NVARCHAR(100),
// MGR_SUBAREA NVARCHAR(100),
// MGR_COUNTRY_HD NVARCHAR(100),
// MGR_RSM NVARCHAR(100),
// MGR_ASM NVARCHAR(100),
// MGR_SO NVARCHAR(100),
// MGR_SR NVARCHAR(100),
// MGR_PROMOTER NVARCHAR(100),
// MGR_SALES_EMP NVARCHAR(100),
// MGR_SLP_CODE NVARCHAR(20),
// MGR_SCHEME_TYPE NVARCHAR(100),
// MGR_TERRITORY NVARCHAR(100),
// MGR_NOTES NCLOB,
// MGR_CREDIT_LMT DECIMAL(18,2) DEFAULT 0,
// MGR_PAY_TERMS NVARCHAR(100),
// MGR_PAY_CODE NVARCHAR(20),
// MGR_AR_ACCOUNT NVARCHAR(30),
// MGR_AR_ACC_NAME NVARCHAR(200),
// MGR_LANGUAGE NVARCHAR(50)
// )
// ─────────────────────────────────────────────────────────────────────────────
const { getPool } = require('./appSqlPool');
const { DEFAULT_COMPANY } = require('./companyConfig');
const DEFAULT_SCHEMA = DEFAULT_COMPANY;
const tbl = (s) => `[dbo].[ZCUST_PORTAL]`;
const seq = (s) => `[dbo].[ZCUST_PORTAL_SEQ]`;
async function exec(sqlQuery, params = []) {
const pool = await getPool();
const request = pool.request();
params.forEach((param, index) => {
request.input(`param${index}`, param);
});
const replacedSql = sqlQuery.replace(/\?/g, (match, offset, string) => {
const paramIndex = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${paramIndex}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
// ── Check if a SQL error means "object already exists" ─────────────────────
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists')
|| m.includes('duplicate')
|| m.includes('existing object')
|| m.includes('there is already an object');
}
// ── Bootstrap: create table if not exists ────────────────────────
async function bootstrap(schema) {
console.log('[SQL-STORE] Checking table', tbl(schema), '...');
try {
// 1. Try to create full table
let tableExisted = false;
await exec(`
CREATE TABLE ${tbl(schema)} (
ID INT IDENTITY(1,1) PRIMARY KEY,
USER_ID NVARCHAR(50),
USER_TYPE NVARCHAR(50),
USER_DEPT NVARCHAR(100),
CARD_NAME NVARCHAR(200),
FOREIGN_NAME NVARCHAR(200),
CUSTOMER_TYPE NVARCHAR(10),
TYPE_OF_BUSINESS NVARCHAR(50),
INDUSTRY NVARCHAR(100),
MOBILE NVARCHAR(30),
EMAIL NVARCHAR(150),
WEBSITE NVARCHAR(200),
CONTACT_FIRST NVARCHAR(100),
CONTACT_LAST NVARCHAR(100),
CONTACT_TITLE NVARCHAR(100),
CONTACT_MOBILE NVARCHAR(30),
CONTACT_EMAIL NVARCHAR(150),
GSTIN NVARCHAR(20),
PAN NVARCHAR(15),
CURRENCY NVARCHAR(50),
REMARKS NVARCHAR(MAX),
HAS_MSME TINYINT DEFAULT 0,
MSME_NO NVARCHAR(30),
MSME_TYPE NVARCHAR(20),
MSME_BTYPE NVARCHAR(20),
STATUS NVARCHAR(20) DEFAULT 'PENDING',
SUBMITTED_AT DATETIME2,
VERIFIED_AT DATETIME2,
APPROVED_AT DATETIME2,
APPROVED_BY NVARCHAR(100),
SAP_CARD_CODE NVARCHAR(50),
SAP_ATT_ENTRY INTEGER,
BILL_ADDR_NAME NVARCHAR(100),
BILL_STREET NVARCHAR(200),
BILL_BLOCK NVARCHAR(200),
BILL_CITY NVARCHAR(100),
BILL_ZIP NVARCHAR(20),
BILL_STATE NVARCHAR(10),
BILL_COUNTRY NVARCHAR(100),
SHIP_ADDR_NAME NVARCHAR(100),
SHIP_STREET NVARCHAR(200),
SHIP_BLOCK NVARCHAR(200),
SHIP_CITY NVARCHAR(100),
SHIP_ZIP NVARCHAR(20),
SHIP_STATE NVARCHAR(10),
SHIP_COUNTRY NVARCHAR(100),
SAME_AS_BILL TINYINT DEFAULT 0,
ALL_BILL_ADDRS NVARCHAR(MAX),
ALL_SHIP_ADDRS NVARCHAR(MAX),
ATTACHMENTS NVARCHAR(MAX),
MGR_PREFIX NVARCHAR(20) DEFAULT 'CUSTA',
MGR_GROUP_CODE NVARCHAR(20),
MGR_GROUP NVARCHAR(100),
MGR_CURRENCY NVARCHAR(50),
MGR_CHAIN NVARCHAR(50),
MGR_MAIN_GROUP NVARCHAR(50),
MGR_BRANCH NVARCHAR(50),
MGR_COUNTRY NVARCHAR(10),
MGR_CITY NVARCHAR(100),
MGR_ZONE NVARCHAR(100),
MGR_AREA NVARCHAR(100),
MGR_SUBAREA NVARCHAR(100),
MGR_COUNTRY_HD NVARCHAR(100),
MGR_RSM NVARCHAR(100),
MGR_ASM NVARCHAR(100),
MGR_SO NVARCHAR(100),
MGR_SR NVARCHAR(100),
MGR_PROMOTER NVARCHAR(100),
MGR_SALES_EMP NVARCHAR(100),
MGR_SLP_CODE NVARCHAR(20),
MGR_SCHEME_TYPE NVARCHAR(100),
MGR_TERRITORY NVARCHAR(100),
MGR_NOTES NVARCHAR(MAX),
MGR_CREDIT_LMT DECIMAL(18,2) DEFAULT 0,
MGR_PAY_TERMS NVARCHAR(100),
MGR_PAY_CODE NVARCHAR(20),
MGR_AR_ACCOUNT NVARCHAR(30),
MGR_AR_ACC_NAME NVARCHAR(200),
MGR_LANGUAGE NVARCHAR(50),
COMPANY NVARCHAR(50) DEFAULT '${DEFAULT_SCHEMA}'
)
`).catch(e => {
if (isAlreadyExists(e)) {
tableExisted = true;
console.log('[SQL-STORE] Table already exists — checking for missing columns...');
} else {
throw e;
}
});
// 3. If table existed, safely add any new columns (migration)
if (tableExisted) {
const migrations = [
`ALTER TABLE ${tbl(schema)} ADD [USER_ID] NVARCHAR(50)`,
`ALTER TABLE ${tbl(schema)} ADD [USER_TYPE] NVARCHAR(50)`,
`ALTER TABLE ${tbl(schema)} ADD [USER_DEPT] NVARCHAR(100)`,
`ALTER TABLE ${tbl(schema)} ADD [COMPANY] NVARCHAR(50) DEFAULT '${DEFAULT_SCHEMA}'`,
];
for (const sql of migrations) {
await exec(sql).catch(e => {
// Ignore "column already exists" errors
const em = (e.message || '').toLowerCase();
if (!em.includes('column') && !em.includes('duplicate') && !isAlreadyExists(e)) {
console.warn('[SQL-STORE] Migration warning:', e.message);
}
});
}
console.log('[SQL-STORE] ✅ Column migration check complete');
}
console.log('[SQL-STORE] ✅ Table ready:', tbl(schema));
} catch (err) {
console.error('[HANA-STORE] ❌ Bootstrap failed:', err.message);
throw err;
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function toTs(isoStr) {
if (!isoStr) return null;
return isoStr.replace('T', ' ').replace('Z', '').substring(0, 23);
}
function fromRow(row) {
if (!row) return null;
return {
id: row.ID,
userId: row.USER_ID || '',
userType: row.USER_TYPE || '',
userDept: row.USER_DEPT || '',
cardName: row.CARD_NAME || '',
foreignName: row.FOREIGN_NAME || '',
customerType: row.CUSTOMER_TYPE || 'B2B',
typeOfBusiness: row.TYPE_OF_BUSINESS || '',
industry: row.INDUSTRY || '',
mobile: row.MOBILE || '',
email: row.EMAIL || '',
website: row.WEBSITE || '',
contactFirst: row.CONTACT_FIRST || '',
contactLast: row.CONTACT_LAST || '',
contactTitle: row.CONTACT_TITLE || '',
contactMobile: row.CONTACT_MOBILE || '',
contactEmail: row.CONTACT_EMAIL || '',
gstin: row.GSTIN || '',
pan: row.PAN || '',
currency: row.CURRENCY || 'Indian Rupee',
remarks: row.REMARKS || '',
hasMsme: row.HAS_MSME === 1,
msmeNo: row.MSME_NO || '',
msmeType: row.MSME_TYPE || '',
msmeBType: row.MSME_BTYPE || '',
status: row.STATUS || 'PENDING',
submittedAt: row.SUBMITTED_AT ? new Date(row.SUBMITTED_AT).toISOString() : null,
verifiedAt: row.VERIFIED_AT ? new Date(row.VERIFIED_AT).toISOString() : null,
approvedAt: row.APPROVED_AT ? new Date(row.APPROVED_AT).toISOString() : null,
approvedBy: row.APPROVED_BY || null,
sapCardCode: row.SAP_CARD_CODE || null,
sapAttachmentEntry: row.SAP_ATT_ENTRY || null,
// Bill
billAddressName: row.BILL_ADDR_NAME || '',
billStreet: row.BILL_STREET || '',
billBlock: row.BILL_BLOCK || '',
billCity: row.BILL_CITY || '',
billZip: row.BILL_ZIP || '',
billState: row.BILL_STATE || '',
billCountry: row.BILL_COUNTRY || 'India',
// Ship
shipAddressName: row.SHIP_ADDR_NAME || '',
shipStreet: row.SHIP_STREET || '',
shipBlock: row.SHIP_BLOCK || '',
shipCity: row.SHIP_CITY || '',
shipZip: row.SHIP_ZIP || '',
shipState: row.SHIP_STATE || '',
shipCountry: row.SHIP_COUNTRY || 'India',
sameAsBill: row.SAME_AS_BILL === 1,
allBillAddresses: safeJson(row.ALL_BILL_ADDRS, []),
allShipAddresses: safeJson(row.ALL_SHIP_ADDRS, []),
attachments: safeJson(row.ATTACHMENTS, {}),
// Manager
mgrCardCodePrefix: row.MGR_PREFIX || 'CUSTA',
mgrGroupCode: row.MGR_GROUP_CODE || '',
mgrGroup: row.MGR_GROUP || '',
mgrCurrency: row.MGR_CURRENCY || 'Indian Rupee',
mgrChain: row.MGR_CHAIN || '',
mgrMainGroup: row.MGR_MAIN_GROUP || '',
mgrBranch: row.MGR_BRANCH || '',
mgrCountry: row.MGR_COUNTRY || 'IN',
mgrCity: row.MGR_CITY || '',
mgrZone: row.MGR_ZONE || '',
mgrArea: row.MGR_AREA || '',
mgrSubarea: row.MGR_SUBAREA || '',
mgrCountryHead: row.MGR_COUNTRY_HD || '',
mgrRsm: row.MGR_RSM || '',
mgrAsm: row.MGR_ASM || '',
mgrSo: row.MGR_SO || '',
mgrSr: row.MGR_SR || '',
mgrPromoter: row.MGR_PROMOTER || '',
mgrSalesEmployee: row.MGR_SALES_EMP || '',
mgrSalesPersonCode: row.MGR_SLP_CODE || '',
mgrSchemeType: row.MGR_SCHEME_TYPE || '',
mgrTerritory: row.MGR_TERRITORY || '',
mgrNotes: row.MGR_NOTES || '',
mgrCreditLimit: row.MGR_CREDIT_LMT || 0,
mgrPayTerms: row.MGR_PAY_TERMS || '',
mgrPayTermsCode: row.MGR_PAY_CODE || '',
mgrArAccount: row.MGR_AR_ACCOUNT || '1101001',
mgrArAccountName: row.MGR_AR_ACC_NAME || 'SUNDRY DEBTORS GT',
mgrLanguage: row.MGR_LANGUAGE || 'English (UK)',
company: row.COMPANY || '',
};
}
function safeJson(val, fallback) {
if (!val) return fallback;
try { return JSON.parse(val); } catch { return fallback; }
}
function s(v, max) {
if (v === null || v === undefined) return null;
const str = String(v);
return max ? str.substring(0, max) : str;
}
// ── CRUD ─────────────────────────────────────────────────────────────────────
// INSERT — returns new customer object with ID
async function insertCustomer(b, _schema) {
const now = toTs(new Date().toISOString());
// INSERT and SELECT SCOPE_IDENTITY() must be one batch — as two separate
// exec() calls, a pooled connection can route the second one to a
// DIFFERENT physical connection than the one that just inserted, where
// SCOPE_IDENTITY() correctly returns NULL (see workOrderStore.js's
// insertWorkOrder() for the full write-up of this bug class).
const idRows = await exec(`
INSERT INTO ${tbl()} (
USER_ID, USER_TYPE, USER_DEPT,
CARD_NAME, FOREIGN_NAME, CUSTOMER_TYPE, TYPE_OF_BUSINESS, INDUSTRY,
MOBILE, EMAIL, WEBSITE,
CONTACT_FIRST, CONTACT_LAST, CONTACT_TITLE, CONTACT_MOBILE, CONTACT_EMAIL,
GSTIN, PAN, CURRENCY, REMARKS,
HAS_MSME, MSME_NO, MSME_TYPE, MSME_BTYPE,
STATUS, SUBMITTED_AT,
BILL_ADDR_NAME, BILL_STREET, BILL_BLOCK, BILL_CITY, BILL_ZIP, BILL_STATE, BILL_COUNTRY,
SHIP_ADDR_NAME, SHIP_STREET, SHIP_BLOCK, SHIP_CITY, SHIP_ZIP, SHIP_STATE, SHIP_COUNTRY,
SAME_AS_BILL, ALL_BILL_ADDRS, ALL_SHIP_ADDRS, ATTACHMENTS,
MGR_PREFIX, MGR_AR_ACCOUNT, MGR_AR_ACC_NAME, MGR_CURRENCY, MGR_LANGUAGE,
COMPANY
) VALUES (
?,?,?,?,
?,?,?,?,?,
?,?,?,
?,?,?,?,?,
?,?,?,?,
?,?,?,?,
?,?,
?,?,?,?,?,?,?,
?,?,?,?,?,?,?,
?,?,?,?,
?,?,?,?,?,
?
);
SELECT SCOPE_IDENTITY() AS ID;
`, [
s(b.userId, 50), s(b.userType, 50), s(b.userDept, 100),
s(b.cardName, 200), s(b.foreignName, 200), s(b.customerType || 'B2B', 10),
s(b.typeOfBusiness, 50), s(b.industry, 100),
s(b.mobile, 30), s(b.email, 150), s(b.website, 200),
s(b.contactFirst, 100), s(b.contactLast, 100), s(b.contactTitle, 100),
s(b.contactMobile, 30), s(b.contactEmail, 150),
s(b.gstin, 20), s(b.pan, 15), s(b.currency || 'Indian Rupee', 50), b.remarks || '',
b.hasMsme ? 1 : 0, s(b.msmeNo, 30), s(b.msmeType, 20), s(b.msmeBType, 20),
'PENDING', now,
s(b.billAddressName, 100), s(b.billStreet, 200), s(b.billBlock, 200),
s(b.billCity, 100), s(b.billZip, 20), s(b.billState, 10), s(b.billCountry, 100),
s(b.shipAddressName, 100), s(b.shipStreet, 200), s(b.shipBlock, 200),
s(b.shipCity, 100), s(b.shipZip, 20), s(b.shipState, 10), s(b.shipCountry, 100),
b.sameAsBill ? 1 : 0,
JSON.stringify(b.allBillAddresses || []),
JSON.stringify(b.allShipAddresses || []),
JSON.stringify(b.attachments || {}),
s(b.mgrCardCodePrefix || 'CUSTA', 20),
s(b.mgrArAccount || '1101001', 30),
s(b.mgrArAccountName || 'SUNDRY DEBTORS GT', 200),
s(b.mgrCurrency || 'Indian Rupee', 50),
s(b.mgrLanguage || 'English (UK)', 50),
s(b.company || '', 50),
]);
const id = idRows[0].ID;
console.log(`[SQL-STORE] ✅ Inserted ID=${id} — ${b.cardName}`);
return { id, ...b, status: 'PENDING', submittedAt: new Date().toISOString() };
}
// SELECT ALL by status — always query default schema table, filter by COMPANY
async function listByStatus(status, companyDB) {
const rows = await exec(
`SELECT * FROM ${tbl()} WHERE STATUS = ? ORDER BY SUBMITTED_AT DESC`,
[status.toUpperCase()]
);
let result = rows.map(fromRow);
if (companyDB) result = result.filter(r => r.company === companyDB);
return result;
}
// SELECT ONE by id — always query default schema table
async function findById(id, companyDB) {
const rows = await exec(
`SELECT * FROM ${tbl()} WHERE ID = ?`,
[parseInt(id)]
);
return rows.length ? fromRow(rows[0]) : null;
}
// UPDATE — apply a partial patch object
async function updateCustomer(id, patch, _schema) {
// Build SET clause dynamically from only provided fields
const FIELD_MAP = {
userId: 'USER_ID',
userType: 'USER_TYPE',
userDept: 'USER_DEPT',
cardName: 'CARD_NAME',
foreignName: 'FOREIGN_NAME',
customerType: 'CUSTOMER_TYPE',
typeOfBusiness: 'TYPE_OF_BUSINESS',
industry: 'INDUSTRY',
mobile: 'MOBILE',
email: 'EMAIL',
website: 'WEBSITE',
contactFirst: 'CONTACT_FIRST',
contactLast: 'CONTACT_LAST',
contactTitle: 'CONTACT_TITLE',
contactMobile: 'CONTACT_MOBILE',
contactEmail: 'CONTACT_EMAIL',
gstin: 'GSTIN',
pan: 'PAN',
currency: 'CURRENCY',
remarks: 'REMARKS',
hasMsme: 'HAS_MSME',
msmeNo: 'MSME_NO',
msmeType: 'MSME_TYPE',
msmeBType: 'MSME_BTYPE',
status: 'STATUS',
verifiedAt: 'VERIFIED_AT',
approvedAt: 'APPROVED_AT',
approvedBy: 'APPROVED_BY',
sapCardCode: 'SAP_CARD_CODE',
sapAttachmentEntry: 'SAP_ATT_ENTRY',
billAddressName: 'BILL_ADDR_NAME',
billStreet: 'BILL_STREET',
billBlock: 'BILL_BLOCK',
billCity: 'BILL_CITY',
billZip: 'BILL_ZIP',
billState: 'BILL_STATE',
billCountry: 'BILL_COUNTRY',
shipAddressName: 'SHIP_ADDR_NAME',
shipStreet: 'SHIP_STREET',
shipBlock: 'SHIP_BLOCK',
shipCity: 'SHIP_CITY',
shipZip: 'SHIP_ZIP',
shipState: 'SHIP_STATE',
shipCountry: 'SHIP_COUNTRY',
sameAsBill: 'SAME_AS_BILL',
allBillAddresses: 'ALL_BILL_ADDRS',
allShipAddresses: 'ALL_SHIP_ADDRS',
attachments: 'ATTACHMENTS',
mgrCardCodePrefix: 'MGR_PREFIX',
mgrGroupCode: 'MGR_GROUP_CODE',
mgrGroup: 'MGR_GROUP',
mgrCurrency: 'MGR_CURRENCY',
mgrChain: 'MGR_CHAIN',
mgrMainGroup: 'MGR_MAIN_GROUP',
mgrBranch: 'MGR_BRANCH',
mgrCountry: 'MGR_COUNTRY',
mgrCity: 'MGR_CITY',
mgrZone: 'MGR_ZONE',
mgrArea: 'MGR_AREA',
mgrSubarea: 'MGR_SUBAREA',
mgrCountryHead: 'MGR_COUNTRY_HD',
mgrRsm: 'MGR_RSM',
mgrAsm: 'MGR_ASM',
mgrSo: 'MGR_SO',
mgrSr: 'MGR_SR',
mgrPromoter: 'MGR_PROMOTER',
mgrSalesEmployee: 'MGR_SALES_EMP',
mgrSalesPersonCode: 'MGR_SLP_CODE',
mgrSchemeType: 'MGR_SCHEME_TYPE',
mgrTerritory: 'MGR_TERRITORY',
mgrNotes: 'MGR_NOTES',
mgrCreditLimit: 'MGR_CREDIT_LMT',
mgrPayTerms: 'MGR_PAY_TERMS',
mgrPayTermsCode: 'MGR_PAY_CODE',
mgrArAccount: 'MGR_AR_ACCOUNT',
mgrArAccountName: 'MGR_AR_ACC_NAME',
mgrLanguage: 'MGR_LANGUAGE',
company: 'COMPANY',
};
// JSON / boolean / timestamp transforms
function prepareValue(key, val) {
if (key === 'hasMsme' || key === 'sameAsBill') return val ? 1 : 0;
if (key === 'allBillAddresses' || key === 'allShipAddresses' || key === 'attachments')
return JSON.stringify(val || (key === 'attachments' ? {} : []));
if (key === 'verifiedAt' || key === 'approvedAt') return toTs(val);
if (key === 'mgrCreditLimit') return parseFloat(val) || 0;
if (key === 'sapAttachmentEntry') return parseInt(val) || null;
return val;
}
const sets = [];
const vals = [];
for (const [jsKey, colName] of Object.entries(FIELD_MAP)) {
if (patch[jsKey] !== undefined) {
sets.push(`${colName} = ?`);
vals.push(prepareValue(jsKey, patch[jsKey]));
}
}
if (!sets.length) return;
vals.push(parseInt(id));
await exec(
`UPDATE ${tbl()} SET ${sets.join(', ')} WHERE ID = ?`,
vals
);
console.log(`[HANA-STORE] ✅ Updated ID=${id} (${sets.length} fields)`);
}
module.exports = { bootstrap, insertCustomer, listByStatus, findById, updateCustomer };
+552
View File
@@ -0,0 +1,552 @@
// backend/services/hanaUsers.js
// Manages portal users in SAP SQL Server
//
// USER ROLES:
// manager — L1 approver: reviews PENDING BOM requests
// sr_manager — L2 approver: reviews L1_APPROVED BOM requests
// sap_adder — Final approver: pushes to SAP B1, manages portal users
//
// TABLE: ZCUST_USERS
// ID INT IDENTITY(1,1) PRIMARY KEY
// USERNAME NVARCHAR(50) UNIQUE NOT NULL
// PASSWORD NVARCHAR(200) -- bcrypt hash
// FULL_NAME NVARCHAR(100)
// EMAIL NVARCHAR(150)
// ROLE NVARCHAR(20) -- 'manager' | 'sr_manager' | 'sap_adder'
// ACTIVE TINYINT DEFAULT 1
// CREATED_AT DATETIME2
// LAST_LOGIN DATETIME2
const bcrypt = require('bcryptjs');
const { getPool } = require('./appSqlPool');
const { DEFAULT_COMPANY } = require('./companyConfig');
const cryptoUtil = require('./cryptoUtil');
const DB_SCHEMA = DEFAULT_COMPANY;
const TABLE = `[dbo].[ZCUST_USERS]`;
const SEQ = `[dbo].[ZCUST_USERS_SEQ]`;
const VALID_ROLES = ['manager', 'sr_manager', 'sap_adder', 'system_admin', 'board_member', 'admin', 'user', 'project_approver'];
async function exec(sqlQuery, params = []) {
const pool = await getPool();
const request = pool.request();
params.forEach((param, index) => {
request.input(`param${index}`, param);
});
const replacedSql = sqlQuery.replace(/\?/g, (match, offset, string) => {
const paramIndex = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${paramIndex}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
// ── Bootstrap ─────────────────────────────────────────────────────────────────
async function bootstrap() {
console.log('[SQL-USERS] Checking user table…');
const alreadyExists = e => {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate')
|| m.includes('existing object') || m.includes('there is already an object');
};
// Table — ROLE is NVARCHAR(20) to hold 'sr_manager'
let tableExisted = false;
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
USERNAME NVARCHAR(50) NOT NULL,
PASSWORD NVARCHAR(200) NOT NULL,
FULL_NAME NVARCHAR(100),
EMAIL NVARCHAR(150),
ROLE NVARCHAR(20) DEFAULT 'manager',
ACTIVE TINYINT DEFAULT 1,
CREATED_AT DATETIME2,
LAST_LOGIN DATETIME2
)
`).catch(e => {
if (alreadyExists(e)) {
tableExisted = true;
console.log('[SQL-USERS] Table already exists — OK');
} else throw e;
});
// Unique index on USERNAME
await exec(`CREATE UNIQUE INDEX IDX_ZCUST_USERS_UN ON ${TABLE} ([USERNAME])`).catch(() => {});
// Migration: add columns to existing tables
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='MODULES') ALTER TABLE ${TABLE} ADD [MODULES] NVARCHAR(MAX)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SAP_USER_ID') ALTER TABLE ${TABLE} ADD [SAP_USER_ID] INT`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='APPROVAL_DEPT') ALTER TABLE ${TABLE} ADD [APPROVAL_DEPT] NVARCHAR(50)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='APPROVAL_STEPS') ALTER TABLE ${TABLE} ADD [APPROVAL_STEPS] NVARCHAR(MAX)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='APPROVAL_STEPS_MIGRATED') ALTER TABLE ${TABLE} ADD [APPROVAL_STEPS_MIGRATED] TINYINT DEFAULT 0`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='APPROVE_PERM_MIGRATED') ALTER TABLE ${TABLE} ADD [APPROVE_PERM_MIGRATED] TINYINT DEFAULT 0`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='PO_MANUAL_CREATE_MIGRATED') ALTER TABLE ${TABLE} ADD [PO_MANUAL_CREATE_MIGRATED] TINYINT DEFAULT 0`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='REQ_MANAGE_MIGRATED') ALTER TABLE ${TABLE} ADD [REQ_MANAGE_MIGRATED] TINYINT DEFAULT 0`).catch(() => {});
// Per-user SAP Service-Layer login (so SAP documents are attributed to the
// actual user, not one shared account). Two users may share the same SAP
// login. Password is AES-encrypted at rest (services/cryptoUtil.js).
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SAP_LOGIN_USER') ALTER TABLE ${TABLE} ADD [SAP_LOGIN_USER] NVARCHAR(100)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SAP_LOGIN_PWD') ALTER TABLE ${TABLE} ADD [SAP_LOGIN_PWD] NVARCHAR(MAX)`).catch(() => {});
// Per-user allowed Item Groups for Issue for Production (JSON array of group codes).
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='ISSUE_ITEM_GROUPS') ALTER TABLE ${TABLE} ADD [ISSUE_ITEM_GROUPS] NVARCHAR(MAX)`).catch(() => {});
// Per-user allowed Item Groups for Production Order — Manual Entry (JSON
// array of group codes). Separate from ISSUE_ITEM_GROUPS above — issuing
// materials and originating a standalone PWO are different actions, an
// admin may want a different scope for each. Empty/null = no restriction.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='MANUAL_PO_ITEM_GROUPS') ALTER TABLE ${TABLE} ADD [MANUAL_PO_ITEM_GROUPS] NVARCHAR(MAX)`).catch(() => {});
// Per-user allowed Man Power tabs (JSON array of tab keys). Empty = all.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='MANPOWER_TABS') ALTER TABLE ${TABLE} ADD [MANPOWER_TABS] NVARCHAR(MAX)`).catch(() => {});
// Per-user allowed OEE (Overall Equipment Efficiency) tabs (JSON array of tab keys). Empty = all.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='OEE_TABS') ALTER TABLE ${TABLE} ADD [OEE_TABS] NVARCHAR(MAX)`).catch(() => {});
// Per-user handwritten signature image (base64 data URI) — drawn on printed
// Work Orders wherever this user signed an approval step.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SIGNATURE') ALTER TABLE ${TABLE} ADD [SIGNATURE] NVARCHAR(MAX)`).catch(() => {});
// Per-user allowed SAP companies to show in the "Displayed SAP Company"
// dropdown (JSON array of company DB names, e.g. "Test_MI-NewDB2"). Empty/
// null = no restriction (sees every company GET /api/companies finds).
// Purely a display-level filter (see public/company-list.js) — it doesn't
// block API calls made with an explicit company param, same class of
// restriction as ISSUE_ITEM_GROUPS/MANPOWER_TABS/OEE_TABS above.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='ALLOWED_COMPANIES') ALTER TABLE ${TABLE} ADD [ALLOWED_COMPANIES] NVARCHAR(MAX)`).catch(() => {});
// Per-company SAP Service-Layer logins — SAP B1 companies each have their
// own OUSR table, so the SAME SAP username can have a DIFFERENT password in
// each company DB. The old SAP_LOGIN_USER/SAP_LOGIN_PWD pair above only
// ever worked for one company; this JSON map holds one {user, pwdEnc} entry
// PER company DB. SAP_LOGIN_USER/SAP_LOGIN_PWD are kept (not dropped) as the
// legacy entry for the default company — see getSapLoginMapRaw()'s
// migration-on-read fold-in, no destructive migration needed.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SAP_LOGIN_MAP') ALTER TABLE ${TABLE} ADD [SAP_LOGIN_MAP] NVARCHAR(MAX)`).catch(() => {});
// Per-user allowed SAP Approval Request document types (JSON array of
// ObjectType codes, e.g. "1470000113" for Purchase Request — see
// public/sap-approvals.html's OBJ_MAP). Empty/null = no restriction (sees
// every type), same convention as ISSUE_ITEM_GROUPS/MANPOWER_TABS/OEE_TABS
// above. Restricts what routes/sap.js's GET /approval-requests returns —
// not just a display-level filter, since approval requests can be acted on.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SAP_APPROVAL_TYPES') ALTER TABLE ${TABLE} ADD [SAP_APPROVAL_TYPES] NVARCHAR(MAX)`).catch(() => {});
// Per-user allowed Purchase Request Departments (JSON array of SAP OUDP
// department names — the same free-text value stored in U_Depart/
// U_Department). Empty/null = no restriction (sees/can use every
// department), same convention as the other per-user restriction lists.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='ALLOWED_DEPARTMENTS') ALTER TABLE ${TABLE} ADD [ALLOWED_DEPARTMENTS] NVARCHAR(MAX)`).catch(() => {});
// Per-user opt-out of stage-change email notifications (services/notifyStore.js).
// DEFAULT 1 so every existing/new user keeps getting emails unless they (or
// an admin) explicitly uncheck it — this only gates whether THIS user's own
// email is ever added as a recipient; admin-configured fixed extra
// recipients (appSettingsStore.notifyExtraEmails()) are unaffected either way.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='EMAIL_NOTIFY') ALTER TABLE ${TABLE} ADD [EMAIL_NOTIFY] TINYINT DEFAULT 1`).catch(() => {});
// Work Order Verify override: lets this user, when stamping a row
// "verified" (routes/workOrders.js POST /:id/row/:section/:index/mark),
// sign as ANY user (not just themselves) and set the sign date/time to a
// backdate instead of the server's current timestamp. Off by default —
// a narrow, explicitly-granted power, not a general permission.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='WO_VERIFY_OVERRIDE') ALTER TABLE ${TABLE} ADD [WO_VERIFY_OVERRIDE] TINYINT DEFAULT 0`).catch(() => {});
// Impersonate: lets this user act on behalf of ANY other (non-admin,
// unless they're admin themselves) user — sees exactly what that user
// would see, with that user's permissions, until they return to their own
// account. Off by default, a narrow explicit grant (see server.js's
// POST /api/auth/impersonate).
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='CAN_IMPERSONATE') ALTER TABLE ${TABLE} ADD [CAN_IMPERSONATE] TINYINT DEFAULT 0`).catch(() => {});
// Seed default users if table is empty
const cntRows = await exec(`SELECT COUNT(*) AS "CNT" FROM ${TABLE}`);
const cnt = Number(cntRows[0]?.CNT || cntRows[0]?.['CNT'] || 0);
if (cnt === 0) {
console.log('[SQL-USERS] Seeding default users…');
await createUser({ username: 'admin', password: 'Admin@123', fullName: 'System Administrator', email: 'admin@company.com', role: 'admin' });
//await createUser({ username: 'admin', password: 'Admin@123', fullName: 'System Admin', email: 'admin@company.com', role: 'sap_adder' });
await createUser({ username: 'manager1', password: 'Manager@123', fullName: 'Field Manager', email: 'manager@company.com', role: 'manager' });
await createUser({ username: 'srmanager1',password: 'SrMgr@123', fullName: 'Senior Manager', email: 'srmgr@company.com', role: 'sr_manager' });
console.log('[SQL-USERS] ✅ Seeded: admin (sap_adder) + manager1 (manager) + srmanager1 (sr_manager)');
}
await backfillApprovalSteps();
await backfillApprovePerm();
await backfillManualCreatePerm();
await backfillRequirementManagePerm();
console.log('[HANA-USERS] ✅ User table ready');
}
// One-time backfill: compute a default approvalSteps array from each user's
// CURRENT role/approvalDept, so switching enforcement from role-based to
// step-based causes ZERO regression on deploy — existing approvers keep
// exactly the access they have today. Only runs for not-yet-migrated rows.
async function backfillApprovalSteps() {
const rows = await exec(`SELECT ID, ROLE, APPROVAL_DEPT FROM ${TABLE} WHERE APPROVAL_STEPS_MIGRATED = 0 OR APPROVAL_STEPS_MIGRATED IS NULL`);
if (!rows.length) return;
console.log(`[SQL-USERS] Backfilling approval steps for ${rows.length} user(s)…`);
for (const r of rows) {
const role = r.ROLE, dept = r.APPROVAL_DEPT;
const steps = [
// Work Order + Customer/Vendor verify were previously UNGATED (anyone
// logged in could act) — preserve that on deploy.
'work_order:prepared_qa', 'work_order:checked_qc', 'work_order:checked_production',
'work_order:checked_mgr_production', 'work_order:approved_mgr_qa',
'customer_vendor:verify',
];
if (role === 'admin') {
steps.push('bom:level1', 'bom:level2', 'bom:level3', 'bom:level4', 'customer_vendor:approve',
'project:purchase', 'project:engineering', 'project:qa', 'project:qc', 'project:legal', 'project:owner', 'project:plant', 'project:finance');
} else {
if (role === 'manager') steps.push('bom:level1');
else if (role === 'sr_manager') steps.push('bom:level2');
else if (role === 'sap_adder') steps.push('bom:level3', 'bom:level4', 'customer_vendor:approve');
if (role === 'project_approver' && dept) steps.push(`project:${dept}`);
}
await exec(`UPDATE ${TABLE} SET APPROVAL_STEPS = ?, APPROVAL_STEPS_MIGRATED = 1 WHERE ID = ?`, [JSON.stringify(steps), r.ID]);
}
console.log('[SQL-USERS] ✅ Approval steps backfill complete');
}
// One-time backfill for the 'approve' permission split (added after 'edit'
// already existed): before this, 'edit' on a step was ALSO what let someone
// approve/reject at that step — there was no separate concept. Any existing
// per-step {step,perms} object that already has 'edit' but not 'approve'
// gets 'approve' added too, so nobody who could already act on a stage
// silently loses that ability the moment this ships. Only runs once per
// user; steps left as legacy plain strings are untouched (they already
// resolve to ALL_PERMS, including 'approve', via normalizeSteps).
async function backfillApprovePerm() {
const rows = await exec(`SELECT ID, APPROVAL_STEPS FROM ${TABLE} WHERE APPROVE_PERM_MIGRATED = 0 OR APPROVE_PERM_MIGRATED IS NULL`);
if (!rows.length) return;
console.log(`[SQL-USERS] Backfilling 'approve' perm for ${rows.length} user(s)…`);
for (const r of rows) {
let steps = safeJson(r.APPROVAL_STEPS, []);
if (Array.isArray(steps)) {
steps = steps.map(s => {
if (s && typeof s === 'object' && Array.isArray(s.perms) && s.perms.includes('edit') && !s.perms.includes('approve'))
return { ...s, perms: [...s.perms, 'approve'] };
return s;
});
}
await exec(`UPDATE ${TABLE} SET APPROVAL_STEPS = ?, APPROVE_PERM_MIGRATED = 1 WHERE ID = ?`, [JSON.stringify(steps), r.ID]);
}
console.log('[SQL-USERS] ✅ Approve-perm backfill complete');
}
// One-time backfill for the Manual-Entry split (production_order:create was
// split into 'create' (from Work Order) and 'manual_create' (standalone) —
// before this, 'add' on 'create' let someone do BOTH). Anyone who already
// held 'add' on production_order:create gets the same perms copied onto
// production_order:manual_create too, so nobody loses manual-entry ability
// the moment this ships. Only runs once per user.
async function backfillManualCreatePerm() {
const rows = await exec(`SELECT ID, APPROVAL_STEPS FROM ${TABLE} WHERE PO_MANUAL_CREATE_MIGRATED = 0 OR PO_MANUAL_CREATE_MIGRATED IS NULL`);
if (!rows.length) return;
console.log(`[SQL-USERS] Backfilling Production Order manual-entry perm for ${rows.length} user(s)…`);
for (const r of rows) {
let steps = safeJson(r.APPROVAL_STEPS, []);
if (Array.isArray(steps)) {
const createEntry = steps.find(s => s && typeof s === 'object' && s.step === 'production_order:create');
const hasManual = steps.some(s => s && typeof s === 'object' && s.step === 'production_order:manual_create');
if (createEntry && Array.isArray(createEntry.perms) && createEntry.perms.includes('add') && !hasManual) {
steps = [...steps, { step: 'production_order:manual_create', perms: [...createEntry.perms] }];
}
}
await exec(`UPDATE ${TABLE} SET APPROVAL_STEPS = ?, PO_MANUAL_CREATE_MIGRATED = 1 WHERE ID = ?`, [JSON.stringify(steps), r.ID]);
}
console.log('[SQL-USERS] ✅ Production Order manual-entry backfill complete');
}
// One-time backfill: Requirements CRUD was previously all-or-nothing via the
// 'production-requirements' MODULES checkbox alone — no per-action gate. Now
// that requirement:manage's view/add/edit/delete perms gate it, anyone who
// already had the module gets full view/add/edit/delete on that step, so
// nobody loses Requirements access the moment this ships. Only runs once
// per user (admins are unaffected — they always bypass via role check).
async function backfillRequirementManagePerm() {
const rows = await exec(`SELECT ID, MODULES, APPROVAL_STEPS FROM ${TABLE} WHERE REQ_MANAGE_MIGRATED = 0 OR REQ_MANAGE_MIGRATED IS NULL`);
if (!rows.length) return;
console.log(`[SQL-USERS] Backfilling Requirements manage perm for ${rows.length} user(s)…`);
for (const r of rows) {
const modules = safeJson(r.MODULES, []);
let steps = safeJson(r.APPROVAL_STEPS, []);
if (!Array.isArray(steps)) steps = [];
if (Array.isArray(modules) && modules.includes('production-requirements')) {
const already = steps.some(s => s && typeof s === 'object' && s.step === 'requirement:manage');
if (!already) steps = [...steps, { step: 'requirement:manage', perms: ['view', 'add', 'edit', 'delete'] }];
}
await exec(`UPDATE ${TABLE} SET APPROVAL_STEPS = ?, REQ_MANAGE_MIGRATED = 1 WHERE ID = ?`, [JSON.stringify(steps), r.ID]);
}
console.log('[SQL-USERS] ✅ Requirements manage-perm backfill complete');
}
// ── Row → JS object ───────────────────────────────────────────────────────────
function fromRow(r) {
if (!r) return null;
return {
id: r.ID,
username: r.USERNAME,
fullName: r.FULL_NAME || '',
email: r.EMAIL || '',
role: r.ROLE || 'manager',
active: r.ACTIVE === 1,
modules: safeJson(r.MODULES, null),
sapUserId: r.SAP_USER_ID || null,
sapLoginUser: r.SAP_LOGIN_USER || '',
hasSapLogin: !!(r.SAP_LOGIN_USER && r.SAP_LOGIN_PWD),
approvalDept: r.APPROVAL_DEPT || null,
approvalSteps: safeJson(r.APPROVAL_STEPS, []),
// Item groups this user may issue in Issue for Production. [] / null = no
// restriction (can issue any item). Non-empty = only these groups.
issueItemGroups: safeJson(r.ISSUE_ITEM_GROUPS, []),
// Item groups this user may originate a standalone (Manual Entry)
// Production Order for. [] / null = no restriction.
manualPoItemGroups: safeJson(r.MANUAL_PO_ITEM_GROUPS, []),
// Man Power tabs this user may fill. [] / null = all (no restriction).
manpowerTabs: safeJson(r.MANPOWER_TABS, []),
// OEE tabs this user may fill. [] / null = all (no restriction).
oeeTabs: safeJson(r.OEE_TABS, []),
// SAP companies shown to this user in company dropdowns. [] / null = all
// (no restriction) — see public/company-list.js.
allowedCompanies: safeJson(r.ALLOWED_COMPANIES, []),
// SAP Approval Request document types (ObjectType codes) this user may
// see/act on in SAP Approval. [] / null = all (no restriction).
sapApprovalTypes: safeJson(r.SAP_APPROVAL_TYPES, []),
// Purchase Request Departments this user may pick from. [] / null = all
// (no restriction).
allowedDepartments: safeJson(r.ALLOWED_DEPARTMENTS, []),
// Per-company SAP logins this user has configured — { [companyDB]: { user, hasPwd } }.
// Never includes the encrypted password itself (see getSapLoginMapRaw for
// the server-only raw form used to build the JWT).
sapLogins: (() => {
const map = safeJson(r.SAP_LOGIN_MAP, {});
const out = {};
Object.keys(map).forEach(c => { out[c] = { user: (map[c] && map[c].user) || '', hasPwd: !!(map[c] && map[c].pwdEnc) }; });
return out;
})(),
// Whether a signature image is on file (the image itself is fetched
// separately via getSignature* — kept out of list/profile payloads).
hasSignature: !!r.SIGNATURE,
// Stage-change email notifications — opt-out, not opt-in: null (legacy
// row before this column existed) or 1 = enabled, only 0 = disabled.
emailNotify: r.EMAIL_NOTIFY !== 0,
// See migration comment above — lets this user sign a Work Order
// verify stamp as any user and backdate it.
woVerifyOverride: r.WO_VERIFY_OVERRIDE === 1,
canImpersonate: r.CAN_IMPERSONATE === 1,
createdAt: r.CREATED_AT ? new Date(r.CREATED_AT).toISOString() : null,
lastLogin: r.LAST_LOGIN ? new Date(r.LAST_LOGIN).toISOString() : null,
};
}
function safeJson(v,fb){if(!v)return fb;try{return JSON.parse(v);}catch(_e){return fb;}}
// ── Create user ───────────────────────────────────────────────────────────────
async function createUser({ username, password, fullName, email, role, modules, approvalDept, approvalSteps, sapLoginUser, sapLoginPwd, issueItemGroups, manualPoItemGroups, manpowerTabs, oeeTabs, signature, allowedCompanies, emailNotify, sapApprovalTypes, allowedDepartments, woVerifyOverride, canImpersonate }) {
if (!VALID_ROLES.includes(role)) throw new Error(`Invalid role: ${role}. Must be one of: ${VALID_ROLES.join(', ')}`);
const hash = await bcrypt.hash(password, 10);
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23);
const sapUser = (sapLoginUser || '').trim() || null;
const sapPwd = sapLoginPwd ? cryptoUtil.encrypt(sapLoginPwd) : null;
// INSERT and SELECT SCOPE_IDENTITY() must be one batch — as two separate
// exec() calls, a pooled connection can route the second one to a
// DIFFERENT physical connection than the one that just inserted, where
// SCOPE_IDENTITY() correctly returns NULL (see workOrderStore.js's
// insertWorkOrder() for the full write-up of this bug class).
const idRows = await exec(
`INSERT INTO ${TABLE} (USERNAME, PASSWORD, FULL_NAME, EMAIL, ROLE, ACTIVE, CREATED_AT, MODULES, APPROVAL_DEPT, APPROVAL_STEPS, APPROVAL_STEPS_MIGRATED, SAP_LOGIN_USER, SAP_LOGIN_PWD, ISSUE_ITEM_GROUPS, MANUAL_PO_ITEM_GROUPS, MANPOWER_TABS, OEE_TABS, SIGNATURE, ALLOWED_COMPANIES, EMAIL_NOTIFY, SAP_APPROVAL_TYPES, ALLOWED_DEPARTMENTS, WO_VERIFY_OVERRIDE, CAN_IMPERSONATE)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
SELECT SCOPE_IDENTITY() AS ID;`,
[(username || '').toLowerCase(), hash, fullName || '', email || '', role || 'manager', now,
modules ? JSON.stringify(modules) : null, approvalDept || null,
JSON.stringify(Array.isArray(approvalSteps) ? approvalSteps : []), sapUser, sapPwd,
JSON.stringify(Array.isArray(issueItemGroups) ? issueItemGroups.map(String) : []),
JSON.stringify(Array.isArray(manualPoItemGroups) ? manualPoItemGroups.map(String) : []),
JSON.stringify(Array.isArray(manpowerTabs) ? manpowerTabs.map(String) : []),
JSON.stringify(Array.isArray(oeeTabs) ? oeeTabs.map(String) : []),
(signature || '').trim() || null,
JSON.stringify(Array.isArray(allowedCompanies) ? allowedCompanies.map(String) : []),
emailNotify === false ? 0 : 1,
JSON.stringify(Array.isArray(sapApprovalTypes) ? sapApprovalTypes.map(String) : []),
JSON.stringify(Array.isArray(allowedDepartments) ? allowedDepartments.map(String) : []),
woVerifyOverride ? 1 : 0, canImpersonate ? 1 : 0]
);
const id = idRows[0].ID;
console.log(`[SQL-USERS] ✅ Created user: ${username} (${role})`);
return id;
}
// ── Find by username (includes hash for auth) ─────────────────────────────────
async function findByUsername(username) {
const rows = await exec(
`SELECT * FROM ${TABLE} WHERE USERNAME = ? AND ACTIVE = 1`,
[(username || '').toLowerCase()]
);
if (!rows.length) return null;
const r = rows[0];
return { ...fromRow(r), passwordHash: r.PASSWORD };
}
// ── List all users ────────────────────────────────────────────────────────────
async function listUsers() {
const rows = await exec(`SELECT * FROM ${TABLE} ORDER BY ROLE, USERNAME`);
return rows.map(fromRow);
}
// ── Find by ID ────────────────────────────────────────────────────────────────
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0]) : null;
}
// ── Update user ───────────────────────────────────────────────────────────────
async function updateUser(id, patch) {
const sets = []; const vals = [];
if (patch.fullName !== undefined) { sets.push('FULL_NAME = ?'); vals.push(patch.fullName); }
if (patch.email !== undefined) { sets.push('EMAIL = ?'); vals.push(patch.email); }
if (patch.role !== undefined) {
if (!VALID_ROLES.includes(patch.role)) throw new Error(`Invalid role: ${patch.role}`);
sets.push('ROLE = ?'); vals.push(patch.role);
}
if (patch.active !== undefined) { sets.push('ACTIVE = ?'); vals.push(patch.active ? 1 : 0); }
if (patch.modules !== undefined) { sets.push('MODULES = ?'); vals.push(JSON.stringify(patch.modules)); }
if (patch.sapUserId !== undefined) { sets.push('SAP_USER_ID = ?'); vals.push(parseInt(patch.sapUserId)||null); }
if (patch.sapLoginUser !== undefined) { sets.push('SAP_LOGIN_USER = ?'); vals.push((patch.sapLoginUser || '').trim() || null); }
// sapLoginPwd: pass the PLAINTEXT password; it's encrypted here. Empty
// string means "clear it"; undefined means "leave unchanged".
if (patch.sapLoginPwd !== undefined) { sets.push('SAP_LOGIN_PWD = ?'); vals.push(patch.sapLoginPwd ? cryptoUtil.encrypt(patch.sapLoginPwd) : null); }
if (patch.approvalDept !== undefined) { sets.push('APPROVAL_DEPT = ?'); vals.push(patch.approvalDept || null); }
if (patch.approvalSteps !== undefined) { sets.push('APPROVAL_STEPS = ?'); vals.push(JSON.stringify(Array.isArray(patch.approvalSteps) ? patch.approvalSteps : [])); }
if (patch.issueItemGroups !== undefined) { sets.push('ISSUE_ITEM_GROUPS = ?'); vals.push(JSON.stringify(Array.isArray(patch.issueItemGroups) ? patch.issueItemGroups.map(String) : [])); }
if (patch.manualPoItemGroups !== undefined) { sets.push('MANUAL_PO_ITEM_GROUPS = ?'); vals.push(JSON.stringify(Array.isArray(patch.manualPoItemGroups) ? patch.manualPoItemGroups.map(String) : [])); }
if (patch.manpowerTabs !== undefined) { sets.push('MANPOWER_TABS = ?'); vals.push(JSON.stringify(Array.isArray(patch.manpowerTabs) ? patch.manpowerTabs.map(String) : [])); }
if (patch.oeeTabs !== undefined) { sets.push('OEE_TABS = ?'); vals.push(JSON.stringify(Array.isArray(patch.oeeTabs) ? patch.oeeTabs.map(String) : [])); }
if (patch.allowedCompanies!== undefined) { sets.push('ALLOWED_COMPANIES = ?'); vals.push(JSON.stringify(Array.isArray(patch.allowedCompanies) ? patch.allowedCompanies.map(String) : [])); }
if (patch.sapApprovalTypes!== undefined) { sets.push('SAP_APPROVAL_TYPES = ?'); vals.push(JSON.stringify(Array.isArray(patch.sapApprovalTypes) ? patch.sapApprovalTypes.map(String) : [])); }
if (patch.allowedDepartments!== undefined) { sets.push('ALLOWED_DEPARTMENTS = ?'); vals.push(JSON.stringify(Array.isArray(patch.allowedDepartments) ? patch.allowedDepartments.map(String) : [])); }
if (patch.emailNotify !== undefined) { sets.push('EMAIL_NOTIFY = ?'); vals.push(patch.emailNotify ? 1 : 0); }
if (patch.woVerifyOverride !== undefined) { sets.push('WO_VERIFY_OVERRIDE = ?'); vals.push(patch.woVerifyOverride ? 1 : 0); }
if (patch.canImpersonate !== undefined) { sets.push('CAN_IMPERSONATE = ?'); vals.push(patch.canImpersonate ? 1 : 0); }
// signature: base64 data URI; '' clears it, undefined leaves unchanged.
if (patch.signature !== undefined) { sets.push('SIGNATURE = ?'); vals.push((patch.signature || '').trim() || null); }
if (patch.password) {
const hash = await bcrypt.hash(patch.password, 10);
sets.push('PASSWORD = ?'); vals.push(hash);
}
if (!sets.length) return;
vals.push(parseInt(id));
await exec(`UPDATE ${TABLE} SET ${sets.join(', ')} WHERE ID = ?`, vals);
console.log(`[HANA-USERS] ✅ Updated user ID=${id} (${sets.length} fields)`);
}
// ── Update last login ─────────────────────────────────────────────────────────
async function touchLastLogin(id) {
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23);
await exec(`UPDATE ${TABLE} SET LAST_LOGIN = ? WHERE ID = ?`, [now, parseInt(id)]);
}
// ── Delete user ───────────────────────────────────────────────────────────────
async function deleteUser(id) {
await exec(`DELETE FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
console.log(`[HANA-USERS] ✅ Deleted user ID=${id}`);
}
// ── Verify password ───────────────────────────────────────────────────────────
async function verifyPassword(plaintext, hash) {
return bcrypt.compare(plaintext, hash);
}
// ── Per-user SAP credentials ──────────────────────────────────────────────────
// Set the current/given user's own SAP Service-Layer login. Password is
// stored encrypted; pass '' to clear.
async function setSapCredentials(id, sapUser, sapPasswordPlain) {
await updateUser(id, { sapLoginUser: sapUser, sapLoginPwd: sapPasswordPlain });
}
// Returns { sapUser, sapPassword } with the password DECRYPTED — server-side
// use only (never send to the client). Null if the user has no SAP login set.
async function getSapCredentials(id) {
const rows = await exec(`SELECT SAP_LOGIN_USER, SAP_LOGIN_PWD FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
if (!rows.length) return null;
const u = rows[0].SAP_LOGIN_USER, p = rows[0].SAP_LOGIN_PWD;
if (!u || !p) return null;
return { sapUser: u, sapPassword: cryptoUtil.decrypt(p) };
}
// ── Per-company SAP credentials ─────────────────────────────────────────────
// SAP B1 companies each have their own OUSR table — the same SAP username can
// have a DIFFERENT password per company. These let one portal user store a
// distinct SAP login per company, instead of the single pair above.
// Raw map with passwords STILL ENCRYPTED (pwdEnc) — safe to embed in a JWT
// (same trust model the old single sapPwdEnc field already used), never sent
// as plain JSON to a client outside the token. Folds the legacy single
// SAP_LOGIN_USER/PWD pair in as the DEFAULT_COMPANY entry when the map itself
// has no explicit entry for it, so nobody's already-configured login breaks.
async function getSapLoginMapRaw(id) {
const rows = await exec(`SELECT SAP_LOGIN_MAP, SAP_LOGIN_USER, SAP_LOGIN_PWD FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
if (!rows.length) return {};
const map = safeJson(rows[0].SAP_LOGIN_MAP, {});
if (!map[DEFAULT_COMPANY] && rows[0].SAP_LOGIN_USER && rows[0].SAP_LOGIN_PWD) {
map[DEFAULT_COMPANY] = { user: rows[0].SAP_LOGIN_USER, pwdEnc: rows[0].SAP_LOGIN_PWD };
}
return map;
}
// Decrypted { sapUser, sapPassword } for ONE company, or null if not set.
async function getSapCredentialsForCompany(id, companyDB) {
const map = await getSapLoginMapRaw(id);
const entry = map[companyDB];
if (!entry || !entry.user || !entry.pwdEnc) return null;
return { sapUser: entry.user, sapPassword: cryptoUtil.decrypt(entry.pwdEnc) };
}
// Read-modify-write: set/replace this user's SAP login for ONE company only,
// leaving every other company's entry untouched.
async function setSapLoginForCompany(id, companyDB, sapUser, sapPasswordPlain) {
const map = await getSapLoginMapRaw(id);
map[companyDB] = { user: sapUser, pwdEnc: cryptoUtil.encrypt(sapPasswordPlain) };
await exec(`UPDATE ${TABLE} SET SAP_LOGIN_MAP = ? WHERE ID = ?`, [JSON.stringify(map), parseInt(id)]);
}
async function clearSapLoginForCompany(id, companyDB) {
const map = await getSapLoginMapRaw(id);
delete map[companyDB];
// getSapLoginMapRaw() folds the legacy single SAP_LOGIN_USER/SAP_LOGIN_PWD
// columns back in as the DEFAULT_COMPANY entry whenever the map has none —
// if those columns are left standing, clearing DEFAULT_COMPANY's entry gets
// silently un-done the very next read (the removed login reappears). Clear
// them too when the company being removed is the default one.
if (companyDB === DEFAULT_COMPANY) {
await exec(`UPDATE ${TABLE} SET SAP_LOGIN_MAP = ?, SAP_LOGIN_USER = NULL, SAP_LOGIN_PWD = NULL WHERE ID = ?`, [JSON.stringify(map), parseInt(id)]);
} else {
await exec(`UPDATE ${TABLE} SET SAP_LOGIN_MAP = ? WHERE ID = ?`, [JSON.stringify(map), parseInt(id)]);
}
}
// ── Signature image (base64 data URI) ──────────────────────────────────────────
async function getSignature(id) {
const rows = await exec(`SELECT SIGNATURE FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
return rows.length ? (rows[0].SIGNATURE || '') : '';
}
async function getSignatureByUsername(username) {
const rows = await exec(`SELECT SIGNATURE FROM ${TABLE} WHERE USERNAME = ?`, [(username || '').toLowerCase()]);
return rows.length ? (rows[0].SIGNATURE || '') : '';
}
module.exports = {
bootstrap,
createUser,
findByUsername,
listUsers,
findById,
updateUser,
deleteUser,
touchLastLogin,
verifyPassword,
setSapCredentials,
getSapCredentials,
getSapLoginMapRaw,
getSapCredentialsForCompany,
setSapLoginForCompany,
clearSapLoginForCompany,
getSignature,
getSignatureByUsername,
VALID_ROLES,
};
+478
View File
@@ -0,0 +1,478 @@
// backend/services/hanaVendorStore.js
// SQL persistence for vendor registrations.
// Schema: uses SAP_B1_COMPANY env var (same as hanaStore.js)
// Table: ZVENDOR_PORTAL (auto-created on first run)
const { getPool } = require('./appSqlPool');
const { DEFAULT_COMPANY } = require('./companyConfig');
const DEFAULT_SCHEMA = DEFAULT_COMPANY;
const tbl = (s) => `[dbo].[ZVENDOR_PORTAL]`;
async function exec(sqlQuery, params = []) {
const pool = await getPool();
const request = pool.request();
params.forEach((param, index) => {
request.input(`param${index}`, param);
});
const replacedSql = sqlQuery.replace(/\?/g, (match, offset, string) => {
const paramIndex = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${paramIndex}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists')
|| m.includes('duplicate')
|| m.includes('existing object')
|| m.includes('there is already an object');
}
// ── Bootstrap ─────────────────────────────────────────────────────────────────
async function bootstrap(schema) {
console.log('[VENDOR STORE] Checking table', tbl(schema), '...');
let tableExisted = false;
await exec(`
CREATE TABLE ${tbl(schema)} (
[ID] INT IDENTITY(1,1) PRIMARY KEY,
[STATUS] NVARCHAR(30) DEFAULT 'PENDING',
[VENDOR_TYPE] NVARCHAR(20) DEFAULT 'SUPPLIER',
[CARD_NAME] NVARCHAR(100),
[FOREIGN_NAME] NVARCHAR(100),
[TYPE_OF_BUSINESS] NVARCHAR(50),
[INDUSTRY] NVARCHAR(100),
[PRODUCTS] NVARCHAR(500),
[PAYMENT_TERMS] NVARCHAR(50),
[CONTACT_FIRST] NVARCHAR(60),
[CONTACT_LAST] NVARCHAR(60),
[CONTACT_TITLE] NVARCHAR(60),
[MOBILE] NVARCHAR(20),
[ALT_CONTACT] NVARCHAR(20),
[EMAIL] NVARCHAR(150),
[BILL_STREET] NVARCHAR(200),
[BILL_BLOCK] NVARCHAR(100),
[BILL_CITY] NVARCHAR(100),
[BILL_ZIP] NVARCHAR(20),
[BILL_STATE] NVARCHAR(100),
[BILL_COUNTRY] NVARCHAR(60),
[GSTIN] NVARCHAR(15),
[PAN] NVARCHAR(10),
[TAN] NVARCHAR(10),
[CURRENCY] NVARCHAR(50),
[HAS_TDS] NVARCHAR(1) DEFAULT 'N',
[TDS_CATEGORY] NVARCHAR(100),
[TDS_RATE] DECIMAL(5,2) DEFAULT 0,
[TDS_LDC_NO] NVARCHAR(50),
[HAS_MSME] NVARCHAR(1) DEFAULT 'N',
[MSME_NO] NVARCHAR(30),
[MSME_TYPE] NVARCHAR(20),
[MSME_BTYPE] NVARCHAR(30),
[FSSAI_NO] NVARCHAR(20),
[REMARKS] NVARCHAR(MAX),
[BANK_ACCOUNTS] NVARCHAR(MAX),
[ATTACHMENTS] NVARCHAR(MAX),
[ALL_BILL_ADDRS] NVARCHAR(MAX),
[ALL_SHIP_ADDRS] NVARCHAR(MAX),
[USER_ID] NVARCHAR(50),
[USER_TYPE] NVARCHAR(50),
[USER_DEPT] NVARCHAR(100),
[SUBMITTED_AT] DATETIME2 DEFAULT GETDATE(),
[MGR_CARD_CODE_PREFIX] NVARCHAR(20) DEFAULT 'VENDA',
[MGR_GROUP_CODE] INT,
[MGR_GROUP] NVARCHAR(100),
[MGR_PAY_TERMS_CODE] INT,
[MGR_PAY_TERMS] NVARCHAR(100),
[MGR_PURCHASE_ACCOUNT] NVARCHAR(20) DEFAULT '2101001',
[MGR_PURCHASE_ACCT_NAME] NVARCHAR(100),
[MGR_CURRENCY] NVARCHAR(50) DEFAULT 'Indian Rupee',
[MGR_LANGUAGE] NVARCHAR(50) DEFAULT 'English (UK)',
[MGR_CREDIT_LIMIT] DECIMAL(18,2) DEFAULT 0,
[MGR_NOTES] NVARCHAR(MAX),
[MGR_BRANCH] NVARCHAR(100),
[MGR_AREA] NVARCHAR(100),
[MGR_TERRITORY] NVARCHAR(100),
[MGR_SALES_PERSON_CODE] INT,
[MGR_SALES_EMPLOYEE] NVARCHAR(100),
[MGR_MAIN_GROUP] NVARCHAR(50),
[MGR_CHAIN] NVARCHAR(50),
[VERIFIED_BY] NVARCHAR(60),
[VERIFIED_AT] DATETIME2,
[APPROVED_BY] NVARCHAR(60),
[APPROVED_AT] DATETIME2,
[REJECTED_BY] NVARCHAR(60),
[REJECTED_AT] DATETIME2,
[SAP_CARD_CODE] NVARCHAR(30),
[SAP_ATTACHMENT_ENTRY] INT,
[COMPANY] NVARCHAR(50) DEFAULT '${DEFAULT_SCHEMA}'
)
`).catch(e => {
if (isAlreadyExists(e)) {
tableExisted = true;
console.log('[VENDOR STORE] Table already exists — checking for missing columns...');
} else {
throw e;
}
});
// ── Safe migrations — only add columns that don't exist yet ──────────────
if (tableExisted) {
const migrations = [
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='MGR_SALES_PERSON_CODE') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [MGR_SALES_PERSON_CODE] INT`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='MGR_SALES_EMPLOYEE') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [MGR_SALES_EMPLOYEE] NVARCHAR(100)`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='MGR_MAIN_GROUP') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [MGR_MAIN_GROUP] NVARCHAR(50)`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='MGR_CHAIN') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [MGR_CHAIN] NVARCHAR(50)`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='COMPANY') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [COMPANY] NVARCHAR(50) DEFAULT '${DEFAULT_SCHEMA}'`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='MSME_BTYPE') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [MSME_BTYPE] NVARCHAR(30)`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='FSSAI_NO') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [FSSAI_NO] NVARCHAR(20)`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='ALL_BILL_ADDRS') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [ALL_BILL_ADDRS] NVARCHAR(MAX)`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='ALL_SHIP_ADDRS') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [ALL_SHIP_ADDRS] NVARCHAR(MAX)`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='VERIFIED_BY') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [VERIFIED_BY] NVARCHAR(60)`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='VERIFIED_AT') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [VERIFIED_AT] DATETIME2`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='APPROVED_BY') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [APPROVED_BY] NVARCHAR(60)`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='APPROVED_AT') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [APPROVED_AT] DATETIME2`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='REJECTED_BY') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [REJECTED_BY] NVARCHAR(60)`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='REJECTED_AT') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [REJECTED_AT] DATETIME2`,
`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZVENDOR_PORTAL' AND COLUMN_NAME='SAP_ATTACHMENT_ENTRY') ALTER TABLE [dbo].[ZVENDOR_PORTAL] ADD [SAP_ATTACHMENT_ENTRY] INT`,
];
for (const migration of migrations) {
await exec(migration).catch(e => {
console.warn('[VENDOR STORE] Migration warning:', e.message);
});
}
console.log('[VENDOR STORE] ✅ Column migration check complete');
}
console.log('[VENDOR STORE] ✅ Table ready:', tbl(schema));
}
// ── Row → JS object ───────────────────────────────────────────────────────────
function rowToVendor(row) {
if (!row) return null;
let bankAccounts = [];
let attachments = {};
try { bankAccounts = JSON.parse(row.BANK_ACCOUNTS || '[]'); } catch (_) {}
try { attachments = JSON.parse(row.ATTACHMENTS || '{}'); } catch (_) {}
return {
id: row.ID,
status: row.STATUS,
vendorType: row.VENDOR_TYPE || 'SUPPLIER',
cardName: row.CARD_NAME || '',
foreignName: row.FOREIGN_NAME || '',
typeOfBusiness: row.TYPE_OF_BUSINESS || '',
industry: row.INDUSTRY || '',
products: row.PRODUCTS || '',
paymentTerms: row.PAYMENT_TERMS || '30 Days',
contactFirst: row.CONTACT_FIRST || '',
contactLast: row.CONTACT_LAST || '',
contactTitle: row.CONTACT_TITLE || '',
mobile: row.MOBILE || '',
altContact: row.ALT_CONTACT || '',
email: row.EMAIL || '',
billStreet: row.BILL_STREET || '',
billBlock: row.BILL_BLOCK || '',
billCity: row.BILL_CITY || '',
billZip: row.BILL_ZIP || '',
billState: row.BILL_STATE || '',
billCountry: row.BILL_COUNTRY || 'India',
gstin: row.GSTIN || '',
pan: row.PAN || '',
tan: row.TAN || '',
currency: row.CURRENCY || 'Indian Rupee',
hasTds: row.HAS_TDS === 'Y',
tdsCategory: row.TDS_CATEGORY || '',
tdsRate: Number(row.TDS_RATE) || 0,
tdsLdcNo: row.TDS_LDC_NO || '',
hasMsme: row.HAS_MSME === 'Y',
msmeNo: row.MSME_NO || '',
msmeType: row.MSME_TYPE || '',
msmeBType: row.MSME_BTYPE || '',
fssaiNo: row.FSSAI_NO || '',
remarks: row.REMARKS || '',
bankAccounts,
attachments,
allBillAddresses: safeJson(row.ALL_BILL_ADDRS, []),
allShipAddresses: safeJson(row.ALL_SHIP_ADDRS, []),
userId: row.USER_ID || '',
userType: row.USER_TYPE || '',
userDept: row.USER_DEPT || '',
submittedAt: row.SUBMITTED_AT ? new Date(row.SUBMITTED_AT).toISOString() : null,
mgrCardCodePrefix: row.MGR_CARD_CODE_PREFIX || 'VENDA',
mgrGroupCode: row.MGR_GROUP_CODE,
mgrGroup: row.MGR_GROUP || '',
mgrPayTermsCode: row.MGR_PAY_TERMS_CODE,
mgrPayTerms: row.MGR_PAY_TERMS || '',
mgrPurchaseAccount: row.MGR_PURCHASE_ACCOUNT || '2101001',
mgrPurchaseAcctName: row.MGR_PURCHASE_ACCT_NAME|| '',
mgrCurrency: row.MGR_CURRENCY || 'Indian Rupee',
mgrLanguage: row.MGR_LANGUAGE || 'English (UK)',
mgrCreditLimit: Number(row.MGR_CREDIT_LIMIT) || 0,
mgrNotes: row.MGR_NOTES || '',
mgrBranch: row.MGR_BRANCH || '',
mgrArea: row.MGR_AREA || '',
mgrTerritory: row.MGR_TERRITORY || '',
mgrSalesPersonCode: row.MGR_SALES_PERSON_CODE || null,
mgrSalesEmployee: row.MGR_SALES_EMPLOYEE || '',
mgrMainGroup: row.MGR_MAIN_GROUP || '',
mgrChain: row.MGR_CHAIN || '',
verifiedBy: row.VERIFIED_BY || null,
verifiedAt: row.VERIFIED_AT ? new Date(row.VERIFIED_AT).toISOString() : null,
approvedBy: row.APPROVED_BY || null,
approvedAt: row.APPROVED_AT ? new Date(row.APPROVED_AT).toISOString() : null,
rejectedBy: row.REJECTED_BY || null,
rejectedAt: row.REJECTED_AT ? new Date(row.REJECTED_AT).toISOString() : null,
sapCardCode: row.SAP_CARD_CODE || null,
sapAttachmentEntry: row.SAP_ATTACHMENT_ENTRY || null,
company: row.COMPANY || '',
};
}
function safeJson(v, fb) {
if (!v) return fb;
try { return JSON.parse(v); } catch (_) { return fb; }
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function toTs(isoStr) {
if (!isoStr) return null;
return isoStr.replace('T', ' ').replace('Z', '').substring(0, 23);
}
function s(v, max) {
if (v === null || v === undefined) return null;
const str = String(v);
return max ? str.substring(0, max) : str;
}
// ── INSERT ────────────────────────────────────────────────────────────────────
async function insertVendor(data, _schema) {
const now = toTs(new Date().toISOString());
// INSERT and SELECT SCOPE_IDENTITY() must be one batch — as two separate
// exec() calls, a pooled connection can route the second one to a
// DIFFERENT physical connection than the one that just inserted, where
// SCOPE_IDENTITY() correctly returns NULL (see workOrderStore.js's
// insertWorkOrder() for the full write-up of this bug class).
const idRows = await exec(`
INSERT INTO ${tbl()} (
[VENDOR_TYPE],[CARD_NAME],[FOREIGN_NAME],[TYPE_OF_BUSINESS],[INDUSTRY],
[PRODUCTS],[PAYMENT_TERMS],
[CONTACT_FIRST],[CONTACT_LAST],[CONTACT_TITLE],
[MOBILE],[ALT_CONTACT],[EMAIL],
[BILL_STREET],[BILL_BLOCK],[BILL_CITY],[BILL_ZIP],[BILL_STATE],[BILL_COUNTRY],
[GSTIN],[PAN],[TAN],[CURRENCY],
[HAS_TDS],[TDS_CATEGORY],[TDS_RATE],[TDS_LDC_NO],
[HAS_MSME],[MSME_NO],[MSME_TYPE],[MSME_BTYPE],[FSSAI_NO],
[REMARKS],[BANK_ACCOUNTS],[ATTACHMENTS],
[ALL_BILL_ADDRS],[ALL_SHIP_ADDRS],
[USER_ID],[USER_TYPE],[USER_DEPT],
[SUBMITTED_AT],
[MGR_CARD_CODE_PREFIX],[MGR_PURCHASE_ACCOUNT],[MGR_CURRENCY],[MGR_LANGUAGE],
[COMPANY]
) VALUES (
?,?,?,?,?,
?,?,
?,?,?,
?,?,?,
?,?,?,?,?,?,
?,?,?,?,
?,?,?,?,
?,?,?,?,?,
?,?,?,
?,?,
?,?,?,
?,
?,?,?,?,
?
);
SELECT SCOPE_IDENTITY() AS ID;
`, [
s(data.vendorType || 'SUPPLIER', 20),
s(data.cardName, 100),
s(data.foreignName || '', 100),
s(data.typeOfBusiness || 'Company', 50),
s(data.industry || '', 100),
s(data.products || '', 500),
s(data.paymentTerms || '30 Days', 50),
s(data.contactFirst, 60),
s(data.contactLast, 60),
s(data.contactTitle || '', 60),
s(data.mobile || '', 20),
s(data.altContact || '', 20),
s(data.email, 150),
s(data.billStreet || '', 200),
s(data.billBlock || '', 100),
s(data.billCity || '', 100),
s(data.billZip || '', 20),
s(data.billState || '', 100),
s(data.billCountry || 'India', 60),
s(data.gstin || '', 15),
s(data.pan || '', 10),
s(data.tan || '', 10),
s(data.currency || 'Indian Rupee', 50),
data.hasTds ? 'Y' : 'N',
s(data.tdsCategory || '', 100),
parseFloat(data.tdsRate) || 0,
s(data.tdsLdcNo || '', 50),
data.hasMsme ? 'Y' : 'N',
s(data.msmeNo || '', 30),
s(data.msmeType || '', 20),
s(data.msmeBType || '', 30),
s(data.fssaiNo || '', 20),
data.remarks || '',
JSON.stringify(data.bankAccounts || []),
JSON.stringify(data.attachments || {}),
JSON.stringify(data.allBillAddresses || []),
JSON.stringify(data.allShipAddresses || []),
s(data.userId || '', 50),
s(data.userType || '', 50),
s(data.userDept || '', 100),
now,
s(data.mgrCardCodePrefix || 'VENDA', 20),
s(data.mgrPurchaseAccount || '2101001', 20),
s(data.mgrCurrency || 'Indian Rupee', 50),
s(data.mgrLanguage || 'English (UK)', 50),
s(data.company || '', 50),
]);
const id = idRows[0].ID;
console.log(`[VENDOR STORE] ✅ Inserted ID=${id} — ${data.cardName}`);
return { id };
}
// ── FIND BY ID ────────────────────────────────────────────────────────────────
async function findById(id, companyDB) {
const rows = await exec(
`SELECT * FROM ${tbl()} WHERE [ID] = ?`,
[parseInt(id)]
);
return rowToVendor(rows[0]);
}
// ── LIST BY STATUS ────────────────────────────────────────────────────────────
async function listByStatus(status, companyDB) {
let rows;
if (status === 'ALL') {
rows = await exec(`SELECT * FROM ${tbl()} ORDER BY [SUBMITTED_AT] DESC`);
} else {
rows = await exec(
`SELECT * FROM ${tbl()} WHERE [STATUS] = ? ORDER BY [SUBMITTED_AT] DESC`,
[status.toUpperCase()]
);
}
let result = rows.map(rowToVendor);
if (companyDB) result = result.filter(r => r.company === companyDB);
return result;
}
// ── UPDATE ────────────────────────────────────────────────────────────────────
async function updateVendor(id, patch, _schema) {
if (!patch || Object.keys(patch).length === 0) return;
const COL_MAP = {
status: 'STATUS',
vendorType: 'VENDOR_TYPE',
cardName: 'CARD_NAME',
foreignName: 'FOREIGN_NAME',
typeOfBusiness: 'TYPE_OF_BUSINESS',
industry: 'INDUSTRY',
products: 'PRODUCTS',
paymentTerms: 'PAYMENT_TERMS',
contactFirst: 'CONTACT_FIRST',
contactLast: 'CONTACT_LAST',
contactTitle: 'CONTACT_TITLE',
mobile: 'MOBILE',
altContact: 'ALT_CONTACT',
email: 'EMAIL',
billStreet: 'BILL_STREET',
billBlock: 'BILL_BLOCK',
billCity: 'BILL_CITY',
billZip: 'BILL_ZIP',
billState: 'BILL_STATE',
billCountry: 'BILL_COUNTRY',
gstin: 'GSTIN',
pan: 'PAN',
tan: 'TAN',
currency: 'CURRENCY',
hasTds: 'HAS_TDS',
tdsCategory: 'TDS_CATEGORY',
tdsRate: 'TDS_RATE',
tdsLdcNo: 'TDS_LDC_NO',
hasMsme: 'HAS_MSME',
msmeNo: 'MSME_NO',
msmeType: 'MSME_TYPE',
msmeBType: 'MSME_BTYPE',
fssaiNo: 'FSSAI_NO',
remarks: 'REMARKS',
bankAccounts: 'BANK_ACCOUNTS',
attachments: 'ATTACHMENTS',
allBillAddresses: 'ALL_BILL_ADDRS',
allShipAddresses: 'ALL_SHIP_ADDRS',
verifiedBy: 'VERIFIED_BY',
verifiedAt: 'VERIFIED_AT',
approvedBy: 'APPROVED_BY',
approvedAt: 'APPROVED_AT',
rejectedBy: 'REJECTED_BY',
rejectedAt: 'REJECTED_AT',
sapCardCode: 'SAP_CARD_CODE',
sapAttachmentEntry: 'SAP_ATTACHMENT_ENTRY',
mgrCardCodePrefix: 'MGR_CARD_CODE_PREFIX',
mgrGroupCode: 'MGR_GROUP_CODE',
mgrGroup: 'MGR_GROUP',
mgrPayTermsCode: 'MGR_PAY_TERMS_CODE',
mgrPayTerms: 'MGR_PAY_TERMS',
mgrPurchaseAccount: 'MGR_PURCHASE_ACCOUNT',
mgrPurchaseAcctName: 'MGR_PURCHASE_ACCT_NAME',
mgrCurrency: 'MGR_CURRENCY',
mgrLanguage: 'MGR_LANGUAGE',
mgrCreditLimit: 'MGR_CREDIT_LIMIT',
mgrNotes: 'MGR_NOTES',
mgrBranch: 'MGR_BRANCH',
mgrArea: 'MGR_AREA',
mgrTerritory: 'MGR_TERRITORY',
mgrSalesPersonCode: 'MGR_SALES_PERSON_CODE',
mgrSalesEmployee: 'MGR_SALES_EMPLOYEE',
mgrMainGroup: 'MGR_MAIN_GROUP',
mgrChain: 'MGR_CHAIN',
company: 'COMPANY',
};
const BOOL_COLS = new Set(['HAS_TDS', 'HAS_MSME']);
const JSON_COLS = new Set(['BANK_ACCOUNTS', 'ATTACHMENTS', 'ALL_BILL_ADDRS', 'ALL_SHIP_ADDRS']);
const NUMBER_COLS = new Set(['MGR_GROUP_CODE', 'MGR_PAY_TERMS_CODE', 'MGR_CREDIT_LIMIT', 'TDS_RATE', 'SAP_ATTACHMENT_ENTRY', 'MGR_SALES_PERSON_CODE']);
const TS_COLS = new Set(['VERIFIED_AT', 'APPROVED_AT', 'REJECTED_AT']);
const setClauses = [];
const vals = [];
Object.entries(patch).forEach(([k, v]) => {
const col = COL_MAP[k];
if (!col) return;
setClauses.push(`[${col}] = ?`);
if (BOOL_COLS.has(col)) {
vals.push(v ? 'Y' : 'N');
} else if (JSON_COLS.has(col)) {
vals.push(JSON.stringify(v || (col === 'ATTACHMENTS' ? {} : [])));
} else if (NUMBER_COLS.has(col)) {
vals.push(v === null || v === undefined ? null : Number(v));
} else if (TS_COLS.has(col)) {
vals.push(v ? toTs(v) : null);
} else {
vals.push(v === null || v === undefined ? null : String(v));
}
});
if (!setClauses.length) return;
vals.push(parseInt(id));
await exec(
`UPDATE ${tbl()} SET ${setClauses.join(', ')} WHERE [ID] = ?`,
vals
);
console.log(`[VENDOR STORE] ✅ Updated ID=${id} (${setClauses.length} fields)`);
}
module.exports = { bootstrap, insertVendor, findById, listByStatus, updateVendor };
+117
View File
@@ -0,0 +1,117 @@
// services/itemGroupClassStore.js
// Admin-configured mapping: SAP Item Group (OITB.ItmsGrpCod) → which Work
// Order table its items should land in — RAW / PACK / COMPONENT. Lets the
// portal replace the hardcoded Solution-name/PK-code heuristics in
// work-order.html with an explicit, editable rule per item group.
const sql = require('mssql');
const TABLE = `[dbo].[ZITEM_GROUP_CLASS]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
const config = {
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
};
_conn = await sql.connect(config);
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (match, offset, string) => {
const paramIndex = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${paramIndex}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[ITEM-GROUP-CLASS-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ITMSGRPCOD INT PRIMARY KEY,
ITMSGRPNAM NVARCHAR(100),
CLASSIFICATION NVARCHAR(20),
UPDATED_BY NVARCHAR(50),
UPDATED_AT DATETIME2
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[ITEM-GROUP-CLASS-STORE] Table exists — OK'); }
else throw e;
});
console.log('[ITEM-GROUP-CLASS-STORE] ✅ Ready');
}
// { ITMSGRPCOD: 'RAW'|'PACK'|'COMPONENT' } for groups that have a classification set
async function getMap() {
const rows = await exec(`SELECT ITMSGRPCOD, CLASSIFICATION FROM ${TABLE} WHERE CLASSIFICATION IS NOT NULL AND CLASSIFICATION <> ''`);
const map = {};
rows.forEach(r => { map[r.ITMSGRPCOD] = r.CLASSIFICATION; });
return map;
}
async function getAll() {
const rows = await exec(`SELECT ITMSGRPCOD, ITMSGRPNAM, CLASSIFICATION FROM ${TABLE}`);
const map = {};
rows.forEach(r => { map[r.ITMSGRPCOD] = r.CLASSIFICATION || ''; });
return map;
}
// CLASSIFICATION is stored as a comma-separated set of tags — a group can be
// tagged BOTH 'RAW' and 'PACK' at once (its items then land in BOTH the Raw
// Material and Packing Material tables), or just 'COMPONENT' (mutually
// exclusive with RAW/PACK — the Components table is the "none of the above"
// bucket, so RAW/PACK always win if COMPONENT is also present).
// 'SFG' (Semi-Finished Good) is a separate, orthogonal axis — it marks a
// group's items as SFG PRODUCTS in their own right (used by the Batch
// Issuance SFG workflow to decide which items may be intimated without a
// Requirement), independent of how those same items route when they appear
// as an INGREDIENT inside another product's BOM — so it can coexist with
// RAW/PACK/COMPONENT rather than being mutually exclusive with them.
const VALID_TOKENS = new Set(['RAW', 'PACK', 'COMPONENT', 'SFG']);
function normalizeClassification(raw) {
const tokens = [...new Set((raw || '').split(',').map(s => s.trim().toUpperCase()).filter(t => VALID_TOKENS.has(t)))];
if (tokens.includes('COMPONENT') && (tokens.includes('RAW') || tokens.includes('PACK')))
return tokens.filter(t => t !== 'COMPONENT').join(',');
return tokens.join(',');
}
async function setClassification(itmsGrpCod, itmsGrpNam, classification, updatedBy) {
const cls = normalizeClassification(classification);
const idRows = await exec(`
MERGE ${TABLE} AS tgt
USING (SELECT ? AS ITMSGRPCOD) AS src
ON tgt.ITMSGRPCOD = src.ITMSGRPCOD
WHEN MATCHED THEN UPDATE SET
ITMSGRPNAM = ?, CLASSIFICATION = ?, UPDATED_BY = ?, UPDATED_AT = SYSDATETIME()
WHEN NOT MATCHED THEN INSERT (ITMSGRPCOD, ITMSGRPNAM, CLASSIFICATION, UPDATED_BY, UPDATED_AT)
VALUES (?, ?, ?, ?, SYSDATETIME());
SELECT ? AS ITMSGRPCOD;
`, [itmsGrpCod, itmsGrpNam || '', cls, updatedBy || '', itmsGrpCod, itmsGrpNam || '', cls, updatedBy || '', itmsGrpCod]);
return idRows[0]?.ITMSGRPCOD;
}
// SAP Item Group codes tagged 'SFG' — used server-side to verify a Batch
// Issuance SFG intimation's products are actually SFG-classified, rather
// than trusting the client's own tab/mode selection.
async function getSfgGroupCodes() {
const map = await getMap();
return Object.keys(map).filter(code => (map[code] || '').split(',').includes('SFG')).map(Number);
}
module.exports = { bootstrap, getMap, getAll, setClassification, getSfgGroupCodes, VALID_TOKENS };
+77
View File
@@ -0,0 +1,77 @@
'use strict';
// services/mailLogStore.js — records every email the portal attempts to
// send (welcome/update mails, stage-change notifications, deviation
// approve/reject, etc.) so admins can confirm delivery without digging
// through server console logs. Instrumented centrally in services/mailer.js
// so every call site is covered automatically — nothing else needs to
// change when a new notification type is added.
const { getPool } = require('./appSqlPool');
const TABLE = `[dbo].[ZMAIL_LOG]`;
async function exec(sqlQuery, params = []) {
const pool = await getPool();
const req = pool.request();
let i = 0;
const text = sqlQuery.replace(/\?/g, () => { const n = `p${i}`; req.input(n, params[i]); i++; return `@${n}`; });
const result = await req.query(text);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[MAIL-LOG] Checking table', TABLE, '…');
await exec(`
CREATE TABLE ${TABLE} (
[ID] BIGINT IDENTITY(1,1) PRIMARY KEY,
[SENT_AT] DATETIME2 NOT NULL,
[TO_EMAILS] NVARCHAR(1000),
[SUBJECT] NVARCHAR(400),
[STATUS] NVARCHAR(20) NOT NULL,
[ERROR] NVARCHAR(1000),
[MESSAGE_ID] NVARCHAR(200)
)
`).catch(e => { if (isAlreadyExists(e)) { console.log('[MAIL-LOG] Table exists — OK'); } else throw e; });
await exec(`CREATE INDEX IX_ZMAIL_LOG_AT ON ${TABLE} ([SENT_AT] DESC)`).catch(() => {});
console.log('[MAIL-LOG] ✅ Ready');
}
// Never throws — a logging failure must never break the mail send it's
// recording, or the request that triggered it.
async function record({ to, subject, status, error, messageId }) {
try {
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23);
const toStr = (Array.isArray(to) ? to : [to]).filter(Boolean).join(', ');
await exec(
`INSERT INTO ${TABLE} ([SENT_AT],[TO_EMAILS],[SUBJECT],[STATUS],[ERROR],[MESSAGE_ID]) VALUES (?,?,?,?,?,?)`,
[now, toStr.slice(0, 1000), (subject || '').slice(0, 400), status || 'UNKNOWN', (error || '').slice(0, 1000), messageId || null]
);
} catch (err) { console.warn('[MAIL-LOG] record failed (non-fatal):', err.message); }
}
function fromRow(r) {
return {
id: Number(r.ID), at: r.SENT_AT ? new Date(r.SENT_AT).toISOString() : null,
to: r.TO_EMAILS || '', subject: r.SUBJECT || '', status: r.STATUS || '',
error: r.ERROR || '', messageId: r.MESSAGE_ID || '',
};
}
async function list({ from, to, status, q, top = 50, skip = 0 } = {}) {
const where = []; const params = [];
if (from) { where.push(`[SENT_AT] >= ?`); params.push(String(from).slice(0, 10) + ' 00:00:00'); }
if (to) { where.push(`[SENT_AT] <= ?`); params.push(String(to).slice(0, 10) + ' 23:59:59'); }
if (status) { where.push(`[STATUS] = ?`); params.push(status); }
if (q) { where.push(`([TO_EMAILS] LIKE ? OR [SUBJECT] LIKE ?)`); const like = `%${q}%`; params.push(like, like); }
const w = where.length ? `WHERE ${where.join(' AND ')}` : '';
const t = Math.min(200, parseInt(top) || 50);
const s = Math.max(0, parseInt(skip) || 0);
const rows = await exec(`SELECT * FROM ${TABLE} ${w} ORDER BY [ID] DESC OFFSET ${s} ROWS FETCH NEXT ${t} ROWS ONLY`, params);
const cnt = await exec(`SELECT COUNT(*) AS N FROM ${TABLE} ${w}`, params);
return { data: rows.map(fromRow), total: Number(cnt[0] && cnt[0].N) || 0 };
}
module.exports = { bootstrap, record, list };
+74
View File
@@ -0,0 +1,74 @@
// services/mailer.js
// Thin nodemailer wrapper for stage-change email notifications (Production
// module). SMTP credentials come from .env (SMTP_HOST/PORT/SECURE/USER/
// PASSWORD/FROM) — there is no SMTP config in the Admin UI. Until SMTP_HOST
// is set, sendMail() is a safe no-op (logs and resolves) so the rest of the
// notification pipeline (and the request that triggered it) never breaks
// because email isn't configured yet.
'use strict';
const nodemailer = require('nodemailer');
let _transporter = null;
function isConfigured() {
return !!process.env.SMTP_HOST;
}
function getTransporter() {
if (_transporter) return _transporter;
_transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: String(process.env.SMTP_SECURE).toLowerCase() === 'true',
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
});
return _transporter;
}
const mailLog = () => require('./mailLogStore');
// Never throws — logs and resolves so a mail failure can't break the caller's
// request. Returns true if actually sent, false if skipped/failed. Every
// outcome (sent/failed/skipped) is also recorded to the Mail Log (see
// services/mailLogStore.js, viewer at /mail-logs) so delivery can be
// confirmed without digging through console output.
async function sendMail({ to, subject, html, text }) {
const recipients = Array.isArray(to) ? to.filter(Boolean) : [to].filter(Boolean);
if (!recipients.length) return false;
if (!isConfigured()) {
console.log(`[MAILER] SMTP not configured — skipping email "${subject}" to ${recipients.join(', ')}`);
await mailLog().record({ to: recipients, subject, status: 'SKIPPED', error: 'SMTP not configured' });
return false;
}
try {
const info = await getTransporter().sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER,
to: recipients.join(', '),
subject,
html,
text: text || undefined,
});
console.log(`[MAILER] sent "${subject}" to ${recipients.join(', ')} (messageId: ${info.messageId})`);
await mailLog().record({ to: recipients, subject, status: 'SENT', messageId: info.messageId });
return true;
} catch (e) {
console.error(`[MAILER] send FAILED for "${subject}" to ${recipients.join(', ')}:`, e.message);
await mailLog().record({ to: recipients, subject, status: 'FAILED', error: e.message });
return false;
}
}
// Checks the SMTP connection/credentials without sending anything — surfaces
// the real error (bad host, auth rejected, etc.) rather than a generic
// send failure.
async function verifyConnection() {
if (!isConfigured()) return { ok: false, message: 'SMTP_HOST is not set in .env' };
try {
await getTransporter().verify();
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
module.exports = { sendMail, isConfigured, verifyConnection };
+106
View File
@@ -0,0 +1,106 @@
// services/notifyStore.js
// Stage-change email notifications for the Production module (Work Order,
// Production Order, Issue for Production, Verify Work Order, Batch Issuance
// Intimation). Given an approval-step fullKey ("workflow:key") and/or a
// module key, works out who should be emailed — whoever currently holds that
// step/module (reverse-lookup over hanaUsers.listUsers(), there is no such
// lookup built into hanaUsers/auth today) PLUS any admin-configured fixed
// extra recipients (services/appSettingsStore.notifyExtraEmails()) — and
// sends via services/mailer.js. Every entry point here is fire-and-forget
// safe: it never throws, so a notification failure can never break the
// mutation that triggered it.
'use strict';
const hanaUsers = require('./hanaUsers');
const { normalizeSteps, ALL_PERMS } = require('../middleware/auth');
const appSettingsStore = require('./appSettingsStore');
const mailer = require('./mailer');
function moduleKeyOf(m) { return `module:${m}`; }
// Does this user explicitly hold `fullKey`? Deliberately NOT using
// middleware/auth's hasStepAssigned() — that helper auto-passes every admin
// for every step (a permission-check bypass, correct for gating actions),
// which would otherwise email admin@company.com on literally every stage
// transition in the whole app regardless of whether they're actually
// assigned to it. Email recipients should reflect real assignment only.
function isExplicitlyAssigned(user, fullKey) {
const steps = normalizeSteps(user?.approvalSteps);
const entry = steps.find(s => s.step === fullKey);
return !!entry && ALL_PERMS.some(p => entry.perms.includes(p));
}
// All active users holding ANY permission on approval step `fullKey`, who
// haven't opted out of email notifications (Admin → user → "Send
// stage-change email notifications to this user").
async function usersForStep(fullKey) {
const all = await hanaUsers.listUsers();
return all.filter(u => u.active && u.emailNotify !== false && isExplicitlyAssigned(u, fullKey));
}
// All active users granted a given sidebar module (public/sidebar.js's
// module keys, e.g. 'production-batch-issuance'). Admins are not
// auto-included — only users explicitly granted the module. Same
// email-notify opt-out as usersForStep() above.
async function usersForModule(moduleKey) {
const all = await hanaUsers.listUsers();
return all.filter(u => u.active && u.emailNotify !== false && Array.isArray(u.modules) && u.modules.includes(moduleKey));
}
function extraEmailsFor(key) {
const map = appSettingsStore.notifyExtraEmails();
return String(map[key] || '').split(',').map(s => s.trim()).filter(Boolean);
}
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function buildHtml({ title, lines, url }) {
const rows = (lines || []).map(([k, v]) => `<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">${esc(k)}</td><td style="padding:3px 0;font-size:13px;font-weight:600">${esc(v)}</td></tr>`).join('');
return `<div style="font-family:Segoe UI,Arial,sans-serif;max-width:520px">
<h2 style="margin:0 0 12px;font-size:16px;color:#1a2b4a">${esc(title)}</h2>
<table>${rows}</table>
${url ? `<p style="margin-top:16px"><a href="${esc(url)}" style="background:#1a6fff;color:#fff;text-decoration:none;padding:8px 16px;border-radius:6px;font-size:13px">Open in Portal</a></p>` : ''}
<p style="margin-top:20px;color:#98a;font-size:11px">Automated notification from the SAP ERP Portal — Production module.</p>
</div>`;
}
// Core dispatcher. `stepFullKey`/`moduleKey` may each be omitted; recipients
// from both are combined and de-duplicated. `excludeUsernames` drops the
// actor who just performed the action (no need to notify yourself) — by
// username rather than email since the JWT payload doesn't carry email.
async function notify({ stepFullKey, moduleKey, title, lines, url, excludeUsernames }) {
try {
if (!appSettingsStore.notifyEmailsEnabled()) return;
const exclU = new Set((excludeUsernames || []).filter(Boolean).map(u => u.toLowerCase()));
const emails = new Set();
if (stepFullKey) {
(await usersForStep(stepFullKey)).forEach(u => u.email && !exclU.has((u.username || '').toLowerCase()) && emails.add(u.email.toLowerCase()));
extraEmailsFor(stepFullKey).forEach(e => emails.add(e.toLowerCase()));
}
if (moduleKey) {
(await usersForModule(moduleKey)).forEach(u => u.email && !exclU.has((u.username || '').toLowerCase()) && emails.add(u.email.toLowerCase()));
extraEmailsFor(moduleKeyOf(moduleKey)).forEach(e => emails.add(e.toLowerCase()));
}
if (!emails.size) return;
await mailer.sendMail({ to: [...emails], subject: title, html: buildHtml({ title, lines, url }) });
} catch (e) {
console.error('[NOTIFY] failed:', e.message);
}
}
// Notify specific users directly by username (e.g. "tell the creator their
// order was rejected/closed") — bypasses step/module lookup entirely.
async function notifyUsernames(usernames, { title, lines, url }) {
try {
if (!appSettingsStore.notifyEmailsEnabled()) return;
const want = new Set((usernames || []).filter(Boolean).map(u => String(u).toLowerCase()));
if (!want.size) return;
const all = await hanaUsers.listUsers();
const emails = all.filter(u => u.active && u.emailNotify !== false && want.has((u.username || '').toLowerCase()) && u.email).map(u => u.email);
if (!emails.length) return;
await mailer.sendMail({ to: emails, subject: title, html: buildHtml({ title, lines, url }) });
} catch (e) {
console.error('[NOTIFY] notifyUsernames failed:', e.message);
}
}
module.exports = { notify, notifyUsernames, usersForStep, usersForModule, moduleKeyOf };
+120
View File
@@ -0,0 +1,120 @@
'use strict';
// services/oeeStore.js — "Overall Equipment Efficiency" (OEE) data entry.
// Stored in the PORTAL's own app DB (not SAP), one document per (tab, month):
// a monthly sheet for one production area (EBB Production, PD Production, …).
// The row grid (date + shift + all the downtime/output inputs, plus the
// derived rates) is kept verbatim as a JSON array — the per-tab column schema
// and formulas live on the client (public/oee.html); the server just persists
// what it is given and enforces per-user tab permissions (see routes/oee.js).
const { getPool } = require('./appSqlPool');
const TABLE = `[dbo].[ZOEE_ENTRIES]`;
async function exec(sqlQuery, params = []) {
const pool = await getPool();
const req = pool.request();
let i = 0;
const text = sqlQuery.replace(/\?/g, () => { const n = `p${i}`; req.input(n, params[i]); i++; return `@${n}`; });
const result = await req.query(text);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[OEE] Checking table', TABLE, '…');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
COMPANY NVARCHAR(100),
TAB NVARCHAR(50) NOT NULL,
MONTH NVARCHAR(20) NOT NULL,
ROWS_JSON NVARCHAR(MAX),
REMARK NVARCHAR(MAX),
CANCELED BIT DEFAULT 0,
CREATED_BY NVARCHAR(50),
CREATED_BY_ID INT,
CREATED_AT DATETIME2,
UPDATED_AT DATETIME2
)
`).catch(e => { if (!isAlreadyExists(e)) throw e; else console.log('[OEE] Table already exists — OK'); });
console.log('[OEE] ✅ Ready');
}
function safeJson(v, fb) { if (!v) return fb; try { return JSON.parse(v); } catch (_e) { return fb; } }
function fromRow(r, withRows) {
if (!r) return null;
const o = {
id: r.ID,
company: r.COMPANY || '',
tab: r.TAB,
month: r.MONTH,
remark: r.REMARK || '',
canceled: !!r.CANCELED,
createdBy: r.CREATED_BY || '',
createdById: r.CREATED_BY_ID || null,
createdAt: r.CREATED_AT ? new Date(r.CREATED_AT).toISOString() : null,
updatedAt: r.UPDATED_AT ? new Date(r.UPDATED_AT).toISOString() : null,
rowCount: 0,
};
const rows = safeJson(r.ROWS_JSON, []);
o.rowCount = Array.isArray(rows) ? rows.length : 0;
if (withRows) o.rows = Array.isArray(rows) ? rows : [];
return o;
}
// List documents (headers only). Optional filters: company, tab, month, and
// an allow-set of tabs (per-user restriction — omit for no restriction).
async function list({ company, tab, month, allowTabs, top = 30, skip = 0 } = {}) {
const where = []; const params = [];
if (company) { where.push(`COMPANY = ?`); params.push(company); }
if (tab) { where.push(`TAB = ?`); params.push(tab); }
if (month) { where.push(`MONTH = ?`); params.push(month); }
if (Array.isArray(allowTabs) && allowTabs.length) {
where.push(`TAB IN (${allowTabs.map(() => '?').join(',')})`);
allowTabs.forEach(t => params.push(t));
}
const w = where.length ? `WHERE ${where.join(' AND ')}` : '';
const rows = await exec(
`SELECT * FROM ${TABLE} ${w} ORDER BY ID DESC OFFSET ${Math.max(0, skip | 0)} ROWS FETCH NEXT ${Math.min(100, top | 0)} ROWS ONLY`,
params
);
return rows.map(r => fromRow(r, false));
}
async function getById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0], true) : null;
}
async function create({ company, tab, month, rows, remark, createdBy, createdById }) {
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23);
const idRows = await exec(
`INSERT INTO ${TABLE} (COMPANY, TAB, MONTH, ROWS_JSON, REMARK, CANCELED, CREATED_BY, CREATED_BY_ID, CREATED_AT, UPDATED_AT)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?);
SELECT SCOPE_IDENTITY() AS ID;`,
[company || '', tab, month, JSON.stringify(Array.isArray(rows) ? rows : []), remark || null,
createdBy || null, createdById || null, now, now]
);
return idRows[0].ID;
}
async function update(id, { month, rows, remark }) {
const sets = []; const vals = [];
if (month !== undefined) { sets.push('MONTH = ?'); vals.push(month); }
if (rows !== undefined) { sets.push('ROWS_JSON = ?'); vals.push(JSON.stringify(Array.isArray(rows) ? rows : [])); }
if (remark !== undefined) { sets.push('REMARK = ?'); vals.push(remark || null); }
sets.push('UPDATED_AT = ?'); vals.push(new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23));
vals.push(parseInt(id));
await exec(`UPDATE ${TABLE} SET ${sets.join(', ')} WHERE ID = ?`, vals);
}
async function cancel(id) {
await exec(`UPDATE ${TABLE} SET CANCELED = 1, UPDATED_AT = SYSUTCDATETIME() WHERE ID = ?`, [parseInt(id)]);
}
module.exports = { bootstrap, list, getById, create, update, cancel };
+96
View File
@@ -0,0 +1,96 @@
// services/passwordResetStore.js
// "Forgot password" requests from the login page — this portal has no
// email/SMTP set up and admins already reset passwords directly in User
// Management, so a request here is just a queue an admin reviews and
// actions (reset the password, then mark the request resolved).
const sql = require('mssql');
const TABLE = `[dbo].[ZPASSWORD_RESET_REQUESTS]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
const config = {
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
};
_conn = await sql.connect(config);
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (match, offset, string) => {
const paramIndex = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${paramIndex}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[PW-RESET-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
USERNAME NVARCHAR(50) NOT NULL,
NOTE NVARCHAR(500),
STATUS NVARCHAR(20) NOT NULL DEFAULT 'PENDING',
CREATED_AT DATETIME2 NOT NULL DEFAULT SYSDATETIME(),
RESOLVED_BY NVARCHAR(50),
RESOLVED_AT DATETIME2
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[PW-RESET-STORE] Table exists — OK'); }
else throw e;
});
console.log('[PW-RESET-STORE] ✅ Ready');
}
function fromRow(row) {
return {
id: row.ID,
username: row.USERNAME,
note: row.NOTE || '',
status: row.STATUS,
createdAt: row.CREATED_AT,
resolvedBy: row.RESOLVED_BY || '',
resolvedAt: row.RESOLVED_AT,
};
}
async function createRequest(username, note) {
const idRows = await exec(`
INSERT INTO ${TABLE} (USERNAME, NOTE) VALUES (?, ?);
SELECT SCOPE_IDENTITY() AS ID;
`, [username, note || '']);
return idRows[0].ID;
}
async function listRequests(status) {
const rows = status
? await exec(`SELECT * FROM ${TABLE} WHERE STATUS = ? ORDER BY CREATED_AT DESC`, [status])
: await exec(`SELECT * FROM ${TABLE} ORDER BY CREATED_AT DESC`);
return rows.map(fromRow);
}
async function resolveRequest(id, status, resolvedBy) {
await exec(`
UPDATE ${TABLE} SET STATUS = ?, RESOLVED_BY = ?, RESOLVED_AT = SYSDATETIME()
WHERE ID = ?
`, [status, resolvedBy || '', id]);
}
module.exports = { bootstrap, createRequest, listRequests, resolveRequest };
+257
View File
@@ -0,0 +1,257 @@
// services/prePwoStore.js
// "Pre-PWO" staging (Admin → System Settings → "Pre-PWO — Store Review
// Before SAP", optional/off by default): when enabled, "Create Production
// Order from Work Order" no longer writes to SAP immediately. It first
// creates a portal-only staging record here — same component/BOM line
// shape the real SAP ProductionOrderLines POST would use — that Production
// may optionally "Share with Store". Store can add/remove/substitute
// component lines outright and revert it back to Production (single round
// trip, no further back-and-forth). There is NO hard gate: Production may
// push the Pre-PWO to SAP at any time, reviewed or not. Once pushed, the
// row is marked CONVERTED and locked — see markConverted().
const sql = require('mssql');
const notify = () => require('./notifyStore');
const TABLE = `[dbo].[ZPRE_PRODUCTION_ORDERS]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[PREPWO-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
WORK_ORDER_ID INT NOT NULL,
ITEM_CODE NVARCHAR(60),
ITEM_NAME NVARCHAR(200),
PLANNED_QTY NVARCHAR(60),
BATCH_NUMBER NVARCHAR(60),
MFG_DATE NVARCHAR(30),
EXP_DATE NVARCHAR(30),
WAREHOUSE NVARCHAR(20),
FG_WAREHOUSE NVARCHAR(20),
LINES NVARCHAR(MAX),
ORIGINAL_LINES NVARCHAR(MAX),
REVIEW_STAGE INT DEFAULT 0,
SHARED_BY NVARCHAR(50),
SHARED_NAME NVARCHAR(100),
SHARED_AT DATETIME2,
REVIEWED_BY NVARCHAR(50),
REVIEWED_NAME NVARCHAR(100),
REVIEWED_AT DATETIME2,
REVIEW_REMARKS NVARCHAR(MAX),
STATUS NVARCHAR(20) DEFAULT 'OPEN',
CONVERTED_PO_ID INT,
CONVERTED_AT DATETIME2,
REMARKS NVARCHAR(MAX),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_AT DATETIME2,
IS_DELETED BIT DEFAULT 0,
COMPANY NVARCHAR(60)
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[PREPWO-STORE] Table exists — OK'); }
else throw e;
});
console.log('[PREPWO-STORE] ✅ Ready');
}
function toTs(iso) { return iso ? iso.replace('T', ' ').replace('Z', '').substring(0, 23) : null; }
function safeJson(v, f) { if (!v) return f; try { return JSON.parse(v); } catch { return f; } }
function fromRow(row) {
if (!row) return null;
return {
id: row.ID,
workOrderId: row.WORK_ORDER_ID,
itemCode: row.ITEM_CODE || '',
itemName: row.ITEM_NAME || '',
plannedQty: row.PLANNED_QTY || '',
batchNumber: row.BATCH_NUMBER || '',
mfgDate: row.MFG_DATE || '',
expDate: row.EXP_DATE || '',
warehouse: row.WAREHOUSE || '',
fgWarehouse: row.FG_WAREHOUSE || '',
lines: safeJson(row.LINES, []),
originalLines: safeJson(row.ORIGINAL_LINES, null),
reviewStage: row.REVIEW_STAGE != null ? row.REVIEW_STAGE : 0,
sharedBy: row.SHARED_BY || '',
sharedByName: row.SHARED_NAME || '',
sharedAt: row.SHARED_AT ? new Date(row.SHARED_AT).toISOString() : null,
reviewedBy: row.REVIEWED_BY || '',
reviewedByName: row.REVIEWED_NAME || '',
reviewedAt: row.REVIEWED_AT ? new Date(row.REVIEWED_AT).toISOString() : null,
reviewRemarks: row.REVIEW_REMARKS || '',
status: row.STATUS || 'OPEN',
convertedPoId: row.CONVERTED_PO_ID || null,
convertedAt: row.CONVERTED_AT ? new Date(row.CONVERTED_AT).toISOString() : null,
remarks: row.REMARKS || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
isDeleted: !!row.IS_DELETED,
company: row.COMPANY || '',
};
}
async function insertPrePwo(p) {
const now = toTs(new Date().toISOString());
// INSERT and SELECT SCOPE_IDENTITY() must be one batch — see
// workOrderStore.js's insertWorkOrder() for the full write-up of why a
// separate exec() for SCOPE_IDENTITY() can return NULL on a pooled conn.
const idRows = await exec(`
INSERT INTO ${TABLE} (
WORK_ORDER_ID, ITEM_CODE, ITEM_NAME, PLANNED_QTY, BATCH_NUMBER, MFG_DATE, EXP_DATE,
WAREHOUSE, FG_WAREHOUSE, LINES, REVIEW_STAGE, STATUS, REMARKS,
CREATED_BY, CREATED_NAME, CREATED_AT, COMPANY
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
parseInt(p.workOrderId),
p.itemCode || '', p.itemName || '', p.plannedQty || '', p.batchNumber || '',
p.mfgDate || '', p.expDate || '', p.warehouse || '', p.fgWarehouse || '',
JSON.stringify(p.lines || []), 0, 'OPEN', p.remarks || '',
p.createdBy, p.createdByName || p.createdBy, now, p.company || '',
]);
const created = await findById(idRows[0].ID);
notify().notify({
stepFullKey: 'production_order:prepwo_share',
title: `Pre-PWO staged for ${created.itemName || created.itemCode} — optional Store review`,
lines: [['Item', created.itemName || created.itemCode], ['Created By', created.createdByName]],
url: `${process.env.APP_BASE_URL || ''}/production-order`,
excludeUsernames: [p.createdBy],
});
return created;
}
async function listPrePwos({ mine, company, status, workOrderId, includeDeleted } = {}) {
const all = await exec(`SELECT * FROM ${TABLE} ORDER BY CREATED_AT DESC`);
return all.map(fromRow).filter(r => {
if (!includeDeleted && r.isDeleted) return false;
if (status && status !== 'ALL' && r.status !== status.toUpperCase()) return false;
if (mine && r.createdBy !== mine) return false;
if (company && r.company && r.company !== company) return false;
if (workOrderId != null && String(r.workOrderId) !== String(workOrderId)) return false;
return true;
});
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0]) : null;
}
async function findOpenByWorkOrderId(workOrderId) {
const rows = await exec(
`SELECT * FROM ${TABLE} WHERE WORK_ORDER_ID = ? AND (IS_DELETED = 0 OR IS_DELETED IS NULL) AND STATUS <> 'CONVERTED'`,
[parseInt(workOrderId)]
);
return rows.length ? fromRow(rows[0]) : null;
}
// Production edits header/lines directly, any time before conversion.
async function updatePrePwo(id, patch) {
const now = toTs(new Date().toISOString());
await exec(`
UPDATE ${TABLE}
SET ITEM_CODE = ?, ITEM_NAME = ?, PLANNED_QTY = ?, BATCH_NUMBER = ?, MFG_DATE = ?, EXP_DATE = ?,
WAREHOUSE = ?, FG_WAREHOUSE = ?, LINES = ?, REMARKS = ?, UPDATED_AT = ?
WHERE ID = ? AND STATUS <> 'CONVERTED' AND (IS_DELETED = 0 OR IS_DELETED IS NULL)
`, [
patch.itemCode || '', patch.itemName || '', patch.plannedQty || '', patch.batchNumber || '',
patch.mfgDate || '', patch.expDate || '', patch.warehouse || '', patch.fgWarehouse || '',
JSON.stringify(patch.lines || []), patch.remarks || '', now, parseInt(id),
]);
return findById(id);
}
// Production → Store: snapshots current LINES into ORIGINAL_LINES (the
// "before" for the eventual before/after view) and moves to stage 1.
async function shareWithStore(id, { by, byName }) {
const now = toTs(new Date().toISOString());
const pre = await findById(id);
if (!pre) throw new Error('Pre-PWO not found');
await exec(`
UPDATE ${TABLE} SET REVIEW_STAGE = 1, ORIGINAL_LINES = ?, SHARED_BY = ?, SHARED_NAME = ?, SHARED_AT = ?, UPDATED_AT = ?
WHERE ID = ?
`, [JSON.stringify(pre.lines || []), by || '', byName || by || '', now, now, parseInt(id)]);
notify().notify({
stepFullKey: 'production_order:prepwo_review',
title: `Pre-PWO ${pre.itemName || pre.itemCode} shared for Store review`,
lines: [['Item', pre.itemName || pre.itemCode], ['Shared By', byName || by]],
url: `${process.env.APP_BASE_URL || ''}/production-order`,
excludeUsernames: [by],
});
return findById(id);
}
// Store → Production: overwrites LINES with Store's edited version (may
// add/remove/substitute lines outright) and moves to stage 2 (done — single
// round trip, no further back-and-forth).
async function revertToProduction(id, { by, byName, lines, remarks }) {
const now = toTs(new Date().toISOString());
const pre = await findById(id);
if (!pre) throw new Error('Pre-PWO not found');
await exec(`
UPDATE ${TABLE} SET REVIEW_STAGE = 2, LINES = ?, REVIEWED_BY = ?, REVIEWED_NAME = ?, REVIEWED_AT = ?, REVIEW_REMARKS = ?, UPDATED_AT = ?
WHERE ID = ?
`, [JSON.stringify(lines || []), by || '', byName || by || '', now, remarks || '', now, parseInt(id)]);
notify().notify({
stepFullKey: 'production_order:prepwo_share',
title: `Pre-PWO ${pre.itemName || pre.itemCode} reviewed by Store — back with Production`,
lines: [['Item', pre.itemName || pre.itemCode], ['Reviewed By', byName || by], ['Remarks', remarks || '']],
url: `${process.env.APP_BASE_URL || ''}/production-order`,
excludeUsernames: [by],
});
return findById(id);
}
// Called once the Pre-PWO's current lines have actually been pushed to SAP
// as a real Production Order — locks the row (updatePrePwo/share/revert all
// refuse once STATUS = 'CONVERTED').
async function markConverted(id, { convertedPoId }) {
const now = toTs(new Date().toISOString());
await exec(`UPDATE ${TABLE} SET STATUS = 'CONVERTED', CONVERTED_PO_ID = ?, CONVERTED_AT = ?, UPDATED_AT = ? WHERE ID = ?`,
[parseInt(convertedPoId), now, now, parseInt(id)]);
return findById(id);
}
async function softDelete(id) {
await exec(`UPDATE ${TABLE} SET IS_DELETED = 1, UPDATED_AT = ? WHERE ID = ? AND STATUS <> 'CONVERTED'`,
[toTs(new Date().toISOString()), parseInt(id)]);
}
module.exports = {
bootstrap, insertPrePwo, listPrePwos, findById, findOpenByWorkOrderId,
updatePrePwo, shareWithStore, revertToProduction, markConverted, softDelete,
};
+485
View File
@@ -0,0 +1,485 @@
// services/productionOrderStore.js
// Local tracking for SAP B1 Production Orders (OWOR) generated FROM a Work
// Order. SAP itself only knows Planned/Released/Closed — this table adds the
// finer 5-step approval chain (Release → Issuance → Receipt → Transfer to
// Finished Goods → Close) and remembers which Work Order originated it.
// Actual SAP transactions (release/issue/receipt/close/transfer) are still
// performed via routes/sap.js's existing OWOR-calling endpoints; this store
// is the local record of WHO did WHICH step and WHEN, plus stage sequencing.
const sql = require('mssql');
const notify = () => require('./notifyStore');
const TABLE = `[dbo].[ZPRODUCTION_ORDERS]`;
// Ordered workflow steps AFTER creation (STAGE = number of completed steps).
// Index-aligned with STEP_KEYS and services/approvalStepsStore.js's
// workflow:'production_order' keys (minus 'create', which is a separate gate).
const STEPS = [
'Release',
'Issuance',
'Receipt from Production',
'Transfer to Finished Goods',
'Close',
];
const STEP_KEYS = ['release', 'issuance', 'receipt', 'transfer_fg', 'close'];
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[PO-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
WORK_ORDER_ID INT NULL, -- NULL = manually-created PO (no source Work Order) — see insertProductionOrder()
SAP_ABS_ENTRY INT,
SAP_DOC_NUM NVARCHAR(30),
ITEM_CODE NVARCHAR(60),
ITEM_NAME NVARCHAR(200),
PLANNED_QTY NVARCHAR(60),
BATCH_NUMBER NVARCHAR(60),
WAREHOUSE NVARCHAR(20),
FG_WAREHOUSE NVARCHAR(20),
STAGE INT DEFAULT 0,
STATUS NVARCHAR(20) DEFAULT 'IN_PROGRESS',
WORKFLOW_LOG NVARCHAR(MAX),
REMARKS NVARCHAR(MAX),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_AT DATETIME2,
IS_DELETED BIT DEFAULT 0,
COMPANY NVARCHAR(60)
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[PO-STORE] Table exists — OK'); }
else throw e;
});
// Migration: MFG/EXP Date, carried from the source Work Order's Batch
// Issuance Intimation data (same free-text format — DD-MMM-YYYY or
// MMM/YYYY — as ZWORK_ORDERS.MFG_DATE/EXP_DATE) so Receipt from
// Production can default to them instead of asking the user to retype.
for (const col of ['MFG_DATE', 'EXP_DATE']) {
await exec(`
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='${col}')
ALTER TABLE ${TABLE} ADD [${col}] NVARCHAR(30)
`).catch(e => console.log(`[PO-STORE] ${col} migration:`, e.message));
}
// Portal-only post-Issuance verification sign-off (not a SAP transaction).
for (const [col, type] of [['VERIFIED_BY', 'NVARCHAR(50)'], ['VERIFIED_BY_NAME', 'NVARCHAR(100)'], ['VERIFIED_AT', 'DATETIME2'], ['VERIFIED_REMARKS', 'NVARCHAR(400)']]) {
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='${col}') ALTER TABLE ${TABLE} ADD [${col}] ${type}`).catch(() => {});
}
// Portal-only "Received By" manual sign-off — independent of whoever
// performed the actual SAP "Receipt from Production" step; when set, this
// always overrides that step's signer on the printed Work Order.
for (const [col, type] of [['RECEIVED_MANUAL_BY', 'NVARCHAR(50)'], ['RECEIVED_MANUAL_BY_NAME', 'NVARCHAR(100)'], ['RECEIVED_MANUAL_AT', 'DATETIME2'], ['RECEIVED_MANUAL_REMARKS', 'NVARCHAR(400)']]) {
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='${col}') ALTER TABLE ${TABLE} ADD [${col}] ${type}`).catch(() => {});
}
// Migration: an existing table from before "manually-created" (no source
// Work Order) Production Orders were supported still has the original
// WORK_ORDER_ID NOT NULL constraint — insertProductionOrder() has allowed
// passing null here for a while now (see its own comment), so every such
// insert fails until this is relaxed. Safe/non-destructive: no data is
// touched, only the column's nullability.
await exec(`ALTER TABLE ${TABLE} ALTER COLUMN [WORK_ORDER_ID] INT NULL`).catch(e => console.log('[PO-STORE] WORK_ORDER_ID nullable migration:', e.message));
// "+ PWO" shortcut (a component of ANOTHER Production Order that's itself
// an SFG, raised as its own standalone order): purely informational
// reference back to that other order's WO No. / Batch No., for traceability
// only — NOT a real workOrderId link (this order wasn't generated from that
// Work Order, so it must never participate in the one-PWO-per-WO gate).
for (const col of ['REF_WO_NO', 'REF_BATCH_NUMBER']) {
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='${col}') ALTER TABLE ${TABLE} ADD [${col}] NVARCHAR(60)`).catch(() => {});
}
// The parent order's own SAP AbsoluteEntry — the UNAMBIGUOUS link (WO
// No./Batch No. above can both be blank) used to detect "a PWO for this
// exact SFG component, off THIS exact parent order, already exists" and
// block/hide raising a duplicate one for the same requirement.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='REF_PARENT_ENTRY') ALTER TABLE ${TABLE} ADD [REF_PARENT_ENTRY] INT NULL`).catch(() => {});
// Consumable Order ("+ Consumable Order" — Manual Entry restricted to
// Service items, Item Group 132): whoever CREATED one may also Release/
// Issue/Receipt THAT SAME order even without the general approval-step
// permissions for those stages — but only for orders flagged here, never
// for a normal PWO (see routes/sap.js's isOwnConsumableOrder()).
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='IS_CONSUMABLE') ALTER TABLE ${TABLE} ADD [IS_CONSUMABLE] BIT DEFAULT 0`).catch(() => {});
// Consumable Orders are raised by many departments — the creator picks
// theirs (from the same OUDP/"PR Departments" list + per-user restriction
// already used for Purchase Requisition) at creation. Purely an
// organizational tag for VIEW scoping (see routes/sap.js's
// consumableDeptFilterFor()) — Release/Issue/Close are deliberately
// department-independent, so this column plays no role in those gates.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_ORDERS' AND COLUMN_NAME='DEPARTMENT') ALTER TABLE ${TABLE} ADD [DEPARTMENT] NVARCHAR(100)`).catch(() => {});
console.log('[PO-STORE] ✅ Ready');
}
function toTs(iso) { return iso ? iso.replace('T', ' ').replace('Z', '').substring(0, 23) : null; }
function safeJson(v, f) { if (!v) return f; try { return JSON.parse(v); } catch { return f; } }
function fromRow(row) {
if (!row) return null;
const stage = row.STAGE || 0;
const status = row.STATUS || 'IN_PROGRESS';
return {
id: row.ID,
workOrderId: row.WORK_ORDER_ID,
sapAbsEntry: row.SAP_ABS_ENTRY || null,
sapDocNum: row.SAP_DOC_NUM || '',
itemCode: row.ITEM_CODE || '',
itemName: row.ITEM_NAME || '',
plannedQty: row.PLANNED_QTY || '',
batchNumber: row.BATCH_NUMBER || '',
refWoNo: row.REF_WO_NO || '',
refBatchNumber:row.REF_BATCH_NUMBER || '',
refParentEntry:row.REF_PARENT_ENTRY || null,
isConsumable: !!row.IS_CONSUMABLE,
department: row.DEPARTMENT || '',
mfgDate: row.MFG_DATE || '',
expDate: row.EXP_DATE || '',
warehouse: row.WAREHOUSE || '',
fgWarehouse: row.FG_WAREHOUSE || '',
stage,
status,
steps: STEPS,
currentStep: status === 'CLOSED' ? 'Closed' : status === 'CANCELLED' ? 'Cancelled' : status === 'REJECTED' ? 'Rejected' : (STEPS[stage] || 'Completed'),
workflowLog: safeJson(row.WORKFLOW_LOG, []),
verifiedBy: row.VERIFIED_BY || '',
verifiedByName: row.VERIFIED_BY_NAME || '',
verifiedAt: row.VERIFIED_AT ? new Date(row.VERIFIED_AT).toISOString() : null,
verifiedRemarks:row.VERIFIED_REMARKS || '',
receivedManualBy: row.RECEIVED_MANUAL_BY || '',
receivedManualByName: row.RECEIVED_MANUAL_BY_NAME || '',
receivedManualAt: row.RECEIVED_MANUAL_AT ? new Date(row.RECEIVED_MANUAL_AT).toISOString() : null,
receivedManualRemarks: row.RECEIVED_MANUAL_REMARKS || '',
remarks: row.REMARKS || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
isDeleted: !!row.IS_DELETED,
company: row.COMPANY || '',
};
}
async function insertProductionOrder(p) {
const now = toTs(new Date().toISOString());
const log = [{ step: 'Created', action: 'created', by: p.createdBy, byName: p.createdByName || p.createdBy, at: new Date().toISOString(), remarks: '' }];
// INSERT and SELECT SCOPE_IDENTITY() must be one batch — as two separate
// exec() calls, a pooled connection can route the second one to a
// DIFFERENT physical connection than the one that just inserted, where
// SCOPE_IDENTITY() correctly returns NULL (see the identical fix + full
// explanation in services/workOrderStore.js's insertWorkOrder()).
const idRows = await exec(`
INSERT INTO ${TABLE} (
WORK_ORDER_ID, SAP_ABS_ENTRY, SAP_DOC_NUM, ITEM_CODE, ITEM_NAME, PLANNED_QTY,
BATCH_NUMBER, REF_WO_NO, REF_BATCH_NUMBER, REF_PARENT_ENTRY, IS_CONSUMABLE, DEPARTMENT, MFG_DATE, EXP_DATE, WAREHOUSE, FG_WAREHOUSE, STAGE, STATUS, WORKFLOW_LOG, REMARKS,
CREATED_BY, CREATED_NAME, CREATED_AT, COMPANY
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
p.workOrderId != null ? parseInt(p.workOrderId) : null, // null = manually-created PO (no source Work Order) — still stage-tracked
p.sapAbsEntry != null ? parseInt(p.sapAbsEntry) : null, p.sapDocNum || '',
p.itemCode || '', p.itemName || '', p.plannedQty || '',
p.batchNumber || '', p.refWoNo || '', p.refBatchNumber || '', p.refParentEntry != null ? parseInt(p.refParentEntry) : null, p.isConsumable ? 1 : 0, p.department || '', p.mfgDate || '', p.expDate || '', p.warehouse || '', p.fgWarehouse || '',
0, 'IN_PROGRESS', JSON.stringify(log), p.remarks || '',
p.createdBy, p.createdByName || p.createdBy, now, p.company || '',
]);
const created = await findById(idRows[0].ID);
notify().notify({
stepFullKey: notifyStepFor(created, STEP_KEYS[0]),
title: `Production Order ${created.sapDocNum || '#' + created.id} — awaiting ${STEPS[0]}`,
lines: [['Production Order', created.sapDocNum || '#' + created.id], ['Item', created.itemName || created.itemCode], ['Created By', created.createdByName], ['Pending Step', STEPS[0]]],
url: `${process.env.APP_BASE_URL || ''}/production-order?id=${created.id}`,
excludeUsernames: [p.createdBy],
});
return created;
}
// Consumable Order ("+ Consumable Order") stages are each gated by their OWN
// dedicated step — not the general per-stage steps, and not one shared
// step either: Release/Issue/Close each have their own independent
// approval step, so a user can hold any subset of them. There is no
// consumable Receipt/Transfer-to-FG step at all (that workflow never
// reaches those stages — confirmed Release → Issue → Close only), so
// notifyStepFor() returns null for those and no "awaiting Receipt" email
// (which nobody could act on anyway) is ever sent for a Consumable Order.
const CONSUMABLE_STEP_FOR = {
release: 'production_order:consumable_release',
issuance: 'production_order:consumable_issue',
close: 'production_order:consumable_close',
};
const CONSUMABLE_STEPS = ['production_order:consumable_create', 'production_order:consumable_release', 'production_order:consumable_issue', 'production_order:consumable_close'];
// Which approval step should be emailed for this order's next pending stage?
// Normal orders keep emailing whoever holds the general step for that stage.
function notifyStepFor(po, generalStepKey) {
if (po.isConsumable) return CONSUMABLE_STEP_FOR[generalStepKey] || null;
return generalStepKey ? `production_order:${generalStepKey}` : null;
}
// For a terminal/informational event (Closed, Rejected, Cancelled, caught
// up) a Consumable Order's "concerned users" = its creator PLUS anyone
// holding ANY permission on ANY of the four Consumable Order steps — not
// just whoever happened to create this one order, and not gated to one
// specific action (this is informational, not an action gate). Collected as
// a single deduplicated username list (not one notify() call per step) so a
// user holding more than one of the four steps gets exactly one email, not
// several duplicates. Normal orders keep the existing creator-only
// notifyUsernames() behavior unchanged.
async function notifyForOrder(po, opts) {
await notify().notifyUsernames([po.createdBy], opts);
if (po.isConsumable) {
const lists = await Promise.all(CONSUMABLE_STEPS.map(s => notify().usersForStep(s)));
const usernames = [...new Set(lists.flat().map(u => u.username))].filter(u => u !== po.createdBy);
if (usernames.length) await notify().notifyUsernames(usernames, opts);
}
}
async function listProductionOrders({ mine, company, workOrderId, status, includeDeleted } = {}) {
const all = await exec(`SELECT * FROM ${TABLE} ORDER BY CREATED_AT DESC`);
return all.map(fromRow).filter(r => {
if (!includeDeleted && r.isDeleted) return false;
if (status && status !== 'ALL' && r.status !== status.toUpperCase()) return false;
if (mine && r.createdBy !== mine) return false;
if (company && r.company && r.company !== company) return false;
if (workOrderId && String(r.workOrderId) !== String(workOrderId)) return false;
return true;
});
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0]) : null;
}
async function findBySapAbsEntry(absEntry) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE SAP_ABS_ENTRY = ? AND (IS_DELETED=0 OR IS_DELETED IS NULL)`, [parseInt(absEntry)]);
return rows.length ? fromRow(rows[0]) : null;
}
// Advance (complete) or reject the CURRENT pending step. `stepKey` must match
// STEP_KEYS[stage] — the caller (routes) is responsible for checking the
// actual SAP transaction succeeded BEFORE calling this, so the local stage
// never claims a step happened when the real SAP call failed.
async function advanceStage(id, { action, stepKey, by, byName, remarks }) {
const po = await findById(id);
if (!po) throw new Error('Production order not found');
// A REJECTED order (its receipt was posted with rejection lines) is an
// OUTCOME, not a dead end — it must still be closeable in SAP and locally.
if (po.status === 'REJECTED' && stepKey === 'close' && action !== 'reject') {
const rlog = po.workflowLog || [];
rlog.push({ step: 'Close', action: 'completed', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || 'Closed after rejected receipt' });
await exec(`UPDATE ${TABLE} SET STAGE=?, STATUS='CLOSED', WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[STEPS.length, JSON.stringify(rlog), toTs(new Date().toISOString()), parseInt(id)]);
const closedAfterReject = await findById(id);
await notifyForOrder(closedAfterReject, {
title: `Production Order ${closedAfterReject.sapDocNum || '#' + closedAfterReject.id} — Closed (after rejected receipt)`,
lines: [['Production Order', closedAfterReject.sapDocNum || '#' + closedAfterReject.id], ['Item', closedAfterReject.itemName || closedAfterReject.itemCode], ['Closed By', byName || by]],
url: `${process.env.APP_BASE_URL || ''}/production-order?id=${closedAfterReject.id}`,
});
return closedAfterReject;
}
if (po.status !== 'IN_PROGRESS') throw new Error('Production order is already ' + po.status.toLowerCase());
const expectedKey = STEP_KEYS[po.stage];
if (stepKey !== expectedKey)
throw new Error(`Out of sequence: next required step is "${STEPS[po.stage]}" (${expectedKey}), not "${stepKey}"`);
const log = po.workflowLog || [];
const now = toTs(new Date().toISOString());
if (action === 'reject') {
log.push({ step: STEPS[po.stage], action: 'rejected', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || '' });
await exec(`UPDATE ${TABLE} SET STATUS='REJECTED', WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[JSON.stringify(log), now, parseInt(id)]);
const rejected = await findById(id);
await notifyForOrder(rejected, {
title: `Production Order ${rejected.sapDocNum || '#' + rejected.id} — Rejected at ${STEPS[po.stage]}`,
lines: [['Production Order', rejected.sapDocNum || '#' + rejected.id], ['Item', rejected.itemName || rejected.itemCode], ['Rejected By', byName || by], ['Remarks', remarks || '']],
url: `${process.env.APP_BASE_URL || ''}/production-order?id=${rejected.id}`,
});
return rejected;
}
log.push({ step: STEPS[po.stage], action: 'completed', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || '' });
const newStage = po.stage + 1;
const newStatus = newStage >= STEPS.length ? 'CLOSED' : 'IN_PROGRESS';
await exec(`UPDATE ${TABLE} SET STAGE=?, STATUS=?, WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[newStage, newStatus, JSON.stringify(log), now, parseInt(id)]);
const advanced = await findById(id);
const url = `${process.env.APP_BASE_URL || ''}/production-order?id=${advanced.id}`;
if (newStatus === 'CLOSED') {
await notifyForOrder(advanced, {
title: `Production Order ${advanced.sapDocNum || '#' + advanced.id} — Closed`,
lines: [['Production Order', advanced.sapDocNum || '#' + advanced.id], ['Item', advanced.itemName || advanced.itemCode], ['Closed By', byName || by]],
url,
});
} else {
const nextStepKey = STEP_KEYS[newStage];
notify().notify({
stepFullKey: notifyStepFor(advanced, nextStepKey),
title: `Production Order ${advanced.sapDocNum || '#' + advanced.id} — awaiting ${STEPS[newStage]}`,
lines: [['Production Order', advanced.sapDocNum || '#' + advanced.id], ['Item', advanced.itemName || advanced.itemCode], ['Completed By', byName || by], ['Pending Step', STEPS[newStage]]],
url,
excludeUsernames: [by],
});
}
return advanced;
}
// Admin/System Admin override — force this local record to CLOSED at
// whatever stage it's currently sitting at, bypassing the normal sequential
// advanceStage() rules entirely (it demands the exact next step in order).
// The caller (routes/sap.js) is responsible for confirming the real SAP
// transaction succeeded first — this only updates the local tracking state
// to match.
async function adminForceClose(id, { by, byName, remarks }) {
const po = await findById(id);
if (!po) throw new Error('Production order not found');
const log = po.workflowLog || [];
log.push({ step: 'Close', action: 'admin_force_closed', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || '' });
await exec(`UPDATE ${TABLE} SET STAGE=?, STATUS='CLOSED', WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[STEPS.length, JSON.stringify(log), toTs(new Date().toISOString()), parseInt(id)]);
const closed = await findById(id);
await notifyForOrder(closed, {
title: `Production Order ${closed.sapDocNum || '#' + closed.id} — Closed (Override)`,
lines: [['Production Order', closed.sapDocNum || '#' + closed.id], ['Item', closed.itemName || closed.itemCode], ['Closed By', byName || by], ['Remarks', remarks || '']],
url: `${process.env.APP_BASE_URL || ''}/production-order?id=${closed.id}`,
});
return closed;
}
// Admin/System Admin override — mark this local record CANCELLED (a new
// terminal state, distinct from CLOSED/REJECTED) at whatever stage it's
// currently sitting at. Same non-sequential bypass as adminForceClose above.
async function adminForceCancel(id, { by, byName, remarks }) {
const po = await findById(id);
if (!po) throw new Error('Production order not found');
const log = po.workflowLog || [];
log.push({ step: 'Cancel', action: 'admin_cancelled', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || '' });
await exec(`UPDATE ${TABLE} SET STATUS='CANCELLED', WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[JSON.stringify(log), toTs(new Date().toISOString()), parseInt(id)]);
const cancelled = await findById(id);
await notifyForOrder(cancelled, {
title: `Production Order ${cancelled.sapDocNum || '#' + cancelled.id} — Cancelled (Admin override)`,
lines: [['Production Order', cancelled.sapDocNum || '#' + cancelled.id], ['Item', cancelled.itemName || cancelled.itemCode], ['Cancelled By', byName || by], ['Remarks', remarks || '']],
url: `${process.env.APP_BASE_URL || ''}/production-order?id=${cancelled.id}`,
});
return cancelled;
}
// Catches the local stage tracker up to `targetStage` directly, skipping the
// normal sequential advanceStage() rules — used when SAP itself already
// shows a later state (e.g. fully Released AND Issued) because someone
// performed those actions directly in SAP B1 instead of through the portal.
// Without this, such an order is stuck below its true SAP state forever: the
// portal's own Issue for Production button only shows while SAP is NOT yet
// fully issued (see production.html's `fullyIssued` gate), so there is no
// sequential path to "catch up" — Verify Work Order, Receipt from
// Production and Close all then stay permanently out of reach even though
// SAP is ready. The caller (routes/sap.js) is responsible for confirming
// SAP's real state matches BEFORE calling this.
async function catchUpStage(id, targetStage, { by, byName, remarks }) {
const po = await findById(id);
if (!po) throw new Error('Production order not found');
if (po.status !== 'IN_PROGRESS') throw new Error('Production order is already ' + po.status.toLowerCase());
if (targetStage <= po.stage) return po;
const log = po.workflowLog || [];
for (let s = po.stage; s < targetStage; s++) {
log.push({ step: STEPS[s], action: 'caught_up', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || 'Already done directly in SAP — caught up in the portal' });
}
const now = toTs(new Date().toISOString());
const newStatus = targetStage >= STEPS.length ? 'CLOSED' : 'IN_PROGRESS';
await exec(`UPDATE ${TABLE} SET STAGE=?, STATUS=?, WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[targetStage, newStatus, JSON.stringify(log), now, parseInt(id)]);
const caughtUp = await findById(id);
const url = `${process.env.APP_BASE_URL || ''}/production-order?id=${caughtUp.id}`;
if (newStatus === 'CLOSED') {
await notifyForOrder(caughtUp, {
title: `Production Order ${caughtUp.sapDocNum || '#' + caughtUp.id} — Closed`,
lines: [['Production Order', caughtUp.sapDocNum || '#' + caughtUp.id], ['Item', caughtUp.itemName || caughtUp.itemCode], ['Closed By', byName || by]],
url,
});
} else {
const nextStepKey = STEP_KEYS[targetStage];
notify().notify({
stepFullKey: notifyStepFor(caughtUp, nextStepKey),
title: `Production Order ${caughtUp.sapDocNum || '#' + caughtUp.id} — awaiting ${STEPS[targetStage]}`,
lines: [['Production Order', caughtUp.sapDocNum || '#' + caughtUp.id], ['Item', caughtUp.itemName || caughtUp.itemCode], ['Pending Step', STEPS[targetStage]]],
url,
excludeUsernames: [by],
});
}
return caughtUp;
}
async function softDelete(id) {
await exec(`UPDATE ${TABLE} SET IS_DELETED=1, UPDATED_AT=? WHERE ID=?`,
[toTs(new Date().toISOString()), parseInt(id)]);
}
// Portal-only verification sign-off performed AFTER Issuance. Does NOT touch
// SAP or the stage chain — records who verified + when (+ a workflow-log entry
// with step 'Verified' so it flows to the Work Order PDF's "Verified By").
// The verifier's stored signature image is drawn on the printed Work Order.
const ISSUANCE_STAGE = STEP_KEYS.indexOf('issuance') + 1; // stage once Issuance is done
async function verify(id, { by, byName, remarks }) {
const po = await findById(id);
if (!po || po.isDeleted) throw new Error('Production order not found');
if (po.verifiedAt) throw new Error('This production order has already been verified by ' + (po.verifiedByName || po.verifiedBy));
if ((po.stage || 0) < ISSUANCE_STAGE) throw new Error('Production order must be Issued before it can be verified');
const atIso = new Date().toISOString();
const log = po.workflowLog || [];
log.push({ step: 'Verified', action: 'verified', by, byName: byName || by, at: atIso, remarks: remarks || '' });
await exec(
`UPDATE ${TABLE} SET VERIFIED_BY=?, VERIFIED_BY_NAME=?, VERIFIED_AT=?, VERIFIED_REMARKS=?, WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[by, byName || by, toTs(atIso), remarks || '', JSON.stringify(log), toTs(atIso), parseInt(id)]
);
return findById(id);
}
// Portal-only "Received By" manual sign-off — same shape/rules as verify()
// above, but independent of it: records who received + when (+ a workflow-log
// entry with step 'Received By (Manual)'). This ALWAYS takes priority over the
// SAP "Receipt from Production" step's signer wherever "Received By" is shown
// (see routes/workOrders.js computeIssuance()).
async function receiveManual(id, { by, byName, remarks }) {
const po = await findById(id);
if (!po || po.isDeleted) throw new Error('Production order not found');
if (po.receivedManualAt) throw new Error('Received By has already been saved by ' + (po.receivedManualByName || po.receivedManualBy));
if ((po.stage || 0) < ISSUANCE_STAGE) throw new Error('Production order must be Issued before Received By can be saved');
const atIso = new Date().toISOString();
const log = po.workflowLog || [];
log.push({ step: 'Received By (Manual)', action: 'received', by, byName: byName || by, at: atIso, remarks: remarks || '' });
await exec(
`UPDATE ${TABLE} SET RECEIVED_MANUAL_BY=?, RECEIVED_MANUAL_BY_NAME=?, RECEIVED_MANUAL_AT=?, RECEIVED_MANUAL_REMARKS=?, WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[by, byName || by, toTs(atIso), remarks || '', JSON.stringify(log), toTs(atIso), parseInt(id)]
);
return findById(id);
}
module.exports = {
bootstrap, STEPS, STEP_KEYS, ISSUANCE_STAGE, insertProductionOrder,
listProductionOrders, findById, findBySapAbsEntry, advanceStage, verify, receiveManual, softDelete,
adminForceClose, adminForceCancel, catchUpStage,
};
@@ -0,0 +1,141 @@
// services/productionPlanningItemDefaultsStore.js
// Per-item-code master defaults for Production Planning: No. of Package,
// Per Cycle Qty, No. of Cycle/Day, Product Type — set once per SAP item
// code, then auto-filled into a new plan entry when that item is picked
// (see routes/productionPlanning.js's /item-defaults endpoints). "Prod. As
// per Single" is NOT stored here — it's always Quantity(required, typed
// per entry) × No. of Package, computed live in the plan entry itself.
const sql = require('mssql');
const TABLE = `[dbo].[ZPP_ITEM_DEFAULTS]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
function nowTs() { return new Date().toISOString().replace('T', ' ').substring(0, 23); }
async function bootstrap() {
console.log('[PP-ITEM-DEFAULTS] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
ITEM_CODE NVARCHAR(50) NOT NULL,
ITEM_NAME NVARCHAR(200),
NO_OF_PACKAGE FLOAT DEFAULT 0,
PER_CYCLE_QTY FLOAT DEFAULT 0,
NO_OF_CYCLE_DAY FLOAT DEFAULT 0,
PRODUCT_TYPE NVARCHAR(50),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_BY NVARCHAR(50),
UPDATED_NAME NVARCHAR(100),
UPDATED_AT DATETIME2,
IS_DELETED BIT DEFAULT 0
)
`).catch(e => { if (!isAlreadyExists(e)) throw e; });
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPP_ITEM_DEFAULTS' AND COLUMN_NAME='PRODUCT_TYPE') ALTER TABLE ${TABLE} ADD [PRODUCT_TYPE] NVARCHAR(50)`).catch(() => {});
console.log('[PP-ITEM-DEFAULTS] ✅ Ready');
}
function fromRow(row) {
return {
id: row.ID,
itemCode: row.ITEM_CODE || '',
itemName: row.ITEM_NAME || '',
noOfPackage: row.NO_OF_PACKAGE || 0,
perCycleQty: row.PER_CYCLE_QTY || 0,
noOfCycleDay: row.NO_OF_CYCLE_DAY || 0,
productType: row.PRODUCT_TYPE || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedBy: row.UPDATED_BY || '',
updatedByName: row.UPDATED_NAME || '',
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
isDeleted: !!row.IS_DELETED,
};
}
async function listDefaults() {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE IS_DELETED=0 ORDER BY ITEM_CODE ASC`);
return rows.map(fromRow);
}
async function findByItemCode(itemCode) {
const code = String(itemCode || '').trim().toUpperCase();
if (!code) return null;
const rows = await exec(`SELECT * FROM ${TABLE} WHERE IS_DELETED=0 AND UPPER(ITEM_CODE)=?`, [code]);
return rows.length ? fromRow(rows[0]) : null;
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID=?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0]) : null;
}
// Create-or-update by item code — one active default row per item.
async function upsertDefault(p, { by, byName } = {}) {
const itemCode = String(p.itemCode || '').trim().toUpperCase();
if (!itemCode) throw new Error('Item Code is required');
const existing = await findByItemCode(itemCode);
const now = nowTs();
if (existing) {
await exec(`
UPDATE ${TABLE} SET
ITEM_NAME=?, NO_OF_PACKAGE=?, PER_CYCLE_QTY=?, NO_OF_CYCLE_DAY=?, PRODUCT_TYPE=?,
UPDATED_BY=?, UPDATED_NAME=?, UPDATED_AT=?
WHERE ID=?
`, [
p.itemName || existing.itemName, parseFloat(p.noOfPackage) || 0,
parseFloat(p.perCycleQty) || 0, parseFloat(p.noOfCycleDay) || 0, p.productType || '',
by || '', byName || by || '', now, existing.id,
]);
return findById(existing.id);
}
const idRows = await exec(`
INSERT INTO ${TABLE} (
ITEM_CODE, ITEM_NAME, NO_OF_PACKAGE, PER_CYCLE_QTY, NO_OF_CYCLE_DAY, PRODUCT_TYPE,
CREATED_BY, CREATED_NAME, CREATED_AT
) VALUES (?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
itemCode, p.itemName || '', parseFloat(p.noOfPackage) || 0,
parseFloat(p.perCycleQty) || 0, parseFloat(p.noOfCycleDay) || 0, p.productType || '',
by || '', byName || by || '', now,
]);
return findById(idRows[0].ID);
}
async function softDeleteDefault(id) {
await exec(`UPDATE ${TABLE} SET IS_DELETED=1 WHERE ID=?`, [parseInt(id)]);
}
module.exports = {
bootstrap, listDefaults, findByItemCode, findById, upsertDefault, softDeleteDefault,
};
+262
View File
@@ -0,0 +1,262 @@
// services/productionPlanningStore.js
// Daily Production Planning — one row per (Date, Product) plan: how many
// packages × quantity per package are planned, the per-cycle quantity and
// cycles/day that implies, and whether it's actually been done yet.
const sql = require('mssql');
const TABLE = `[dbo].[ZPRODUCTION_PLANNING]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
function nowTs() { return new Date().toISOString().replace('T', ' ').substring(0, 23); }
async function bootstrap() {
console.log('[PRODUCTION-PLANNING] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
PLAN_DATE NVARCHAR(20) NOT NULL,
PRODUCT_NAME NVARCHAR(200) NOT NULL,
PRODUCT_TYPE NVARCHAR(50),
MARKET NVARCHAR(100),
NO_OF_PACKAGE FLOAT DEFAULT 0,
QUANTITY FLOAT DEFAULT 0,
PROD_AS_PER_SINGLE FLOAT DEFAULT 0,
PER_CYCLE_QTY FLOAT DEFAULT 0,
NO_OF_CYCLE_DAY FLOAT DEFAULT 0,
STATUS NVARCHAR(20) DEFAULT 'PLANNED',
BATCH_NO NVARCHAR(60),
COMPANY NVARCHAR(60),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_BY NVARCHAR(50),
UPDATED_NAME NVARCHAR(100),
UPDATED_AT DATETIME2,
APPROVED_BY NVARCHAR(50),
APPROVED_NAME NVARCHAR(100),
APPROVED_AT DATETIME2,
REJECTED_BY NVARCHAR(50),
REJECTED_NAME NVARCHAR(100),
REJECTED_AT DATETIME2,
REJECT_REASON NVARCHAR(500),
IS_DELETED BIT DEFAULT 0
)
`).catch(e => { if (!isAlreadyExists(e)) throw e; });
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='PRODUCT_TYPE') ALTER TABLE ${TABLE} ADD [PRODUCT_TYPE] NVARCHAR(50)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='MARKET') ALTER TABLE ${TABLE} ADD [MARKET] NVARCHAR(100)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='APPROVED_BY') ALTER TABLE ${TABLE} ADD [APPROVED_BY] NVARCHAR(50)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='APPROVED_NAME') ALTER TABLE ${TABLE} ADD [APPROVED_NAME] NVARCHAR(100)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='APPROVED_AT') ALTER TABLE ${TABLE} ADD [APPROVED_AT] DATETIME2`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='REJECTED_BY') ALTER TABLE ${TABLE} ADD [REJECTED_BY] NVARCHAR(50)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='REJECTED_NAME') ALTER TABLE ${TABLE} ADD [REJECTED_NAME] NVARCHAR(100)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='REJECTED_AT') ALTER TABLE ${TABLE} ADD [REJECTED_AT] DATETIME2`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='REJECT_REASON') ALTER TABLE ${TABLE} ADD [REJECT_REASON] NVARCHAR(500)`).catch(() => {});
console.log('[PRODUCTION-PLANNING] ✅ Ready');
}
function fromRow(row) {
return {
id: row.ID,
planDate: row.PLAN_DATE || '',
productName: row.PRODUCT_NAME || '',
productType: row.PRODUCT_TYPE || '',
market: row.MARKET || '',
noOfPackage: row.NO_OF_PACKAGE || 0,
quantity: row.QUANTITY || 0,
prodAsPerSingle: row.PROD_AS_PER_SINGLE || 0,
perCycleQty: row.PER_CYCLE_QTY || 0,
noOfCycleDay: row.NO_OF_CYCLE_DAY || 0,
status: row.STATUS || 'PLANNED',
batchNo: row.BATCH_NO || '',
company: row.COMPANY || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedBy: row.UPDATED_BY || '',
updatedByName: row.UPDATED_NAME || '',
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
approvedBy: row.APPROVED_BY || '',
approvedByName: row.APPROVED_NAME || '',
approvedAt: row.APPROVED_AT ? new Date(row.APPROVED_AT).toISOString() : null,
rejectedBy: row.REJECTED_BY || '',
rejectedByName: row.REJECTED_NAME || '',
rejectedAt: row.REJECTED_AT ? new Date(row.REJECTED_AT).toISOString() : null,
rejectReason: row.REJECT_REASON || '',
isDeleted: !!row.IS_DELETED,
};
}
async function listPlans({ company, from, to, status, productType, market, q, includeDeleted } = {}) {
const rows = await exec(`SELECT * FROM ${TABLE} ORDER BY PLAN_DATE DESC, ID DESC`);
const qLower = q ? String(q).trim().toLowerCase() : '';
return rows.map(fromRow).filter(p => {
if (!includeDeleted && p.isDeleted) return false;
if (company && p.company && p.company !== company) return false;
if (status && status !== 'ALL' && p.status !== status.toUpperCase()) return false;
if (from && p.planDate && p.planDate < from) return false;
if (to && p.planDate && p.planDate > to) return false;
if (productType && productType !== 'ALL' && p.productType !== productType) return false;
if (market && p.market !== market) return false;
if (qLower && !(p.productName || '').toLowerCase().includes(qLower) && !(p.batchNo || '').toLowerCase().includes(qLower)) return false;
return true;
});
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID=?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0]) : null;
}
// "Prod. As per Single" is always No. of Package × Quantity — computed
// server-side so a client can never submit a mismatched figure.
function computeProdAsPerSingle(noOfPackage, quantity) {
return (parseFloat(noOfPackage) || 0) * (parseFloat(quantity) || 0);
}
// Quantity may never exceed the day's capacity (Per Cycle Qty × No. of Cycle/Day).
function validateQuantity(quantity, perCycleQty, noOfCycleDay) {
const totalPerDay = (parseFloat(perCycleQty) || 0) * (parseFloat(noOfCycleDay) || 0);
const qty = parseFloat(quantity) || 0;
if (totalPerDay > 0 && qty > totalPerDay) {
throw new Error(`Quantity (${qty}) cannot exceed Total Qty/Day (${totalPerDay})`);
}
}
async function createPlan(p) {
if (!p.planDate) throw new Error('Date is required');
if (!p.productName || !String(p.productName).trim()) throw new Error('Product Name is required');
validateQuantity(p.quantity, p.perCycleQty, p.noOfCycleDay);
const prodAsPerSingle = computeProdAsPerSingle(p.noOfPackage, p.quantity);
const now = nowTs();
const idRows = await exec(`
INSERT INTO ${TABLE} (
PLAN_DATE, PRODUCT_NAME, PRODUCT_TYPE, MARKET, NO_OF_PACKAGE, QUANTITY, PROD_AS_PER_SINGLE,
PER_CYCLE_QTY, NO_OF_CYCLE_DAY, STATUS, BATCH_NO, COMPANY,
CREATED_BY, CREATED_NAME, CREATED_AT
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
p.planDate, String(p.productName).trim(), p.productType || '', p.market || '', parseFloat(p.noOfPackage) || 0, parseFloat(p.quantity) || 0, prodAsPerSingle,
parseFloat(p.perCycleQty) || 0, parseFloat(p.noOfCycleDay) || 0, (p.status === 'DONE' ? 'DONE' : 'PLANNED'), p.batchNo || '', p.company || '',
p.createdBy || '', p.createdByName || p.createdBy || '', now,
]);
return findById(idRows[0].ID);
}
async function updatePlan(id, p, { by, byName } = {}) {
const existing = await findById(id);
if (!existing) throw new Error('Plan not found');
// An approved plan is locked — no silent re-editing that would leave an
// old sign-off attached to changed numbers (or quietly invalidate it
// without the approver noticing). It must be rejected first, which
// reopens it for editing; editing a REJECTED plan is exactly how that
// rejection gets resolved, clearing it back to "pending review" so the
// approver sees it again.
if (existing.approvedBy) throw new Error('This plan is approved and locked. It must be rejected before it can be edited again.');
const noOfPackage = p.noOfPackage !== undefined ? p.noOfPackage : existing.noOfPackage;
const quantity = p.quantity !== undefined ? p.quantity : existing.quantity;
const perCycleQty = p.perCycleQty !== undefined ? p.perCycleQty : existing.perCycleQty;
const noOfCycleDay = p.noOfCycleDay !== undefined ? p.noOfCycleDay : existing.noOfCycleDay;
validateQuantity(quantity, perCycleQty, noOfCycleDay);
const prodAsPerSingle = computeProdAsPerSingle(noOfPackage, quantity);
await exec(`
UPDATE ${TABLE} SET
PLAN_DATE=?, PRODUCT_NAME=?, PRODUCT_TYPE=?, MARKET=?, NO_OF_PACKAGE=?, QUANTITY=?, PROD_AS_PER_SINGLE=?,
PER_CYCLE_QTY=?, NO_OF_CYCLE_DAY=?, STATUS=?, BATCH_NO=?,
UPDATED_BY=?, UPDATED_NAME=?, UPDATED_AT=?,
REJECTED_BY=NULL, REJECTED_NAME=NULL, REJECTED_AT=NULL, REJECT_REASON=NULL
WHERE ID=?
`, [
p.planDate !== undefined ? p.planDate : existing.planDate,
p.productName !== undefined ? String(p.productName).trim() : existing.productName,
p.productType !== undefined ? p.productType : existing.productType,
p.market !== undefined ? p.market : existing.market,
parseFloat(noOfPackage) || 0, parseFloat(quantity) || 0, prodAsPerSingle,
p.perCycleQty !== undefined ? (parseFloat(p.perCycleQty) || 0) : existing.perCycleQty,
p.noOfCycleDay !== undefined ? (parseFloat(p.noOfCycleDay) || 0) : existing.noOfCycleDay,
p.status !== undefined ? (p.status === 'DONE' ? 'DONE' : 'PLANNED') : existing.status,
p.batchNo !== undefined ? p.batchNo : existing.batchNo,
by || '', byName || by || '', nowTs(),
parseInt(id),
]);
return findById(id);
}
async function approvePlan(id, { by, byName } = {}) {
const existing = await findById(id);
if (!existing) throw new Error('Plan not found');
await exec(`
UPDATE ${TABLE} SET APPROVED_BY=?, APPROVED_NAME=?, APPROVED_AT=?,
REJECTED_BY=NULL, REJECTED_NAME=NULL, REJECTED_AT=NULL, REJECT_REASON=NULL
WHERE ID=?
`, [
by || '', byName || by || '', nowTs(), parseInt(id),
]);
return findById(id);
}
async function rejectPlan(id, reason, { by, byName } = {}) {
const existing = await findById(id);
if (!existing) throw new Error('Plan not found');
if (!reason || !String(reason).trim()) throw new Error('A reason is required to reject a plan');
await exec(`
UPDATE ${TABLE} SET REJECTED_BY=?, REJECTED_NAME=?, REJECTED_AT=?, REJECT_REASON=?,
APPROVED_BY=NULL, APPROVED_NAME=NULL, APPROVED_AT=NULL
WHERE ID=?
`, [
by || '', byName || by || '', nowTs(), String(reason).trim(), parseInt(id),
]);
return findById(id);
}
// Marking a plan Planned/Done is an operational progress flag, not a
// content edit — it must stay changeable even on an approved (locked)
// plan, otherwise nobody could ever record that an approved plan actually
// ran. Deliberately does NOT touch APPROVED_BY/REJECTED_BY — the approval
// itself still stands.
async function updateStatus(id, status, { by, byName } = {}) {
const existing = await findById(id);
if (!existing) throw new Error('Plan not found');
const s = status === 'DONE' ? 'DONE' : 'PLANNED';
await exec(`UPDATE ${TABLE} SET STATUS=?, UPDATED_BY=?, UPDATED_NAME=?, UPDATED_AT=? WHERE ID=?`, [
s, by || '', byName || by || '', nowTs(), parseInt(id),
]);
return findById(id);
}
async function softDeletePlan(id) {
const existing = await findById(id);
if (!existing) throw new Error('Plan not found');
if (existing.approvedBy) throw new Error('This plan is approved and locked. It must be rejected before it can be deleted.');
await exec(`UPDATE ${TABLE} SET IS_DELETED=1 WHERE ID=?`, [parseInt(id)]);
}
module.exports = {
bootstrap, listPlans, findById, createPlan, updatePlan, approvePlan, rejectPlan, updateStatus, softDeletePlan,
};
+305
View File
@@ -0,0 +1,305 @@
'use strict';
const sql = require('mssql');
async function getPool() {
return require('./appSqlPool').getPool();
}
async function bootstrap() {
const pool = await getPool();
await pool.request().query(`
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='project_requests' AND xtype='U')
CREATE TABLE project_requests (
id INT IDENTITY(1,1) PRIMARY KEY,
project_code NVARCHAR(50) UNIQUE,
project_name NVARCHAR(200) NOT NULL,
project_category NCHAR(1) NOT NULL DEFAULT 'B',
total_days INT,
initiation_dt DATE,
est_start_dt DATE,
est_finish_dt DATE,
purpose NVARCHAR(MAX),
roi NVARCHAR(MAX),
labour_cost DECIMAL(15,2) DEFAULT 0,
material_cost DECIMAL(15,2) DEFAULT 0,
consultancy_fees DECIMAL(15,2) DEFAULT 0,
promotional_fees DECIMAL(15,2) DEFAULT 0,
other_cost DECIMAL(15,2) DEFAULT 0,
total_est_cost DECIMAL(15,2) DEFAULT 0,
department_name NVARCHAR(100),
prepared_by_name NVARCHAR(100),
prepared_by_desig NVARCHAR(100),
accountable_person NVARCHAR(100),
team_members NVARCHAR(MAX),
status NVARCHAR(50) DEFAULT 'DRAFT',
authorised_by NVARCHAR(100),
authorised_dt DATE,
comments NVARCHAR(MAX),
submitted_by NVARCHAR(100),
project_type NVARCHAR(20) DEFAULT 'NEW',
req_new_project NVARCHAR(MAX),
req_upgradation NVARCHAR(MAX),
time_cost_recovery NVARCHAR(200),
remarks_p2 NVARCHAR(MAX),
attachments NVARCHAR(MAX),
created_at DATETIME DEFAULT GETDATE(),
updated_at DATETIME DEFAULT GETDATE()
)
`);
// safe-add every column that might be missing on older DBs
const alter = pool.request();
for (const col of [
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='project_type') ALTER TABLE project_requests ADD project_type NVARCHAR(20) DEFAULT 'NEW'`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='req_new_project') ALTER TABLE project_requests ADD req_new_project NVARCHAR(MAX)`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='req_upgradation') ALTER TABLE project_requests ADD req_upgradation NVARCHAR(MAX)`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='time_cost_recovery') ALTER TABLE project_requests ADD time_cost_recovery NVARCHAR(200)`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='remarks_p2') ALTER TABLE project_requests ADD remarks_p2 NVARCHAR(MAX)`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='attachments') ALTER TABLE project_requests ADD attachments NVARCHAR(MAX)`,
// purchase
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='purchase_verified_dt') ALTER TABLE project_requests ADD purchase_verified_dt DATE`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='purchase_applicable') ALTER TABLE project_requests ADD purchase_applicable BIT DEFAULT 1`,
// engineering
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='engineering_verified_dt') ALTER TABLE project_requests ADD engineering_verified_dt DATE`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='engineering_applicable') ALTER TABLE project_requests ADD engineering_applicable BIT DEFAULT 1`,
// qa
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='qa_verified_dt') ALTER TABLE project_requests ADD qa_verified_dt DATE`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='qa_applicable') ALTER TABLE project_requests ADD qa_applicable BIT DEFAULT 1`,
// qc
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='qc_verified_dt') ALTER TABLE project_requests ADD qc_verified_dt DATE`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='qc_applicable') ALTER TABLE project_requests ADD qc_applicable BIT DEFAULT 1`,
// legal
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='legal_verified_dt') ALTER TABLE project_requests ADD legal_verified_dt DATE`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='legal_applicable') ALTER TABLE project_requests ADD legal_applicable BIT DEFAULT 1`,
// project owner
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='owner_approved_dt') ALTER TABLE project_requests ADD owner_approved_dt DATE`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='owner_applicable') ALTER TABLE project_requests ADD owner_applicable BIT DEFAULT 1`,
// plant manager
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='plant_applicable') ALTER TABLE project_requests ADD plant_applicable BIT DEFAULT 1`,
// finance (last step)
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='finance_verified_dt') ALTER TABLE project_requests ADD finance_verified_dt DATE`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='finance_verified_by') ALTER TABLE project_requests ADD finance_verified_by NVARCHAR(100)`,
`IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id=OBJECT_ID('project_requests') AND name='finance_applicable') ALTER TABLE project_requests ADD finance_applicable BIT DEFAULT 1`,
]) await alter.query(col);
console.log('[ProjectStore] ✅ Table ready');
}
async function nextCode() {
const pool = await getPool();
// Financial year: April–March e.g. May 2026 → "26-27", Jan 2027 → "26-27"
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
const fyStart = month >= 4 ? year : year - 1;
const fy = `${String(fyStart).slice(-2)}-${String(fyStart + 1).slice(-2)}`;
const prefix = `MIPL/${fy}/`;
// Max from existing MIPL codes (handles gaps after deletion)
const r1 = await pool.request().query(
`SELECT MAX(CAST(SUBSTRING(project_code, ${prefix.length + 1}, 10) AS INT)) AS mipl_max
FROM project_requests WHERE project_code LIKE '${prefix}%'`
);
// Total project count covers migration from old PRJ-YYYY format
const r2 = await pool.request().query(
`SELECT COUNT(*) AS total FROM project_requests`
);
const miplMax = r1.recordset[0].mipl_max || 0;
const total = r2.recordset[0].total || 0;
// Floor of 11 so the sequence never generates below 012
const next = Math.max(miplMax, total, 11) + 1;
return `${prefix}${String(next).padStart(3, '0')}`;
}
async function create(data) {
const pool = await getPool();
const code = await nextCode();
const r = pool.request();
r.input('code', sql.NVarChar, code);
r.input('name', sql.NVarChar, data.project_name);
r.input('cat', sql.NChar, data.project_category || 'B');
r.input('days', sql.Int, data.total_days || null);
r.input('init_dt', sql.Date, data.initiation_dt || null);
r.input('start_dt',sql.Date, data.est_start_dt || null);
r.input('end_dt', sql.Date, data.est_finish_dt || null);
r.input('purpose', sql.NVarChar, data.purpose || null);
r.input('roi', sql.NVarChar, data.roi || null);
r.input('labour', sql.Decimal, parseFloat(data.labour_cost) || 0);
r.input('material',sql.Decimal, parseFloat(data.material_cost) || 0);
r.input('consult', sql.Decimal, parseFloat(data.consultancy_fees) || 0);
r.input('promo', sql.Decimal, parseFloat(data.promotional_fees) || 0);
r.input('other', sql.Decimal, parseFloat(data.other_cost) || 0);
r.input('total', sql.Decimal, parseFloat(data.total_est_cost) || 0);
r.input('dept', sql.NVarChar, data.department_name || null);
r.input('pbname', sql.NVarChar, data.prepared_by_name || null);
r.input('pbdesig', sql.NVarChar, data.prepared_by_desig || null);
r.input('acct', sql.NVarChar, data.accountable_person || null);
r.input('team', sql.NVarChar, JSON.stringify(data.team_members || []));
r.input('status', sql.NVarChar, data.status || 'DRAFT');
r.input('subby', sql.NVarChar, data.submitted_by || null);
r.input('ptype', sql.NVarChar, data.project_type || 'NEW');
r.input('reqnew', sql.NVarChar, data.req_new_project || null);
r.input('requpg', sql.NVarChar, data.req_upgradation || null);
r.input('tcr', sql.NVarChar, data.time_cost_recovery || null);
r.input('rem2', sql.NVarChar, data.remarks_p2 || null);
// BIT flags — use explicit 0/1, never || null (0 is valid)
r.input('qa_app', sql.Bit, data.qa_applicable === 0 ? 0 : 1);
r.input('qc_app', sql.Bit, data.qc_applicable === 0 ? 0 : 1);
const res = await r.query(`
INSERT INTO project_requests (
project_code, project_name, project_category, total_days,
initiation_dt, est_start_dt, est_finish_dt, purpose, roi,
labour_cost, material_cost, consultancy_fees, promotional_fees,
other_cost, total_est_cost, department_name, prepared_by_name,
prepared_by_desig, accountable_person, team_members, status, submitted_by,
project_type, req_new_project, req_upgradation, time_cost_recovery, remarks_p2,
qa_applicable, qc_applicable
) VALUES (
@code,@name,@cat,@days,@init_dt,@start_dt,@end_dt,@purpose,@roi,
@labour,@material,@consult,@promo,@other,@total,
@dept,@pbname,@pbdesig,@acct,@team,@status,@subby,
@ptype,@reqnew,@requpg,@tcr,@rem2,
@qa_app,@qc_app
);
SELECT SCOPE_IDENTITY() AS id;
`);
return { id: res.recordset[0].id, project_code: code };
}
async function list({ status, search } = {}) {
const pool = await getPool();
const r = pool.request();
let q = `SELECT id, project_code, project_name, project_category, status,
department_name, prepared_by_name, est_start_dt, est_finish_dt,
total_est_cost, submitted_by, created_at
FROM project_requests WHERE 1=1`;
if (status) { r.input('st', sql.NVarChar, status); q += ' AND status=@st'; }
if (search) { r.input('s', sql.NVarChar, `%${search}%`); q += ' AND (project_name LIKE @s OR project_code LIKE @s)'; }
q += ' ORDER BY created_at DESC';
const res = await r.query(q);
return res.recordset || [];
}
async function findById(id) {
const pool = await getPool();
const res = await pool.request().input('id', sql.Int, parseInt(id))
.query('SELECT * FROM project_requests WHERE id=@id');
const row = res.recordset[0];
if (!row) return null;
try { row.team_members = JSON.parse(row.team_members || '[]'); } catch { row.team_members = []; }
try { row.attachments = JSON.parse(row.attachments || '[]'); } catch { row.attachments = []; }
return row;
}
async function update(id, data) {
const pool = await getPool();
const r = pool.request();
r.input('id', sql.Int, parseInt(id));
const sets = ['updated_at=GETDATE()'];
const map = {
project_name: ['nm', sql.NVarChar], project_category: ['cat', sql.NChar],
total_days: ['days',sql.Int], initiation_dt: ['idt', sql.Date],
est_start_dt: ['sdt', sql.Date], est_finish_dt: ['edt', sql.Date],
purpose: ['pur', sql.NVarChar], roi: ['roi', sql.NVarChar],
labour_cost: ['lab', sql.Decimal], material_cost: ['mat', sql.Decimal],
consultancy_fees: ['con', sql.Decimal], promotional_fees: ['pro', sql.Decimal],
other_cost: ['oth', sql.Decimal], total_est_cost: ['tot', sql.Decimal],
department_name: ['dep', sql.NVarChar], prepared_by_name: ['pbn', sql.NVarChar],
prepared_by_desig: ['pbd', sql.NVarChar], accountable_person: ['acc', sql.NVarChar],
status: ['sta', sql.NVarChar],
authorised_by: ['aub', sql.NVarChar], authorised_dt: ['aud', sql.Date],
comments: ['com', sql.NVarChar],
project_type: ['pty', sql.NVarChar], req_new_project: ['rnw', sql.NVarChar],
req_upgradation: ['rug', sql.NVarChar], time_cost_recovery: ['tcr', sql.NVarChar],
remarks_p2: ['rm2', sql.NVarChar],
// purchase
purchase_verified_dt: ['pvd', sql.Date], purchase_applicable: ['pap', sql.Bit],
// engineering
engineering_verified_dt: ['evd', sql.Date], engineering_applicable: ['eap', sql.Bit],
// qa
qa_verified_dt: ['qad', sql.Date], qa_applicable: ['qaa', sql.Bit],
// qc
qc_verified_dt: ['qcd', sql.Date], qc_applicable: ['qca', sql.Bit],
// legal
legal_verified_dt: ['lvd', sql.Date], legal_applicable: ['lap', sql.Bit],
// owner
owner_approved_dt: ['oad', sql.Date], owner_applicable: ['oap', sql.Bit],
// plant
plant_applicable: ['pla', sql.Bit],
// finance
finance_verified_dt: ['fvd', sql.Date], finance_verified_by: ['fvb', sql.NVarChar],
finance_applicable: ['fap', sql.Bit],
};
for (const [col, [param, type]] of Object.entries(map)) {
if (data[col] !== undefined) {
// Use ?? so 0/false are stored as-is (|| null would wrongly convert 0 → null for BIT columns)
const v = data[col];
r.input(param, type, (v !== null && v !== undefined && v !== '') ? v : null);
sets.push(`${col}=@${param}`);
}
}
if (data.team_members !== undefined) {
r.input('tm', sql.NVarChar, JSON.stringify(data.team_members));
sets.push('team_members=@tm');
}
if (data.attachments !== undefined) {
r.input('att', sql.NVarChar, JSON.stringify(data.attachments));
sets.push('attachments=@att');
}
await r.query(`UPDATE project_requests SET ${sets.join(',')} WHERE id=@id`);
}
// Approval step map: Purchase → Engineering → QA → QC → Legal → Owner → Plant → Finance
const STEP_MAP = [
{ key: 'purchase', from: 'PENDING', to: 'PURCHASE', dtCol: 'purchase_verified_dt', appCol: 'purchase_applicable' },
{ key: 'engineering', from: 'PURCHASE', to: 'ENGINEERING', dtCol: 'engineering_verified_dt', appCol: 'engineering_applicable' },
{ key: 'qa', from: 'ENGINEERING', to: 'QA_DONE', dtCol: 'qa_verified_dt', appCol: 'qa_applicable' },
{ key: 'qc', from: 'QA_DONE', to: 'QC_DONE', dtCol: 'qc_verified_dt', appCol: 'qc_applicable' },
{ key: 'legal', from: 'QC_DONE', to: 'LEGAL', dtCol: 'legal_verified_dt', appCol: 'legal_applicable' },
{ key: 'owner', from: 'LEGAL', to: 'OWNER', dtCol: 'owner_approved_dt', appCol: 'owner_applicable' },
{ key: 'plant', from: 'OWNER', to: 'PLANT', dtCol: 'authorised_dt', appCol: 'plant_applicable', nameCol: 'authorised_by' },
{ key: 'finance', from: 'PLANT', to: 'APPROVED', dtCol: 'finance_verified_dt', appCol: 'finance_applicable' },
];
async function stepApprove(id, { step, applicable, date, name }) {
const p = await findById(id);
if (!p) throw new Error('Project not found');
const s = STEP_MAP.find(m => m.key === step);
if (!s) throw new Error('Invalid approval step: ' + step);
if (p.status !== s.from) throw new Error(`Expected status ${s.from}, current is ${p.status}`);
const updates = { status: s.to };
if (applicable && date) updates[s.dtCol] = date;
if (!applicable && s.appCol) updates[s.appCol] = 0;
if (s.nameCol && name) updates[s.nameCol] = name;
await update(id, updates);
// Auto-advance through any consecutive steps pre-marked as N/A (e.g. QA/QC not applicable in team)
// Auto-advance through any consecutive steps pre-marked as N/A
for (let i = 0; i < STEP_MAP.length; i++) {
const current = await findById(id);
if (!current || current.status === 'APPROVED' || current.status === 'REJECTED') break;
const next = STEP_MAP.find(m => m.from === current.status);
if (!next) break;
// If no appCol, step is always required — stop
if (!next.appCol) break;
const appVal = current[next.appCol];
// SQL Server BIT returns as JS boolean (true/false) OR number (1/0).
// null/undefined = never explicitly set = treat as applicable, do NOT skip.
const isNotApplicable = (appVal === false || appVal === 0);
if (isNotApplicable) {
await update(id, { status: next.to });
} else {
break;
}
}
}
async function remove(id) {
const pool = await getPool();
await pool.request().input('id', sql.Int, parseInt(id))
.query('DELETE FROM project_requests WHERE id=@id');
}
module.exports = { bootstrap, create, list, findById, update, remove, stepApprove, nextCode };
+373
View File
@@ -0,0 +1,373 @@
// services/rejectionRegisterStore.js
// In-process rejection tracking for a manufacturing stage (e.g. EBB, Sheet
// Welding, Moulding) — a line/shift user counts rejections against a set of
// admin-configured causes for the batch they're inspecting, records what got
// reworked back to good, and QA can edit after submission. Two tables:
// ZREJECTION_CAUSES (admin-maintained, per-stage, each with a root cause
// roll-up) and ZREJECTION_ENTRIES (one row per batch/stage counted).
const sql = require('mssql');
const CAUSES_TABLE = `[dbo].[ZREJECTION_CAUSES]`;
const ENTRIES_TABLE = `[dbo].[ZREJECTION_ENTRIES]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
function nowTs() { return new Date().toISOString().replace('T', ' ').substring(0, 23); }
function safeJson(v, f) { if (!v) return f; try { return JSON.parse(v); } catch { return f; } }
async function bootstrap() {
console.log('[REJECTION-REGISTER] Checking tables...');
await exec(`
CREATE TABLE ${CAUSES_TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
STAGE NVARCHAR(50) NOT NULL,
NAME NVARCHAR(150) NOT NULL,
ROOT_CAUSE NVARCHAR(150),
ACTIVE BIT DEFAULT 1,
COMPANY NVARCHAR(60),
CREATED_BY NVARCHAR(50),
CREATED_AT DATETIME2
)
`).catch(e => { if (!isAlreadyExists(e)) throw e; });
await exec(`
CREATE TABLE ${ENTRIES_TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
BATCH_NO NVARCHAR(60) NOT NULL,
PRODUCT_CODE NVARCHAR(60),
PRODUCT_NAME NVARCHAR(200),
STAGE NVARCHAR(50) NOT NULL,
SHIFT NVARCHAR(50),
PROD_DATE NVARCHAR(20),
BATCH_SIZE FLOAT,
CAUSES NVARCHAR(MAX),
REMARKS NVARCHAR(400),
NET_QTY FLOAT DEFAULT 0,
GROSS_QTY FLOAT DEFAULT 0,
NET_PCT FLOAT DEFAULT 0,
STATUS NVARCHAR(20) DEFAULT 'SUBMITTED',
COMPANY NVARCHAR(60),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_BY NVARCHAR(50),
UPDATED_NAME NVARCHAR(100),
UPDATED_AT DATETIME2,
IS_DELETED BIT DEFAULT 0
)
`).catch(e => { if (!isAlreadyExists(e)) throw e; });
// Set at creation when the entry's Stage had ZERO active causes at that
// moment (the Entry wizard's free-text fallback screen) — flags it for
// QA follow-up: add causes for that stage, then go back and code this
// entry properly. See createEntry()/pendingCauseSetupSummary() below.
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZREJECTION_ENTRIES' AND COLUMN_NAME='NEEDS_CAUSE_SETUP') ALTER TABLE ${ENTRIES_TABLE} ADD [NEEDS_CAUSE_SETUP] BIT DEFAULT 0`).catch(() => {});
console.log('[REJECTION-REGISTER] ✅ Ready');
}
// ── Causes (admin/QA-maintained, per stage) ────────────────────────────
function causeFromRow(row) {
return {
id: row.ID,
stage: row.STAGE || '',
name: row.NAME || '',
rootCause: row.ROOT_CAUSE || '',
active: !!row.ACTIVE,
company: row.COMPANY || '',
createdBy: row.CREATED_BY || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
};
}
async function listCauses({ stage, company, includeInactive } = {}) {
const rows = await exec(`SELECT * FROM ${CAUSES_TABLE} ORDER BY STAGE, NAME`);
return rows.map(causeFromRow).filter(c => {
if (!includeInactive && !c.active) return false;
if (stage && c.stage !== stage) return false;
if (company && c.company && c.company !== company) return false;
return true;
});
}
async function createCause({ stage, name, rootCause, company, createdBy }) {
if (!stage || !String(stage).trim()) throw new Error('Stage is required');
if (!name || !String(name).trim()) throw new Error('Cause name is required');
const idRows = await exec(`
INSERT INTO ${CAUSES_TABLE} (STAGE, NAME, ROOT_CAUSE, ACTIVE, COMPANY, CREATED_BY, CREATED_AT)
VALUES (?,?,?,1,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [String(stage).trim(), String(name).trim(), rootCause || '', company || '', createdBy || '', nowTs()]);
const rows = await exec(`SELECT * FROM ${CAUSES_TABLE} WHERE ID=?`, [idRows[0].ID]);
return causeFromRow(rows[0]);
}
async function updateCause(id, { name, rootCause, active }) {
const sets = [], vals = [];
if (name !== undefined) { sets.push('NAME=?'); vals.push(String(name).trim()); }
if (rootCause !== undefined) { sets.push('ROOT_CAUSE=?'); vals.push(rootCause || ''); }
if (active !== undefined) { sets.push('ACTIVE=?'); vals.push(active ? 1 : 0); }
if (!sets.length) return findCauseById(id);
vals.push(parseInt(id));
await exec(`UPDATE ${CAUSES_TABLE} SET ${sets.join(', ')} WHERE ID=?`, vals);
return findCauseById(id);
}
async function findCauseById(id) {
const rows = await exec(`SELECT * FROM ${CAUSES_TABLE} WHERE ID=?`, [parseInt(id)]);
return rows.length ? causeFromRow(rows[0]) : null;
}
async function deleteCause(id) {
await exec(`DELETE FROM ${CAUSES_TABLE} WHERE ID=?`, [parseInt(id)]);
}
// ── Entries (one per batch/stage counted) ──────────────────────────────
function entryFromRow(row) {
const causes = safeJson(row.CAUSES, []);
return {
id: row.ID,
batchNo: row.BATCH_NO || '',
productCode: row.PRODUCT_CODE || '',
productName: row.PRODUCT_NAME || '',
stage: row.STAGE || '',
shift: row.SHIFT || '',
prodDate: row.PROD_DATE || '',
batchSize: row.BATCH_SIZE || 0,
causes,
remarks: row.REMARKS || '',
netQty: row.NET_QTY || 0,
grossQty: row.GROSS_QTY || 0,
netPct: row.NET_PCT || 0,
status: row.STATUS || 'SUBMITTED',
company: row.COMPANY || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedBy: row.UPDATED_BY || '',
updatedByName: row.UPDATED_NAME || '',
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
isDeleted: !!row.IS_DELETED,
needsCauseSetup: !!row.NEEDS_CAUSE_SETUP,
};
}
// Derives gross/net qty + net% from a causes array — kept server-side so a
// client can never fake the totals it submits.
// "Okay after rework" is the OUTCOME of the pieces that were "Sent for
// rework" — a SUBSET of it, not a separate bucket alongside it. The real
// constraint is Okay after rework ≤ Sent for rework ≤ Rejected (checking
// "Rework + OK ≤ Rejected" instead would double-count and wrongly reject
// the legitimate all-recovered case: Rejected=10, Rework=10, OK=10 is
// perfectly valid — Net comes out to exactly 0, everything accounted for).
// The client already clamps this in the UI, but never trust that alone; a
// hand-crafted request could still send a bad value.
// `batchSize` is optional — pass it whenever it's known (createEntry/
// updateEntry both do) to also enforce the total-rejected-vs-batch-size
// ceiling; the client already clamps this in the UI, but never trust that
// alone.
// Rejected and Sent for rework are now TWO INDEPENDENT piles pulled from
// the same batch (both entered at Count/Step 2, not one nested inside the
// other) — so there's no Rework ≤ Rejected rule anymore; instead the two
// piles TOGETHER can never exceed the Batch Size. Okay after rework IS a
// subset of Sent for rework specifically (Okay after Rework ≤ Sent for
// Rework).
function validateCauses(causes, batchSize) {
let totalPulled = 0;
(causes || []).forEach(c => {
const rejected = parseFloat(c.rejected) || 0;
const rework = parseFloat(c.rework) || 0;
const ok = parseFloat(c.ok) || 0;
if (ok > rework) throw new Error(`"${c.name}": Okay after rework (${ok}) can't exceed Sent for rework (${rework})`);
totalPulled += rejected + rework;
});
const size = parseFloat(batchSize) || 0;
if (size > 0 && totalPulled > size) throw new Error(`Rejected + Sent for rework across all causes (${totalPulled}) can't exceed Batch Size (${size})`);
}
// Reject after rework = Sent for rework − Okay after rework (whatever
// didn't recover). Net = Rejected + Reject after rework — the
// outright-rejected pile PLUS whatever the (separate) rework pile still
// lost, added together since they're independent piles pulled from the
// same batch. E.g. Rejected=10, Rework=8, OK=8 → Net=10 (all 8 reworked
// pieces recovered, but the original 10 rejects still stand); Rejected=10,
// Rework=8, OK=5 → Net=13 (10 rejected + 3 that failed rework).
function computeTotals(causes, batchSize) {
const gross = (causes || []).reduce((s, c) => s + (parseFloat(c.rejected) || 0), 0);
const rework = (causes || []).reduce((s, c) => s + (parseFloat(c.rework) || 0), 0);
const ok = (causes || []).reduce((s, c) => s + (parseFloat(c.ok) || 0), 0);
const net = gross + (rework - ok);
const size = parseFloat(batchSize) || 0;
const pct = size ? Math.round((net / size) * 1000) / 10 : 0;
return { gross, net, pct };
}
async function listEntries({ company, stage, from, to, includeDeleted } = {}) {
const rows = await exec(`SELECT * FROM ${ENTRIES_TABLE} ORDER BY CREATED_AT DESC`);
return rows.map(entryFromRow).filter(e => {
if (!includeDeleted && e.isDeleted) return false;
if (company && e.company && e.company !== company) return false;
if (stage && e.stage !== stage) return false;
if (from && e.prodDate && e.prodDate < from) return false;
if (to && e.prodDate && e.prodDate > to) return false;
return true;
});
}
async function findEntryById(id) {
const rows = await exec(`SELECT * FROM ${ENTRIES_TABLE} WHERE ID=?`, [parseInt(id)]);
return rows.length ? entryFromRow(rows[0]) : null;
}
async function createEntry(p) {
if (!p.batchNo || !String(p.batchNo).trim()) throw new Error('Batch number is required');
if (!p.stage) throw new Error('Stage is required');
validateCauses(p.causes, p.batchSize);
const { gross, net, pct } = computeTotals(p.causes, p.batchSize);
// Flags this entry as needing QA follow-up in either of two cases —
// computed here, never trusted from the client, so it can't be spoofed
// either way:
// 1. The Stage had zero ACTIVE causes set up at submission (Entry
// wizard's free-text fallback screen), OR
// 2. Real causes DID exist, but the operator used the fixed "Other /
// Unknown" sentinel bucket for some/all of the qty because they
// didn't know (or it wasn't) one of the listed ones — see
// public/rejection-register.html's stepCount().
const activeCauses = await listCauses({ stage: p.stage, company: p.company });
const usedOtherBucket = (p.causes || []).some(c => c.name === 'Other / Unknown' && (parseFloat(c.rejected) || 0) > 0);
const needsCauseSetup = activeCauses.length === 0 || usedOtherBucket;
const now = nowTs();
const idRows = await exec(`
INSERT INTO ${ENTRIES_TABLE} (
BATCH_NO, PRODUCT_CODE, PRODUCT_NAME, STAGE, SHIFT, PROD_DATE, BATCH_SIZE,
CAUSES, REMARKS, NET_QTY, GROSS_QTY, NET_PCT, STATUS, COMPANY,
CREATED_BY, CREATED_NAME, CREATED_AT, NEEDS_CAUSE_SETUP
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
String(p.batchNo).trim(), p.productCode || '', p.productName || '', p.stage, p.shift || '', p.prodDate || '', parseFloat(p.batchSize) || 0,
JSON.stringify(p.causes || []), p.remarks || '', net, gross, pct, 'SUBMITTED', p.company || '',
p.createdBy || '', p.createdByName || p.createdBy || '', now, needsCauseSetup ? 1 : 0,
]);
return findEntryById(idRows[0].ID);
}
// QA-only edit after submission — recomputes totals from whatever causes
// array is passed (never trusts a client-sent total).
async function updateEntry(id, p, { by, byName } = {}) {
const existing = await findEntryById(id);
if (!existing) throw new Error('Entry not found');
const causes = p.causes !== undefined ? p.causes : existing.causes;
const stage = p.stage !== undefined ? p.stage : existing.stage;
const batchSize = p.batchSize !== undefined ? p.batchSize : existing.batchSize;
validateCauses(causes, batchSize);
const { gross, net, pct } = computeTotals(causes, batchSize);
// Re-coding the causes only clears the flag if it's ACTUALLY resolved now
// (same check as createEntry() — no leftover "Other / Unknown" qty, and
// the stage has real active causes) — saving the modal without actually
// moving the Other/Unknown qty into a real cause must NOT silently mark
// this as handled. An edit that doesn't touch causes at all (Shift/Date/
// Remarks only) leaves the flag exactly as it was.
let needsCauseSetup = existing.needsCauseSetup;
if (p.causes !== undefined) {
const activeCauses = await listCauses({ stage, company: existing.company });
const usedOtherBucket = (causes || []).some(c => c.name === 'Other / Unknown' && (parseFloat(c.rejected) || 0) > 0);
needsCauseSetup = activeCauses.length === 0 || usedOtherBucket;
}
await exec(`
UPDATE ${ENTRIES_TABLE} SET
PRODUCT_CODE=?, PRODUCT_NAME=?, SHIFT=?, PROD_DATE=?, BATCH_SIZE=?,
CAUSES=?, REMARKS=?, NET_QTY=?, GROSS_QTY=?, NET_PCT=?, STATUS='QA_EDITED',
NEEDS_CAUSE_SETUP=?, UPDATED_BY=?, UPDATED_NAME=?, UPDATED_AT=?
WHERE ID=?
`, [
p.productCode !== undefined ? p.productCode : existing.productCode,
p.productName !== undefined ? p.productName : existing.productName,
p.shift !== undefined ? p.shift : existing.shift,
p.prodDate !== undefined ? p.prodDate : existing.prodDate,
parseFloat(batchSize) || 0,
JSON.stringify(causes || []), p.remarks !== undefined ? p.remarks : existing.remarks,
net, gross, pct,
needsCauseSetup ? 1 : 0, by || '', byName || by || '', nowTs(),
parseInt(id),
]);
return findEntryById(id);
}
async function softDeleteEntry(id) {
await exec(`UPDATE ${ENTRIES_TABLE} SET IS_DELETED=1 WHERE ID=?`, [parseInt(id)]);
}
// ── Analytics — aggregated over a filtered entry set ───────────────────
// Everything here is derived straight from listEntries()'s own filtering
// (company/stage/date range), so the caller decides scope; this just does
// the rollup math once instead of repeating it per screen.
function analyticsFor(entries) {
const totalGross = entries.reduce((s, e) => s + (e.grossQty || 0), 0);
const totalNet = entries.reduce((s, e) => s + (e.netQty || 0), 0);
const totalSize = entries.reduce((s, e) => s + (e.batchSize || 0), 0);
const avgPct = totalSize ? Math.round((totalNet / totalSize) * 1000) / 10 : 0;
const causeAgg = {};
entries.forEach(e => (e.causes || []).forEach(c => {
// Same per-cause formula as computeTotals()'s batch-level Net: rejected
// + (rework − ok), the two independent piles added together.
const net = (parseFloat(c.rejected) || 0) + ((parseFloat(c.rework) || 0) - (parseFloat(c.ok) || 0));
if (!c.name) return;
causeAgg[c.name] = (causeAgg[c.name] || 0) + net;
}));
const topCauses = Object.entries(causeAgg).map(([name, qty]) => ({ name, qty })).sort((a, b) => b.qty - a.qty);
// Daily trend — date -> {gross,net,size}
const byDate = {};
entries.forEach(e => {
const d = e.prodDate || (e.createdAt || '').slice(0, 10);
if (!byDate[d]) byDate[d] = { date: d, gross: 0, net: 0, size: 0 };
byDate[d].gross += e.grossQty || 0;
byDate[d].net += e.netQty || 0;
byDate[d].size += e.batchSize || 0;
});
const trend = Object.values(byDate).sort((a, b) => (a.date > b.date ? 1 : -1)).map(d => ({
date: d.date, grossPct: d.size ? Math.round((d.gross / d.size) * 1000) / 10 : 0, netPct: d.size ? Math.round((d.net / d.size) * 1000) / 10 : 0,
}));
return { totalGross, totalNet, totalSize, avgPct, savedByRework: totalGross - totalNet, topCauses, trend, batchCount: entries.length };
}
// Every entry flagged NEEDS_CAUSE_SETUP at creation (free-text fallback),
// grouped by Stage — what Rejection Analytics → Root Cause Setup's "Needs
// Causes" card shows QA. Not auto-cleared once causes are added for that
// stage (see routes/rejectionRegister.js's PUT /entries/:id — recoding the
// entry itself clears its own flag; adding a cause for the stage doesn't
// retroactively touch older already-submitted entries).
// Grouped by Stage, each with the actual pending entries (not just a count)
// — who recorded each one (createdByName) and when, so QA knows both WHO to
// follow up with and can jump straight to fixing it (the client pairs this
// with PUT /entries/:id to actually re-code the causes).
async function pendingCauseSetupSummary({ company } = {}) {
const entries = await listEntries({ company });
const byStage = {};
entries.filter(e => e.needsCauseSetup).forEach(e => {
if (!byStage[e.stage]) byStage[e.stage] = { stage: e.stage, count: 0, latestAt: e.createdAt, entries: [] };
byStage[e.stage].count++;
byStage[e.stage].entries.push({ id: e.id, batchNo: e.batchNo, createdBy: e.createdBy, createdByName: e.createdByName, createdAt: e.createdAt });
if (e.createdAt && (!byStage[e.stage].latestAt || e.createdAt > byStage[e.stage].latestAt)) byStage[e.stage].latestAt = e.createdAt;
});
Object.values(byStage).forEach(s => s.entries.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)));
return Object.values(byStage).sort((a, b) => b.count - a.count);
}
module.exports = {
bootstrap,
listCauses, createCause, updateCause, findCauseById, deleteCause,
listEntries, findEntryById, createEntry, updateEntry, softDeleteEntry,
analyticsFor, pendingCauseSetupSummary,
};
+114
View File
@@ -0,0 +1,114 @@
// services/reqCalcGroupStore.js
// Saved "Item Groups" for the Requirement Calculator (public/requirements.html
// Calculator tab) — a user-defined preset list of item codes (e.g. "All Blood
// Bag Items") that can be picked to fetch all its items into the calculator
// worksheet at once, instead of searching and adding items one by one.
// Shared company-wide: every user with Requirements access can see and use
// any group. Only the creator or an admin may edit/delete one.
'use strict';
const sql = require('mssql');
const TABLE = `[dbo].[ZREQCALC_GROUPS]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[REQCALC-GROUPS] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
NAME NVARCHAR(150) NOT NULL,
ITEMS NVARCHAR(MAX),
COMPANY NVARCHAR(60),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_AT DATETIME2
)
`).catch(e => { if (isAlreadyExists(e)) { console.log('[REQCALC-GROUPS] Table exists — OK'); } else throw e; });
console.log('[REQCALC-GROUPS] ✅ Ready');
}
function toTs(d) { return d.toISOString().replace('T', ' ').replace('Z', '').substring(0, 23); }
function safeJson(val, fallback) { if (!val) return fallback; try { return JSON.parse(val); } catch { return fallback; } }
function fromRow(row) {
if (!row) return null;
return {
id: row.ID,
name: row.NAME || '',
items: safeJson(row.ITEMS, []), // [{itemCode,itemName}]
company: row.COMPANY || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
};
}
// Shared company-wide — every group for the given company, regardless of creator.
async function listGroups(company) {
const rows = await exec(
`SELECT * FROM ${TABLE} ${company ? 'WHERE COMPANY = ?' : ''} ORDER BY NAME ASC`,
company ? [company] : []
);
return rows.map(fromRow);
}
async function getGroup(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [id]);
return fromRow(rows[0]);
}
async function createGroup(g) {
const now = toTs(new Date());
const rows = await exec(
`INSERT INTO ${TABLE} (NAME, ITEMS, COMPANY, CREATED_BY, CREATED_NAME, CREATED_AT, UPDATED_AT)
OUTPUT INSERTED.*
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[g.name, JSON.stringify(g.items || []), g.company || '', g.createdBy || '', g.createdByName || '', now, now]
);
return fromRow(rows[0]);
}
async function updateGroup(id, g) {
const now = toTs(new Date());
const rows = await exec(
`UPDATE ${TABLE} SET NAME = ?, ITEMS = ?, UPDATED_AT = ? OUTPUT INSERTED.* WHERE ID = ?`,
[g.name, JSON.stringify(g.items || []), now, id]
);
return fromRow(rows[0]);
}
async function deleteGroup(id) {
await exec(`DELETE FROM ${TABLE} WHERE ID = ?`, [id]);
}
module.exports = { bootstrap, listGroups, getGroup, createGroup, updateGroup, deleteGroup };
+255
View File
@@ -0,0 +1,255 @@
// services/requirementStore.js
// Stores logistics "Requirements Management" entries in SQL: ZREQUIREMENTS
// Ref No format: REQ-{MM}-{YY}-{NNNN} (NNNN = per month-year running number)
const sql = require('mssql');
const TABLE = `[dbo].[ZREQUIREMENTS]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
const config = {
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
};
_conn = await sql.connect(config);
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (match, offset, string) => {
const paramIndex = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${paramIndex}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[REQ-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
REF_NO NVARCHAR(30) NOT NULL,
PERIOD_FROM DATE,
PERIOD_TO DATE,
LINES NVARCHAR(MAX),
STATUS NVARCHAR(30) DEFAULT 'OPEN',
REMARKS NVARCHAR(MAX),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_AT DATETIME2,
IS_DELETED BIT DEFAULT 0,
COMPANY NVARCHAR(60)
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[REQ-STORE] Table exists — OK'); }
else throw e;
});
// Migrations for existing tables
await exec(`
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZREQUIREMENTS' AND COLUMN_NAME='IS_DELETED')
ALTER TABLE ${TABLE} ADD IS_DELETED BIT DEFAULT 0
`).catch(e => console.log('[REQ-STORE] IS_DELETED migration:', e.message));
await exec(`
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZREQUIREMENTS' AND COLUMN_NAME='UPDATED_AT')
ALTER TABLE ${TABLE} ADD UPDATED_AT DATETIME2
`).catch(e => console.log('[REQ-STORE] UPDATED_AT migration:', e.message));
// Production ↔ Store review loop (Admin → System Settings → "Requirement
// — Store Review Workflow", optional/off by default): Production shares a
// Requirement with Store, Store cross-checks it against SAP's own MRP
// Wizard output and edits the line items accordingly, then reverts it back
// to Production — who then raises the Batch Intimation as normal.
// REVIEW_STAGE: 0 = not shared yet (or feature unused), 1 = shared with
// Store (pending their review), 2 = Store reverted — done, single round
// trip only (no back-and-forth). ORIGINAL_LINES snapshots what LINES
// looked like at the moment it was shared, so Production can see exactly
// what Store changed ("before/after") once it comes back.
const addCol = (col, ddl) => exec(`
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZREQUIREMENTS' AND COLUMN_NAME='${col}')
ALTER TABLE ${TABLE} ADD ${ddl}
`).catch(e => console.log(`[REQ-STORE] ${col} migration:`, e.message));
await addCol('REVIEW_STAGE', 'REVIEW_STAGE INT DEFAULT 0');
await addCol('ORIGINAL_LINES', 'ORIGINAL_LINES NVARCHAR(MAX)');
await addCol('SHARED_BY', 'SHARED_BY NVARCHAR(50)');
await addCol('SHARED_NAME', 'SHARED_NAME NVARCHAR(100)');
await addCol('SHARED_AT', 'SHARED_AT DATETIME2');
await addCol('REVIEWED_BY', 'REVIEWED_BY NVARCHAR(50)');
await addCol('REVIEWED_NAME', 'REVIEWED_NAME NVARCHAR(100)');
await addCol('REVIEWED_AT', 'REVIEWED_AT DATETIME2');
await addCol('REVIEW_REMARKS', 'REVIEW_REMARKS NVARCHAR(MAX)');
console.log('[REQ-STORE] ✅ Ready');
}
function toTs(isoStr) {
if (!isoStr) return null;
return isoStr.replace('T', ' ').replace('Z', '').substring(0, 23);
}
function safeJson(val, fallback) {
if (!val) return fallback;
try { return JSON.parse(val); } catch { return fallback; }
}
function fromRow(row) {
if (!row) return null;
return {
id: row.ID,
refNo: row.REF_NO || '',
periodFrom: row.PERIOD_FROM ? new Date(row.PERIOD_FROM).toISOString().slice(0, 10) : null,
periodTo: row.PERIOD_TO ? new Date(row.PERIOD_TO).toISOString().slice(0, 10) : null,
lines: safeJson(row.LINES, []),
status: row.STATUS || 'OPEN',
remarks: row.REMARKS || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
isDeleted: !!row.IS_DELETED,
company: row.COMPANY || '',
reviewStage: row.REVIEW_STAGE != null ? row.REVIEW_STAGE : 0,
originalLines: safeJson(row.ORIGINAL_LINES, null),
sharedBy: row.SHARED_BY || '',
sharedByName: row.SHARED_NAME || '',
sharedAt: row.SHARED_AT ? new Date(row.SHARED_AT).toISOString() : null,
reviewedBy: row.REVIEWED_BY || '',
reviewedByName: row.REVIEWED_NAME || '',
reviewedAt: row.REVIEWED_AT ? new Date(row.REVIEWED_AT).toISOString() : null,
reviewRemarks: row.REVIEW_REMARKS || '',
};
}
// Generate REQ-MM-YY-NNNN — NNNN is a running number within the current month/year.
async function generateRefNo(company) {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const yy = String(now.getFullYear()).slice(-2);
const prefix = `REQ-${mm}-${yy}-`;
const rows = await exec(
`SELECT REF_NO FROM ${TABLE} WHERE REF_NO LIKE ? ${company ? 'AND COMPANY = ?' : ''}`,
company ? [prefix + '%', company] : [prefix + '%']
);
let max = 0;
rows.forEach(r => {
const n = parseInt((r.REF_NO || '').slice(prefix.length), 10);
if (!isNaN(n) && n > max) max = n;
});
const next = String(max + 1).padStart(4, '0');
return prefix + next;
}
async function insertRequirement(r) {
const now = toTs(new Date().toISOString());
const refNo = await generateRefNo(r.company);
// INSERT and SELECT SCOPE_IDENTITY() must be one batch — as two separate
// exec() calls, a pooled connection can route the second one to a
// DIFFERENT physical connection than the one that just inserted, where
// SCOPE_IDENTITY() correctly returns NULL (see workOrderStore.js's
// insertWorkOrder() for the full write-up of this bug class).
const idRows = await exec(`
INSERT INTO ${TABLE} (
REF_NO, PERIOD_FROM, PERIOD_TO, LINES, STATUS, REMARKS,
CREATED_BY, CREATED_NAME, CREATED_AT, COMPANY
) VALUES (?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
refNo,
r.periodFrom || null,
r.periodTo || null,
JSON.stringify(r.lines || []),
'OPEN',
r.remarks || '',
r.createdBy,
r.createdByName || r.createdBy,
now,
r.company || '',
]);
const id = idRows[0].ID;
return { id, refNo, ...r, status: 'OPEN', createdAt: new Date().toISOString() };
}
async function listRequirements({ mine, company, status, includeDeleted } = {}) {
const all = await exec(`SELECT * FROM ${TABLE} ORDER BY CREATED_AT DESC`);
return all.map(fromRow).filter(r => {
if (!includeDeleted && r.isDeleted) return false;
if (status && status !== 'ALL' && r.status !== status.toUpperCase()) return false;
if (mine && r.createdBy !== mine) return false;
if (company && r.company && r.company !== company) return false;
return true;
});
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0]) : null;
}
async function updateRequirement(id, patch) {
const now = toTs(new Date().toISOString());
await exec(`
UPDATE ${TABLE}
SET PERIOD_FROM = ?, PERIOD_TO = ?, LINES = ?, REMARKS = ?, UPDATED_AT = ?
WHERE ID = ? AND (IS_DELETED = 0 OR IS_DELETED IS NULL)
`, [
patch.periodFrom || null,
patch.periodTo || null,
JSON.stringify(patch.lines || []),
patch.remarks || '',
now,
parseInt(id),
]);
return findById(id);
}
async function updateStatus(id, status) {
await exec(`UPDATE ${TABLE} SET STATUS = ? WHERE ID = ?`, [String(status).toUpperCase(), parseInt(id)]);
}
async function softDelete(id) {
await exec(`UPDATE ${TABLE} SET IS_DELETED = 1, UPDATED_AT = ? WHERE ID = ?`,
[toTs(new Date().toISOString()), parseInt(id)]);
}
// Production → Store: snapshots the CURRENT lines into ORIGINAL_LINES (the
// "before" for the eventual before/after view) and moves to stage 1.
async function shareWithStore(id, { by, byName }) {
const now = toTs(new Date().toISOString());
const req = await findById(id);
if (!req) throw new Error('Requirement not found');
await exec(`
UPDATE ${TABLE} SET REVIEW_STAGE = 1, ORIGINAL_LINES = ?, SHARED_BY = ?, SHARED_NAME = ?, SHARED_AT = ?, UPDATED_AT = ?
WHERE ID = ?
`, [JSON.stringify(req.lines || []), by || '', byName || by || '', now, now, parseInt(id)]);
return findById(id);
}
// Store → Production: overwrites LINES with Store's edited version and moves
// to stage 2 (done — single round trip, no further back-and-forth).
async function revertToProduction(id, { by, byName, lines, remarks }) {
const now = toTs(new Date().toISOString());
await exec(`
UPDATE ${TABLE} SET REVIEW_STAGE = 2, LINES = ?, REVIEWED_BY = ?, REVIEWED_NAME = ?, REVIEWED_AT = ?, REVIEW_REMARKS = ?, UPDATED_AT = ?
WHERE ID = ?
`, [JSON.stringify(lines || []), by || '', byName || by || '', now, remarks || '', now, parseInt(id)]);
return findById(id);
}
module.exports = {
bootstrap, generateRefNo, insertRequirement,
listRequirements, findById, updateRequirement, updateStatus, softDelete,
shareWithStore, revertToProduction,
};
+135
View File
@@ -0,0 +1,135 @@
// services/rmOvgSettingsStore.js
// Admin-configured Raw Material "Ovg%" override — maps an item code and/or
// its SAP item group to a default overage percentage. Used by work-order.html
// to pre-fill a raw material row's Ovg. cell and to recompute "Qty Req./100 ml"
// as (BOM value) − (BOM value × Ovg%/100) instead of the plain BOM figure,
// for any item this resolves a match for. See [[work-order-qty-req-two-way-sync]]
// for the existing Raw Material calc model this feature layers on top of.
const sql = require('mssql');
const TABLE = `[dbo].[ZRM_OVG_SETTINGS]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[RM-OVG-SETTINGS] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
ITEM_CODE NVARCHAR(60) NULL,
ITEM_GROUP INT NULL,
OVG_PERCENT FLOAT NOT NULL,
CREATED_BY NVARCHAR(50),
CREATED_AT DATETIME2,
UPDATED_BY NVARCHAR(50),
UPDATED_AT DATETIME2
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[RM-OVG-SETTINGS] Table exists — OK'); }
else throw e;
});
console.log('[RM-OVG-SETTINGS] ✅ Ready');
}
function fromRow(row) {
return {
id: row.ID,
itemCode: row.ITEM_CODE || '',
itemGroup: row.ITEM_GROUP != null ? row.ITEM_GROUP : null,
ovgPercent: row.OVG_PERCENT,
createdBy: row.CREATED_BY || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedBy: row.UPDATED_BY || '',
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
};
}
async function listAll() {
const rows = await exec(`SELECT * FROM ${TABLE} ORDER BY ITEM_CODE, ITEM_GROUP`);
return rows.map(fromRow);
}
async function create({ itemCode, itemGroup, ovgPercent, by }) {
const code = (itemCode || '').trim().toUpperCase() || null;
const group = itemGroup != null && itemGroup !== '' ? parseInt(itemGroup) : null;
if (!code && group == null) throw new Error('Provide an Item Code and/or Item Group');
const pct = parseFloat(ovgPercent);
if (isNaN(pct)) throw new Error('Ovg% must be a number');
const now = new Date().toISOString();
const idRows = await exec(`
INSERT INTO ${TABLE} (ITEM_CODE, ITEM_GROUP, OVG_PERCENT, CREATED_BY, CREATED_AT, UPDATED_BY, UPDATED_AT)
VALUES (?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [code, group, pct, by || '', now.replace('T', ' ').substring(0, 23), by || '', now.replace('T', ' ').substring(0, 23)]);
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [idRows[0].ID]);
return fromRow(rows[0]);
}
async function update(id, { itemCode, itemGroup, ovgPercent, by }) {
const code = (itemCode || '').trim().toUpperCase() || null;
const group = itemGroup != null && itemGroup !== '' ? parseInt(itemGroup) : null;
if (!code && group == null) throw new Error('Provide an Item Code and/or Item Group');
const pct = parseFloat(ovgPercent);
if (isNaN(pct)) throw new Error('Ovg% must be a number');
const now = new Date().toISOString().replace('T', ' ').substring(0, 23);
await exec(`UPDATE ${TABLE} SET ITEM_CODE=?, ITEM_GROUP=?, OVG_PERCENT=?, UPDATED_BY=?, UPDATED_AT=? WHERE ID=?`,
[code, group, pct, by || '', now, parseInt(id)]);
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
if (!rows.length) throw new Error('Not found');
return fromRow(rows[0]);
}
async function remove(id) {
await exec(`DELETE FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
}
// Resolves an Ovg% for each item code, given that item's own SAP item group
// (caller looks the group up from OITM — this store has no SAP connection of
// its own). Item-code-specific rows win over item-group rows for the same
// item. Returns { itemCode: percent } — items with no match are simply
// absent from the result (never a 0 entry, since 0 IS a valid configured %).
async function resolveForItems(itemCodesUpper, groupByItemCode) {
const rows = await listAll();
const byCode = {};
const byGroup = {};
rows.forEach(r => {
if (r.itemCode) byCode[r.itemCode] = r.ovgPercent;
else if (r.itemGroup != null) byGroup[r.itemGroup] = r.ovgPercent;
});
const out = {};
itemCodesUpper.forEach(code => {
if (byCode[code] != null) { out[code] = byCode[code]; return; }
const grp = groupByItemCode[code];
if (grp != null && byGroup[grp] != null) out[code] = byGroup[grp];
});
return out;
}
module.exports = { bootstrap, listAll, create, update, remove, resolveForItems };
+258
View File
@@ -0,0 +1,258 @@
'use strict';
const { getPool } = require('./appSqlPool');
const COST_CENTRES = [
'Administration overhead', 'BB & CAPD', 'BLOOD BAG', 'Boiler', 'CAPD', 'Diff', 'Equipment',
'FACTORY OVERHEAD', 'POWER & FUEL', 'QA', 'QC', 'R&D',
'Selling & distribution overhead', 'STENT',
];
async function bootstrap() {
const pool = await getPool();
await pool.request().query(`
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME='salary_uploads')
CREATE TABLE salary_uploads (
id INT IDENTITY(1,1) PRIMARY KEY,
period VARCHAR(7) NOT NULL,
sheet_name NVARCHAR(100) NOT NULL,
sr_no NVARCHAR(20) DEFAULT '',
emp_code NVARCHAR(50) DEFAULT '',
emp_name NVARCHAR(200) DEFAULT '',
department NVARCHAR(100) DEFAULT '',
gross_amt DECIMAL(15,2) DEFAULT 0,
net_pay DECIMAL(15,2) DEFAULT 0,
uploaded_at DATETIME DEFAULT GETDATE()
)`);
await pool.request().query(`
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME='salary_cost_config')
CREATE TABLE salary_cost_config (
id INT IDENTITY(1,1) PRIMARY KEY,
sheet_name NVARCHAR(100) NOT NULL,
cost_centre NVARCHAR(100) NOT NULL,
alloc_pct DECIMAL(7,4) NOT NULL DEFAULT 0,
updated_at DATETIME DEFAULT GETDATE(),
CONSTRAINT uq_sal_cfg UNIQUE (sheet_name, cost_centre)
)`);
await pool.request().query(`
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME='salary_bbcapd_ratio')
CREATE TABLE salary_bbcapd_ratio (
id INT IDENTITY(1,1) PRIMARY KEY,
sheet_name NVARCHAR(100) NOT NULL UNIQUE,
bb_pct DECIMAL(7,4) NOT NULL DEFAULT 70,
capd_pct DECIMAL(7,4) NOT NULL DEFAULT 30,
updated_at DATETIME DEFAULT GETDATE()
)`);
await pool.request().query(`
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME='salary_dept_mapping')
CREATE TABLE salary_dept_mapping (
id INT IDENTITY(1,1) PRIMARY KEY,
dept_name NVARCHAR(200) NOT NULL UNIQUE,
cost_centre NVARCHAR(100) NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT GETDATE()
)`);
}
async function clearPeriod(period) {
const pool = await getPool();
await pool.request().input('p', period)
.query(`DELETE FROM salary_uploads WHERE period=@p`);
}
async function saveSheetRows(period, sheetName, rows) {
const pool = await getPool();
await pool.request()
.input('p', period).input('s', sheetName)
.query(`DELETE FROM salary_uploads WHERE period=@p AND sheet_name=@s`);
for (const r of rows) {
await pool.request()
.input('p', period).input('s', sheetName)
.input('sr', r.sr_no || '').input('ec', r.employee_code || '')
.input('en', r.employee_name || '').input('dp', r.department || '')
.input('gr', r.gross_salary || 0).input('np', r.net_pay || 0)
.query(`INSERT INTO salary_uploads (period,sheet_name,sr_no,emp_code,emp_name,department,gross_amt,net_pay)
VALUES(@p,@s,@sr,@ec,@en,@dp,@gr,@np)`);
}
}
async function getPeriods() {
const pool = await getPool();
const r = await pool.request()
.query(`SELECT DISTINCT period FROM salary_uploads ORDER BY period DESC`);
return (r.recordset || []).map(x => x.period);
}
async function getSheets(period) {
const pool = await getPool();
const r = await pool.request().input('p', period).query(`
SELECT sheet_name, COUNT(*) AS row_count,
SUM(gross_amt) AS total_gross, SUM(net_pay) AS total_net
FROM salary_uploads WHERE period=@p
GROUP BY sheet_name ORDER BY sheet_name`);
return r.recordset || [];
}
async function getRows(period, sheetName) {
const pool = await getPool();
const r = await pool.request()
.input('p', period).input('s', sheetName)
.query(`SELECT sr_no,emp_code,emp_name,department,gross_amt,net_pay
FROM salary_uploads WHERE period=@p AND sheet_name=@s ORDER BY id`);
return r.recordset || [];
}
async function getConfig() {
const pool = await getPool();
const r = await pool.request()
.query(`SELECT sheet_name,cost_centre,alloc_pct FROM salary_cost_config ORDER BY sheet_name,cost_centre`);
return r.recordset || [];
}
async function saveConfig(entries) {
const pool = await getPool();
for (const e of entries) {
await pool.request()
.input('s', e.sheet_name).input('c', e.cost_centre).input('p', e.alloc_pct)
.query(`MERGE salary_cost_config AS t
USING (SELECT @s AS sheet_name,@c AS cost_centre) AS src
ON t.sheet_name=src.sheet_name AND t.cost_centre=src.cost_centre
WHEN MATCHED THEN UPDATE SET alloc_pct=@p,updated_at=GETDATE()
WHEN NOT MATCHED THEN INSERT(sheet_name,cost_centre,alloc_pct) VALUES(@s,@c,@p);`);
}
}
async function getBBRatios() {
const pool = await getPool();
const r = await pool.request()
.query(`SELECT sheet_name,bb_pct,capd_pct FROM salary_bbcapd_ratio`);
return r.recordset || [];
}
async function saveBBRatio(sheetName, bbPct, capdPct) {
const pool = await getPool();
await pool.request()
.input('s', sheetName).input('b', bbPct).input('c', capdPct)
.query(`MERGE salary_bbcapd_ratio AS t
USING (SELECT @s AS sheet_name) AS src ON t.sheet_name=src.sheet_name
WHEN MATCHED THEN UPDATE SET bb_pct=@b,capd_pct=@c,updated_at=GETDATE()
WHEN NOT MATCHED THEN INSERT(sheet_name,bb_pct,capd_pct) VALUES(@s,@b,@c);`);
}
async function getDeptMappings() {
const pool = await getPool();
const r = await pool.request()
.query(`SELECT dept_name, cost_centre FROM salary_dept_mapping ORDER BY dept_name`);
return r.recordset || [];
}
async function saveDeptMapping(deptName, costCentre) {
const pool = await getPool();
await pool.request()
.input('d', deptName).input('c', costCentre)
.query(`MERGE salary_dept_mapping AS t
USING (SELECT @d AS dept_name) AS src ON t.dept_name=src.dept_name
WHEN MATCHED THEN UPDATE SET cost_centre=@c, updated_at=GETDATE()
WHEN NOT MATCHED THEN INSERT(dept_name,cost_centre) VALUES(@d,@c);`);
}
async function getDistinctDepts(period) {
const pool = await getPool();
const r = await pool.request().input('p', period).query(`
SELECT department AS dept_name,
COUNT(*) AS emp_count,
SUM(net_pay) AS total_net
FROM salary_uploads
WHERE period=@p AND department <> ''
GROUP BY department
ORDER BY department`);
return r.recordset || [];
}
function applyToCC(total, sheetAlloc, cc, amt, bbRatio) {
if (cc === 'BB & CAPD') {
const ratio = bbRatio || { bb_pct: 70, capd_pct: 30 };
const bbAmt = amt * (parseFloat(ratio.bb_pct) || 70) / 100;
const capdAmt = amt * (parseFloat(ratio.capd_pct) || 30) / 100;
total['BLOOD BAG'] += bbAmt; sheetAlloc['BLOOD BAG'] += bbAmt;
total['CAPD'] += capdAmt; sheetAlloc['CAPD'] += capdAmt;
} else if (COST_CENTRES.includes(cc)) {
total[cc] += amt; sheetAlloc[cc] += amt;
}
}
async function getSummary(period) {
const pool = await getPool();
const sheets = await getSheets(period);
const config = await getConfig();
const bbRatios = await getBBRatios();
const deptMaps = await getDeptMappings();
const cfgMap = {};
config.forEach(c => {
if (!cfgMap[c.sheet_name]) cfgMap[c.sheet_name] = {};
cfgMap[c.sheet_name][c.cost_centre] = parseFloat(c.alloc_pct) || 0;
});
const bbMap = {};
bbRatios.forEach(b => { bbMap[b.sheet_name] = b; });
const deptMap = {};
deptMaps.forEach(d => { if (d.cost_centre) deptMap[d.dept_name.toLowerCase().trim()] = d.cost_centre; });
const total = {};
COST_CENTRES.forEach(cc => { total[cc] = 0; });
const bySheet = {};
for (const sheet of sheets) {
const sheetAlloc = {};
COST_CENTRES.forEach(cc => { sheetAlloc[cc] = 0; });
const bbRatio = bbMap[sheet.sheet_name];
const alloc = cfgMap[sheet.sheet_name] || {};
// Get per-employee rows to apply dept mapping individually
const rows = await pool.request()
.input('p', period).input('s', sheet.sheet_name)
.query(`SELECT department, net_pay FROM salary_uploads WHERE period=@p AND sheet_name=@s`);
const unmappedDepts = {};
(rows.recordset || []).forEach(row => {
const net = parseFloat(row.net_pay) || 0;
const dept = (row.department || '').toLowerCase().trim();
const mappedCC = deptMap[dept];
if (mappedCC) {
applyToCC(total, sheetAlloc, mappedCC, net, bbRatio);
} else {
// Check if dept value directly matches a cost centre name (sheets 6-8 COST CENTER column)
const directCC = COST_CENTRES.find(cc => cc.toLowerCase() === dept);
if (directCC) {
applyToCC(total, sheetAlloc, directCC, net, bbRatio);
} else {
// Fall back to sheet-level % allocation
const allocated = Object.entries(alloc).reduce((sum, [cc, pct]) => {
applyToCC(total, sheetAlloc, cc, net * pct / 100, bbRatio);
return sum + pct;
}, 0);
const key = row.department || '(no department)';
if (!unmappedDepts[key]) unmappedDepts[key] = { net: 0, emp: 0 };
unmappedDepts[key].net += net;
unmappedDepts[key].emp += 1;
unmappedDepts[key].pct_applied = allocated;
}
}
});
bySheet[sheet.sheet_name] = {
total_net: parseFloat(sheet.total_net) || 0,
row_count: sheet.row_count,
allocation: sheetAlloc,
unmapped_depts: unmappedDepts,
};
}
return { total, by_sheet: bySheet };
}
module.exports = {
bootstrap, clearPeriod, saveSheetRows, getPeriods, getSheets, getRows,
getConfig, saveConfig, getBBRatios, saveBBRatio,
getDeptMappings, saveDeptMapping, getDistinctDepts,
getSummary, COST_CENTRES,
};
+82
View File
@@ -0,0 +1,82 @@
param([string]$PayloadJson)
$ErrorActionPreference = 'Stop'
try {
$p = $PayloadJson | ConvertFrom-Json
# ── Connect ──────────────────────────────────────────────────────
$co = New-Object -ComObject SAPbobsCOM.Company
$co.Server = $p.Server
$co.CompanyDB = $p.CompanyDB
$co.UserName = $p.UserName
$co.Password = $p.Password
$co.DbUserName = $p.DbUserName
$co.DbPassword = $p.DbPassword
$co.DbServerType = 11 # confirmed working for this SAP B1 installation
$co.UseTrusted = $false
[Console]::Error.WriteLine("[DI-API] Connecting as $($p.UserName) to $($p.CompanyDB)")
$ret = $co.Connect()
if ($ret -ne 0) {
$err = $co.GetLastErrorDescription()
Write-Output ("{`"success`":false,`"error`":`"DI Login failed [$ret]: $err`"}")
exit 1
}
# ── Purchase Request ─────────────────────────────────────────────
$pr = $co.GetBusinessObject(1470000113)
# Header — use payload values, fall back to sensible defaults
$pr.DocDate = if ($p.DocDate) { [DateTime]$p.DocDate } else { Get-Date }
$pr.DocDueDate = if ($p.DocDueDate) { [DateTime]$p.DocDueDate } else { (Get-Date).AddDays(2) }
$pr.RequriedDate = if ($p.RequriedDate) { [DateTime]$p.RequriedDate } else { (Get-Date).AddDays(2) }
if ($p.Comments) { $pr.Comments = $p.Comments }
if ($p.CardCode) { $pr.CardCode = $p.CardCode }
if ($p.NumAtCard) { $pr.NumAtCard = $p.NumAtCard }
if ($p.RequesterEmail) { $pr.RequesterEmail = $p.RequesterEmail }
# UDFs
try { if ($p.U_ReqDqte) { $pr.UserFields.Fields.Item("U_ReqDqte").Value = $p.U_ReqDqte } } catch {}
try { if ($p.U_Depart) { $pr.UserFields.Fields.Item("U_Depart").Value = $p.U_Depart } } catch {}
try { if ($p.U_Department) { $pr.UserFields.Fields.Item("U_Department").Value = $p.U_Department } } catch {}
try { if ($p.U_STS_REQD) { $pr.UserFields.Fields.Item("U_STS_REQD").Value = $p.U_STS_REQD } } catch {}
# ── Lines ─────────────────────────────────────────────────────────
$first = $true
foreach ($line in $p.DocumentLines) {
if (-not $first) { $pr.Lines.Add() | Out-Null }
$pr.Lines.ItemCode = $line.ItemCode
if ($line.ItemDescription) { $pr.Lines.ItemDescription = $line.ItemDescription }
if ($line.Quantity) { $pr.Lines.Quantity = [double]$line.Quantity }
if ($null -ne $line.UnitPrice) { $pr.Lines.UnitPrice = [double]$line.UnitPrice }
if ($line.WarehouseCode) { $pr.Lines.WarehouseCode = $line.WarehouseCode }
if ($line.RequiredDate) { $pr.Lines.RequiredDate = [DateTime]$line.RequiredDate }
$first = $false
}
# ── Add Document ─────────────────────────────────────────────────
[Console]::Error.WriteLine("[PR] Adding Purchase Request...")
$addRet = $pr.Add()
if ($addRet -ne 0) {
$err = $co.GetLastErrorDescription()
$co.Disconnect()
Write-Output ("{`"success`":false,`"error`":`"PR Add failed [$addRet]: $err`"}")
exit 1
}
$docEntry = $co.GetNewObjectKey()
[Console]::Error.WriteLine("[PR] SUCCESS : $docEntry")
$co.Disconnect()
Write-Output ("{`"success`":true,`"DocEntry`":$docEntry}")
} catch {
Write-Output ("{`"success`":false,`"error`":`"$($_.Exception.Message -replace '`"','')`"}")
}
+904
View File
@@ -0,0 +1,904 @@
// backend/services/sapServiceLayer.js — FIXED FILE
// Fixes applied:
// 1. IFSC → BICSwiftCode in BPBankAccounts (OCRB)
// 2. PAN → TaxId0 in BPFiscalTaxIDCollection (CRD7)
// 3. Attachment FileName now includes extension to fix "File cannot be displayed" [40003-12]
// 4. MSME: U_MSME_Type values → 'Micro','Small','Medium','Large'
// 5. MSME: U_MSME_BType field wired
// 6. U_Fssai field support added
'use strict';
const axios = require('axios');
const https = require('https');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { DEFAULT_COMPANY } = require('./companyConfig');
const { AsyncLocalStorage } = require('async_hooks');
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
const SAP_BASE = process.env.SAP_B1_SERVER;
const DEFAULT_DB = DEFAULT_COMPANY; // ← read ONCE at startup
const _sessions = {}; // companyDB → cookie (shared .env account)
// ── Per-request SAP identity (AsyncLocalStorage) ─────────────────────
// Every authenticated HTTP request runs inside a context (set by the
// server's attachSapUser middleware) that names WHICH SAP user its SAP calls
// should act as. sapRequest() reads this so documents are attributed to the
// real person, not one shared login — without every route having to pass
// credentials through explicitly.
// ctx = { sapUser, sapPassword } → act as this SAP user
// ctx = { blocked: true } → authenticated user has NO SAP login set
// ctx = null/undefined → no user context (background/unauth) → shared account
const _sapUserALS = new AsyncLocalStorage();
const _userSessions = {}; // `${db}::${sapUser}` → cookie (per-SAP-user; shared IDs share one)
function runWithSapUser(ctx, fn) { return _sapUserALS.run(ctx || null, fn); }
function currentSapCtx() { return _sapUserALS.getStore() || null; }
// ════════════════════════════════════════════════════════════════
// SMB / UNC SHARE HELPERS
// ════════════════════════════════════════════════════════════════
let _shareAuthenticated = false;
let _lastMountedShare = '';
function sanitizeFolderName(name) {
return (name || 'VENDOR')
.replace(/[\\/:*?"<>|.]/g, '')
.replace(/\s+/g, '_')
.toUpperCase()
.trim()
.substring(0, 60) || 'VENDOR';
}
function runCmd(cmd) {
try {
const stdout = execSync(cmd, { stdio: 'pipe', timeout: 15000 }).toString().trim();
return { ok: true, stdout, stderr: '' };
} catch (e) {
return {
ok: false,
stdout: '',
stderr: (e.stderr?.toString() || e.message || '').trim(),
};
}
}
function mountShare(shareRoot) {
if (_shareAuthenticated && _lastMountedShare === shareRoot) {
console.log('[ATTACH] Share already authenticated — skipping net use');
return true;
}
const smbUser = (process.env.SMB_USER || '').trim();
const smbPass = (process.env.SMB_PASSWORD || '').trim();
const smbDomain = (process.env.SMB_DOMAIN || '').trim();
const testA = runCmd(`dir "${shareRoot}" /b`);
if (testA.ok) {
console.log('[ATTACH] ✅ Strategy A: share accessible without net use');
_shareAuthenticated = true;
_lastMountedShare = shareRoot;
return true;
}
runCmd(`net use "${shareRoot}" /delete /y`);
if (smbUser && smbPass) {
const userArg = smbDomain ? `${smbDomain}\\${smbUser}` : smbUser;
const cmdB = `net use "${shareRoot}" /user:${userArg} "${smbPass}" /persistent:no`;
console.log(`[ATTACH] Strategy B: net use as ${userArg}`);
const b = runCmd(cmdB);
if (b.ok) {
console.log('[ATTACH] ✅ Strategy B succeeded');
_shareAuthenticated = true;
_lastMountedShare = shareRoot;
return true;
}
console.warn('[ATTACH] Strategy B failed:', b.stderr);
runCmd(`net use "${shareRoot}" /delete /y`);
const cmdC = `net use "${shareRoot}" /user:WORKGROUP\\${smbUser} "${smbPass}" /persistent:no`;
console.log(`[ATTACH] Strategy C: net use as WORKGROUP\\${smbUser}`);
const c = runCmd(cmdC);
if (c.ok) {
console.log('[ATTACH] ✅ Strategy C succeeded');
_shareAuthenticated = true;
_lastMountedShare = shareRoot;
return true;
}
console.warn('[ATTACH] Strategy C failed:', c.stderr);
runCmd(`net use "${shareRoot}" /delete /y`);
} else {
console.warn('[ATTACH] SMB_USER / SMB_PASSWORD not set in .env — skipping B & C');
}
const cmdD = `net use "${shareRoot}" /persistent:no`;
console.log('[ATTACH] Strategy D: net use with current Windows session');
const d = runCmd(cmdD);
if (d.ok) {
console.log('[ATTACH] ✅ Strategy D succeeded (current session)');
_shareAuthenticated = true;
_lastMountedShare = shareRoot;
return true;
}
console.error('[ATTACH] ❌ All 4 mount strategies failed. Last error:', d.stderr);
return false;
}
// ════════════════════════════════════════════════════════════════
// SAP B1 SERVICE LAYER — LOGIN
// ════════════════════════════════════════════════════════════════
const _loginPromises = {}; // companyDB → promise
async function loginFor(companyDB) {
const db = companyDB || process.env.SAP_B1_COMPANY;
if (_loginPromises[db]) {
console.log(`[SAP] Login in progress for ${db} — waiting...`);
return _loginPromises[db];
}
_loginPromises[db] = _doLoginFor(db).finally(() => { delete _loginPromises[db]; });
return _loginPromises[db];
}
// Keep legacy login() as alias for the default company (backward compat)
async function login() { return loginFor(process.env.SAP_B1_COMPANY); }
async function _doLoginFor(companyDB) {
console.log(`\n[SAP] ══ LOGIN ══════════════════════════════`);
console.log(`[SAP] Server : ${SAP_BASE}`);
console.log(`[SAP] Company : ${companyDB}`);
console.log(`[SAP] User : ${process.env.SAP_B1_USER}`);
try {
const res = await axios.post(`${SAP_BASE}/b1s/v2/Login`, {
CompanyDB: companyDB,
UserName: process.env.SAP_B1_USER,
Password: process.env.SAP_B1_PASSWORD,
}, { httpsAgent, timeout: 30000 });
const cookies = res.headers['set-cookie'];
if (!cookies?.length) throw new Error('SAP B1 returned no session cookie');
_sessions[companyDB] = cookies.map(c => c.split(';')[0]).join('; ');
console.log(`[SAP] ✅ Login successful for ${companyDB}\n`);
return true;
} catch (err) {
delete _sessions[companyDB];
const detail = err.response?.data?.error?.message?.value
|| err.response?.data
|| err.message;
console.error(`[SAP] ❌ Login FAILED for ${companyDB}:`, detail);
throw new Error('SAP B1 Login failed: ' + JSON.stringify(detail));
}
}
// Log in as a specific SAP user (not the shared .env account) and cache the
// cookie by `${db}::${sapUser}`, so two portal users sharing one SAP login
// reuse one session. Throws with a clear message on bad credentials.
async function loginUserFor(db, sapUser, sapPassword) {
const key = `${db}::${sapUser}`;
try {
const res = await axios.post(`${SAP_BASE}/b1s/v2/Login`, {
CompanyDB: db, UserName: sapUser, Password: sapPassword,
}, { httpsAgent, timeout: 30000 });
const cookies = res.headers['set-cookie'];
if (!cookies?.length) throw new Error('SAP returned no session cookie');
_userSessions[key] = cookies.map(c => c.split(';')[0]).join('; ');
return _userSessions[key];
} catch (err) {
delete _userSessions[key];
const detail = err.response?.data?.error?.message?.value || err.response?.data || err.message;
throw new Error('SAP login failed for user "' + sapUser + '": ' + (typeof detail === 'string' ? detail : JSON.stringify(detail)));
}
}
// Validate a SAP login (used when a user saves their credentials). Returns
// true on success, throws a friendly error otherwise. Logs out immediately.
async function testSapLogin(companyDB, sapUser, sapPassword) {
const db = companyDB || process.env.SAP_B1_COMPANY;
let cookie;
try {
const res = await axios.post(`${SAP_BASE}/b1s/v2/Login`, {
CompanyDB: db, UserName: sapUser, Password: sapPassword,
}, { httpsAgent, timeout: 30000 });
const cookies = res.headers['set-cookie'];
if (!cookies?.length) throw new Error('SAP returned no session cookie');
cookie = cookies.map(c => c.split(';')[0]).join('; ');
return true;
} catch (err) {
const detail = err.response?.data?.error?.message?.value || err.response?.data || err.message;
throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));
} finally {
if (cookie) axios.post(`${SAP_BASE}/b1s/v2/Logout`, null, { httpsAgent, timeout: 10000, headers: { Cookie: cookie } }).catch(() => {});
}
}
// ════════════════════════════════════════════════════════════════
// SAP REQUEST
// ════════════════════════════════════════════════════════════════
async function sapRequest(method, endpoint, data = null, companyDB = null, retry = true, extraHeaders = null, timeoutMs = 60000) {
const db = companyDB || process.env.SAP_B1_COMPANY;
const ctx = currentSapCtx();
const isWrite = String(method).toUpperCase() !== 'GET';
// Decide which SAP identity to use for this call.
let cookie, userKey = null;
if (ctx && ctx.sapUser && ctx.sapPassword) {
// Authenticated user with their own SAP login → act as them.
userKey = `${db}::${ctx.sapUser}`;
if (!_userSessions[userKey]) await loginUserFor(db, ctx.sapUser, ctx.sapPassword);
cookie = _userSessions[userKey];
} else if (ctx && ctx.blocked && isWrite) {
// Authenticated user WITHOUT a SAP login trying to change SAP data — block
// (read-only lookups fall through to the shared account below).
throw new Error('[SAP] You have not set your SAP User ID & Password yet. Open "My SAP Account" and add them before performing SAP actions.');
} else {
// No user identity (background/read-only) → shared .env account.
if (!_sessions[db]) await loginFor(db);
cookie = _sessions[db];
}
const url = `${SAP_BASE}/b1s/v2/${endpoint}`;
console.log(`[SAP:${db}${userKey ? ' as ' + ctx.sapUser : ''}] ${method.toUpperCase()} ${url}`);
try {
const cfg = {
method, url, httpsAgent, timeout: timeoutMs,
headers: {
Cookie: cookie,
'Content-Type': 'application/json',
'B1S-CaseInsensitive': 'true',
...(extraHeaders || {}),
},
};
if (data) cfg.data = data;
const res = await axios(cfg);
return res.data;
} catch (err) {
const status = err.response?.status;
const body = err.response?.data || {};
const isSaml = JSON.stringify(body).includes('SAML')
|| body?.error?.code === '299'
|| status === 401
|| status === 302;
if (retry && isSaml) {
console.log(`[SAP] Session expired for ${db}${userKey ? ' (user ' + ctx.sapUser + ')' : ''} — re-logging in...`);
if (userKey) {
delete _userSessions[userKey];
try { await loginUserFor(db, ctx.sapUser, ctx.sapPassword); } catch (le) { throw new Error('SAP re-login failed: ' + le.message); }
} else {
delete _sessions[db];
try { await loginFor(db); } catch (le) { throw new Error('SAP re-login failed: ' + le.message); }
}
return sapRequest(method, endpoint, data, db, false, extraHeaders, timeoutMs);
}
const sapErr = err.response?.data?.error;
const sapMsg = sapErr?.message?.value || sapErr?.message || err.message;
const sapCode = sapErr?.code || status;
console.error(`[SAP] ❌ [${sapCode}]: ${sapMsg}`);
if (data) console.error('[SAP] Payload:', JSON.stringify(data, null, 2));
throw new Error(`[SAP ${sapCode}] ${sapMsg}`);
}
}
// ════════════════════════════════════════════════════════════════
// CARD CODE HELPERS
// ════════════════════════════════════════════════════════════════
async function getNextCardCode(prefix = 'CUSTA', companyDB = null) {
try {
const encoded = encodeURIComponent(
`startswith(CardCode,'${prefix}') and CardType eq 'cCustomer'`
);
const r = await sapRequest('GET',
`BusinessPartners?$filter=${encoded}&$select=CardCode&$orderby=CardCode desc&$top=1`,
null, companyDB
);
if (r?.value?.length) {
const last = r.value[0].CardCode;
const numStr = last.slice(prefix.length).replace(/\D/g, '');
const next = `${prefix}${String((parseInt(numStr) || 0) + 1).padStart(numStr.length || 6, '0')}`;
console.log(`[SAP] CardCode: ${last} → ${next}`);
return next;
}
return `${prefix}000001`;
} catch (e) {
const fb = `${prefix}${Date.now().toString().slice(-6)}`;
console.log(`[SAP] getNextCardCode fallback: ${fb}`);
return fb;
}
}
async function getNextVendorCardCode(prefix = 'VENDA', companyDB = null) {
try {
const safePrefix = (prefix || 'VENDA').replace(/'/g, "''");
const encoded = encodeURIComponent(
`startswith(CardCode,'${safePrefix}') and CardType eq 'cSupplier'`
);
const r = await sapRequest('GET',
`BusinessPartners?$filter=${encoded}&$select=CardCode&$orderby=CardCode desc&$top=1`,
null, companyDB
);
if (r?.value?.length) {
const last = r.value[0].CardCode;
const numStr = last.slice(safePrefix.length).replace(/\D/g, '');
const next = `${safePrefix}${String((parseInt(numStr) || 0) + 1).padStart(numStr.length || 6, '0')}`;
console.log(`[SAP] VendorCardCode: ${last} → ${next}`);
return next;
}
return `${safePrefix}000001`;
} catch (e) {
const fb = `${safePrefix}${Date.now().toString().slice(-6)}`;
console.log(`[SAP] getNextVendorCardCode fallback: ${fb}`);
return fb;
}
}
// ════════════════════════════════════════════════════════════════
// BANK CODES
// ════════════════════════════════════════════════════════════════
async function getBankCodes(countryCode = 'IN', companyDB = null) {
try {
console.log(`[SAP] Fetching bank codes for country: ${countryCode}`);
const encoded = encodeURIComponent(`CountryCode eq '${countryCode}'`);
let url = `Banks?$filter=${encoded}&$select=BankCode,BankName,SwiftNo,CountryCode&$orderby=BankName`;
let allBanks = [];
while (url) {
const r = await sapRequest('GET', url, null, companyDB);
const page = (r?.value || []).map(b => ({
BankCode: b.BankCode,
BankName: b.BankName,
SwiftNo: b.SwiftNo || '',
CountryCode: b.CountryCode,
}));
allBanks = allBanks.concat(page);
const nextLink = r?.['@odata.nextLink'];
url = nextLink ? nextLink.replace(/^.*\/b1s\/v2\//, '') : null;
}
console.log(`[SAP] ✅ getBankCodes: ${allBanks.length} banks for ${countryCode}`);
return allBanks;
} catch (err) {
console.error(`[SAP] ❌ getBankCodes failed: ${err.message}`);
throw err;
}
}
// ════════════════════════════════════════════════════════════════
// ADDRESS / MAP HELPERS
// ════════════════════════════════════════════════════════════════
function mapCountry(name) {
const m = {
'India':'IN','United States':'US','United Kingdom':'GB',
'UAE':'AE','Singapore':'SG','Germany':'DE','Japan':'JP','Australia':'AU',
};
return m[name] || (name && name.length <= 3 ? name.toUpperCase() : 'IN');
}
function mapStateCode(state) {
if (!state) return '';
const SAP_CODES = new Set([
'AN','AP','AR','AS','BH','CH','CT','DD','DL','DN','GA','GJ','HP',
'HR','JH','JK','KA','KL','LA','LD','MH','MN','MP','MZ','NL','OR',
'PB','PY','RJ','SK','TG','TN','TR','UP','UT','WB',
]);
const upper = state.trim().toUpperCase();
if (upper.length <= 3 && SAP_CODES.has(upper)) return upper;
const MAP = {
'Andaman and Nicobar Islands':'AN','Andaman & Nicobar Islands':'AN',
'Andhra Pradesh':'AP','Arunachal Pradesh':'AR','Assam':'AS','Bihar':'BH',
'Chandigarh':'CH','Chhattisgarh':'CT','Dadra & Nagar Haveli':'DN',
'Daman & Diu':'DD','Delhi':'DL','Goa':'GA','Gujarat':'GJ','Haryana':'HR',
'Himachal Pradesh':'HP','Jammu & Kashmir':'JK','Jammu and Kashmir':'JK',
'Jharkhand':'JH','Karnataka':'KA','Kerala':'KL','Ladakh':'LA',
'Lakshadweep':'LD','Madhya Pradesh':'MP','Maharashtra':'MH','Manipur':'MN',
'Meghalaya':'ME','Mizoram':'MZ','Nagaland':'NL','Odisha':'OR','Orissa':'OR',
'Puducherry':'PY','Pondicherry':'PY','Punjab':'PB','Rajasthan':'RJ',
'Sikkim':'SK','Tamil Nadu':'TN','Telangana':'TG','Tripura':'TR',
'Uttar Pradesh':'UP','Uttarakhand':'UT','Uttaranchal':'UT','West Bengal':'WB',
};
const code = MAP[state] || MAP[state.trim()];
if (code) return code;
console.warn(`[SAP] Unknown state "${state}"`);
return '';
}
function buildAddrObj(a, type) {
const addrName = (a.addrName || a.addressName || '').substring(0, 50);
const obj = {
AddressName: addrName,
AddressType: type,
Street: (a.street || '').substring(0, 100),
Block: (a.block || '').substring(0, 100),
City: (a.city || '').substring(0, 100),
ZipCode: (a.zip || '').substring(0, 20),
State: mapStateCode(a.state || ''),
Country: mapCountry(a.country || 'India'),
};
const gstin = (a.gstin || '').trim().toUpperCase();
if (gstin && /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/.test(gstin)) {
obj.GSTIN = gstin;
obj.GstType = 'gstRegularTDSISD';
}
console.log(`[SAP] ${type} "${addrName}" State:${obj.State} Country:${obj.Country}`);
return obj;
}
function sanitizeMobileForSAP(raw) {
if (!raw) return '';
const digits = String(raw).replace(/\D/g, '');
if (digits.length === 12 && digits.startsWith('91')) return digits.slice(2);
if (digits.length === 13 && digits.startsWith('091')) return digits.slice(3);
return digits.slice(-10);
}
// ════════════════════════════════════════════════════════════════
// ATTACHMENT UPLOAD
// FIX: FileName must include extension so SAP can open the file.
// SAP error [40003-12] "File cannot be displayed" happens when
// FileName has no extension — Windows has no program associated.
// ════════════════════════════════════════════════════════════════
async function uploadAttachmentsToSAP(attachments, cardName, companyDB = null) {
if (!attachments || typeof attachments !== 'object') return null;
const nodePath = process.env.SAP_ATTACHMENT_PATH;
const svrPath = process.env.SAP_ATTACHMENT_SERVER_PATH || nodePath;
if (!nodePath) {
console.log('[ATTACH] SAP_ATTACHMENT_PATH not set — skipping attachment upload');
return null;
}
const isUNC = nodePath.startsWith('\\\\') || nodePath.startsWith('//');
if (isUNC) {
const parts = nodePath.replace(/\\/g, '/').split('/').filter(Boolean);
const shareRoot = `\\\\${parts[0]}\\${parts[1]}`;
const mounted = mountShare(shareRoot);
if (!mounted) {
console.error('[ATTACH] ❌ Cannot reach share — vendor will still be created in SAP B1');
return null;
}
}
const folderName = sanitizeFolderName(cardName);
const vendorFolder = `${nodePath}\\${folderName}`;
const vendorSvrDir = `${svrPath}\\${folderName}`;
try {
if (!fs.existsSync(vendorFolder)) {
fs.mkdirSync(vendorFolder, { recursive: true });
console.log(`[ATTACH] ✅ Created vendor folder: ${vendorFolder}`);
}
} catch (mkErr) {
console.error(`[ATTACH] ❌ mkdir failed "${vendorFolder}": ${mkErr.message}`);
_shareAuthenticated = false;
return null;
}
const filesToUpload = [];
['pan', 'cheque', 'gst', 'msme', 'other'].forEach(key => {
const val = attachments[key];
if (!val) return;
const arr = Array.isArray(val) ? val : [val];
arr.forEach(a => { if (a?.data) filesToUpload.push({ key, ...a }); });
});
if (!filesToUpload.length) {
console.log('[ATTACH] No attachment data — nothing to upload');
return null;
}
console.log(`\n[ATTACH] Uploading ${filesToUpload.length} file(s) → ${vendorFolder}`);
const attachmentLines = [];
try {
for (const file of filesToUpload) {
const base64 = file.data.includes(',') ? file.data.split(',')[1] : file.data;
const buffer = Buffer.from(base64, 'base64');
// ── FIX [40003-12]: derive extension from original filename or mime type
let ext = '';
if (file.name && file.name.includes('.')) {
ext = file.name.split('.').pop().toLowerCase().replace(/[^a-z0-9]/g, '');
} else if (file.type) {
const mimeMap = {
'application/pdf': 'pdf',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/tiff': 'tif',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
};
ext = mimeMap[file.type] || 'pdf';
} else {
ext = 'pdf'; // safe default
}
const stem = `${folderName}_${file.key.toUpperCase()}_${Date.now()}`;
const fileName = `${stem}.${ext}`; // ← MUST include extension
const dest = path.join(vendorFolder, fileName);
fs.writeFileSync(dest, buffer);
console.log(`[ATTACH] ✅ ${(buffer.length / 1024).toFixed(1)} KB → ${dest}`);
// ── FIX: SAP Attachments2_Lines
// FileName = stem WITHOUT extension (SAP stores stem + FileExtension separately)
// FileExtension = extension WITHOUT dot
// SourcePath = folder path (no trailing slash)
attachmentLines.push({
FileName: stem, // no extension here — SAP adds it from FileExtension
FileExtension: ext, // no dot, e.g. "pdf", "jpg"
SourcePath: vendorSvrDir, // folder only, no filename
UserID: '1',
Override: 'tYES',
});
}
const result = await sapRequest('POST', 'Attachments2', { Attachments2_Lines: attachmentLines }, companyDB);
const absEntry = result?.AbsoluteEntry;
if (absEntry) {
console.log(`[ATTACH] ✅ Attachments2 AbsoluteEntry=${absEntry} folder: ${folderName}`);
} else {
console.warn('[ATTACH] ⚠ No AbsoluteEntry returned from SAP Attachments2');
}
return absEntry || null;
} catch (err) {
console.error(`[ATTACH] ❌ Upload error: ${err.message}`);
return null;
}
}
// ════════════════════════════════════════════════════════════════
// CREATE CUSTOMER
// ════════════════════════════════════════════════════════════════
async function createCustomer(d, companyDB = null) {
console.log(`\n[SAP] ══ CREATE CUSTOMER ════════════════════`);
console.log(`[SAP] CardCode : ${d.cardCode}`);
console.log(`[SAP] CardName : ${d.cardName}`);
const hasAttachData = d.attachments &&
Object.values(d.attachments).some(v => (Array.isArray(v) ? v : [v]).some(f => f?.data));
let attachmentEntry = null;
if (hasAttachData) attachmentEntry = await uploadAttachmentsToSAP(d.attachments, d.cardName, companyDB);
const payload = { CardCode: d.cardCode, CardName: d.cardName, CardType: 'cCustomer' };
if (d.currency) payload.Currency = d.currency;
if (d.phone1) payload.Phone1 = d.phone1;
if (d.mobile) payload.Cellular = d.mobile;
if (d.email) payload.EmailAddress = d.email;
if (d.website) payload.Website = d.website;
if (d.creditLimit > 0) payload.CreditLimit = parseFloat(d.creditLimit);
if (d.remarks) payload.Notes = d.remarks;
if (d.groupCode != null && !isNaN(d.groupCode)) payload.GroupCode = parseInt(d.groupCode);
if (d.payTermsGrpCode != null && !isNaN(d.payTermsGrpCode) && d.payTermsGrpCode >= 0)
payload.PayTermsGrpCode = parseInt(d.payTermsGrpCode);
if (d.salesPersonCode != null && !isNaN(d.salesPersonCode) && d.salesPersonCode > 0)
payload.SalesPersonCode = parseInt(d.salesPersonCode);
if (attachmentEntry)
payload.AttachmentEntry = parseInt(attachmentEntry);
const contactName = `${d.contactFirst || ''} ${d.contactLast || ''}`.trim();
if (contactName) {
payload.ContactEmployees = [{
Name: contactName,
FirstName: d.contactFirst || '',
LastName: d.contactLast || '',
MobilePhone: d.contactMobile || '',
E_Mail: d.contactEmail || d.email || '',
Active: 'tYES',
}];
}
payload.BPAddresses = [];
if (Array.isArray(d.allBillAddresses) && d.allBillAddresses.length) {
d.allBillAddresses.forEach(a => payload.BPAddresses.push(buildAddrObj(a, 'bo_BillTo')));
} else {
payload.BPAddresses.push(buildAddrObj({
addrName: d.billAddressName, street: d.billStreet, block: d.billBlock,
city: d.billCity, zip: d.billZip, state: d.billState,
country: d.billCountry, gstin: d.gstin,
}, 'bo_BillTo'));
}
if (Array.isArray(d.allShipAddresses) && d.allShipAddresses.length) {
d.allShipAddresses.forEach(a => payload.BPAddresses.push(buildAddrObj(a, 'bo_ShipTo')));
} else {
payload.BPAddresses.push(buildAddrObj({
addrName: d.shipAddressName || d.billAddressName,
street: d.shipStreet || d.billStreet,
block: d.shipBlock || d.billBlock,
city: d.shipCity || d.billCity,
zip: d.shipZip || d.billZip,
state: d.shipState || d.billState,
country: d.shipCountry || d.billCountry,
gstin: '',
}, 'bo_ShipTo'));
}
if (d.mgrMainGroup) payload.U_Main_Group = d.mgrMainGroup;
if (d.mgrChain) payload.U_Chain = d.mgrChain;
// ── MSME UDFs ─────────────────────────────────────────────────
if (d.hasMsme && d.msmeNo) {
payload.U_MSME = d.msmeNo;
// U_MSME_Type: exact SAP UDF values — Micro, Small, Medium, Large
payload.U_MSME_Type = d.msmeType || '';
payload.U_MSME_BType = d.msmeBType || '';
}
// FSSAI
if (d.fssaiNo && d.fssaiNo.trim()) {
payload.U_Fssai = d.fssaiNo.trim().toUpperCase();
}
if (d.mgrArAccount) payload.DebitorAccount = d.mgrArAccount;
// ── PAN → BPFiscalTaxIDCollection (CRD7.TaxId0) ──────────────
const panUpper = (d.pan || '').toUpperCase().trim();
if (panUpper && /^[A-Z]{5}[0-9]{4}[A-Z]$/.test(panUpper)) {
const billAddrName = (
(Array.isArray(d.allBillAddresses) && d.allBillAddresses[0]?.addrName) ||
d.billAddressName || d.cardName || ''
).substring(0, 50);
payload.BPFiscalTaxIDCollection = [{
Address: billAddrName,
AddrType: 'bo_BillTo',
TaxId0: panUpper, // ← PAN goes in TaxId0 (maps to CRD7.TaxId0)
}];
console.log(`[SAP] PAN (TaxId0): ${panUpper} Address: ${billAddrName}`);
}
console.log('[SAP] Customer Payload:\n' + JSON.stringify(payload, null, 2));
try {
const result = await sapRequest('POST', 'BusinessPartners', payload, companyDB);
console.log(`\n[SAP] ✅ Customer created: ${result?.CardCode || d.cardCode}`);
return { ...result, attachmentEntry };
} catch (err) {
console.error(`[SAP] ❌ createCustomer failed: ${err.message}`);
throw err;
}
}
// ════════════════════════════════════════════════════════════════
// CREATE VENDOR
// ════════════════════════════════════════════════════════════════
async function createVendor(d, companyDB = null) {
console.log(`\n[SAP] ══ CREATE VENDOR (SUPPLIER) ══════════`);
console.log(`[SAP] CardCode : ${d.cardCode}`);
console.log(`[SAP] CardName : ${d.cardName}`);
let attachmentEntry = null;
const hasAttachData = d.attachments &&
Object.values(d.attachments).some(v => (Array.isArray(v) ? v : [v]).some(f => f?.data));
if (hasAttachData) {
try {
attachmentEntry = await uploadAttachmentsToSAP(d.attachments, d.cardName, companyDB);
} catch (attErr) {
console.warn('[SAP] ⚠ Attachment upload skipped (non-fatal):', attErr.message);
}
}
const payload = {
CardCode: d.cardCode,
CardName: d.cardName,
CardType: 'cSupplier',
};
if (d.currency) payload.Currency = d.currency;
if (d.phone1) payload.Phone1 = d.phone1;
if (d.email) payload.EmailAddress = d.email;
if (d.creditLimit > 0) payload.CreditLimit = parseFloat(d.creditLimit);
if (d.remarks) payload.Notes = d.remarks;
if (d.groupCode != null && !isNaN(d.groupCode))
payload.GroupCode = parseInt(d.groupCode);
if (d.payTermsGrpCode != null && !isNaN(d.payTermsGrpCode) && d.payTermsGrpCode >= 0)
payload.PayTermsGrpCode = parseInt(d.payTermsGrpCode);
if (d.salesPersonCode != null && !isNaN(d.salesPersonCode) && d.salesPersonCode > 0)
payload.SalesPersonCode = parseInt(d.salesPersonCode);
if (attachmentEntry)
payload.AttachmentEntry = parseInt(attachmentEntry);
// AP Account
payload.DebitorAccount = (d.mgrPurchaseAccount && d.mgrPurchaseAccount.trim())
? d.mgrPurchaseAccount.trim()
: '2110005';
console.log(`[SAP] DebitorAccount (AP): ${payload.DebitorAccount}`);
// Contact person
const contactName = `${d.contactFirst || ''} ${d.contactLast || ''}`.trim();
if (contactName) {
payload.ContactEmployees = [{
Name: contactName,
FirstName: d.contactFirst || '',
LastName: d.contactLast || '',
MobilePhone: sanitizeMobileForSAP(d.contactMobile || d.mobile || d.phone1 || ''),
E_Mail: d.contactEmail || d.email || '',
Active: 'tYES',
}];
}
// Addresses
payload.BPAddresses = [];
if (Array.isArray(d.allBillAddresses) && d.allBillAddresses.length) {
d.allBillAddresses.forEach(a => payload.BPAddresses.push(buildAddrObj(a, 'bo_BillTo')));
} else {
const billAddrName = `${d.cardName.substring(0, 25)}-${mapStateCode(d.billState)}`;
payload.BPAddresses.push(buildAddrObj({
addrName: billAddrName, street: d.billStreet, block: d.billBlock,
city: d.billCity, zip: d.billZip, state: d.billState,
country: d.billCountry || 'India', gstin: d.gstin,
}, 'bo_BillTo'));
}
if (Array.isArray(d.allShipAddresses) && d.allShipAddresses.length) {
d.allShipAddresses.forEach(a => payload.BPAddresses.push(buildAddrObj(a, 'bo_ShipTo')));
} else {
const shipAddrName = `${d.cardName.substring(0, 25)}-${mapStateCode(d.billState)}`;
payload.BPAddresses.push(buildAddrObj({
addrName: shipAddrName, street: d.billStreet, block: d.billBlock,
city: d.billCity, zip: d.billZip, state: d.billState,
country: d.billCountry || 'India', gstin: d.gstin,
}, 'bo_ShipTo'));
}
// ── MSME UDFs ─────────────────────────────────────────────────
// U_MSME_Type valid values in SAP: 'Micro', 'Small', 'Medium', 'Large'
if (d.hasMsme && d.msmeNo) {
payload.U_MSME = d.msmeNo.trim();
payload.U_MSME_Type = d.msmeType || ''; // 'Micro'|'Small'|'Medium'|'Large'
payload.U_MSME_BType = d.msmeBType || ''; // e.g. 'Manufacturing'|'Service'
console.log(`[SAP] MSME: ${payload.U_MSME} Type: ${payload.U_MSME_Type} BType: ${payload.U_MSME_BType}`);
}
// ── FSSAI ─────────────────────────────────────────────────────
if (d.fssaiNo && d.fssaiNo.trim()) {
payload.U_Fssai = d.fssaiNo.trim().toUpperCase();
console.log(`[SAP] FSSAI: ${payload.U_Fssai}`);
}
// UDF fields
if (d.mgrMainGroup && d.mgrMainGroup.trim()) {
payload.U_Main_Group = d.mgrMainGroup.trim();
console.log(`[SAP] U_Main_Group: ${payload.U_Main_Group}`);
}
if (d.mgrChain && d.mgrChain.trim()) {
payload.U_Chain = d.mgrChain.trim();
console.log(`[SAP] U_Chain: ${payload.U_Chain}`);
}
// ── PAN → BPFiscalTaxIDCollection (CRD7.TaxId0) ──────────────
// TaxId0 is the PAN field in SAP B1 India localisation.
// Address must match an existing BPAddress AddressName exactly.
const panUpper = (d.pan || '').toUpperCase().trim();
const billAddrNameForPan = (
(Array.isArray(d.allBillAddresses) && d.allBillAddresses[0]?.addrName) ||
`${d.cardName.substring(0, 25)}-${mapStateCode(d.billState)}`
).substring(0, 50);
if (panUpper && /^[A-Z]{5}[0-9]{4}[A-Z]$/.test(panUpper)) {
payload.BPFiscalTaxIDCollection = [{
Address: billAddrNameForPan, // must match BPAddresses[0].AddressName
AddrType: 'bo_BillTo',
TaxId0: panUpper, // ← this is PAN in CRD7
}];
console.log(`[SAP] PAN (TaxId0): ${panUpper} AddrName: ${billAddrNameForPan}`);
}
// ── Bank accounts ──────────────────────────────────────────────
// FIX: IFSC goes in BICSwiftCode (maps to OCRB.BICSwiftCode in SAP B1)
// NOT in UserNo1. The field was previously wrong.
if (Array.isArray(d.bankAccounts) && d.bankAccounts.length > 0) {
const validBanks = d.bankAccounts.filter(b => b.accNo && b.accNo.trim());
if (validBanks.length > 0) {
const bankAccountsForSAP = [];
for (const b of validBanks) {
const bankCode = b.bankCode || b.mgrBankCode || null;
if (!bankCode) {
console.warn(`[SAP] ⚠ Skipping bank account ${b.accNo} — BankCode not resolved`);
continue;
}
const ifscCode = (b.ifsc || '').trim().toUpperCase();
console.log(`[SAP] Bank: ${bankCode} | A/C: ${b.accNo} | IFSC→BICSwiftCode: ${ifscCode} | Type: ${b.accountType || 'Current'}`);
bankAccountsForSAP.push({
BankCode: bankCode,
AccountNo: b.accNo.trim(),
Branch: (b.branch || '').trim().substring(0, 50),
AccountName: (b.bankName || d.cardName || '').trim().substring(0, 100),
// ── FIX: IFSC → BICSwiftCode (OCRB.BICSwiftCode) ──────────────
BICSwiftCode: ifscCode.substring(0, 50), // ← CORRECTED field name
// UserNo1 / UserNo2 can hold additional reference info
UserNo1: ifscCode.substring(0, 50), // keep for backward compat
UserNo2: (b.accountType || 'Current').substring(0, 50),
IBAN: (b.swiftCode || '').trim().substring(0, 34),
});
}
if (bankAccountsForSAP.length > 0) {
payload.BPBankAccounts = bankAccountsForSAP;
console.log(`[SAP] BPBankAccounts: ${bankAccountsForSAP.length} account(s) with BICSwiftCode (IFSC)`);
} else {
console.warn('[SAP] ⚠ No bank accounts added — BankCode unresolved for all');
}
}
}
console.log('[SAP] Vendor Payload:\n' + JSON.stringify(payload, null, 2));
try {
const result = await sapRequest('POST', 'BusinessPartners', payload, companyDB);
console.log(`\n[SAP] ✅ Vendor created: ${result?.CardCode || d.cardCode}`);
return { ...result, attachmentEntry };
} catch (err) {
console.error(`[SAP] ❌ createVendor failed: ${err.message}`);
throw err;
}
}
// ════════════════════════════════════════════════════════════════
// SAP REQUEST AS SPECIFIC USER — one-off login, no session cache
// ════════════════════════════════════════════════════════════════
async function sapRequestAs(userName, password, companyDB, method, endpoint, data = null) {
const db = companyDB || process.env.SAP_B1_COMPANY;
console.log(`[SAP:${db}] ── one-off login as ${userName} for ${method.toUpperCase()} ${endpoint}`);
let cookie;
try {
const lr = await axios.post(`${SAP_BASE}/b1s/v2/Login`, {
CompanyDB: db, UserName: userName, Password: password,
}, { httpsAgent, timeout: 30000 });
const cookies = lr.headers['set-cookie'];
if (!cookies?.length) throw new Error('no session cookie returned');
cookie = cookies.map(c => c.split(';')[0]).join('; ');
} catch (err) {
const detail = err.response?.data?.error?.message?.value || err.response?.data || err.message;
throw new Error('SAP login as ' + userName + ' failed: ' + (typeof detail === 'string' ? detail : JSON.stringify(detail)));
}
try {
const cfg = {
method, url: `${SAP_BASE}/b1s/v2/${endpoint}`, httpsAgent, timeout: 60000,
headers: { Cookie: cookie, 'Content-Type': 'application/json', 'B1S-CaseInsensitive': 'true' },
};
if (data) cfg.data = data;
const res = await axios(cfg);
return res.data;
} catch (err) {
const sapErr = err.response?.data?.error;
const sapMsg = sapErr?.message?.value || sapErr?.message || err.message;
const sapCode = sapErr?.code || err.response?.status;
if (data) console.error('[SAP] Payload:', JSON.stringify(data, null, 2));
throw new Error(`[SAP ${sapCode}] ${sapMsg}`);
} finally {
axios.post(`${SAP_BASE}/b1s/v2/Logout`, null, {
httpsAgent, timeout: 10000, headers: { Cookie: cookie },
}).catch(() => {});
}
}
// ════════════════════════════════════════════════════════════════
// EXPORTS
// ════════════════════════════════════════════════════════════════
module.exports = {
login,
sapRequest,
sapRequestAs,
runWithSapUser,
testSapLogin,
getNextCardCode,
getNextVendorCardCode,
getBankCodes,
createCustomer,
createVendor,
uploadAttachmentsToSAP,
sanitizeFolderName,
};
+102
View File
@@ -0,0 +1,102 @@
// services/sqlPool.js
// MSSQL connection pool(s) for all portal stores' direct-SQL access to SAP
// tables. ONE POOL PER DATABASE NAME — the SQL Server login (SQL_USER/
// SQL_PASSWORD) is a single server-level credential that can reach every SAP
// B1 company database on that server (unlike SAP Service Layer, where each
// company has its own OUSR table with its own per-company password — see
// [[per-company-sap-login]]), so only the DATABASE a pool points at needs to
// vary, not the credentials. getPool() with no argument keeps its original
// behavior (the .env SQL_DATABASE default) for 100% backward compat with
// every existing caller; pass a companyDB to get/create a pool for that
// specific database instead.
//
// Bug this fixes (2026-08-04): getPool() used to be a SINGLE global pool
// hardcoded to .env's SQL_DATABASE with no way to target any other company at
// all — every direct-SQL route (routes/sap.js's item/warehouse/etc. lookups,
// routes/workOrders.js, reports, dashboards, GST, general ledger…) silently
// queried the same one database regardless of which company a user selected
// or was restricted to. Using ConnectionPool directly (not the global
// sql.connect() singleton) avoids conflicts between concurrently-initialising
// pools for different databases.
'use strict';
const sql = require('mssql');
const _pools = new Map(); // dbName → { pool, connecting, waiters }
const DEFAULT_DB = process.env.SQL_DATABASE;
function buildConfig(database) {
return {
server: process.env.SQL_SERVER_NAME || process.env.SQL_HOST,
port: parseInt(process.env.SQL_PORT) || 1433,
user: process.env.SQL_USER,
password: process.env.SQL_PASSWORD,
database,
connectionTimeout: 30000,
requestTimeout: 60000,
pool: { max: 10, min: 0, idleTimeoutMillis: 30000 },
options: {
encrypt: true,
trustServerCertificate: true,
enableArithAbort: true,
// SQL Server 2017 on this host requires TLS 1.0.
// Node.js 17+ disables TLS 1.0 by default — re-enable it for this pool.
cryptoCredentialsDetails: { minVersion: 'TLSv1' },
},
};
}
async function getPool(companyDB) {
const db = companyDB || DEFAULT_DB;
let entry = _pools.get(db);
if (!entry) { entry = { pool: null, connecting: false, waiters: [] }; _pools.set(db, entry); }
// Return healthy pool immediately
if (entry.pool && entry.pool.connected) return entry.pool;
// Queue if already connecting
if (entry.connecting) {
return new Promise((resolve, reject) => entry.waiters.push({ resolve, reject }));
}
entry.connecting = true;
try {
const pool = new sql.ConnectionPool(buildConfig(db));
// Reset on pool-level errors so the next caller reconnects
pool.on('error', err => {
console.error(`[SQL-POOL] Pool error (${db}) — resetting:`, err.message);
entry.pool = null;
});
await pool.connect();
entry.pool = pool;
console.log(`[SQL-POOL] ✅ Connected ${db} @ ${process.env.SQL_HOST}`);
entry.waiters.forEach(w => w.resolve(entry.pool));
return entry.pool;
} catch (err) {
entry.pool = null;
console.error(`[SQL-POOL] ❌ Connection failed (${db}):`, err.message);
entry.waiters.forEach(w => w.reject(err));
throw err;
} finally {
entry.connecting = false;
entry.waiters = [];
}
}
// Parameterised query helper — use ? as placeholder, works like the legacy per-store exec()
async function query(sqlText, params = [], companyDB) {
const pool = await getPool(companyDB);
const req = pool.request();
let i = 0;
const text = sqlText.replace(/\?/g, () => {
const name = `p${i}`;
req.input(name, params[i]);
i++;
return `@${name}`;
});
const result = await req.query(text);
return result.recordset || [];
}
module.exports = { getPool, query };
+76
View File
@@ -0,0 +1,76 @@
// services/warehouseTranTypeStore.js
// Admin-managed mapping of Warehouse → Transaction Type (Complete='C' /
// Reject='R') for Receipt from Production. When a warehouse is mapped, the
// receipt page shows its transaction type automatically (the user can't pick
// it). Unmapped warehouses default to Complete. Company-scoped. App DB only.
'use strict';
const sql = require('mssql');
const TABLE = `[dbo].[ZWAREHOUSE_TRANTYPE]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[WH-TRANTYPE-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
COMPANY NVARCHAR(60) NOT NULL,
WHS_CODE NVARCHAR(20) NOT NULL,
TRAN_TYPE CHAR(1) NOT NULL, -- 'C' Complete | 'R' Reject
UPDATED_AT DATETIME2
)
`).catch(e => { if (isAlreadyExists(e)) console.log('[WH-TRANTYPE-STORE] Table exists — OK'); else throw e; });
await exec(`CREATE UNIQUE INDEX IDX_ZWH_TT_UN ON ${TABLE} ([COMPANY],[WHS_CODE])`).catch(() => {});
console.log('[WH-TRANTYPE-STORE] ✅ Ready');
}
// { whCode: 'C'|'R', ... } for a company
async function getMap(company) {
const rows = await exec(`SELECT WHS_CODE, TRAN_TYPE FROM ${TABLE} WHERE COMPANY = ?`, [company || '']);
const map = {};
rows.forEach(r => { map[r.WHS_CODE] = (r.TRAN_TYPE === 'R' ? 'R' : 'C'); });
return map;
}
// Replace the whole mapping for a company with the given { whCode: 'C'|'R' }.
async function setMap(company, map) {
const co = company || '';
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23);
await exec(`DELETE FROM ${TABLE} WHERE COMPANY = ?`, [co]);
const entries = Object.entries(map || {}).filter(([wh, t]) => wh && (t === 'C' || t === 'R'));
for (const [wh, t] of entries) {
await exec(`INSERT INTO ${TABLE} (COMPANY, WHS_CODE, TRAN_TYPE, UPDATED_AT) VALUES (?, ?, ?, ?)`,
[co, String(wh).trim(), t, now]);
}
return { count: entries.length };
}
module.exports = { bootstrap, getMap, setMap };
+174
View File
@@ -0,0 +1,174 @@
// services/woHeaderProfileStore.js
// Work Order print/PDF header — previously a single flat config (Admin →
// System Settings → "Header Details") burned onto every printed Work Order
// regardless of product. Now a LIST of named profiles (e.g. "Blood Bag",
// "CAPD", "Accessories"), each mapping to a set of SAP Item Groups; the
// profile actually used for a given Work Order is resolved client-side in
// work-order-print.html from the WO's product's SAP item group. Exactly one
// profile is always the Default — used as the fallback for any item group
// not explicitly mapped to a specific profile.
const sql = require('mssql');
const TABLE = `[dbo].[ZWO_HEADER_PROFILES]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[WO-HEADER-PROFILES] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
NAME NVARCHAR(100) NOT NULL,
IS_DEFAULT BIT DEFAULT 0,
ITEM_GROUPS NVARCHAR(MAX),
COMPANY_NAME NVARCHAR(200),
COMPANY_ADDRESS NVARCHAR(400),
FORM_NO NVARCHAR(100),
EFFECTIVE_DATE NVARCHAR(20),
REVIEW_DATE NVARCHAR(20),
LOGO NVARCHAR(MAX),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_AT DATETIME2,
IS_DELETED BIT DEFAULT 0
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[WO-HEADER-PROFILES] Table exists — OK'); }
else throw e;
});
// One-time seed: if the table is completely empty (fresh install of this
// feature), carry the OLD single flat System Settings values over as the
// Default profile, so nothing on any already-printed-from workflow breaks
// the moment this ships — existing users see exactly the same header they
// had yesterday, just now editable as "Default" instead of the old flat
// settings fields.
const existing = await exec(`SELECT COUNT(*) AS N FROM ${TABLE} WHERE IS_DELETED = 0 OR IS_DELETED IS NULL`);
if (!existing.length || !existing[0].N) {
const appSettings = require('./appSettingsStore');
const now = toTs(new Date().toISOString());
await exec(`
INSERT INTO ${TABLE} (NAME, IS_DEFAULT, ITEM_GROUPS, COMPANY_NAME, COMPANY_ADDRESS, FORM_NO, EFFECTIVE_DATE, REVIEW_DATE, LOGO, CREATED_BY, CREATED_NAME, CREATED_AT)
VALUES ('Default', 1, '[]', ?, ?, ?, ?, ?, ?, 'system', 'System (migrated)', ?)
`, [
appSettings.woCompanyName(), appSettings.woCompanyAddress(), appSettings.woFormNo(),
appSettings.woEffectiveDate(), appSettings.woReviewDate(), appSettings.woLogo(), now,
]);
console.log('[WO-HEADER-PROFILES] Seeded "Default" profile from legacy System Settings values');
}
console.log('[WO-HEADER-PROFILES] ✅ Ready');
}
function toTs(iso) { return iso ? iso.replace('T', ' ').replace('Z', '').substring(0, 23) : null; }
function safeJson(v, f) { if (!v) return f; try { return JSON.parse(v); } catch { return f; } }
function fromRow(row) {
if (!row) return null;
return {
id: row.ID,
name: row.NAME || '',
isDefault: !!row.IS_DEFAULT,
itemGroups: safeJson(row.ITEM_GROUPS, []).map(String),
companyName: row.COMPANY_NAME || '',
companyAddress: row.COMPANY_ADDRESS || '',
formNo: row.FORM_NO || '',
effectiveDate: row.EFFECTIVE_DATE || '',
reviewDate: row.REVIEW_DATE || '',
logo: row.LOGO || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
isDeleted: !!row.IS_DELETED,
};
}
async function listProfiles() {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE IS_DELETED = 0 OR IS_DELETED IS NULL ORDER BY IS_DEFAULT DESC, NAME`);
return rows.map(fromRow);
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0]) : null;
}
async function createProfile(p) {
const now = toTs(new Date().toISOString());
const idRows = await exec(`
INSERT INTO ${TABLE} (NAME, IS_DEFAULT, ITEM_GROUPS, COMPANY_NAME, COMPANY_ADDRESS, FORM_NO, EFFECTIVE_DATE, REVIEW_DATE, LOGO, CREATED_BY, CREATED_NAME, CREATED_AT)
VALUES (?,0,?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
p.name || 'Untitled', JSON.stringify(p.itemGroups || []),
p.companyName || '', p.companyAddress || '', p.formNo || '',
p.effectiveDate || '', p.reviewDate || '', p.logo || '',
p.createdBy, p.createdByName || p.createdBy, now,
]);
return findById(idRows[0].ID);
}
async function updateProfile(id, patch) {
const now = toTs(new Date().toISOString());
await exec(`
UPDATE ${TABLE}
SET NAME = ?, ITEM_GROUPS = ?, COMPANY_NAME = ?, COMPANY_ADDRESS = ?, FORM_NO = ?, EFFECTIVE_DATE = ?, REVIEW_DATE = ?, LOGO = ?, UPDATED_AT = ?
WHERE ID = ? AND (IS_DELETED = 0 OR IS_DELETED IS NULL)
`, [
patch.name || 'Untitled', JSON.stringify(patch.itemGroups || []),
patch.companyName || '', patch.companyAddress || '', patch.formNo || '',
patch.effectiveDate || '', patch.reviewDate || '', patch.logo || '',
now, parseInt(id),
]);
return findById(id);
}
// Exactly one profile is ever the Default — set this one, unset every other.
async function setDefault(id) {
const now = toTs(new Date().toISOString());
await exec(`UPDATE ${TABLE} SET IS_DEFAULT = 0, UPDATED_AT = ? WHERE IS_DEFAULT = 1`, [now]);
await exec(`UPDATE ${TABLE} SET IS_DEFAULT = 1, UPDATED_AT = ? WHERE ID = ?`, [now, parseInt(id)]);
return findById(id);
}
// Refuses to delete the last remaining Default (there must always be a
// fallback for unmapped item groups) — the caller should setDefault() on a
// different profile first if they want to remove today's Default.
async function softDelete(id) {
const p = await findById(id);
if (!p) return;
if (p.isDefault) throw new Error('Cannot delete the Default profile — set a different profile as Default first');
await exec(`UPDATE ${TABLE} SET IS_DELETED = 1, UPDATED_AT = ? WHERE ID = ?`, [toTs(new Date().toISOString()), parseInt(id)]);
}
module.exports = {
bootstrap, listProfiles, findById, createProfile, updateProfile, setDefault, softDelete,
};
+273
View File
@@ -0,0 +1,273 @@
'use strict';
// services/workOrderPdf.js — server-side Production Work Order PDF via pdfmake
// (pure JS, no headless browser). Uses PDF's built-in Helvetica so no font
// files are needed. Mirrors public/work-order-print.html: configurable header
// band, info box, material tables with auto issuance columns, and the five
// approval sign-offs with signature images. Returns a PdfKit document stream.
let _printer = null;
function printer() {
if (_printer) return _printer;
const PdfPrinter = require('pdfmake/src/printer');
_printer = new PdfPrinter({
Helvetica: { normal: 'Helvetica', bold: 'Helvetica-Bold', italics: 'Helvetica-Oblique', bolditalics: 'Helvetica-BoldOblique' },
});
return _printer;
}
const GREY = '#f2f2f2';
const layout = {
hLineWidth: () => 0.5, vLineWidth: () => 0.5,
hLineColor: () => '#000', vLineColor: () => '#000',
paddingLeft: () => 2, paddingRight: () => 2, paddingTop: () => 1.5, paddingBottom: () => 1.5,
};
const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtD(iso) { if (!iso) return ''; const d = new Date(iso); if (isNaN(d)) return ''; return `${String(d.getDate()).padStart(2,'0')}-${MON[d.getMonth()]}-${d.getFullYear()}`; }
function num3(v) { if (v == null || v === '') return ''; const n = Number(v); if (!isFinite(n) || n === 0) return ''; return String(Math.round(n * 1000) / 1000); }
const S = (v) => (v == null ? '' : String(v));
// Raw Material's "Qty Req./100 ml" / "Qty Req." auto-scale for display —
// EXACT mirror of public/work-order.html's own scaling (its "View" mode is
// the reference this must match; see that file for the full rationale).
// Read-only here: this just reproduces the same scaled numbers/units the
// app itself shows on-screen (Weight family mg/gm/Kg/Ton, Volume family
// µL/mL/Ltrs/KL — ratios are IDENTICAL between the two, so the same
// underlying number relabels cleanly as either), honoring each row's own
// saved display-unit choice (stdQtyDispUnit/qtyReqDispUnit) first and
// falling back to the auto-picked best-fit unit within the item's own
// family (volume for a liquid real UOM like LTR, weight otherwise).
function round3(n) { return Math.round((Number(n) || 0) * 1000) / 1000; }
function toStockQty(grams, uom) { const u = (uom || '').toUpperCase().trim();
return (u.startsWith('KG') || u.startsWith('LTR') || u.startsWith('LITR') || u === 'L' || u === 'LT' || u === 'KL') ? grams / 1000 : grams; }
function fromStockQty(qty, uom) { const u = (uom || '').toUpperCase().trim();
return (u.startsWith('KG') || u.startsWith('LTR') || u.startsWith('LITR') || u === 'L' || u === 'LT' || u === 'KL') ? qty * 1000 : qty; }
const UNIT_FAMILIES = {
volume: { 'µL': 1, 'mL': 1000, 'Ltrs': 1e6, 'KL': 1e9 },
weight: { 'mg': 1, 'gm': 1000, 'Kg': 1e6, 'Ton': 1e9 },
};
function defaultRawFamily(uom) {
const u = (uom || '').trim().toUpperCase();
return ['LTR', 'LTRS', 'LITRE', 'LITER', 'LITRES', 'LITERS', 'ML', 'KL', 'L'].includes(u) ? 'volume' : 'weight';
}
const RAW_UNIT_OPTIONS = { ...UNIT_FAMILIES.weight, ...UNIT_FAMILIES.volume };
function scaleGrams(grams, uom) {
const totalBase = (Number(grams) || 0) * 1000;
const units = UNIT_FAMILIES[defaultRawFamily(uom)];
const entries = Object.entries(units).sort((a, b) => b[1] - a[1]);
for (const [u, mult] of entries) { if (totalBase >= mult) return { value: round3(totalBase / mult), label: u }; }
const smallest = entries[entries.length - 1];
return { value: round3(totalBase / smallest[1]), label: smallest[0] };
}
function scaleGramsToUnit(grams, unit) {
const mult = RAW_UNIT_OPTIONS[unit] != null ? RAW_UNIT_OPTIONS[unit] : 1000;
return round3((Number(grams) || 0) * 1000 / mult);
}
// Admin-configured per-item SOP display factor — see work-order.html's
// potencyFactor() for the full rationale. Applies ONLY to stdQty ("Qty
// Req./100 ml"); qtyReq ("Qty Req.") is never adjusted.
const appSettings = require('./appSettingsStore');
function potencyFactor(itemCode) {
const p = parseFloat((appSettings.woRawPotencyFactors() || {})[itemCode]);
return (p > 0) ? p / 100 : 1;
}
// srcField lets a caller display a DIFFERENT row value (e.g. the persisted
// "qtyIssued") using 'field''s own unit/scaling conventions — used by the Qty
// Issued column for Raw Material rows (see rawSection() below).
function rawScaledDisp(r, field, noUnit, srcField) {
const vf = srcField || field;
if (r.directRaw) return S(r[vf]);
const dispField = field === 'stdQty' ? 'stdQtyDispUnit' : 'qtyReqDispUnit';
const raw = parseFloat(r[vf]) || 0;
const grams = field === 'stdQty' ? raw * potencyFactor(r.itemCode) : fromStockQty(raw, r.uom);
const unit = r[dispField] || scaleGrams(grams, r.uom).label;
const value = scaleGramsToUnit(grams, unit);
return noUnit ? String(value) : `${value} ${unit}`;
}
// Scaled unit label alone, for the UOM column — shows Qty Req./100 ml's
// unit (the user's own chosen one, if set) instead of the item's static SAP
// stock UOM.
function rawScaledUnit(r) {
if (r.directRaw) return S(r.uom);
const grams = (parseFloat(r.stdQty) || 0) * potencyFactor(r.itemCode);
return S(r.stdQtyDispUnit || scaleGrams(grams, r.uom).label);
}
function K(text, opts) { return Object.assign({ text: S(text), bold: true, fillColor: GREY, fontSize: 7 }, opts || {}); }
function C(text, opts) { return Object.assign({ text: S(text), fontSize: 7 }, opts || {}); }
// A signature cell: signature image (if any) over name + date.
function sigCell(sig, sigs, opts) {
if (!sig || !sig.name) return Object.assign({ text: '' }, opts || {});
const stack = [];
const img = sig.user && sigs[sig.user];
if (img) stack.push({ image: img, fit: [92, 24], alignment: 'center' });
stack.push({ text: S(sig.name), bold: true, fontSize: 6.5, alignment: 'center' });
if (sig.at) stack.push({ text: fmtD(sig.at), fontSize: 6, color: '#444', alignment: 'center' });
return Object.assign({ stack, alignment: 'center' }, opts || {});
}
// Per-ROW Issued/Received/Verified sign-offs — each material line is stamped
// independently (see public/verify-work-order.html), since different items
// can be issued/received/verified on different days by different people.
function rowSig(row, which) {
const at = row[`${which}At`];
if (!at) return null;
return { name: row[`${which}ByName`] || row[`${which}By`], user: row[`${which}By`], at };
}
function rowSigCells(row, sigs) {
return [sigCell(rowSig(row, 'issued'), sigs, {}), sigCell(rowSig(row, 'received'), sigs, {}), sigCell(rowSig(row, 'verified'), sigs, {})];
}
function headerBand(settings, pageBreak) {
const logo = settings.woLogo
? { image: settings.woLogo, fit: [74, 40], alignment: 'center', rowSpan: 2, margin: [0, 4, 0, 0] }
: { text: '', rowSpan: 2 };
const t = {
table: { widths: [80, 72, '*', 86, '*', 86, '*'], body: [
[ logo,
{ stack: [
{ text: S(settings.woCompanyName), bold: true, fontSize: 13, alignment: 'center' },
{ text: S(settings.woCompanyAddress), fontSize: 8, alignment: 'center' },
], colSpan: 6, margin: [0, 4, 0, 4] }, {}, {}, {}, {}, {} ],
[ {}, K('FORM NO.', { alignment: 'center' }), C(settings.woFormNo, { alignment: 'center' }),
K('EFFECTIVE DATE', { alignment: 'center' }), C(settings.woEffectiveDate, { alignment: 'center' }),
K('REVIEW DATE', { alignment: 'center' }), C(settings.woReviewDate, { alignment: 'center' }) ],
] },
layout,
};
if (pageBreak) t.pageBreak = 'before';
return t;
}
function titleBar(suffix) {
return { table: { widths: ['*'], body: [[{ text: 'PRODUCTION WORK ORDER' + (suffix || ''), bold: true, alignment: 'center', fillColor: GREY, fontSize: 10 }]] }, layout };
}
function infoBox(wo, newItemCode) {
const pn = [wo.productName, wo.productDesc].filter(Boolean).join(' — ');
const sameCode = newItemCode && String(newItemCode).trim().toUpperCase() === String(wo.productCode || '').trim().toUpperCase();
const productCode = wo.productCode + (newItemCode && !sameCode ? ` (${newItemCode})` : '');
const body = [
[K('Product Name & Description'), Object.assign(C(pn), { colSpan: 5 }), {}, {}, {}, {}],
[K('Generic Name'), Object.assign(C(wo.genericName), { colSpan: 5 }), {}, {}, {}, {}],
[K('Batch Number'), C(wo.batchNumber), K('Batch Size'), C(wo.batchSize), K('Total Units'), C(wo.totalUnits)],
[K('MFG. Date'), C(wo.mfgDate), K('EXP. Date'), C(wo.expDate), K('Pack Size'), C(wo.packSize)],
[K('Reference'), C(wo.reference), K('Product code'), C(productCode), K('Type'), C(wo.type)],
[K('Market'), C(wo.market), K('Intimation No.'), Object.assign(C(wo.intimationDocNo), { colSpan: 3 }), {}, {}],
];
return { table: { widths: [112, '*', 82, '*', 66, '*'], body }, layout };
}
function rawSection(wo, iss, sigs) {
const map = new Map();
// solBatchSize can legitimately be 0 (tiny Total Units rounded down) — check
// for null/undefined specifically so a real 0 still prints, not silently
// dropped by `|| ''`/truthiness checks.
(wo.rawMaterials || []).forEach(r => { const k = r.solCode || ''; if (!map.has(k)) map.set(k, { name: r.solName || '', batchSize: r.solBatchSize != null ? r.solBatchSize : '', rows: [] }); map.get(k).rows.push(r); });
const nodes = [];
for (const g of map.values()) {
if (g.name) nodes.push({ text: g.name + (g.batchSize !== '' ? ` — Batch Size ${g.batchSize} Ltr` : ''), bold: true, fontSize: 8, alignment: 'center', fillColor: GREY, margin: [0, 4, 0, 0] });
const widths = [38, '*', 24, 36, 22, 20, 40, 48, 34, 34, 58, 58, 58];
const head = ['Item code','Raw materials','Spec','Qty Req./100 ml','UOM','Ovg.','Qty Req.','Weighing Balance ID','AR No.','Qty issued','Issued by /date','Received by','Verified by'].map(h => K(h, { alignment: 'center' }));
const body = [head];
g.rows.forEach((r) => {
// Raw Material rows are never SAP Production Order lines themselves
// (they're the exploded recipe of a Solution/SFG item, which IS the PO
// line), so their Qty Issued can't come from iss.qtyByItem like
// Packing Material's does — it's prorated and PERSISTED on the row
// itself server-side (see routes/sap.js's /issue-production).
const issuedQty = r.qtyIssued ? rawScaledDisp(r, 'qtyReq', false, 'qtyIssued') : '';
const row = [C(r.itemCode, { alignment: 'center' }), C(r.rawMaterial), C(r.spec, { alignment: 'center' }), C(rawScaledDisp(r, 'stdQty', true), { alignment: 'center' }), C(rawScaledUnit(r), { alignment: 'center' }), C(r.ovg, { alignment: 'center' }), C(rawScaledDisp(r, 'qtyReq'), { alignment: 'center' }), C(r.weighingBalanceId, { alignment: 'center' }), C(r.arNo, { alignment: 'center' }), C(issuedQty, { alignment: 'center' }), ...rowSigCells(r, sigs)];
body.push(row);
});
nodes.push({ table: { widths, headerRows: 1, body }, layout, margin: [0, 0, 0, 4] });
}
return nodes;
}
// Packing Material/Components rows ARE real SAP Production Order lines, so
// under the default 'issue_for_production' their Qty Issued reads live off
// iss.qtyByItem; only under 'mark_issued' (Admin → System Settings → "Raw
// Material Qty Issued Source") do they ALSO switch to the server-PERSISTED
// row.qtyIssued, matching Raw Material's own always-persisted behavior.
function packQtyIssued(r, iss, qtyIssuedSource) {
if (qtyIssuedSource === 'mark_issued') return r.qtyIssued ? num3(r.qtyIssued) : '';
return num3(iss.qtyByItem[(r.itemCode || '').trim()]);
}
function packSection(wo, iss, sigs, qtyIssuedSource) {
const rows = wo.packingMaterials || [];
const widths = [40, '*', 60, 50, 30, 30, 54, 34, 40, 60, 60, 60];
const head = ['Item code','Packing Materials','Artwork No.','Std. Qty/Unit','UOM','Ovg.%','Qty Req.(Units)','AR No.','Qty issued','Issued by /date','Received by','Verified by'].map(h => K(h, { alignment: 'center' }));
const body = [head];
if (!rows.length) body.push([{ text: '—', colSpan: 12, alignment: 'center' }, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}]);
rows.forEach((r) => {
const std = r.stdQtyUnit ? `${S(r.stdQtyPerUnit)} ${S(r.stdQtyUnit)}` : S(r.stdQtyPerUnit);
const qty = r.qtyUnitLabel ? `${S(r.qtyReqUnits)} ${S(r.qtyUnitLabel)}` : S(r.qtyReqUnits);
const row = [C(r.itemCode, { alignment: 'center' }), C(r.packingMaterial), C(r.artworkNo, { alignment: 'center' }), C(std, { alignment: 'center' }), C(r.uom || '-', { alignment: 'center' }), C(r.ovgPercent, { alignment: 'center' }), C(qty, { alignment: 'center' }), C(r.arNo, { alignment: 'center' }), C(packQtyIssued(r, iss, qtyIssuedSource), { alignment: 'center' }), ...rowSigCells(r, sigs)];
body.push(row);
});
return { table: { widths, headerRows: 1, body }, layout, margin: [0, 0, 0, 4] };
}
function componentsSection(wo, iss, sigs, qtyIssuedSource) {
const rows = wo.packingMaterials || []; // components-only mode stores rows here
const widths = [44, '*', 60, 30, 36, 70, 40, 44, 64, 64, 64];
const head = ['Item code','Components','Std. Qty/Unit','UOM','Ovg.','Qty Req.','AR No.','Qty issued','Issued by /date','Received by','Verified by'].map(h => K(h, { alignment: 'center' }));
const body = [head];
if (!rows.length) body.push([{ text: '—', colSpan: 11, alignment: 'center' }, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}]);
rows.forEach((r) => {
const std = r.stdQtyUnit ? `${S(r.stdQtyPerUnit)} ${S(r.stdQtyUnit)}` : S(r.stdQtyPerUnit);
const qty = r.qtyUnitLabel ? `${S(r.qtyReqUnits)} ${S(r.qtyUnitLabel)}` : S(r.qtyReqUnits);
const row = [C(r.itemCode, { alignment: 'center' }), C(r.packingMaterial), C(std, { alignment: 'center' }), C(r.uom || '-', { alignment: 'center' }), C(r.ovgPercent, { alignment: 'center' }), C(qty, { alignment: 'center' }), C(r.arNo, { alignment: 'center' }), C(packQtyIssued(r, iss, qtyIssuedSource), { alignment: 'center' }), ...rowSigCells(r, sigs)];
body.push(row);
});
return { table: { widths, headerRows: 1, body }, layout, margin: [0, 0, 0, 4] };
}
// 'aliases' covers a step whose display name changed later (e.g. "Checked
// By Production" → "Checked By Store In-Charge") — older Work Orders'
// workflowLog entries were recorded with the OLD text at the time and that
// stays exactly as-is (it's an immutable historical record), so the lookup
// below must still match it to print that sign-off correctly.
const STEP_LABELS = [
{ step: 'Prepared By QA', label: 'Prepared By (QA)' },
{ step: 'Checked By QC', label: 'Checked By (QC)' },
{ step: 'Checked By Store In-Charge', aliases: ['Checked By Production'], label: 'Checked By (Store In-Charge)' },
{ step: 'Checked By (Manager Production)', label: 'Checked By (Manager Production)' },
{ step: 'Approved By (Manager QA)', label: 'Approved By (Manager QA)' },
];
function signoffs(wo, sigs) {
const log = Array.isArray(wo.workflowLog) ? wo.workflowLog : [];
// A step this WO auto-skipped (e.g. "Checked By QC" while workOrderSkipQc
// is on) is left off the printed sign-off list entirely — matches the
// stepper on the Work Order page itself (public/work-order.html stepper()).
const skipped = new Set(log.filter(l => l.action === 'auto-skipped').map(l => l.step));
const visible = STEP_LABELS.filter(s => !skipped.has(s.step) && !(s.aliases || []).some(a => skipped.has(a)));
const logFor = (step, aliases) => { for (let i = log.length - 1; i >= 0; i--) { const l = log[i]; if ((l.step === step || (aliases || []).includes(l.step)) && l.action !== 'rejected' && l.action !== 'auto-skipped') return l; } return null; };
const body = visible.map(s => {
const l = logFor(s.step, s.aliases);
const sig = l ? { name: l.byName || l.by, user: l.by, at: l.at } : null;
return [Object.assign(K(s.label), { margin: [2, 8, 2, 8] }), sigCell(sig, sigs, { margin: [2, 3, 2, 3], minHeight: 30 })];
});
return { table: { widths: [240, '*'], body }, layout, margin: [0, 6, 0, 0] };
}
function buildDoc({ wo, settings, iss, sigs, newItemCode, qtyIssuedSource }) {
// Authoritative flag, not inferred from rawMaterials.length — a
// componentsOnly save also writes a legacy-compatible copy of the same
// components into rawMaterials, so its length alone can't be used to tell
// components-only orders apart from real split Raw/Packing ones.
const componentsMode = !!wo.componentsOnly;
const content = [];
if (componentsMode) {
content.push(headerBand(settings), titleBar(''), infoBox(wo, newItemCode), componentsSection(wo, iss, sigs, qtyIssuedSource), signoffs(wo, sigs));
} else {
content.push(headerBand(settings), titleBar(' (RAW MATERIAL)'), infoBox(wo, newItemCode), ...rawSection(wo, iss, sigs), signoffs(wo, sigs));
content.push(headerBand(settings, true), titleBar(' (PACKING MATERIAL)'), infoBox(wo, newItemCode), packSection(wo, iss, sigs, qtyIssuedSource), signoffs(wo, sigs));
}
return {
pageSize: 'A4', pageOrientation: 'landscape', pageMargins: [16, 14, 16, 24],
defaultStyle: { font: 'Helvetica', fontSize: 7 }, content,
footer: (currentPage, pageCount) => ({ text: `Page ${currentPage} of ${pageCount}`, alignment: 'center', fontSize: 8, margin: [0, 6, 0, 0] }),
};
}
// Returns a PdfKit document stream (call .pipe(res) then it self-ends).
function generate(data) {
const doc = printer().createPdfKitDocument(buildDoc(data));
return doc;
}
module.exports = { generate, buildDoc };
+404
View File
@@ -0,0 +1,404 @@
// services/workOrderStore.js
// Production Work Orders in SQL: ZWORK_ORDERS. Generated from a Batch Intimation.
// WO No format: WO-{MM}-{YY}-{NNNN}. 5-step approval workflow.
const sql = require('mssql');
const TABLE = `[dbo].[ZWORK_ORDERS]`;
// Ordered workflow steps (STAGE = number of completed steps)
const STEPS = [
'Prepared By QA',
'Checked By QC',
'Checked By Store In-Charge',
'Checked By (Manager Production)',
'Approved By (Manager QA)',
];
// Optional toggle: skip "Checked By QC" entirely (new work orders start
// already past it; existing ones stuck waiting on it get auto-advanced too
// — see autoAdvanceStuckQC()). Now an admin-editable setting (services/
// appSettingsStore.js) rather than an .env var — read live via SKIP_QC() so
// a change in the Admin panel takes effect without a restart.
const appSettings = require('./appSettingsStore');
const SKIP_QC = () => appSettings.workOrderSkipQc();
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[WO-STORE] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
WO_NO NVARCHAR(30) NOT NULL,
INTIMATION_ID INT,
REFERENCE NVARCHAR(60),
PRODUCT_NAME NVARCHAR(200),
PRODUCT_DESC NVARCHAR(400),
GENERIC_NAME NVARCHAR(200),
PRODUCT_CODE NVARCHAR(60),
BATCH_NUMBER NVARCHAR(60),
BATCH_SIZE NVARCHAR(60),
TOTAL_UNITS NVARCHAR(60),
MFG_DATE NVARCHAR(30),
EXP_DATE NVARCHAR(30),
PACK_SIZE NVARCHAR(60),
TYPE NVARCHAR(60),
MARKET NVARCHAR(120),
RAW_MATERIALS NVARCHAR(MAX),
PACKING_MATERIALS NVARCHAR(MAX),
STAGE INT DEFAULT 1,
STATUS NVARCHAR(20) DEFAULT 'IN_PROGRESS',
WORKFLOW_LOG NVARCHAR(MAX),
REMARKS NVARCHAR(MAX),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_AT DATETIME2,
IS_DELETED BIT DEFAULT 0,
COMPANY NVARCHAR(60)
)
`).catch(e => {
if (isAlreadyExists(e)) { console.log('[WO-STORE] Table exists — OK'); }
else throw e;
});
// Migration: MFG/EXP dates may be MMM/YYYY — switch DATE columns to text
for (const col of ['MFG_DATE', 'EXP_DATE']) {
await exec(`
IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME='ZWORK_ORDERS' AND COLUMN_NAME='${col}' AND DATA_TYPE='date')
ALTER TABLE ${TABLE} ALTER COLUMN ${col} NVARCHAR(30)
`).catch(e => console.log(`[WO-STORE] ${col} migration:`, e.message));
}
// COMPONENTS_ONLY: true when this WO was saved in the simplified single
// "Components" table mode (no real raw material found via BOM). In that
// case RAW_MATERIALS is a SAVE-TIME-ONLY duplicate of the Components rows
// (for downstream consumers that specifically look at raw materials, e.g.
// Production Order issuance) — an explicit flag, not re-derived from
// array contents, is what tells the UI to keep showing Components mode
// when this record is reopened for editing (otherwise the non-empty
// duplicated RAW_MATERIALS would look like "real" raw material and flip
// the page back to the two-section layout).
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZWORK_ORDERS' AND COLUMN_NAME='COMPONENTS_ONLY') ALTER TABLE ${TABLE} ADD [COMPONENTS_ONLY] BIT DEFAULT 0`).catch(() => {});
// Widen columns that hit "String or binary data would be truncated" in
// practice — the header's "Product Name & Description" is ONE combined
// field (auto-filled from the SAP item's own name, which can easily run
// past 200 chars for pharma-style compound descriptions) mapped straight
// to PRODUCT_NAME; BATCH_SIZE became free text in Components-only mode and
// can hold a multi-line multi-solution summary. SQL Server's truncation
// error doesn't say which column, so widened the realistic candidates
// together rather than chasing them one at a time.
const WIDEN = { PRODUCT_NAME: 500, GENERIC_NAME: 300, REFERENCE: 200, BATCH_SIZE: 200 };
for (const [col, size] of Object.entries(WIDEN)) {
await exec(`
IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME='ZWORK_ORDERS' AND COLUMN_NAME='${col}' AND CHARACTER_MAXIMUM_LENGTH < ${size})
ALTER TABLE ${TABLE} ALTER COLUMN [${col}] NVARCHAR(${size})
`).catch(e => console.log(`[WO-STORE] ${col} widen migration:`, e.message));
}
await autoAdvanceStuckQC();
console.log('[WO-STORE] ✅ Ready');
}
function toTs(iso) { return iso ? iso.replace('T', ' ').replace('Z', '').substring(0, 23) : null; }
function safeJson(v, f) { if (!v) return f; try { return JSON.parse(v); } catch { return f; } }
// While WORK_ORDER_SKIP_QC is on, also un-stick any work order that's
// CURRENTLY sitting at stage 1 waiting for a QC check that will never come
// (a one-time cleanup, not just new orders going forward). Safe to re-run
// on every boot — a WO only ever matches STAGE=1 once, since this always
// advances it to stage 2. If the flag is later turned back off, whatever
// got skipped stays skipped (their history is immutable); new work orders
// simply go back to requiring the QC step normally.
async function autoAdvanceStuckQC() {
if (!SKIP_QC()) return;
const rows = await exec(`SELECT ID, WORKFLOW_LOG FROM ${TABLE} WHERE STAGE = 1 AND STATUS = 'IN_PROGRESS'`);
if (!rows.length) return;
console.log(`[WO-STORE] QC step disabled — auto-advancing ${rows.length} stuck work order(s) past "Checked By QC"…`);
const now = toTs(new Date().toISOString());
for (const r of rows) {
const log = safeJson(r.WORKFLOW_LOG, []);
log.push({ step: STEPS[1], action: 'auto-skipped', by: 'system', byName: 'System (QC step disabled)', at: new Date().toISOString(), remarks: 'Checked By QC temporarily disabled' });
await exec(`UPDATE ${TABLE} SET STAGE = 2, WORKFLOW_LOG = ?, UPDATED_AT = ? WHERE ID = ?`, [JSON.stringify(log), now, r.ID]);
}
console.log('[WO-STORE] ✅ Stuck work orders advanced past QC');
}
function fromRow(row) {
if (!row) return null;
const stage = row.STAGE || 1;
const status = row.STATUS || 'IN_PROGRESS';
return {
id: row.ID,
woNo: row.WO_NO || '',
intimationId: row.INTIMATION_ID || null,
reference: row.REFERENCE || '',
productName: row.PRODUCT_NAME || '',
productDesc: row.PRODUCT_DESC || '',
genericName: row.GENERIC_NAME || '',
productCode: row.PRODUCT_CODE || '',
batchNumber: row.BATCH_NUMBER || '',
batchSize: row.BATCH_SIZE || '',
totalUnits: row.TOTAL_UNITS || '',
mfgDate: row.MFG_DATE || '', // text — may be DD-MMM-YYYY or MMM/YYYY
expDate: row.EXP_DATE || '',
packSize: row.PACK_SIZE || '',
type: row.TYPE || '',
market: row.MARKET || '',
rawMaterials: safeJson(row.RAW_MATERIALS, []),
packingMaterials: safeJson(row.PACKING_MATERIALS, []),
componentsOnly: !!row.COMPONENTS_ONLY,
stage,
status,
steps: STEPS,
currentStep: status === 'APPROVED' ? 'Approved' : status === 'REJECTED' ? 'Rejected' : STEPS[stage] || 'Completed',
workflowLog: safeJson(row.WORKFLOW_LOG, []),
remarks: row.REMARKS || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
isDeleted: !!row.IS_DELETED,
company: row.COMPANY || '',
};
}
async function generateWoNo(company) {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const yy = String(now.getFullYear()).slice(-2);
const prefix = `WO-${mm}-${yy}-`;
const rows = await exec(
`SELECT WO_NO FROM ${TABLE} WHERE WO_NO LIKE ? ${company ? 'AND COMPANY = ?' : ''}`,
company ? [prefix + '%', company] : [prefix + '%']
);
let max = 0;
rows.forEach(r => { const n = parseInt((r.WO_NO || '').slice(prefix.length), 10); if (!isNaN(n) && n > max) max = n; });
return prefix + String(max + 1).padStart(4, '0');
}
async function insertWorkOrder(w) {
const now = toTs(new Date().toISOString());
const woNo = await generateWoNo(w.company);
const log = [{ step: STEPS[0], action: 'prepared', by: w.createdBy, byName: w.createdByName || w.createdBy, at: new Date().toISOString(), remarks: '' }];
let stage = 1;
if (SKIP_QC()) {
log.push({ step: STEPS[1], action: 'auto-skipped', by: 'system', byName: 'System (QC step disabled)', at: new Date().toISOString(), remarks: 'Checked By QC temporarily disabled' });
stage = 2;
}
// INSERT and SELECT SCOPE_IDENTITY() MUST be one batch/one request — as two
// separate exec() calls, the pooled connection can hand the second call a
// DIFFERENT physical connection than the one that just did the INSERT,
// where SCOPE_IDENTITY() correctly returns NULL (it's scoped to the
// session that ran the insert). That silently made insertWorkOrder()
// return null while the row was actually saved fine — the API then sent
// back {success:true, data:null}, so the frontend's `if(_created)` check
// never showed the success screen, and the user (seeing nothing happen)
// kept resubmitting the same form, creating duplicate work orders.
const idRows = await exec(`
INSERT INTO ${TABLE} (
WO_NO, INTIMATION_ID, REFERENCE, PRODUCT_NAME, PRODUCT_DESC, GENERIC_NAME, PRODUCT_CODE,
BATCH_NUMBER, BATCH_SIZE, TOTAL_UNITS, MFG_DATE, EXP_DATE, PACK_SIZE, TYPE, MARKET,
RAW_MATERIALS, PACKING_MATERIALS, COMPONENTS_ONLY, STAGE, STATUS, WORKFLOW_LOG, REMARKS,
CREATED_BY, CREATED_NAME, CREATED_AT, COMPANY
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
woNo, w.intimationId ? parseInt(w.intimationId) : null, w.reference || '',
w.productName || '', w.productDesc || '', w.genericName || '', w.productCode || '',
w.batchNumber || '', w.batchSize || '', w.totalUnits || '',
w.mfgDate || null, w.expDate || null, w.packSize || '', w.type || '', w.market || '',
JSON.stringify(w.rawMaterials || []), JSON.stringify(w.packingMaterials || []), w.componentsOnly ? 1 : 0,
stage, 'IN_PROGRESS', JSON.stringify(log), w.remarks || '',
w.createdBy, w.createdByName || w.createdBy, now, w.company || '',
]);
return findById(idRows[0].ID);
}
// Edit + resubmit a REJECTED work order in one save — restarts the full
// 5-step approval chain from the top (STAGE reset to 0/1/2 exactly like a
// brand-new insertWorkOrder(), including the SKIP_QC auto-skip), since the
// edited content was never seen by any of the original approvers. Keeps the
// rejection's history in WORKFLOW_LOG (appends, doesn't clear) for audit.
async function resubmitAfterReject(id, w, { by, byName }) {
const wo = await findById(id);
if (!wo) throw new Error('Work order not found');
if (wo.status !== 'REJECTED') throw new Error('Work order is not rejected — cannot resubmit');
const now = toTs(new Date().toISOString());
const log = wo.workflowLog || [];
let stage = 1;
log.push({ step: STEPS[0], action: 'resubmitted', by, byName: byName || by, at: new Date().toISOString(), remarks: 'Edited and resubmitted after rejection' });
if (SKIP_QC()) {
log.push({ step: STEPS[1], action: 'auto-skipped', by: 'system', byName: 'System (QC step disabled)', at: new Date().toISOString(), remarks: 'Checked By QC temporarily disabled' });
stage = 2;
}
await exec(`
UPDATE ${TABLE} SET
PRODUCT_NAME=?, PRODUCT_DESC=?, GENERIC_NAME=?, PRODUCT_CODE=?, BATCH_NUMBER=?,
BATCH_SIZE=?, TOTAL_UNITS=?, MFG_DATE=?, EXP_DATE=?, PACK_SIZE=?, TYPE=?, MARKET=?,
REFERENCE=?, RAW_MATERIALS=?, PACKING_MATERIALS=?, COMPONENTS_ONLY=?, REMARKS=?,
STAGE=?, STATUS='IN_PROGRESS', WORKFLOW_LOG=?, UPDATED_AT=?
WHERE ID=? AND (IS_DELETED=0 OR IS_DELETED IS NULL)
`, [
w.productName || '', w.productDesc || '', w.genericName || '', w.productCode || '', w.batchNumber || '',
w.batchSize || '', w.totalUnits || '', w.mfgDate || null, w.expDate || null, w.packSize || '', w.type || '', w.market || '',
w.reference || '', JSON.stringify(w.rawMaterials || []), JSON.stringify(w.packingMaterials || []), w.componentsOnly ? 1 : 0, w.remarks || '',
stage, JSON.stringify(log), now,
parseInt(id),
]);
return findById(id);
}
// Admin-only recall of a FULLY APPROVED Work Order back to QA for editing —
// re-uses the exact same mechanics as a normal rejection (STATUS='REJECTED')
// so it picks up the already-built "Edit & Resubmit" flow for free
// (resubmitAfterReject() restarts the full chain from step 1 once someone
// edits it). No approval step is required for this action — it's an admin
// override for a document that already finished its whole chain, not a
// normal in-flight rejection by whoever holds the current step.
async function sendBackToQaForEdit(id, { by, byName, remarks }) {
const wo = await findById(id);
if (!wo) throw new Error('Work order not found');
if (wo.status !== 'APPROVED') throw new Error('Only a fully approved Work Order can be sent back to QA');
const now = toTs(new Date().toISOString());
const log = wo.workflowLog || [];
log.push({ step: STEPS[STEPS.length - 1], action: 'rejected', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || 'Sent back to QA for edit by admin' });
await exec(`UPDATE ${TABLE} SET STATUS='REJECTED', WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[JSON.stringify(log), now, parseInt(id)]);
return findById(id);
}
// `reference` is a free-text field on the Work Order header (editable in
// Create/Edit — see work-order.html's hf('Reference','reference')), NOT a
// reliable mirror of the Intimation it was generated from: a user can (and
// in practice does) overwrite it with their own note/PO reference after
// generation, so it can legitimately read something like "MIPL-QS-029"
// while intimationId still correctly points at BII-09-26-0070. The ONLY
// authoritative source for "which Intimation was this generated from" is
// the intimationId foreign key — this attaches that Intimation's real
// DOC_NO as `intimationDocNo` so the UI can show it without confusing it
// with the separate, freely-editable `reference` field.
async function attachIntimationDocNos(list) {
const ids = [...new Set(list.map(r => r.intimationId).filter(Boolean))];
if (!ids.length) return list;
const placeholders = ids.map(() => '?').join(',');
const rows = await exec(`SELECT ID, DOC_NO FROM [dbo].[ZBATCH_INTIMATIONS] WHERE ID IN (${placeholders})`, ids);
const byId = {};
rows.forEach(r => { byId[r.ID] = r.DOC_NO || ''; });
list.forEach(r => { r.intimationDocNo = r.intimationId ? (byId[r.intimationId] || '') : ''; });
return list;
}
async function listWorkOrders({ mine, company, status, includeDeleted } = {}) {
const all = await exec(`SELECT * FROM ${TABLE} ORDER BY CREATED_AT DESC`);
const filtered = all.map(fromRow).filter(r => {
if (!includeDeleted && r.isDeleted) return false;
if (status && status !== 'ALL' && r.status !== status.toUpperCase()) return false;
if (mine && r.createdBy !== mine) return false;
if (company && r.company && r.company !== company) return false;
return true;
});
return attachIntimationDocNos(filtered);
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]);
if (!rows.length) return null;
const [wo] = await attachIntimationDocNos([fromRow(rows[0])]);
return wo;
}
async function updateWorkOrder(id, w) {
const now = toTs(new Date().toISOString());
await exec(`
UPDATE ${TABLE} SET
PRODUCT_NAME=?, PRODUCT_DESC=?, GENERIC_NAME=?, PRODUCT_CODE=?, BATCH_NUMBER=?,
BATCH_SIZE=?, TOTAL_UNITS=?, MFG_DATE=?, EXP_DATE=?, PACK_SIZE=?, TYPE=?, MARKET=?,
REFERENCE=?, RAW_MATERIALS=?, PACKING_MATERIALS=?, COMPONENTS_ONLY=?, REMARKS=?, UPDATED_AT=?
WHERE ID=? AND (IS_DELETED=0 OR IS_DELETED IS NULL)
`, [
w.productName || '', w.productDesc || '', w.genericName || '', w.productCode || '', w.batchNumber || '',
w.batchSize || '', w.totalUnits || '', w.mfgDate || null, w.expDate || null, w.packSize || '', w.type || '', w.market || '',
w.reference || '', JSON.stringify(w.rawMaterials || []), JSON.stringify(w.packingMaterials || []), w.componentsOnly ? 1 : 0, w.remarks || '', now,
parseInt(id),
]);
return findById(id);
}
// Advance or reject the workflow
async function workflowAction(id, { action, by, byName, remarks }) {
const wo = await findById(id);
if (!wo) throw new Error('Work order not found');
if (wo.status !== 'IN_PROGRESS') throw new Error('Work order is already ' + wo.status.toLowerCase());
const log = wo.workflowLog || [];
const pendingStep = STEPS[wo.stage] || 'Approved By (Manager QA)';
const now = toTs(new Date().toISOString());
if (action === 'reject') {
log.push({ step: pendingStep, action: 'rejected', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || '' });
await exec(`UPDATE ${TABLE} SET STATUS='REJECTED', WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[JSON.stringify(log), now, parseInt(id)]);
return findById(id);
}
// approve → complete current pending step
log.push({ step: pendingStep, action: 'approved', by, byName: byName || by, at: new Date().toISOString(), remarks: remarks || '' });
const newStage = wo.stage + 1;
const newStatus = newStage >= STEPS.length ? 'APPROVED' : 'IN_PROGRESS';
await exec(`UPDATE ${TABLE} SET STAGE=?, STATUS=?, WORKFLOW_LOG=?, UPDATED_AT=? WHERE ID=?`,
[newStage, newStatus, JSON.stringify(log), now, parseInt(id)]);
return findById(id);
}
async function softDelete(id) {
await exec(`UPDATE ${TABLE} SET IS_DELETED=1, UPDATED_AT=? WHERE ID=?`,
[toTs(new Date().toISOString()), parseInt(id)]);
}
// Merge `patch` into ONE material row (raw or packing) — used for per-row
// Weighing Balance ID / AR No. edits and the Issued/Received/Verified
// one-click stamps (see routes/workOrders.js). Independent of the work
// order's own approval-workflow status/edit-lock — these actions happen
// AFTER the work order is fully approved, gated by their own approval steps
// (work_order:issue/receive/verify), not the 5-step QA chain.
async function patchMaterialRow(id, section, index, patch) {
const wo = await findById(id);
if (!wo) throw new Error('Work order not found');
const arr = (section === 'raw' ? wo.rawMaterials : wo.packingMaterials) || [];
const i = parseInt(index);
if (!(i >= 0) || i >= arr.length) throw new Error('Row not found');
arr[i] = { ...arr[i], ...patch };
if (section === 'raw') wo.rawMaterials = arr; else wo.packingMaterials = arr;
return updateWorkOrder(id, wo);
}
module.exports = {
bootstrap, STEPS, generateWoNo, insertWorkOrder,
listWorkOrders, findById, updateWorkOrder, workflowAction, softDelete, patchMaterialRow,
resubmitAfterReject, sendBackToQaForEdit,
};