// backend/routes/sap.js 'use strict'; const express = require('express'); const router = express.Router(); const { verifyToken, requireApprovalStep, requireStepAssigned, hasStepPerm, hasStepAssigned } = require('../middleware/auth'); const { resolve: resolveCompany } = require('../services/companyConfig'); const poStore = () => require('../services/productionOrderStore'); const devStore = () => require('../services/deviationStore'); const prePwoStore = () => require('../services/prePwoStore'); const appSettings = require('../services/appSettingsStore'); // ── Lazy-load services ────────────────────────────────────────── let _sapSvc = null; function getSap(){ if(!_sapSvc) _sapSvc = require('../services/sapServiceLayer'); return _sapSvc; } // A Production Order's local step tracker only advances past stage 0 // ("Release") when someone with the production_order:release approval steps // through THIS PORTAL's own Release action — that's the point of the gate: // it's the recorded approval, not a mirror of SAP's raw status. If a // PRODUCTION-side status check finds SAP already showing boposReleased while // the portal is still at stage 0, that means the order was released directly // in SAP B1, bypassing the portal and its approval step entirely. Deliberately // NOT auto-advancing here: silently accepting SAP's status as good enough // would mean the "concerned user" never needs to touch the portal to // release — everyone would just release in SAP and the portal's own Release // approval step becomes pointless. Instead: flag it to the Audit Trail (so // admins can see who tried to proceed against an order released outside the // portal, and when) and keep blocking exactly as before — the concerned // user must still perform Release IN THE PORTAL (a harmless idempotent // re-PATCH of SAP's already-Released status) for that approval to be // properly recorded and the stage to actually advance. async function flagOutOfPortalRelease(linked, co, req) { if (!linked || linked.stage !== 0) return linked; try { const sapPo = await getSap().sapRequest('GET', `ProductionOrders(${linked.sapAbsEntry})?$select=ProductionOrderStatus`, null, co); if (sapPo?.ProductionOrderStatus === 'boposReleased') { require('../services/auditStore').record({ userId: req?.user?.id, username: req?.user?.username, role: req?.user?.role, method: req?.method, action: 'FLAG', entity: 'ProductionOrder', entityId: String(linked.id), sub: 'released_outside_portal', path: req?.originalUrl || '', status: 409, ok: false, summary: `Production Order ${linked.sapDocNum || '#' + linked.id} (${linked.itemCode || ''}) shows Released in SAP but was never Released through this portal — likely released directly in SAP B1.`, details: { sapAbsEntry: linked.sapAbsEntry, sapDocNum: linked.sapDocNum, itemCode: linked.itemCode, localStage: linked.stage }, ip: req?.ip, company: co, }).catch(() => {}); } } catch (e) { console.warn('[PROD] release status check failed (non-fatal):', e.message); } return linked; } const { getPool } = require('../services/sqlPool'); // `companyDB` picks which SAP company database this query runs against (see // services/sqlPool.js) — omit only for queries that are genuinely // company-agnostic (there should be none among SAP-table queries; always // pass the request's resolved company). async function hanaQuery(sqlQuery, companyDB){ console.log(`[SQL]${companyDB?' ('+companyDB+')':''}`,sqlQuery.slice(0,120).replace(/\s+/g,' ')); const pool = await getPool(companyDB); const result = await pool.request().query(sqlQuery); console.log(`[SQL] ✅ ${(result.recordset||[]).length} rows`); return result.recordset||[]; } const DB=(c)=>`[dbo]`; const cq =(req)=>req.query?.company||req.body?.company||null; // extract company from request async function safeLookup(res,sql,mapFn,companyDB){ try{ const rows=await hanaQuery(sql,companyDB); return res.json({success:true,data:rows.map(mapFn)}); }catch(err){ console.warn('[SAP-ROUTE] safeLookup fallback:',err.message); return res.json({success:true,data:[],warning:err.message}); } } function cleanEmpty(obj){ Object.keys(obj).forEach(k=>{ if(obj[k]===''||obj[k]===null||obj[k]===undefined) delete obj[k]; }); return obj; } function cleanItemPayload(body){ const src=body||{}; const materialTypeMap={ 'Finished Goods':'mt_FinishedGoods', 'Raw Material':'mt_RawMaterial', 'Semi-Finished':'mt_SemiFinishedGoods', 'Trading':'mt_FinishedGoods', 'Service':'mt_RawMaterial', 'Consumable':'mt_RawMaterial', }; const gstCategoryMap={ Regular:'gtc_Regular', Exempt:'gtc_Exempt', Composition:'gtc_Regular', }; const costMethodMap={ vmMovingAverage:'bis_MovingAverage', vmFIFO:'bis_FIFO', vmStandard:'bis_Standard', }; const glMethodMap={ ItemGroup:'glm_ItemClass', Warehouse:'glm_Warehouse', ItemLevel:'glm_ItemLevel', }; const payload={ ItemCode: src.ItemCode, ItemName: src.ItemName, ForeignName: src.ForeignName, ItemType: src.ItemType||'itItems', ItemClass: src.ItemClass || (src.ItemCategory==='Service'?'itcService':'itcMaterial'), ItemsGroupCode: src.ItemsGroupCode!==undefined&&src.ItemsGroupCode!=='' ? parseInt(src.ItemsGroupCode) : undefined, InventoryItem: src.InventoryItem, SalesItem: src.SalesItem, PurchaseItem: src.PurchaseItem, Valid: src.Valid || (src.Frozen==='tYES'?undefined:'tYES'), Frozen: src.Frozen, ManageSerialNumbers: src.ManageSerialNumbers, ManageBatchNumbers: src.ManageBatchNumbers, SRIAndBatchManageMethod: src.SRIAndBatchManageMethod, PlanningSystem: src.PlanningSystem, ProcurementMethod: src.ProcurementMethod, ComponentWarehouse: src.ComponentWarehouse, IssueMethod: src.IssueMethod, User_Text: src.User_Text || src.UserText, TreeType: src.TreeType, GLMethod: src.GLMethod ? (glMethodMap[src.GLMethod]||src.GLMethod) : undefined, CostAccountingMethod: src.CostAccountingMethod || (src.ValuationMethod ? costMethodMap[src.ValuationMethod] : undefined), WTLiable: src.WTLiable || src.WithholdingTaxLiable, NoDiscounts: src.NoDiscounts, Excisable: src.Excisable, GSTRelevnt: src.GSTRelevnt || src.GSTRelevant, GSTTaxCategory: src.GSTTaxCategory || (src.TaxCategory ? gstCategoryMap[src.TaxCategory] : undefined), MaterialType: src.MaterialType ? (materialTypeMap[src.MaterialType]||src.MaterialType) : undefined, ProductSource: src.ProductSource, }; if(src.InventoryUOM) payload.InventoryUOM=src.InventoryUOM; if(src.SalesUnit) payload.SalesUnit=src.SalesUnit; if(src.PurchaseUnit) payload.PurchaseUnit=src.PurchaseUnit; if(src.BarCode) payload.BarCode=src.BarCode; if(src.AdditionalIdentifier) payload.SWW=src.AdditionalIdentifier; if(src.DefaultVendor) payload.Mainsupplier=src.DefaultVendor; // if(src.DefaultVendor) payload.Mainsupplier=src.DefaultVendor; if(src.DefaultWarehouse) payload.DefaultWarehouse=src.DefaultWarehouse; // ← ADD THIS if(src.SalesTaxCode) payload.ArTaxCode=src.SalesTaxCode; if(src.PurchaseTaxCode) payload.ApTaxCode=src.PurchaseTaxCode; if(src.SalesRevenueAccount) payload.IncomeAccount=src.SalesRevenueAccount; if(src.PurchaseAccount) payload.ExpanseAccount=src.PurchaseAccount; if(src.ShippingType!==undefined&&src.ShippingType!==''&&!isNaN(Number(src.ShippingType))) payload.ShipType=Number(src.ShippingType); [ 'CustomsGroupCode','VatLiable','ForceSelectionOfSerialNumber', 'ManageSerialNumbersOnReleaseOnly','AssetItem','TaxType','Valid', 'SRIAndBatchManageMethod','ItemClass','TreeType','ComponentWarehouse', 'ProductSource','GSTRelevnt','GSTTaxCategory','Excisable', 'ManageStockByWarehouse','InCostRollup' ].forEach(k=>{ if(src[k]!==undefined&&src[k]!==''&&src[k]!==null) payload[k]=src[k]; }); [ ['UoMGroupEntry','UoMGroupEntry'], ['SalesItemsPerUnit','SalesItemsPerUnit'], ['SalesQtyPerPackage','SalesQtyPerPackUnit'], ['SalesMinimumOrderQuantity','MinInventory'], ['PurchaseItemsPerUnit','PurchaseItemsPerUnit'], ['ItemsPerPurchaseUnit','PurchaseItemsPerUnit'], ['PurchaseQtyPerPackage','PurchaseQtyPerPackUnit'], ['DesiredInventory','DesiredInventory'], ['MinimumInventory','MinInventory'], ['MinimumQuantityInWarehouse','MinInventory'], ['MaximumQuantityInWarehouse','MaxInventory'], ['Weight1','InventoryWeight'], ['OrderMultiple','OrderMultiple'], ['MinimumOrderQuantity','MinOrderQuantity'], ['MinOrderQuantity','MinOrderQuantity'], ['LeadTimeDays','LeadTime'], ['LeadTime','LeadTime'], ['ToleranceDays','ToleranceDays'], ['ProductionStandardCost','ProdStdCost'], ['ProdStdCost','ProdStdCost'], ['AssessableValue','AssessableValue'], ['AssessableValueForWTR','AssVal4WTR'], ['AssVal4WTR','AssVal4WTR'], ['WarrantyTemplate','WarrantyTemplate'], ].forEach(([from,to])=>{ if(src[from]!==undefined&&src[from]!==''&&!isNaN(Number(src[from]))) payload[to]=Number(src[from]); }); if(src.IncludeInStdCostRollup) payload.InCostRollup=src.IncludeInStdCostRollup; if(src.InCostRollup) payload.InCostRollup=src.InCostRollup; if(src.HsnCode!==undefined&&src.HsnCode!==''&&!isNaN(parseInt(src.HsnCode))) payload.ChapterID=parseInt(src.HsnCode); if(src.ChapterID!==undefined&&src.ChapterID!==''&&!isNaN(parseInt(src.ChapterID))) payload.ChapterID=parseInt(src.ChapterID); for(let i=1;i<=64;i++){ const key=`Properties${i}`; if(src[key]) payload[key]=src[key]; } if(Array.isArray(src.ItemPrices)) payload.ItemPrices=src.ItemPrices; if(Array.isArray(src.ItemWarehouseInfoCollection)) payload.ItemWarehouseInfoCollection=src.ItemWarehouseInfoCollection; cleanEmpty(payload); if(src.OrderIntervals!==undefined&&src.OrderIntervals!==''&&src.OrderIntervals!==null&&String(src.OrderIntervals)!=='0'){ payload.OrderIntervals=src.OrderIntervals; } else { delete payload.OrderIntervals; } if(payload.ItemsGroupCode!==undefined&&isNaN(payload.ItemsGroupCode)) delete payload.ItemsGroupCode; if(payload.UoMGroupEntry!==undefined&&isNaN(payload.UoMGroupEntry)) delete payload.UoMGroupEntry; return payload; } // ════════════════════════════════════════════════════════════════ // ITEM SEARCH // ════════════════════════════════════════════════════════════════ router.get('/lookup/items', verifyToken, async(req,res)=>{ const q=(req.query.q||'').trim().toUpperCase(); const co=cq(req); // Opt-in: excludes inactive items (SAP's own "Invalid"/"Frozen" flags). // Off by default since this search is shared by many pages — only pass // activeOnly=1 where issuing/selecting an inactive item would actually // fail against SAP (e.g. Raise Deviation's Substitution item picker). const activeOnly=req.query.activeOnly==='1'; // Batch Issuance's SFG workflow: restrict results to items whose SAP Item // Group is tagged 'SFG' in Item Group Rules (services/itemGroupClassStore.js). const sfgOnly=req.query.sfgOnly==='1'; // Consumable Order (Production Order — Manual Entry restricted to Service // items): a fixed literal SAP Item Group code, not admin-configurable — // ?itemGroup=132 restricts results to exactly that group. const itemGroup=req.query.itemGroup?parseInt(req.query.itemGroup):null; // Manual PWO Item Groups restriction (Admin → user → Manual PWO Item // Groups): a per-user LIST of allowed groups, unlike the single-group // itemGroup param above — ?itemGroups=101,102 restricts results to any of // those. Purely a search-UX convenience; POST /sap/production-order // independently re-verifies the submitted item's group server-side. const itemGroups=(req.query.itemGroups||'').split(',').map(s=>parseInt(s.trim())).filter(n=>!isNaN(n)); if(!q||q.length<2) return res.json({success:true,data:[]}); try{ const safeQ=q.replace(/'/g,"''"); const activeSql=activeOnly?` AND "validFor"='Y' AND "frozenFor"='N'`:''; let sfgSql=''; if(sfgOnly){ const sfgGroups=await require('../services/itemGroupClassStore').getSfgGroupCodes(); sfgSql=sfgGroups.length?` AND "ItmsGrpCod" IN (${sfgGroups.join(',')})`:` AND 1=0`; } const groupSql=(itemGroup!=null&&!isNaN(itemGroup))?` AND "ItmsGrpCod"=${itemGroup}` :itemGroups.length?` AND "ItmsGrpCod" IN (${itemGroups.join(',')})`:''; const rows=await hanaQuery(` SELECT TOP 20 "ItemCode","ItemName","InvntryUom","LastPurPrc","DfltWH" FROM ${DB(co)}."OITM" WHERE (UPPER("ItemCode") LIKE '%${safeQ}%' OR UPPER("ItemName") LIKE '%${safeQ}%')${activeSql}${sfgSql}${groupSql} ORDER BY "ItemCode"`,co); // A successful HANA query is authoritative even when it returns zero // rows (e.g. sfgOnly correctly filtered everything out) — falling // through to the SL fallback below in that case used to silently drop // the sfgOnly restriction and return non-SFG items instead. return res.json({success:true,data:rows.map(i=>({ ItemCode:i.ItemCode,ItemName:i.ItemName,UoM:i.InvntryUom||'PCS',Price:Number(i.LastPurPrc)||0,Warehouse:i.DfltWH||'', }))}); }catch{console.warn('[SAP] HANA items → fallback SL');} try{ const safeQ=q.replace(/'/g,"''"); // sfgOnly must still be honored here — this path only runs when HANA // itself failed (see above), not merely returned zero matches. let sfgGroups=null; if(sfgOnly){ sfgGroups=await require('../services/itemGroupClassStore').getSfgGroupCodes(); if(!sfgGroups.length) return res.json({success:true,data:[]}); } // contains(...) is the OData v4 substring filter — substringof(...) eq // true (OData v2) is rejected by this SAP B1 Service Layer version with // error 205 "Query string error - the value 'true' of property ')' is // invalid". Was masked for a long time: the direct-SQL path above almost // always finds a match on the default company, so this fallback rarely // ran; it surfaced once a company with fewer/different items (0 real SQL // matches) started hitting it on every search. let filterStr=`contains(ItemCode,'${safeQ}') or contains(ItemName,'${safeQ}')`; if(activeOnly) filterStr=`(${filterStr}) and Valid eq 'tYES' and Frozen eq 'tNO'`; if(sfgGroups) filterStr=`(${filterStr}) and (${sfgGroups.map(g=>`ItemsGroupCode eq ${g}`).join(' or ')})`; if(itemGroup!=null&&!isNaN(itemGroup)) filterStr=`(${filterStr}) and ItemsGroupCode eq ${itemGroup}`; else if(itemGroups.length) filterStr=`(${filterStr}) and (${itemGroups.map(g=>`ItemsGroupCode eq ${g}`).join(' or ')})`; const filter=encodeURIComponent(filterStr); // 'LastPurchasePrice' is NOT a real field on the Service Layer Items // entity (confirmed against a live item — it has no such property at // all, always a bad $select here); 'AvgStdPrice' is the closest // available scalar reference price. Never noticed before because this // fallback almost never ran (see contains() fix above) — the filter // error always short-circuited the request before $select was even // evaluated. const result=await getSap().sapRequest('GET',`Items?$filter=${filter}&$select=ItemCode,ItemName,InventoryUOM,AvgStdPrice,DefaultWarehouse&$top=20`,null,co); res.json({success:true,data:(result?.value||[]).map(i=>({ ItemCode:i.ItemCode,ItemName:i.ItemName,UoM:i.InventoryUOM||'PCS',Price:Number(i.AvgStdPrice)||0,Warehouse:i.DefaultWarehouse||'', }))}); }catch(err){res.json({success:true,data:[],warning:err.message});} }); // GET /lookup/item-active-map?codes=A,B,C — which of the given item codes // are currently ACTIVE in SAP (validFor='Y' and frozenFor='N') — used by // Raise Deviation's "Affected Component" picker to hide components that // exist on the Production Order but have since been made inactive/frozen in // SAP (issuing/transferring an inactive item fails with SAP error -10). router.get('/lookup/item-active-map', verifyToken, async(req,res)=>{ const co=cq(req); const codes=(req.query.codes||'').split(',').map(s=>s.trim()).filter(Boolean); if(!codes.length) return res.json({success:true,data:{}}); try{ const list=codes.map(c=>`'${c.replace(/'/g,"''")}'`).join(','); const rows=await hanaQuery(`SELECT "ItemCode","validFor","frozenFor" FROM ${DB(co)}."OITM" WHERE "ItemCode" IN (${list})`,co); const map={}; rows.forEach(r=>{map[r.ItemCode]=(r.validFor==='Y'&&r.frozenFor==='N');}); // Any code not found in OITM at all — treat as inactive/unavailable too. codes.forEach(c=>{if(!(c in map))map[c]=false;}); res.json({success:true,data:map}); }catch(e){ // Fail open: if the lookup itself breaks, don't hide every component — // just skip the active-only filtering for this load. const map={};codes.forEach(c=>{map[c]=true;}); res.json({success:true,data:map,warning:e.message}); } }); // GET /lookup/item-stock?codes=A,B,C&warehouse=01&company=Y — on-hand qty // (OITW.OnHand) per item code, optionally scoped to one warehouse (summed // across every warehouse if omitted). Used by New Production Order to show // "Available Qty" next to each component and, when System Settings → // "Require Stock Availability for PWO" is on, to pre-check before Save // (the authoritative check is server-side in POST /production-order below). router.get('/lookup/item-stock', verifyToken, async(req,res)=>{ const co=cq(req); const codes=(req.query.codes||'').split(',').map(s=>s.trim().toUpperCase()).filter(Boolean); const warehouse=(req.query.warehouse||'').trim(); if(!codes.length) return res.json({success:true,data:{}}); try{ const list=codes.map(c=>`'${c.replace(/'/g,"''")}'`).join(','); const whSql=warehouse?` AND "WhsCode"='${warehouse.replace(/'/g,"''")}'`:''; const rows=await hanaQuery(`SELECT "ItemCode",SUM("OnHand") AS Qty FROM ${DB(co)}."OITW" WHERE "ItemCode" IN (${list})${whSql} GROUP BY "ItemCode"`,co); const map={};codes.forEach(c=>{map[c]=0;}); rows.forEach(r=>{map[r.ItemCode]=Number(r.Qty)||0;}); res.json({success:true,data:map}); }catch(e){ res.json({success:true,data:{},warning:e.message}); } }); // GET /lookup/item-code-exists?code=X&company=Y — does this exact item code // already exist in SAP? Used by Create Item's manual-entry code field (live, // debounced, as the user types) so a duplicate is caught with a clear // message before submit, instead of relying on whatever raw error SAP's own // rejection happens to surface. router.get('/lookup/item-code-exists', verifyToken, async(req,res)=>{ const co=cq(req); const code=(req.query.code||'').trim().toUpperCase(); if(!code) return res.json({success:true,exists:false}); try{ const safeCode=code.replace(/'/g,"''"); const rows=await hanaQuery(`SELECT TOP 1 "ItemCode" FROM ${DB(co)}."OITM" WHERE UPPER("ItemCode")='${safeCode}'`,co); res.json({success:true,exists:rows.length>0}); }catch(e){ // Fail open — never block item creation on a broken lookup; SAP's own // create call still independently rejects a real duplicate. res.json({success:true,exists:false,warning:e.message}); } }); // ════════════════════════════════════════════════════════════════ // ITEM UOMs — batch lookup of inventory UoM by item codes // GET /lookup/item-uoms?codes=RM001,RM002&company=... // ════════════════════════════════════════════════════════════════ router.get('/lookup/item-uoms', verifyToken, async(req,res)=>{ const co=cq(req); const codes=(req.query.codes||'').split(',').map(s=>s.trim()).filter(Boolean); if(!codes.length) return res.json({success:true,data:{}}); const list=codes.map(c=>`'${c.replace(/'/g,"''")}'`).join(','); try{ const rows=await hanaQuery(`SELECT "ItemCode","InvntryUom" FROM ${DB(co)}."OITM" WHERE "ItemCode" IN (${list})`,co); const map={}; rows.forEach(r=>{map[r.ItemCode]=r.InvntryUom||'';}); return res.json({success:true,data:map}); }catch{console.warn('[SAP] HANA item-uoms → fallback SL');} try{ const filter=encodeURIComponent(codes.map(c=>`ItemCode eq '${c.replace(/'/g,"''")}'`).join(' or ')); const result=await getSap().sapRequest('GET',`Items?$filter=${filter}&$select=ItemCode,InventoryUOM&$top=${codes.length}`,null,co); const map={}; (result?.value||[]).forEach(i=>{map[i.ItemCode]=i.InventoryUOM||'';}); res.json({success:true,data:map}); }catch(err){res.json({success:true,data:{},warning:err.message});} }); // ════════════════════════════════════════════════════════════════ // NEW ITEM CODE — batch lookup of the U_NewItemCode UDF (OITM) by item // codes. Used by Work Order print/PDF to show the new coding scheme // alongside the header Product Code, e.g. "Do45CIP (BD4530CA0IF0P1N1)". // GET /lookup/new-item-codes?codes=Do45CIP&company=... // ════════════════════════════════════════════════════════════════ router.get('/lookup/new-item-codes', verifyToken, async(req,res)=>{ const co=cq(req); const codes=(req.query.codes||'').split(',').map(s=>s.trim()).filter(Boolean); if(!codes.length) return res.json({success:true,data:{}}); const list=codes.map(c=>`'${c.replace(/'/g,"''")}'`).join(','); try{ const rows=await hanaQuery(`SELECT "ItemCode","U_NewItemCode" FROM ${DB(co)}."OITM" WHERE "ItemCode" IN (${list})`,co); const map={}; rows.forEach(r=>{if(r.U_NewItemCode)map[r.ItemCode]=r.U_NewItemCode;}); return res.json({success:true,data:map}); }catch{console.warn('[SAP] HANA new-item-codes → fallback SL');} try{ const filter=encodeURIComponent(codes.map(c=>`ItemCode eq '${c.replace(/'/g,"''")}'`).join(' or ')); const result=await getSap().sapRequest('GET',`Items?$filter=${filter}&$select=ItemCode,U_NewItemCode&$top=${codes.length}`,null,co); const map={}; (result?.value||[]).forEach(i=>{if(i.U_NewItemCode)map[i.ItemCode]=i.U_NewItemCode;}); res.json({success:true,data:map}); }catch(err){res.json({success:true,data:{},warning:err.message});} }); // ════════════════════════════════════════════════════════════════ // ITEM GROUPS — batch lookup of ItmsGrpCod by item codes (used by // Work Order's BOM classification against the configurable Item // Group → Raw/Packing/Component mapping) // GET /lookup/item-groups-for?codes=RM001,RM002&company=... // ════════════════════════════════════════════════════════════════ router.get('/lookup/item-groups-for', verifyToken, async(req,res)=>{ const co=cq(req); const codes=(req.query.codes||'').split(',').map(s=>s.trim()).filter(Boolean); if(!codes.length) return res.json({success:true,data:{}}); const list=codes.map(c=>`'${c.replace(/'/g,"''")}'`).join(','); try{ const rows=await hanaQuery(`SELECT "ItemCode","ItmsGrpCod" FROM ${DB(co)}."OITM" WHERE "ItemCode" IN (${list})`,co); const map={}; rows.forEach(r=>{map[r.ItemCode]=r.ItmsGrpCod;}); return res.json({success:true,data:map}); }catch(err){res.json({success:true,data:{},warning:err.message});} }); // ════════════════════════════════════════════════════════════════ // ITEM GROUPS // ════════════════════════════════════════════════════════════════ router.get('/lookup/item-groups', verifyToken, async(req,res)=>{ const co=cq(req); try{ const rows=await hanaQuery(` SELECT "ItmsGrpCod","ItmsGrpNam" FROM ${DB(co)}."OITB" ORDER BY "ItmsGrpNam"`,co); if(rows.length){ return res.json({success:true,data:rows.map(r=>({ code:r.ItmsGrpCod, name:r.ItmsGrpNam||String(r.ItmsGrpCod), }))}); } throw new Error('OITB returned 0 rows'); }catch(e){ console.warn('[SAP] HANA item groups failed → SL fallback:',e.message); try{ const result=await getSap().sapRequest('GET',`ItemGroups?$select=Number,GroupName&$orderby=GroupName`,null,co); return res.json({success:true,data:(result?.value||[]).map(r=>({ code:r.Number, name:r.GroupName||String(r.Number), }))}); }catch(e2){ return res.json({success:true,data:[],warning:e2.message}); } } }); // ════════════════════════════════════════════════════════════════ // ITEMS CRUD // ════════════════════════════════════════════════════════════════ router.get('/items/:itemCode', verifyToken, async(req,res)=>{ const co=cq(req); try{ const code=encodeURIComponent(req.params.itemCode.replace(/'/g,"''")); const result=await getSap().sapRequest('GET',`Items('${code}')`,null,co); res.json({success:true,data:result}); }catch(err){res.status(404).json({success:false,message:err.message});} }); router.post('/items', verifyToken, async(req,res)=>{ const co=cq(req); try{ const payload=cleanItemPayload(req.body); if(!payload.ItemCode) return res.status(400).json({success:false,message:'ItemCode is required'}); if(!payload.ItemName) return res.status(400).json({success:false,message:'ItemName is required'}); delete payload.company; console.log('[ITEM] Creating item:',payload.ItemCode); const result=await getSap().sapRequest('POST','Items',payload,co); console.log('[ITEM] ✅ Created:',result?.ItemCode||payload.ItemCode); res.json({success:true,data:result}); }catch(err){ console.error('[ITEM] ❌ Create failed:',err.message); res.status(400).json({success:false,message:err.message}); } }); router.patch('/items/:itemCode', verifyToken, async(req,res)=>{ const co=cq(req); try{ const payload=cleanItemPayload(req.body); delete payload.ItemCode; delete payload.company; const code=encodeURIComponent(req.params.itemCode.replace(/'/g,"''")); console.log('[ITEM] Updating item:',req.params.itemCode); const result=await getSap().sapRequest('PATCH',`Items('${code}')`,payload,co); res.json({success:true,data:result}); }catch(err){ console.error('[ITEM] ❌ Update failed:',err.message); res.status(400).json({success:false,message:err.message}); } }); router.post('/items/:itemCode/cancel', verifyToken, async(req,res)=>{ const co=cq(req); try{ const code=encodeURIComponent(req.params.itemCode.replace(/'/g,"''")); const result=await getSap().sapRequest('POST',`Items('${code}')/Cancel`,{},co); res.json({success:true,data:result}); }catch(err){ console.error('[ITEM] ❌ Cancel failed:',err.message); res.status(400).json({success:false,message:err.message}); } }); // ════════════════════════════════════════════════════════════════ // SAC CODES (OSAC — Service/Expense Accounting Codes, India GST) // SACEntry on service lines is mandatory when a GST tax code is set. // ════════════════════════════════════════════════════════════════ router.get('/lookup/sac-codes', verifyToken, async(req,res)=>{ const q=(req.query.q||'').replace(/'/g,"''").toUpperCase(); const co=cq(req); try{ const rows=await hanaQuery(` SELECT TOP 50 "AbsEntry","ServCode","ServName" FROM ${DB(co)}."OSAC" WHERE (UPPER("ServCode") LIKE '%${q}%' OR UPPER("ServName") LIKE '%${q}%') ORDER BY "ServCode"`,co); if(rows.length){ console.log(`[SAP] SAC codes (OSAC): ${rows.length}`); return res.json({success:true,data:rows.map(r=>({ AbsEntry: r.AbsEntry, SACCode: r.ServCode, SACName: r.ServName||r.ServCode, }))}); } throw new Error('OSAC returned 0 rows'); }catch(e){ console.warn('[SAP] OSAC fallback to SL:', e.message); try{ const result=await getSap().sapRequest('GET',`SACCollection?$select=AbsEntry,ServCode,ServName&$top=50`,null,co); res.json({success:true,data:(result?.value||[]).map(r=>({ AbsEntry: r.AbsEntry, SACCode: r.ServCode, SACName: r.ServName||r.ServCode, }))}); }catch(err){res.json({success:true,data:[],warning:err.message});} } }); // ════════════════════════════════════════════════════════════════ // LOCATIONS (OLCT — Location Master Data) // ════════════════════════════════════════════════════════════════ router.get('/lookup/locations', verifyToken, async(req,res)=>{ const q=(req.query.q||'').replace(/'/g,"''").toUpperCase(); const co=cq(req); try{ const rows=await hanaQuery(` SELECT TOP 40 "AbsEntry","Name" FROM ${DB(co)}."OLCT" WHERE "Inactive"='N' AND (UPPER(CAST("AbsEntry" AS NVARCHAR)) LIKE '%${q}%' OR UPPER("Name") LIKE '%${q}%') ORDER BY "AbsEntry"`,co); if(rows.length){ console.log(`[SAP] Locations (OLCT): ${rows.length}`); return res.json({success:true,data:rows.map(r=>({code:String(r.AbsEntry),name:r.Name||String(r.AbsEntry)}))}); } throw new Error('OLCT empty or not found'); }catch(e){ console.warn('[SAP] OLCT fallback to OWHS:', e.message); try{ const rows2=await hanaQuery(` SELECT TOP 40 "WhsCode","WhsName" FROM ${DB(co)}."OWHS" WHERE "Inactive"='N' AND (UPPER("WhsCode") LIKE '%${q}%' OR UPPER("WhsName") LIKE '%${q}%') ORDER BY "WhsCode"`,co); return res.json({success:true,data:rows2.map(r=>({code:r.WhsCode,name:r.WhsName||r.WhsCode})),warning:'Fallback to OWHS'}); }catch(e2){ res.json({success:true,data:[],warning:e2.message}); } } }); // ════════════════════════════════════════════════════════════════ // WAREHOUSES // ════════════════════════════════════════════════════════════════ router.get('/lookup/warehouses', verifyToken, async(req,res)=>{ const co=cq(req); try{ const rows=await hanaQuery(`SELECT "WhsCode","WhsName" FROM ${DB(co)}."OWHS" WHERE "Inactive"='N' ORDER BY "WhsCode"`,co); if(rows.length) return res.json({success:true,data:rows.map(r=>({code:r.WhsCode,name:r.WhsName}))}); }catch{console.warn('[SAP] HANA warehouse → fallback SL');} try{ const result=await getSap().sapRequest('GET',`Warehouses?$select=WarehouseCode,WarehouseName&$top=100`,null,co); res.json({success:true,data:(result?.value||[]).map(w=>({code:w.WarehouseCode,name:w.WarehouseName}))}); }catch(err){res.json({success:true,data:[],warning:err.message});} }); // GET /api/sap/lookup/series?objectCode=67 — SAP's own document numbering // series (NNM1) for a given document object type (67 = Inventory Transfer/ // StockTransfers), so admin settings and on-page displays can show the // human-readable Series NAME (e.g. "IC2627") instead of just its opaque // internal numeric ID (e.g. 28615) — the ID is what SAP's API actually // needs, the name is what a user recognizes. router.get('/lookup/series', verifyToken, async(req,res)=>{ const co=cq(req); const objectCode=(req.query.objectCode||'').trim(); if(!objectCode) return res.json({success:true,data:[]}); try{ const rows=await hanaQuery(`SELECT "Series","SeriesName","Locked" FROM ${DB(co)}."NNM1" WHERE "ObjectCode"='${objectCode.replace(/'/g,"''")}' ORDER BY "SeriesName"`,co); res.json({success:true,data:rows.map(r=>({series:Number(r.Series),seriesName:r.SeriesName,locked:r.Locked==='Y'}))}); }catch(err){res.json({success:true,data:[],warning:err.message});} }); // ════════════════════════════════════════════════════════════════ // DEPARTMENTS (OUDP) — used by Purchase Request's Department field, and // Admin → Users → "PR Departments" restriction checklist. // ════════════════════════════════════════════════════════════════ router.get('/lookup/departments', verifyToken, async(req,res)=>{ const co=cq(req); try{ const rows=await hanaQuery(`SELECT "Code","Name" FROM ${DB(co)}."OUDP" ORDER BY "Name"`,co); res.json({success:true,data:rows.map(r=>({code:r.Code,name:r.Name}))}); }catch(err){res.json({success:true,data:[],warning:err.message});} }); // ════════════════════════════════════════════════════════════════ // GL ACCOUNTS // ════════════════════════════════════════════════════════════════ router.get('/lookup/gl-accounts', verifyToken, async(req,res)=>{ const q=(req.query.q||'').replace(/'/g,"''").toUpperCase(); const co=cq(req); try{ const rows=await hanaQuery(` SELECT TOP 30 "AcctCode","AcctName" FROM ${DB(co)}."OACT" WHERE "Postable"='Y' AND (UPPER("AcctCode") LIKE '%${q}%' OR UPPER("AcctName") LIKE '%${q}%') ORDER BY "AcctCode"`,co); res.json({success:true,data:rows.map(r=>({AcctCode:r.AcctCode,AcctName:r.AcctName}))}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // ════════════════════════════════════════════════════════════════ // TAX CODES — query OSTC // ════════════════════════════════════════════════════════════════ router.get('/lookup/tax-codes', verifyToken, async(req,res)=>{ const co=cq(req); try{ const rows=await hanaQuery(` SELECT "Code","Name","Rate" FROM ${DB(co)}."OSTC" WHERE "Locked"='N' ORDER BY "Code"`,co); console.log(`[SAP] Tax codes from HANA: ${rows.length}`); if(rows.length){ return res.json({success:true,data:rows.map(r=>({ Code:r.Code, Name:r.Name||r.Code, Rate:Number(r.Rate)||0 }))}); } throw new Error('No rows from OSTC'); }catch(e){ console.warn('[SAP] HANA OSTC failed → trying VatGroups SL:', e.message); try{ const result=await getSap().sapRequest('GET', `VatGroups?$select=Code,Name,Inactive,VatGroups_Lines&$top=200`,null,co); const items=(result?.value||[]) .filter(r=>r.Inactive!=='tYES') // skip inactive groups in JS, avoid tNO OData bug .map(r=>{ const rate=r.VatGroups_Lines?.[0]?.Rate||0; return {Code:r.Code,Name:r.Name||r.Code,Rate:Number(rate)}; }); console.log(`[SAP] VatGroups SL fallback: ${items.length}`); return res.json({success:true,data:items}); }catch(e2){ console.warn('[SAP] VatGroups SL also failed:', e2.message); return res.json({success:true,data:[ {Code:'CG+SG@0', Name:'CGST+SGST 0%', Rate:0}, {Code:'CG+SG@5', Name:'CGST+SGST 5%', Rate:5}, {Code:'CG+SG@12', Name:'CGST+SGST 12%', Rate:12}, {Code:'CG+SG@18', Name:'CGST+SGST 18%', Rate:18}, {Code:'CG+SG@28', Name:'CGST+SGST 28%', Rate:28}, {Code:'IGST@0', Name:'IGST 0%', Rate:0}, {Code:'IGST@5', Name:'IGST 5%', Rate:5}, {Code:'IGST@12', Name:'IGST 12%', Rate:12}, {Code:'IGST@18', Name:'IGST 18%', Rate:18}, {Code:'IGST@28', Name:'IGST 28%', Rate:28}, {Code:'EXEMPT', Name:'Exempt', Rate:0}, {Code:'NIL', Name:'NIL Rated', Rate:0}, ],warning:'Fallback codes — HANA/SL unavailable'}); } } }); // ════════════════════════════════════════════════════════════════ // COSTING CODES (by dimension) // Dim1=Budget Dim2=Eff.Month Dim3=Variety Dim4=Sub Budget Dim5=Location // ════════════════════════════════════════════════════════════════ router.get('/lookup/costing-codes', verifyToken, async(req,res)=>{ const q=(req.query.q||'').replace(/'/g,"''").toUpperCase(); const dim=parseInt(req.query.dim)||1; const co=cq(req); try{ const rows=await hanaQuery(` SELECT TOP 100 "PrcCode","PrcName" FROM ${DB(co)}."OPRC" WHERE "DimCode"=${dim} AND "Locked"='N' AND (UPPER("PrcCode") LIKE '%${q}%' OR UPPER("PrcName") LIKE '%${q}%') ORDER BY "PrcCode"`,co); res.json({success:true,data:rows.map(r=>({PrcCode:r.PrcCode,PrcName:r.PrcName}))}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // ════════════════════════════════════════════════════════════════ // BRANCHES // ════════════════════════════════════════════════════════════════ router.get('/lookup/branches', verifyToken, async(req,res)=>{ const co=cq(req); try{ const rows=await hanaQuery(` SELECT "BPLId","BPLName","TaxIdNum" FROM ${DB(co)}."OBPL" WHERE "Disabled"='N' ORDER BY "BPLName"`,co); res.json({success:true,data:rows.map(r=>({BPLId:r.BPLId,BPLName:r.BPLName,TaxIdNum:r.TaxIdNum}))}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // ════════════════════════════════════════════════════════════════ // CUSTOMERS // ════════════════════════════════════════════════════════════════ router.get('/lookup/customers', verifyToken, async(req,res)=>{ const q=(req.query.q||'').replace(/'/g,"''").toUpperCase(); const co=cq(req); try{ const rows=await hanaQuery(` SELECT TOP 30 "CardCode","CardName" FROM ${DB(co)}."OCRD" WHERE "CardType"='C' AND "validFor"='Y' AND (UPPER("CardCode") LIKE '%${q}%' OR UPPER("CardName") LIKE '%${q}%') ORDER BY "CardName"`,co); res.json({success:true,data:rows.map(r=>({CardCode:r.CardCode,CardName:r.CardName}))}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // ════════════════════════════════════════════════════════════════ // VENDORS // ════════════════════════════════════════════════════════════════ router.get('/lookup/vendors', verifyToken, async(req,res)=>{ const q=(req.query.q||'').replace(/'/g,"''").toUpperCase(); const co=cq(req); try{ const rows=await hanaQuery(` SELECT TOP 30 "CardCode","CardName" FROM ${DB(co)}."OCRD" WHERE "CardType"='S' AND "validFor"='Y' AND (UPPER("CardCode") LIKE '%${q}%' OR UPPER("CardName") LIKE '%${q}%') ORDER BY "CardName"`,co); res.json({success:true,data:rows.map(r=>({CardCode:r.CardCode,CardName:r.CardName}))}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // ════════════════════════════════════════════════════════════════ // SALES EMPLOYEES // ════════════════════════════════════════════════════════════════ router.get('/lookup/sales-employees', verifyToken, (req,res)=> safeLookup(res, `SELECT "SlpCode","SlpName" FROM ${DB(cq(req))}."OSLP" WHERE "SlpCode">0 AND "Locked"='N' ORDER BY "SlpName"`, r=>({SlpCode:r.SlpCode,SlpName:r.SlpName}),cq(req)) ); // ════════════════════════════════════════════════════════════════ // PAYMENT TERMS // ════════════════════════════════════════════════════════════════ router.get('/lookup/payment-terms', verifyToken, (req,res)=> safeLookup(res, `SELECT "GroupNum","PymntGroup" FROM ${DB(cq(req))}."OCTG" ORDER BY "PymntGroup"`, r=>({Code:r.GroupNum,Name:r.PymntGroup}),cq(req)) ); // ════════════════════════════════════════════════════════════════ // AR ACCOUNTS // ════════════════════════════════════════════════════════════════ router.get('/lookup/ar-accounts', verifyToken, (req,res)=> safeLookup(res, `SELECT "AcctCode","AcctName" FROM ${DB(cq(req))}."OACT" WHERE "FatherNum"='1101000' ORDER BY "AcctCode"`, r=>({AcctCode:r.AcctCode,AcctName:r.AcctName}),cq(req)) ); // ════════════════════════════════════════════════════════════════ // AP ACCOUNTS // ════════════════════════════════════════════════════════════════ router.get('/lookup/ap-accounts', verifyToken, (req,res)=> safeLookup(res, `SELECT "AcctCode","AcctName" FROM ${DB(cq(req))}."OACT" WHERE "FatherNum"='2101000' ORDER BY "AcctCode"`, r=>({AcctCode:r.AcctCode,AcctName:r.AcctName}),cq(req)) ); // ════════════════════════════════════════════════════════════════ // MAIN GROUP / CHAIN (UDT) // ════════════════════════════════════════════════════════════════ router.get('/lookup/main-group', verifyToken, (req,res)=> safeLookup(res, `SELECT "Code","Name" FROM ${DB(cq(req))}."@MAIN_GROUP" ORDER BY "Code"`, r=>({Code:r.Code,Name:r.Name||r.Code}),cq(req)) ); router.get('/lookup/chain', verifyToken, (req,res)=> safeLookup(res, `SELECT "Code","Name" FROM ${DB(cq(req))}."@CHAIN" ORDER BY "Code"`, r=>({Code:r.Code,Name:r.Name||r.Code}),cq(req)) ); // ════════════════════════════════════════════════════════════════ // STATES (paginated) // ════════════════════════════════════════════════════════════════ router.get('/lookup/states', verifyToken, async(req,res)=>{ const co=cq(req); try{ let allStates=[]; let url=`States?$filter=Country eq 'IN'&$select=Code,Name&$orderby=Name`; while(url){ const result=await getSap().sapRequest('GET',url,null,co); allStates=allStates.concat(result?.value||[]); const nextLink=result?.['@odata.nextLink']; url=nextLink?nextLink.replace(/^.*\/b1s\/v2\//,''):null; } res.json({success:true,data:allStates.map(r=>({Code:r.Code,Name:r.Name}))}); }catch(err){res.json({success:true,data:[],warning:err.message});} }); // ════════════════════════════════════════════════════════════════ // BP GROUPS (Customer) // ════════════════════════════════════════════════════════════════ router.get('/lookup/bp-groups', verifyToken, async(req,res)=>{ const co=cq(req); try{ const result=await getSap().sapRequest('GET', `BusinessPartnerGroups?$filter=Type eq 'bbpgt_CustomerGroup'&$select=Code,Name&$orderby=Name`,null,co); res.json({success:true,data:(result?.value||[]).map(r=>({GroupCode:r.Code,GroupName:r.Name}))}); }catch(err){res.json({success:true,data:[],warning:err.message});} }); // ════════════════════════════════════════════════════════════════ // BOM SEARCH // ════════════════════════════════════════════════════════════════ router.get('/bom/search', verifyToken, async(req,res)=>{ const co=cq(req); const q=(req.query.q||'').toUpperCase(); try{ // Fetch first page from SL console.log('[BOM-SEARCH] Searching q='+q+' co='+co); const result=await getSap().sapRequest('GET',`ProductTrees?$select=TreeCode,TreeType,Quantity,Warehouse,ProductDescription&$top=100`,null,co); const all=result?.value||[]; console.log('[BOM-SEARCH] Got '+all.length+' BOMs'); const filtered=q?all.filter(r=>(r.TreeCode||'').toUpperCase().includes(q)||(r.ProductDescription||'').toUpperCase().includes(q)):all; console.log('[BOM-SEARCH] Filtered to '+filtered.length); res.json({success:true,data:filtered.slice(0,30)}); }catch(e){ console.error('[BOM-SEARCH] Error:',e.message); res.json({success:true,data:[],warning:e.message}); } }); // ════════════════════════════════════════════════════════════════ // BOM LIST + DETAIL // ════════════════════════════════════════════════════════════════ router.get('/bom/list', verifyToken, async(req,res)=>{ const co=cq(req); try{ const top=Number(req.query.top)||50; const skip=Number(req.query.skip)||0; const result=await getSap().sapRequest('GET', `ProductTrees?$select=TreeCode,TreeType,Quantity,Warehouse,ProductDescription&$top=${top}&$skip=${skip}`,null,co); res.json({success:true,data:result.value||[]}); }catch(err){res.status(500).json({success:false,message:err.message});} }); // Item codes that count as "a real part of this product's BOM" — one // recursive SQL query over ITT1 (BOM lines) instead of one Service-Layer call // per BOM node. Used by create-PO-from-WO to detect hand-added WO items that // aren't in the BOM, and by the QA release-review comparison: the per-node SL // walk took several seconds on big trees, so the extras appeared late and // users thought they were missing. // // Deliberately NOT "every node found anywhere in the tree": an intermediate // sub-assembly that itself has a further BOM (e.g. a Solution's own // ingredient that is itself assembled from something else, like SFG00090 // "WFI" sitting under SFG00001 with its own child) is a PASS-THROUGH node — // this app's own explosion logic (work-order.html's explodeToLeaves/scanTree) // never keeps such a node as a line item, it always recurses through it to // the real leaf underneath. So counting it as "in BOM" here would wrongly // clear an item that was hand-added AS that intermediate code instead of its // actual leaf descendant. A code counts as real here only if it's (a) a // DIRECT top-level line of the product itself (kept as-is — Solution, // packing, or component), or (b) a genuine LEAF anywhere in the tree (no // further BOM of its own — a real purchasable/raw ingredient). router.get('/bom-tree-codes/:treeCode', verifyToken, async(req,res)=>{ try{ const co=cq(req); const code=(req.params.treeCode||'').replace(/'/g,"''"); const rows=await hanaQuery(` WITH tree AS ( SELECT "Code", 1 AS lvl FROM [dbo]."ITT1" WHERE "Father"=N'${code}' UNION ALL SELECT c."Code", t.lvl+1 FROM [dbo]."ITT1" c JOIN tree t ON c."Father"=t."Code" WHERE t.lvl<10 ) SELECT DISTINCT t."Code" AS code FROM tree t WHERE t.lvl = 1 OR NOT EXISTS (SELECT 1 FROM [dbo]."ITT1" x WHERE x."Father" = t."Code")`,co); res.json({success:true,data:rows.map(r=>r.code)}); }catch(err){res.status(500).json({success:false,message:err.message});} }); // Batch "does this item have its own SAP BOM" check — ONE direct SQL query // against ITT1 (father/child BOM lines) for many item codes at once, instead // of a separate ProductTrees Service Layer round-trip per code. Used by the // Production Order detail view's "+ PWO" shortcut to find SFG components // without slowing down opening a multi-component order. router.get('/lookup/has-bom', verifyToken, async(req,res)=>{ try{ const co=cq(req); const codes=(req.query.codes||'').split(',').map(c=>c.trim()).filter(Boolean); if(!codes.length) return res.json({success:true,data:[]}); const list=codes.map(c=>`N'${c.replace(/'/g,"''")}'`).join(','); const rows=await hanaQuery(`SELECT DISTINCT "Father" AS code FROM [dbo]."ITT1" WHERE "Father" IN (${list})`,co); res.json({success:true,data:rows.map(r=>r.code)}); }catch(err){res.status(500).json({success:false,message:err.message});} }); router.get('/bom/:treeCode', verifyToken, async(req,res)=>{ const co=cq(req); try{ const code=encodeURIComponent(req.params.treeCode); const result=await getSap().sapRequest('GET',`ProductTrees('${code}')`,null,co); res.json({success:true,data:result}); }catch(err){ // No ProductTree = the item is a leaf (raw/packing) with no sub-BOM. // Return 200 (not 404) so BOM-explosion probes across many item codes // don't flood the browser console with 404s. Callers treat data:null as "no BOM". res.json({success:false,data:null,message:err.message}); } }); // ════════════════════════════════════════════════════════════════ // HELPER — resolve LocationCode // // ONLY use an explicit integer LocationCode field sent by the // frontend. Do NOT fall back to CostingCode5 — that field is a // costing-dimension string (e.g. "DL", "Factory") and has nothing // to do with OLCT.AbsEntry. Treating it as a location integer // causes SAP error -5002 "Linked value N does not exist". // // The GRPO frontend must send LocationCode as a proper integer // obtained from the /api/sap/lookup/locations endpoint. // ════════════════════════════════════════════════════════════════ function resolveLocationCode(line, idx){ const loc = parseInt(line.LocationCode); if(!isNaN(loc) && loc > 0){ console.log(`[GRPO] Line ${idx}: LocationCode=${loc}`); return loc; } console.warn(`[GRPO] Line ${idx}: LocationCode missing or invalid — field will be omitted`); return undefined; } // ════════════════════════════════════════════════════════════════ // GRPO POST PROXY → POST /api/sap/grpo // // Dimension mapping: // CostingCode = Dim1 → Budget // CostingCode2 = Dim2 → Eff. Month // CostingCode3 = Dim3 → Variety // CostingCode4 = Dim4 → Sub Budget // CostingCode5 = Dim5 → Location dimension code (string) // // LocationCode = separate integer field → OLCT.AbsEntry // must be supplied explicitly by the frontend // via the /lookup/locations picker. // // UDF: U_Litres mapped from litres field on each line. // ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════ // PURCHASE REQUESTS — full CRUD + lifecycle actions // ════════════════════════════════════════════════════════════════ // GET list router.get('/purchase-requests', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); // Default matches SAP Service Layer's own MaxPageSize (confirmed live: a // page is silently capped at 20 rows regardless of a higher $top) — see // public/purchase-request.html's PAGE_SIZE. const top=parseInt(req.query.$top)||20; const skip=parseInt(req.query.$skip)||0; const q=req.query.q||''; const status=req.query.status||''; const filters=[]; if(q){ const qSafe=q.replace(/'/g,"''"); const parts=[`contains(Comments,'${qSafe}')`]; const numQ=parseInt(q); if(!isNaN(numQ)) parts.push(`DocNum eq ${numQ}`); filters.push(`(${parts.join(' or ')})`); } if(status) filters.push(`DocumentStatus eq '${status}'`); // Admin → Users → "PR Departments" — same restriction already applied to // creating a PR now also applies to searching/listing them, so a user // only ever sees their own department's requests. Empty = no restriction. // Filters on U_Department, NOT U_Depart — confirmed live that DI API (the // engine that creates every portal PR) silently fails to set U_Depart at // all (SetUdf's try/catch swallows it), leaving it null on every single // portal-created document, while U_Department is reliably set every // time. Filtering on U_Depart would therefore never match anything. try{ const acting=await require('../services/hanaUsers').findById(req.user.id); const allowedDepts=Array.isArray(acting?.allowedDepartments)?acting.allowedDepartments.map(String):[]; if(allowedDepts.length){ filters.push(`(${allowedDepts.map(d=>`U_Department eq '${d.replace(/'/g,"''")}'`).join(' or ')})`); } }catch(e){ console.warn('[PR] department restriction check failed:',e.message); } const filterStr=filters.length?`$filter=${filters.join(' and ')}&`:''; const result=await sap.sapRequest('GET', `PurchaseRequests?${filterStr}$select=DocEntry,DocNum,DocDate,DocDueDate,RequriedDate,Comments,DocumentStatus,U_Department&$orderby=DocEntry desc&$top=${top}&$skip=${skip}`, null,co); // Genuine total (same filter, no paging) — drives "Page X of Y" on the // client instead of an open-ended "Page X". Non-fatal: a count failure // just falls back to the unlabeled pager, same as before this existed. let total=null; try{ const countFilter=filters.length?`?$filter=${filters.join(' and ')}`:''; const c=await sap.sapRequest('GET',`PurchaseRequests/$count${countFilter}`,null,co); const n=Number(c); if(!isNaN(n)) total=n; }catch(e){ console.warn('[PR-LIST] $count failed — pager will show no total:',e.message); } res.json({success:true,data:result?.value||[],total}); }catch(err){res.status(400).json({success:false,message:err.message});} }); // GET single router.get('/purchase-requests/:id', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const id=parseInt(req.params.id); if(isNaN(id)) return res.status(400).json({success:false,message:'Invalid DocEntry: '+req.params.id}); const result=await sap.sapRequest('GET',`PurchaseRequests(${id})`,null,co); res.json({success:true,data:result}); }catch(err){res.status(404).json({success:false,message:err.message});} }); // PATCH update router.patch('/purchase-requests/:id', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const body={...req.body}; delete body.company; await sap.sapRequest('PATCH',`PurchaseRequests(${parseInt(req.params.id)})`,body,co); res.json({success:true,message:'Updated'}); }catch(err){res.status(400).json({success:false,message:err.message});} }); // Close/Cancel/Create Cancellation/Delete are restricted to admin/ // system_admin — everyone else can view and create Purchase Requests but // not act on an existing one this way. Matches the button visibility in // public/purchase-request.html; enforced here too since hiding a button // alone doesn't stop a direct API call. Reopen is intentionally NOT gated — // not part of what was restricted. function requirePrManage(req,res,next){ if(req.user?.role==='admin'||req.user?.role==='system_admin')return next(); return res.status(403).json({success:false,message:'Only admin/system admin can do this to a Purchase Request.'}); } // DELETE router.delete('/purchase-requests/:id', verifyToken, requirePrManage, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); await sap.sapRequest('DELETE',`PurchaseRequests(${parseInt(req.params.id)})`,null,co); res.json({success:true,message:'Deleted'}); }catch(err){res.status(400).json({success:false,message:err.message});} }); // POST close / cancel / reopen / create-cancellation const PR_ACTIONS={close:'Close',cancel:'Cancel',reopen:'Reopen','create-cancellation':'CreateCancellationDocument'}; Object.entries(PR_ACTIONS).forEach(([route,sapAction])=>{ router.post(`/purchase-requests/:id/${route}`, verifyToken, ...(route==='reopen'?[]:[requirePrManage]), async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const result=await sap.sapRequest('POST',`PurchaseRequests(${parseInt(req.params.id)})/${sapAction}`,null,co); res.json({success:true,message:`${sapAction} successful`,data:result||null}); }catch(err){res.status(400).json({success:false,message:err.message});} }); }); // ════════════════════════════════════════════════════════════════ // PURCHASE REQUEST → POST /api/sap/purchase-request (create) // ════════════════════════════════════════════════════════════════ router.post('/purchase-request', verifyToken, async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const body=req.body; const co=cq(req); if(!body.DocumentLines?.length) return res.status(400).json({success:false,message:'DocumentLines required'}); if(!(body.Comments||'').trim()) return res.status(400).json({success:false,message:'Remarks / Purpose is required'}); if(!(body.U_Depart||body.U_Department||'').trim()) return res.status(400).json({success:false,message:'Department is required'}); // Admin → Users → "PR Departments" — same restriction the Department // dropdown already applies client-side (public/purchase-request.html's // loadDepartments()); enforced here too since hiding an option doesn't // stop a direct API call. Empty = no restriction. try{ const acting=await require('../services/hanaUsers').findById(req.user.id); const allowedDepts=Array.isArray(acting?.allowedDepartments)?acting.allowedDepartments.map(String):[]; const dept=body.U_Depart||body.U_Department||''; if(allowedDepts.length&&dept&&!allowedDepts.includes(String(dept))) return res.status(403).json({success:false,message:`You are not permitted to raise a Purchase Request for department "${dept}".`}); }catch(e){ console.warn('[PR] department restriction check failed:',e.message); } // Remove null/empty dates ['DocDate','DocDueDate','RequriedDate'].forEach(k=>{ if(!body[k]) delete body[k]; }); if(!body.RequriedDate && body.DocDueDate) body.RequriedDate = body.DocDueDate; // Clean lines if(Array.isArray(body.DocumentLines)){ body.DocumentLines=body.DocumentLines.map((line,idx)=>{ const clean={...line}; ['RequiredDate'].forEach(k=>{ if(!clean[k]) delete clean[k]; }); clean.LineNum=idx; return clean; }); } delete body.company; if(!body.RequesterEmail) body.RequesterEmail = req.user?.email || process.env.SAP_B1_PR_EMAIL || ''; // Use DI API via PowerShell COM — DI API respects approval templates like the SAP UI does. const diApi = require('../services/diApiService'); const result = await diApi.addPurchaseRequest(body, co); res.json({success:true,data:result}); }catch(err){ console.error('[PR] ❌',err.message); res.status(400).json({success:false,message:err.message}); } }); // ── GET pending PR drafts (awaiting approval) ────────────────── router.get('/purchase-request/drafts', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const result=await sap.sapRequest('GET', `Drafts?$filter=DocObjectCode eq 'oPurchaseRequest'&$select=DocEntry,DocDate,DocDueDate,RequriedDate,Comments,CardCode,CardName,U_Depart,U_Department&$orderby=DocEntry desc&$top=100`, null,co); res.json({success:true,data:result?.value||[]}); }catch(err){res.status(400).json({success:false,message:err.message});} }); // ── Approve PR draft → convert to actual PurchaseRequest ─────── router.post('/purchase-request/drafts/:id/approve', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const id=parseInt(req.params.id); const draft=await sap.sapRequest('GET',`Drafts(${id})`,null,co); if(draft.AuthorizationStatus === 'dasPending'){ return res.status(400).json({ success: false, message: 'This PR requires SAP approval before it can be posted. An authoriser must approve it via the Approval Decisions workflow.', authorizationStatus: 'dasPending' }); } // Strip OData/draft-only fields before posting as actual PR const STRIP=['@odata.context','@odata.etag','DocEntry','DocNum','DocObjectCode', 'Series','CreationDate','UpdateDate','UserSign','TransNum','FinancialPeriod', 'HandWritten','Printed','DocTime','SummeryType']; const body={}; for(const[k,v] of Object.entries(draft)){if(!STRIP.includes(k)&&!k.startsWith('@'))body[k]=v;} ['DocDate','DocDueDate','RequriedDate','TaxDate'].forEach(k=>{if(!body[k])delete body[k];}); const result=await sap.sapRequest('POST','PurchaseRequests',body,co); // Remove the draft after successful posting try{await sap.sapRequest('DELETE',`Drafts(${id})`,null,co);}catch(_){} console.log('[PR] ✅ Approved — DocEntry:',result?.DocEntry,'DocNum:',result?.DocNum); res.json({success:true,data:result}); }catch(err){res.status(400).json({success:false,message:err.message});} }); // ── Reject PR draft → delete it ──────────────────────────────── router.delete('/purchase-request/drafts/:id', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); await sap.sapRequest('DELETE',`Drafts(${parseInt(req.params.id)})`,null,co); res.json({success:true,message:'Draft rejected and deleted'}); }catch(err){res.status(400).json({success:false,message:err.message});} }); // ════════════════════════════════════════════════════════════════ // PURCHASE QUOTATION → POST /api/sap/purchase-quotation // ════════════════════════════════════════════════════════════════ router.post('/purchase-quotation', verifyToken, async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const body=req.body; const co=cq(req); if(!body.CardCode) return res.status(400).json({success:false,message:'Vendor (CardCode) required'}); if(!body.DocumentLines?.length) return res.status(400).json({success:false,message:'DocumentLines required'}); ['DocDate','DocDueDate','TaxDate'].forEach(k=>{ if(!body[k]) delete body[k]; }); if(Array.isArray(body.DocumentLines)){ body.DocumentLines=body.DocumentLines.map((line,idx)=>{ const clean={...line}; ['CostingCode','CostingCode2','CostingCode3','CostingCode4','CostingCode5'].forEach(k=>{ if(clean[k]===''||clean[k]===null||clean[k]===undefined) delete clean[k]; }); if(!clean.TaxCode) delete clean.TaxCode; if(!clean.WarehouseCode) delete clean.WarehouseCode; clean.LineNum=idx; return clean; }); } delete body.company; console.log('[PQ] Posting Purchase Quotation, vendor:',body.CardCode,'lines:',body.DocumentLines?.length); const result=await sap.sapRequest('POST','PurchaseQuotations',body,co); console.log('[PQ] ✅ DocEntry:',result?.DocEntry,'DocNum:',result?.DocNum); res.json({success:true,data:result}); }catch(err){ console.error('[PQ] ❌',err.message); res.status(400).json({success:false,message:err.message}); } }); // ════════════════════════════════════════════════════════════════ // PURCHASE QUOTATIONS — SQL list/detail (bypasses SL VatGroup bug) // ════════════════════════════════════════════════════════════════ // Safe select for PQ list — excludes DocType which triggers VatGroup SL bug on this SAP version const PQ_LIST_SELECT='DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,DocTotal,DocCurrency,Comments,DocumentStatus,NumAtCard'; router.get('/purchase-quotations', verifyToken, async(req,res)=>{ const co = cq(req); const top = Math.min(parseInt(req.query.top)||30, 200); const skip = parseInt(req.query.skip)||0; const q = (req.query.q||'').trim(); const status= req.query.status||''; try{ const sap = getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const filters=[]; if(status) filters.push(`DocumentStatus eq '${status}'`); if(q){ const safeQ=q.replace(/'/g,"''"); const num=parseInt(q); const parts=[]; if(!isNaN(num)) parts.push(`DocNum eq ${num}`); parts.push(`contains(CardCode,'${safeQ}')`,`contains(CardName,'${safeQ}')`,`contains(Comments,'${safeQ}')`); filters.push(`(${parts.join(' or ')})`); } const filterStr=filters.length?`&$filter=${encodeURIComponent(filters.join(' and '))}`:'' const result=await sap.sapRequest('GET', `PurchaseQuotations?$select=${PQ_LIST_SELECT}&$orderby=DocEntry desc&$top=${top}&$skip=${skip}${filterStr}`, null,co); const rows=result?.value||[]; console.log(`[PQ-LIST] ✅ ${rows.length} quotations`); // Genuine total (same filter, no paging) — drives "Page X of Y" on the // client instead of an open-ended "Page X". let total=null; try{ const c=await sap.sapRequest('GET',`PurchaseQuotations/$count${filters.length?`?$filter=${encodeURIComponent(filters.join(' and '))}`:''}`,null,co); const n=Number(c); if(!isNaN(n)) total=n; }catch(e){ console.warn('[PQ-LIST] $count failed — pager will show no total:',e.message); } res.json({success:true,data:rows,total}); }catch(err){ console.error('[PQ-LIST] ❌',err.message); res.status(400).json({success:false,message:err.message}); } }); router.get('/purchase-quotations/:id', verifyToken, async(req,res)=>{ const co=cq(req); try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const id=parseInt(req.params.id); if(isNaN(id)) return res.status(400).json({success:false,message:'Invalid DocEntry'}); // Fetch full document — DocumentLines included by default; no $select so we get all fields const result=await sap.sapRequest('GET',`PurchaseQuotations(${id})`,null,co); // SL returns full document with DocumentLines included — pass through as-is res.json({success:true,data:result}); }catch(err){ console.error('[PQ-DETAIL] ❌',err.message); res.status(400).json({success:false,message:err.message}); } }); // ════════════════════════════════════════════════════════════════ // PURCHASE ORDER → POST /api/sap/purchase-order // ════════════════════════════════════════════════════════════════ router.post('/purchase-order', verifyToken, async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const body=req.body; const co=cq(req); if(!body.CardCode) return res.status(400).json({success:false,message:'Vendor (CardCode) required'}); if(!body.DocumentLines?.length) return res.status(400).json({success:false,message:'DocumentLines required'}); ['DocDate','DocDueDate','TaxDate'].forEach(k=>{ if(!body[k]) delete body[k]; }); if(Array.isArray(body.DocumentLines)){ body.DocumentLines=body.DocumentLines.map((line,idx)=>{ const clean={...line}; ['CostingCode','CostingCode2','CostingCode3','CostingCode4','CostingCode5'].forEach(k=>{ if(clean[k]===''||clean[k]===null||clean[k]===undefined) delete clean[k]; }); if(!clean.TaxCode) delete clean.TaxCode; if(!clean.WarehouseCode) delete clean.WarehouseCode; clean.LineNum=idx; return clean; }); } delete body.company; console.log('[PO] Posting Purchase Order, vendor:',body.CardCode,'lines:',body.DocumentLines?.length); const result=await sap.sapRequest('POST','PurchaseOrders',body,co); console.log('[PO] ✅ DocEntry:',result?.DocEntry,'DocNum:',result?.DocNum); res.json({success:true,data:result}); }catch(err){ console.error('[PO] ❌',err.message); res.status(400).json({success:false,message:err.message}); } }); router.post('/grpo', verifyToken, async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const body=req.body; const co=cq(req); console.log('[GRPO] Posting DocType:',body.DocType,'Lines:',body.DocumentLines?.length); // Remove null/empty date fields — SAP rejects null ISO strings if(!body.DocDate) delete body.DocDate; if(!body.DocDueDate) delete body.DocDueDate; if(!body.TaxDate) delete body.TaxDate; // ── Service type: clean each line ────────────────────────── if(body.DocType==='dDocument_Service' && Array.isArray(body.DocumentLines)){ body.DocumentLines=body.DocumentLines.map((line,idx)=>{ const clean={...line}; // Remove item-only fields that cause errors on service lines delete clean.ItemCode; delete clean.WarehouseCode; // Remove empty costing codes (SAP rejects empty string for dim fields) ['CostingCode','CostingCode2','CostingCode3','CostingCode4','CostingCode5'].forEach(k=>{ if(clean[k]===''||clean[k]===null||clean[k]===undefined) delete clean[k]; }); // ── LocationCode ───────────────────────────────────────── // Must be an explicit OLCT.AbsEntry integer from the frontend. // Resolved from the original `line` (before the cleanup above) // so nothing is lost even if CostingCode5 was on the same object. const locCode=resolveLocationCode(line, idx); if(locCode!==undefined){ clean.LocationCode=locCode; } else { delete clean.LocationCode; } // U_Litres UDF — only set if > 0 if(clean.U_Litres && Number(clean.U_Litres)>0){ clean.U_Litres=Number(clean.U_Litres); } else { delete clean.U_Litres; } // Ensure LineNum is sequential clean.LineNum=idx; return clean; }); } // ── Items type: clean each line ───────────────────────────── if(body.DocType==='dDocument_Items' && Array.isArray(body.DocumentLines)){ body.DocumentLines=body.DocumentLines.map((line,idx)=>{ const clean={...line}; // Remove service-only fields delete clean.AccountCode; ['CostingCode','CostingCode2','CostingCode3','CostingCode4','CostingCode5'].forEach(k=>{ if(clean[k]===''||clean[k]===null||clean[k]===undefined) delete clean[k]; }); // ── LocationCode (same logic as service lines) ─────────── const locCode=resolveLocationCode(line, idx); if(locCode!==undefined){ clean.LocationCode=locCode; } else { delete clean.LocationCode; } // U_Litres UDF if(clean.U_Litres && Number(clean.U_Litres)>0){ clean.U_Litres=Number(clean.U_Litres); } else { delete clean.U_Litres; } clean.LineNum=idx; return clean; }); } // ── Attachment upload (optional) ───────────────────────────── // attachments sent as { bilty:[{name,size,type,data},...], invoice:[...], ... } // uploadAttachmentsToSAP expects the same shape used by vendor/customer forms. const attachments = body.attachments; delete body.attachments; // remove before sending to SAP if(attachments && typeof attachments === 'object'){ const hasFiles = Object.values(attachments).some(v => (Array.isArray(v)?v:[v]).some(f=>f?.data)); if(hasFiles){ try{ const vendorName = body.CardCode || 'GRPO'; const absEntry = await sap.uploadAttachmentsToSAP(attachments, vendorName, co); if(absEntry){ body.AttachmentEntry = parseInt(absEntry); console.log('[GRPO] AttachmentEntry:', body.AttachmentEntry); } }catch(attErr){ console.warn('[GRPO] ⚠ Attachment upload failed (non-fatal):', attErr.message); } } } console.log('[GRPO] Final payload:\n', JSON.stringify(body,null,2)); const result=await sap.sapRequest('POST','PurchaseDeliveryNotes',body,co); console.log('[GRPO] ✅ Posted DocEntry:',result?.DocEntry,'DocNum:',result?.DocNum); res.json(result); }catch(err){ const sapMsg=err.message||'Unknown SAP error'; console.error('[GRPO] ❌',sapMsg); res.status(400).json({success:false,message:sapMsg,error:{message:{value:sapMsg}}}); } }); // ════════════════════════════════════════════════════════════════ // PRODUCTION ORDER POST → POST /api/sap/production-order // ════════════════════════════════════════════════════════════════ // Create is gated by ONE of two distinct steps depending on whether this is // linked to a Work Order or a standalone/manual entry — `workOrderId` in the // body is how the frontend tells us which flow this is. Checked here (not a // static requireApprovalStep) since the required step depends on the request // body, not just the route. workOrderId itself is stripped before it ever // reaches SAP (not a real ProductionOrders field) — it's only consulted for // this permission check and by the caller afterward when linking the local record. router.post('/production-order', verifyToken, (req,res,next)=>{ const perm = req.body?.workOrderId ? 'production_order:create' : req.body?.fromComponent ? 'production_order:create_from_component' : req.body?.fromConsumable ? 'production_order:consumable_create' : 'production_order:manual_create'; if (hasStepPerm(req.user, perm, 'add')) return next(); res.status(403).json({ success:false, message:`You are not assigned "add" on approval step: ${perm}` }); }, async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const body=req.body; const co=cq(req); // A Work Order may only ever produce ONE Production Order — its full // quantity is captured the first time. Checked BEFORE calling SAP: once // the SAP order exists, rejecting the local-tracking POST afterward would // leave an orphan SAP Production Order with no local stage tracking, // which is worse than blocking here. if(body.workOrderId){ const existing=await poStore().listProductionOrders({workOrderId:body.workOrderId}); if(existing.length) return res.status(409).json({success:false,message:`A Production Order (${existing[0].sapDocNum||'#'+existing[0].id}) has already been generated for this Work Order.`}); // Hard gate (Admin → System Settings → "Pre-PWO — Store Review Before // SAP"): when ON, a Work-Order-sourced Production Order can ONLY reach // SAP via a Pre-PWO that Store has actually reviewed — no bypass, even // by calling this route directly. See services/prePwoStore.js. if (appSettings.preWoStoreReviewEnabled()) { const pre = await prePwoStore().findOpenByWorkOrderId(body.workOrderId); if (!pre) return res.status(409).json({ success:false, message:'Pre-PWO Store Review is enabled — this Work Order must first be staged as a Pre-PWO (see the "Pre-PWO" tab) before it can be sent to SAP.' }); if (pre.reviewStage !== 2) return res.status(409).json({ success:false, message:`This Pre-PWO hasn't completed Store review yet (${pre.reviewStage === 1 ? 'awaiting Store' : 'not yet shared with Store'}) — it must be shared and reverted by Store before it can be sent to SAP.` }); } } // "+ PWO" shortcut: at most ONE sub-PWO per (parent order, component // item) pair — same idea as the one-PWO-per-WO gate above, checked // BEFORE calling SAP for the same reason (an orphaned SAP order with no // local link is worse than blocking here). if(body.fromComponent&&body.refParentEntry!=null&&body.ItemNo){ const all=await poStore().listProductionOrders({company:body.company}); const dup=all.find(p=>!p.isDeleted&&p.refParentEntry===parseInt(body.refParentEntry)&&(p.itemCode||'').toUpperCase()===String(body.ItemNo).toUpperCase()); if(dup) return res.status(409).json({success:false,message:`A Production Order (${dup.sapDocNum||'#'+dup.id}) has already been created for ${body.ItemNo} from this order.`}); } // Consumable Order: never trust the client's own item-restricted search — // independently re-verify the submitted item's SAP Item Group really is // 132 (Service) before letting this reach SAP at all. if(body.fromConsumable&&body.ItemNo){ const CONSUMABLE_ITEM_GROUP=132; const rows=await hanaQuery(`SELECT "ItmsGrpCod" FROM [dbo]."OITM" WHERE "ItemCode"='${String(body.ItemNo).replace(/'/g,"''")}'`,co); const grp=rows[0]?.ItmsGrpCod; if(grp!==CONSUMABLE_ITEM_GROUP) return res.status(400).json({success:false,message:`Consumable Order requires a Service item (Item Group ${CONSUMABLE_ITEM_GROUP}) — ${body.ItemNo} is in group ${grp!=null?grp:'unknown'}.`}); } // Plain Manual Entry (not linked to a Work Order, not "+PWO" from a // component, not a Consumable Order): if this user has a Manual PWO // Item Groups restriction (Admin → user → Manual PWO Item Groups), // the submitted item's SAP Item Group must be in that list. Never // trust the client's own item-restricted search — independently // re-verify here, same pattern as the Consumable Order check above. if(!body.workOrderId&&!body.fromComponent&&!body.fromConsumable&&body.ItemNo){ const acting=await require('../services/hanaUsers').findById(req.user.id); const allowed=Array.isArray(acting?.manualPoItemGroups)?acting.manualPoItemGroups.map(String):[]; if(allowed.length){ const rows=await hanaQuery(`SELECT "ItmsGrpCod" FROM [dbo]."OITM" WHERE "ItemCode"='${String(body.ItemNo).replace(/'/g,"''")}'`,co); const grp=rows[0]?.ItmsGrpCod; if(!allowed.includes(String(grp))) return res.status(403).json({success:false,message:`You are not permitted to create a Manual Entry Production Order for ${body.ItemNo} (item group not in your allowed list).`}); } } // Admin-configurable (System Settings → "Require Stock Availability for // PWO", default OFF): block creation outright when a real Item // component's Planned Quantity exceeds what's actually on hand in its // own warehouse. Resources aren't inventory items, so they're excluded; // a Consumable Order's Service item is never stock-checked either. // Never trusts the client's own "Available Qty" display — independently // re-verifies here against OITW right before posting. if(appSettings.poRequireStockAvailability()&&!body.fromConsumable){ const stockLines=(body.ProductionOrderLines||[]).filter(l=>l.ItemType!=='pit_Resource'&&l.ItemNo&&(parseFloat(l.PlannedQuantity)||0)>0); if(stockLines.length){ // Group required qty by (item, warehouse) — the same item can // legitimately appear on more than one line/warehouse. const need={}; stockLines.forEach(l=>{ const wh=l.Warehouse||body.Warehouse||''; const key=`${l.ItemNo}|${wh}`; need[key]=(need[key]||0)+(parseFloat(l.PlannedQuantity)||0); }); const codes=[...new Set(stockLines.map(l=>l.ItemNo))]; const list=codes.map(c=>`'${String(c).replace(/'/g,"''")}'`).join(','); const rows=await hanaQuery(`SELECT "ItemCode","WhsCode","OnHand" FROM ${DB(co)}."OITW" WHERE "ItemCode" IN (${list})`,co); const onHand={}; rows.forEach(r=>{ onHand[`${r.ItemCode}|${r.WhsCode}`]=Number(r.OnHand)||0; }); const short=Object.entries(need) .map(([key,reqQty])=>{ const [item,wh]=key.split('|'); const avail=onHand[key]||0; return {item,wh,reqQty,avail}; }) .filter(x=>x.avail`${x.item} (WH ${x.wh||'—'}): need ${x.reqQty}, have ${x.avail}`).join('; '); return res.status(409).json({success:false,message:`Cannot create — insufficient stock for: ${msg}`}); } } } delete body.workOrderId; // consulted above only, not a real SAP field delete body.fromComponent; // consulted above only, not a real SAP field delete body.refParentEntry; // consulted above only, not a real SAP field delete body.fromConsumable; // consulted above only, not a real SAP field console.log('[PROD] Posting Production Order, ItemNo:',body.ItemNo,'Qty:',body.PlannedQuantity); // Clean empty dates if(!body.DueDate) delete body.DueDate; if(!body.PostingDate) delete body.PostingDate; if(!body.StartDate) delete body.StartDate; // Clean lines if(Array.isArray(body.ProductionOrderLines)){ body.ProductionOrderLines=body.ProductionOrderLines.map((line,idx)=>{ const clean={...line}; // Remove empty string fields ['DistributionRule','DistributionRule2','DistributionRule3','DistributionRule4','DistributionRule5','Project','WipAccount'].forEach(k=>{ if(clean[k]===''||clean[k]===null||clean[k]===undefined) delete clean[k]; }); if(!clean.Warehouse) delete clean.Warehouse; clean.VisualOrder=idx; return clean; }); } // Remove company from payload (not a SAP field) delete body.company; console.log('[PROD] Final payload:\n',JSON.stringify(body,null,2)); const result=await sap.sapRequest('POST','ProductionOrders',body,co); console.log('[PROD] ✅ Created AbsEntry:',result?.AbsoluteEntry,'DocNum:',result?.DocumentNumber); res.json({success:true,data:result}); }catch(err){ const sapMsg=err.message||'Unknown SAP error'; console.error('[PROD] ❌',sapMsg); res.status(400).json({success:false,message:sapMsg}); } }); // Consumable Order ("+ Consumable Order" — Manual Entry restricted to // Service items, Item Group 132) uses FOUR separate, independent approval // steps — Add (production_order:consumable_create), Release, Issue, Close — // completely independent of the general per-stage approval steps: holding // production_order:release/issuance/close (like qa3 does) does NOT by // itself grant access to a Consumable Order, and vice versa a Consumable // Order step grants NOTHING on a normal PWO. NO creator bypass — even the // order's own creator needs the matching step assigned to progress it // further (each user acts only on what they're explicitly permitted for). // There is deliberately no consumable Receipt step at all — the confirmed // workflow is Release → Issue → Close only, Receipt is never performed. const CONSUMABLE_STEP_FOR = { release: 'production_order:consumable_release', issuance: 'production_order:consumable_issue', close: 'production_order:consumable_close', }; function canActOnProductionOrder(linked, req, hasGeneralPerm, stepKey){ if(linked&&linked.isConsumable){ const step=CONSUMABLE_STEP_FOR[stepKey]; return !!step && hasStepPerm(req.user,step,'approve'); } return hasGeneralPerm; } // Who may even SEE a Consumable Order at all (list it / open its detail) — // its own creator (so they can at least find what they made, even without // action rights on it), an admin/system admin, or anyone holding ANY // permission on ANY of the four Consumable Order steps (they need to be // able to open one to act on it). const CONSUMABLE_STEPS = ['production_order:consumable_create','production_order:consumable_release','production_order:consumable_issue','production_order:consumable_close']; function canViewConsumableOrder(linked, req){ if(!linked) return true; if(linked.createdBy===req.user?.username) return true; if(['admin','system_admin'].includes(req.user?.role)) return true; return CONSUMABLE_STEPS.some(step=>hasStepAssigned(req.user,step)); } // The mirror image of canViewConsumableOrder(): a user who holds ONLY // Consumable Order steps (any of the four) and NONE of the general // per-stage/creation production_order steps must see ONLY Consumable // Orders — everywhere a normal (non-consumable) PWO would otherwise be // visible to anyone holding the module regardless of approval steps. Admin/ // system admin are never restricted this way. const GENERAL_PO_STEPS = ['production_order:create','production_order:manual_create','production_order:create_from_component','production_order:release','production_order:issuance','production_order:receipt','production_order:transfer_fg','production_order:close']; function isConsumableOnlyUser(req){ if(['admin','system_admin'].includes(req.user?.role)) return false; if(GENERAL_PO_STEPS.some(step=>hasStepAssigned(req.user,step))) return false; return CONSUMABLE_STEPS.some(step=>hasStepAssigned(req.user,step)); } // Consumable Orders are raised by many departments — but per the confirmed // design, Department scopes ADD/VIEW ONLY. Release/Issue/Close are // deliberately department-independent: anyone holding 'approve' on those // dedicated steps sees/acts on every department's Consumable Orders, same // as before this feature. Only a user with NEITHER of those three action // permissions (a plain Add-only creator, in practice) gets narrowed down to // their own department(s) — reusing the same OUDP/"PR Departments" // allowedDepartments restriction list already used for Purchase Requisition // (empty/absent = unrestricted, sees every department, same convention as // that feature). Returns a Set of allowed department names, or null when // this user should see every department (no restriction applies). async function consumableDeptRestriction(req){ if(['admin','system_admin'].includes(req.user?.role)) return null; const hasAction=['production_order:consumable_release','production_order:consumable_issue','production_order:consumable_close'] .some(step=>hasStepPerm(req.user,step,'approve')); if(hasAction) return null; try{ const acting=await require('../services/hanaUsers').findById(req.user.id); const allowed=Array.isArray(acting?.allowedDepartments)?acting.allowedDepartments.map(String):[]; return allowed.length?new Set(allowed):null; }catch(_e){ return null; } } // ════════════════════════════════════════════════════════════════ // PRODUCTION ORDER RELEASE → POST /api/sap/production-order/:id/release // ════════════════════════════════════════════════════════════════ router.post('/production-order/:id/release', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const id=parseInt(req.params.id); // Stage sequencing: if this order is linked to a Work Order, "Release" // must be the current pending step — checked BEFORE touching SAP so a // rejected/out-of-order attempt never fires a real SAP transaction. const linked=await poStore().findBySapAbsEntry(id); if(!canActOnProductionOrder(linked,req,hasStepPerm(req.user,'production_order:release','approve'),'release')) return res.status(403).json({success:false,message:linked&&linked.isConsumable?'You are not permitted to release this Consumable Order.':'You are not assigned "approve" on approval step: production_order:release'}); if(linked&&linked.stage!==0) return res.status(409).json({success:false,message:`Out of sequence: this order's next required step is "${linked.currentStep}", not Release`}); console.log('[PROD] Releasing Production Order:',id); const result=await sap.sapRequest('PATCH',`ProductionOrders(${id})`,{ProductionOrderStatus:'boposReleased'},co); console.log('[PROD] ✅ Released:',id); if(linked){ try{ // An order with NO real issuable lines (e.g. a Consumable Order whose // only line is a Resource, or none at all) has nothing to ever run // through Issue for Production — jump straight to stage 2 (Issuance // done) instead of leaving it stuck at stage 1 forever, needing the // "Catch Up Issuance" button every single time. Best-effort: a // failure here just means the normal catch-up banner offers it // manually afterward instead. let noIssuableLines=false; try{ const ord=await sap.sapRequest('GET',`ProductionOrders(${id})?$select=ProductionOrderLines`,null,co); const lines=(ord?.ProductionOrderLines||[]).filter(l=>l.ItemType!=='pit_Resource'&&l.ProductionOrderIssueType!=='im_Backflush'); noIssuableLines=lines.length===0; }catch(_e){} if(noIssuableLines) await poStore().catchUpStage(linked.id,2,{by:req.user.username,byName:req.user.name||req.user.username,remarks:'No issuable components — Issuance auto-completed at Release'}); else await poStore().advanceStage(linked.id,{action:'complete',stepKey:'release',by:req.user.username,byName:req.user.name||req.user.username}); } catch(e){ console.warn('[PROD] local stage advance failed (non-fatal):',e.message); } } res.json({success:true,data:result}); }catch(err){ console.error('[PROD] ❌ Release failed:',err.message); res.status(400).json({success:false,message:err.message}); } }); // ════════════════════════════════════════════════════════════════ // CATCH UP ISSUANCE → POST /api/sap/production-order/:id/catchup-issuance // // An order Released AND Issued directly in SAP B1 (never through the // portal's own Release/Issue for Production actions) leaves the local // stage tracker stuck below stage 2 forever — advanceStage() only allows // the EXACT next sequential step, and the portal's own "Issue for // Production" button only shows while SAP is NOT yet fully issued, so // there's no ordinary path to record it after the fact. That leaves Verify // Work Order (which lists orders at stage ≥ ISSUANCE_STAGE), Receipt from // Production and Close all permanently unreachable even though SAP is // actually ready. This jumps the local stage straight to 2, but only after // independently re-confirming SAP itself really is Released and fully // issued (Backflush/Resource lines excluded, same rule the rest of this // file uses) — never trusts the client's own claim. // ════════════════════════════════════════════════════════════════ router.post('/production-order/:id/catchup-issuance', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const id=parseInt(req.params.id); const linked=await poStore().findBySapAbsEntry(id); if(!linked) return res.status(404).json({success:false,message:'No local tracking record found for this Production Order.'}); if(!canActOnProductionOrder(linked,req,hasStepAssigned(req.user,'production_order:issuance'),'issuance')) return res.status(403).json({success:false,message:linked.isConsumable?'You are not permitted to issue this Consumable Order.':'You are not assigned to approval step: production_order:issuance'}); if(linked.stage>=2) return res.json({success:true,data:linked,message:'Already caught up.'}); const ord=await sap.sapRequest('GET',`ProductionOrders(${id})?$select=ProductionOrderStatus,ProductionOrderLines`,null,co); if(ord?.ProductionOrderStatus!=='boposReleased'&&ord?.ProductionOrderStatus!=='boposClosed') return res.status(409).json({success:false,message:'SAP shows this order is not yet Released — nothing to catch up.'}); const lines=(ord?.ProductionOrderLines||[]).filter(l=>l.ItemType!=='pit_Resource'&&l.ProductionOrderIssueType!=='im_Backflush'); const notFullyIssued=lines.filter(l=>(l.IssuedQuantity||0)<(l.PlannedQuantity||0)); if(notFullyIssued.length) return res.status(409).json({success:false,message:`SAP shows ${notFullyIssued.length} component(s) not yet fully issued (${notFullyIssued.map(l=>l.ItemNo).join(', ')}) — Issue for Production first.`}); const result=await poStore().catchUpStage(linked.id,2,{by:req.user.username,byName:req.user.name||req.user.username,remarks:req.body?.remarks||''}); console.log('[PROD] Caught up local stage to Issuance for',id,'by',req.user.username); res.json({success:true,data:result}); }catch(err){ console.error('[PROD] ❌ Catch-up failed:',err.message); res.status(400).json({success:false,message:err.message}); } }); // ════════════════════════════════════════════════════════════════ // PRODUCTION ORDER LIST → GET /api/sap/production-orders // ════════════════════════════════════════════════════════════════ // GET single production order by AbsoluteEntry router.get('/production-orders/:id', verifyToken, async(req,res)=>{ const co=cq(req); try{ const id=parseInt(req.params.id); const result=await getSap().sapRequest('GET',`ProductionOrders(${id})`,null,co); // MFG/EXP Date aren't SAP fields — they come from the source Work // Order's Batch Issuance Intimation data, carried onto our own local // link record at creation. Attached here (rather than the separate // GET /api/production-orders list, which needs its own 'view' workflow // permission) so Receipt from Production can default to them regardless // of whether the caller also holds that separate permission. const linked=await poStore().findBySapAbsEntry(id).catch(()=>null); // A Consumable Order is only visible to its own creator, an admin, or // someone holding 'approve' on production_order:consumable_create — the // same people allowed to act on it (see canActOnProductionOrder() below). // Everyone else gets a 403 here, not just hidden buttons — merely // knowing "qa3 has no consumable permission" must also mean qa3 can't // open/read the order at all. if(linked?.isConsumable&&!canViewConsumableOrder(linked,req)) return res.status(403).json({success:false,message:'You are not permitted to view this Consumable Order.'}); // Mirror image: a Consumable-only user (no general production_order step // at all) must not be able to open a REGULAR order's detail directly by // ID either, even though it's not itself a Consumable Order. if(!linked?.isConsumable&&isConsumableOnlyUser(req)) return res.status(403).json({success:false,message:'You are only permitted to view Consumable Orders.'}); // Department scoping — an Add-only Consumable Order user (no Release/ // Issue/Close permission) may only open orders from their own // department(s); the order's own creator is always exempt. if(linked?.isConsumable&&linked.createdBy!==req.user?.username){ const deptSet=await consumableDeptRestriction(req); if(deptSet&&!deptSet.has(String(linked.department||''))) return res.status(403).json({success:false,message:'You are not permitted to view this Consumable Order (different department).'}); } result._localMfgDate=linked?.mfgDate||''; result._localExpDate=linked?.expDate||''; // Batch No. / WO No. — same local-only fields the list route joins in // (see GET /production-orders above) — needed here too so the "+ PWO" // shortcut can carry this order's own Batch/WO No. forward as a // reference on the new sub-order it creates. result._localBatchNumber=linked?.batchNumber||''; result._localRefWoNo=linked?.refWoNo||''; result._localRefBatchNumber=linked?.refBatchNumber||''; result._localDepartment=linked?.department||''; // Consumable Order: whoever created it may Release/Issue/Receipt THAT // order even without the general approval-step permissions (server // enforces the same rule — see isOwnConsumableOrder() below); the client // needs to know both flags to decide whether to even show those buttons. result._localIsConsumable=!!linked?.isConsumable; result._localCreatedBy=linked?.createdBy||''; // Stage only — used by Issue for Production to offer "Confirm Issued in // SAP" ONLY when the local approval chain genuinely hasn't recorded // Issuance yet (stage 1); a value of null means this order isn't linked // to the portal's own tracking at all, so the button never applies. result._localStage=linked?linked.stage:null; result._localWoNo=''; if(linked?.workOrderId!=null){ try{ const wo=await require('../services/workOrderStore').findById(linked.workOrderId); result._localWoNo=wo?.woNo||''; }catch(_e){} } // "+ PWO" shortcut: this order's own parent (the order whose component // table it was raised from). refWoNo/refBatchNumber above are already // carried for reference, but the parent PWO itself (its Doc No./Item) // is worth surfacing too so it's one click away instead of just a WO/ // Batch string. Best-effort — a live SAP lookup only fires when this // order actually has a ref, so it costs nothing for the common case. result._localRefParentEntry=linked?.refParentEntry||null; result._localRefParentDocNum=''; result._localRefParentItemNo=''; if(linked?.refParentEntry!=null){ try{ const parent=await getSap().sapRequest('GET',`ProductionOrders(${linked.refParentEntry})?$select=DocumentNumber,ItemNo`,null,co); result._localRefParentDocNum=parent?.DocumentNumber||''; result._localRefParentItemNo=parent?.ItemNo||''; }catch(_e){} } res.json({success:true,data:result}); }catch(err){res.status(404).json({success:false,message:err.message});} }); // GET issues (InventoryGenExits) linked to a production order router.get('/production-orders/:id/issues', verifyToken, async(req,res)=>{ const co=cq(req); const id=parseInt(req.params.id); try{ const filter=encodeURIComponent(`DocumentLines/any(d:d/BaseEntry eq ${id} and d/BaseType eq 202)`); const result=await getSap().sapRequest('GET', `InventoryGenExits?$filter=${filter}&$select=DocEntry,DocNum,DocDate,DocTotal,Comments,DocumentLines&$orderby=DocEntry desc&$top=50`,null,co); res.json({success:true,data:result?.value||[]}); }catch(err){ // Fallback: some SAP versions don't support /any filter on lines res.json({success:true,data:[],warning:err.message}); } }); router.get('/production-orders', verifyToken, async(req,res)=>{ const co=cq(req); const top=Number(req.query.top)||50; const skip=Number(req.query.skip)||0; const status=req.query.status||''; const search=(req.query.search||'').trim(); // Due Date range — pushed into the actual SQL/OData query below (NOT a // post-fetch filter): restrictedMode's SQL branch does real OFFSET/FETCH // pagination, so filtering "orders" after that page is already fetched // would only ever narrow whichever 20 rows happened to land on the // current page — silently dropping real matches sitting on OTHER pages, // and breaking the pager (a page that filtered down to near-zero looked // like "no more results" even though plenty existed further in). const dateFrom=(req.query.dateFrom||'').trim(); const dateTo=(req.query.dateTo||'').trim(); // Item Group visibility restriction (Admin → user → Issue Items allowed // groups) — resolved once, up front, so it can be pushed into the actual // SQL WHERE below for restrictedMode, instead of filtering the response // after SAP/SQL has already paginated it. A post-fetch filter here had the // exact same "narrower page than requested" problem the Due Date filter // used to have: a 20-row SQL page could filter down to 1 visible card, // the pager's total went null, and Next/Prev no longer lined up with what // was actually left to show. let allowedGroups=[]; try{ const acting=await require('../services/hanaUsers').findById(req.user.id); allowedGroups=Array.isArray(acting&&acting.issueItemGroups)?acting.issueItemGroups.map(String):[]; // Issue Items restricts which REGULAR production items a user may see — // a completely separate concern from Consumable Orders (always Item // Group 132, Service items, never run through Issue for Production at // all). Without this, a user restricted to e.g. group 128 who also holds // a Consumable Order step (create/release/issue/close) can create or be // assigned a Consumable Order but then never see it in the list at all — // this same allowedGroups filter, applied below to the SQL/OData query, // silently drops it since 132 isn't in their regular allowlist. Fixed by // always widening to include 132 whenever ANY Consumable step is held, // independent of whatever Issue Items groups were separately configured. if(allowedGroups.length&&CONSUMABLE_STEPS.some(step=>hasStepAssigned(req.user,step))&&!allowedGroups.includes('132')){ allowedGroups=[...allowedGroups,'132']; } }catch(_e){} // "Close Production Order" / "Receipt from Production" screens only: list // only orders that would actually pass that screen's own posting gates // (Admin → System Settings → "Require full issuance before Receipt/Close" // / "Close — require full quantity") — no point showing a card that just // 409s when clicked. SAP's OData can't filter on an aggregate condition // across ProductionOrderLines (IssuedQuantity vs PlannedQuantity per // line), so in either mode we fetch a larger batch and paginate the // filtered result ourselves instead of letting SAP page it. Doesn't // account for the Close endpoint's own REJECTED-order exemption (would // need an extra local-tracking lookup per order) — a rejected order // that's otherwise closeable may be hidden here; still reachable via // search or the general (non-filtered) list. const readyToClose=req.query.readyToClose==='1'&&status==='Released'&&(appSettings.poRequireFullIssuance()||appSettings.closeRequireFullQty()); // Always enters the SQL-based restrictedMode below when the flag is set — // previously gated behind Admin → "Require full issuance before Receipt/ // Close" being ON, which meant: setting OFF → falls through to the plain // OData list (ALL Released orders, unfiltered by issuance) → the CLIENT // (public/receipt-production.html) then filtered that page down to // "actually issued, not yet received" itself. Same bug class as // readyToIssue above: a 20-row page could filter down to ZERO visible // cards (an order not issued AT ALL is still "Released"), while the pager // kept counting every Released order as the total — "Page 8 of 43" could // show nothing at all despite the total being technically correct. The // setting itself still matters (see poRequireFullIssuance branch below); // it now only decides WHICH SQL condition applies, not whether SQL-level // filtering happens at all. const readyToReceive=req.query.readyToReceive==='1'&&status==='Released'; // Issue for Production's own "ready" list — not yet fully issued. Unlike // the two above, this isn't gated by an admin setting: "not fully issued" // is just what "ready to issue" inherently means. Without this, the page // filtered _issueStatus!=='full' CLIENT-SIDE, after the server had already // paginated a fixed page of raw "Released" orders — so a page whose raw // orders all happened to already be fully issued rendered completely // empty, while the pager's total still counted every Released order // regardless of issue status, producing pages with real data scattered // seemingly at random (1, 8, 12, 16, 18…) and long empty gaps between them. const readyToIssue=req.query.readyToIssue==='1'&&status==='Released'; const restrictedMode=readyToClose||readyToReceive||readyToIssue; try{ // Batch No. and the source Work Order's own No. aren't Production Order // fields in SAP itself — both live on this portal's own local tracking // record (batchNumber carried from the source Work Order/Batch Issuance // Intimation at PWO creation; woNo via WORK_ORDER_ID). Loaded ONCE here // (company-scoped, so no more than a normal admin screen's worth of // rows) and reused for two things below: (1) widening the search to // match by Batch No./WO No., since neither exists on the raw SAP // entity for OData's own $filter to search directly, and (2) enriching // every returned card with both fields so a search on either doesn't // require the frontend to already know them. let localRecs=[],woByIdMap={}; try{ localRecs=await poStore().listProductionOrders({company:co}); const woIds=[...new Set(localRecs.map(p=>p.workOrderId).filter(id=>id!=null))]; if(woIds.length){ const wos=await require('../services/workOrderStore').listWorkOrders({company:co}); wos.forEach(w=>{ woByIdMap[w.id]=w.woNo||''; }); } }catch(_e){ /* non-fatal — search/columns just fall back to SAP-only fields */ } // Build a combined OData $filter from status + free-text search. Search // matches item code / product description (contains), and — when the term // is numeric — the document number or AbsoluteEntry exactly, so users can // find ANY order (not just whatever happened to be in the first page). // Also widened to Batch No./WO No. via the local join above — since // neither is a real field on the SAP entity, a match there is folded in // as extra `AbsoluteEntry eq X` clauses instead of a `contains(...)`. // readyToIssue builds its own SQL-based WHERE further down instead of an // OData $filter (see its branch below) — this whole block only matters // for the other modes, so it's skipped entirely for readyToIssue rather // than computed and then thrown away. const parts=[]; // Consumable/Regular split — resolved to concrete AbsoluteEntry values // from the local tracking table (SAP itself has no "is this a Consumable // Order" concept) so it can be pushed into the actual SQL/OData query // BEFORE pagination, same reasoning as the Item Group embedding below. // Without this, ?orderType=consumable used to filter the response AFTER // a fixed-size page had already been fetched — since real Consumable // Orders are a small fraction of all orders, a raw page of 20-50 could // easily contain zero of them, producing the exact "some pages empty, // then data again" symptom reported live (bheekam's own #7485 among // them) that Due Date/Item Group filtering had before being fixed the // same way. isConsumableOnlyUser's hard floor (never show a regular PWO // to a Consumable-only user) is included here too, not just the // query-string toggle — it's the same problem either way. const effectiveOrderType=isConsumableOnlyUser(req)?'consumable':(req.query.orderType==='consumable'||req.query.orderType==='regular'?req.query.orderType:null); // Hoisted (not block-scoped) — reused below by restrictedMode's own SQL // WHERE as well as the plain-list OData $filter immediately below. const consumableEntries=effectiveOrderType?localRecs.filter(p=>p.isConsumable&&p.sapAbsEntry!=null).map(p=>p.sapAbsEntry):[]; let orderTypeFilterEmbedded=false; if(!readyToIssue&&effectiveOrderType){ // Only orders this portal has ever locally tracked can be resolved this // way — a "regular" order created directly in SAP with no local row at // all is never a Consumable Order either, so it belongs in the // "regular" set but isn't IN localRecs. For orderType=regular this // means "NOT one of the known consumable entries" rather than // "IN the known regular entries", so untracked orders aren't wrongly // excluded. // Budget on the ACTUAL built string, not a flat entry-count cap — a // flat count (originally 300) still let 88 real entries blow past // SAP's 2048-char OData query-string ceiling (SAP -207) once combined // with the other $filter parts (status/date/search/item-group) also // sharing that same 2048 budget. 1200 chars leaves headroom for those. // Doesn't embed at all when over budget — no post-fetch fallback here // either (that's the exact sparse-page bug this was fixing in the // first place); see needsOrderTypeSql below instead, which switches // the ENTIRE query to a real SQL WHERE with no length ceiling. if(effectiveOrderType==='consumable'){ if(!consumableEntries.length){ parts.push(`AbsoluteEntry eq -1`); // no known consumable orders at all for this company — fail closed orderTypeFilterEmbedded=true; }else{ const clause='('+consumableEntries.map(e=>`AbsoluteEntry eq ${e}`).join(' or ')+')'; if(clause.length<=1200){ parts.push(clause); orderTypeFilterEmbedded=true; } } }else if(!consumableEntries.length){ orderTypeFilterEmbedded=true; // nothing to exclude — every order already qualifies as "regular" }else{ const clause='('+consumableEntries.map(e=>`AbsoluteEntry ne ${e}`).join(' and ')+')'; if(clause.length<=1200){ parts.push(clause); orderTypeFilterEmbedded=true; } } } // When the entry set was too large to embed in OData, the ENTIRE query // switches to the SQL-based path below (shared with restrictedMode) — // SQL has no practical length ceiling for an IN(...) list, unlike an // HTTP query string. const needsOrderTypeSql=!readyToIssue&&!!effectiveOrderType&&!orderTypeFilterEmbedded; // Item Group visibility restriction, pushed into the OData $filter itself // (not a post-fetch pass) whenever the allowed-groups' actual item list is // small enough to embed as an OR-list — SAP's Service Layer has no way to // filter on "this item's OWN group is in a list" server-side, so the only // way to get this scoped BEFORE pagination is to resolve it to concrete // ItemNo values first. Falls back to the old post-fetch narrowing (with // its "page can come back shorter than requested" caveat) only when the // allowed set is too large to embed — SAP's $filter string has a real // length ceiling (-207 "invalid filter" observed past ~2048 chars). let groupFilterEmbedded=false; if(!readyToIssue&&allowedGroups.length){ try{ const groupList=allowedGroups.map(g=>parseInt(g)).filter(n=>!isNaN(n)); const codeRows=groupList.length?await hanaQuery(`SELECT "ItemCode" FROM ${DB(co)}."OITM" WHERE "ItmsGrpCod" IN (${groupList.join(',')})`,co):[]; const codes=codeRows.map(r=>r.ItemCode).filter(Boolean); if(codes.length&&codes.length<=200){ parts.push('('+codes.map(c=>`ItemNo eq '${c.replace(/'/g,"''")}'`).join(' or ')+')'); groupFilterEmbedded=true; }else if(!codes.length){ parts.push(`ItemNo eq '__NO_ITEMS_IN_ALLOWED_GROUPS__'`); // fail closed, not open groupFilterEmbedded=true; } // else: too many codes to embed — leave groupFilterEmbedded false, the // existing post-fetch pass further down still catches it. }catch(e){ console.warn('[PRODUCTION-ORDERS] item-group $filter resolution failed (non-fatal):',e.message); } } if(!readyToIssue){ if(status==='Planned') parts.push(`ProductionOrderStatus eq 'boposPlanned'`); else if(status==='Released') parts.push(`ProductionOrderStatus eq 'boposReleased'`); else if(status==='Closed') parts.push(`ProductionOrderStatus eq 'boposClosed'`); if(/^\d{4}-\d{2}-\d{2}$/.test(dateFrom)) parts.push(`DueDate ge '${dateFrom}'`); if(/^\d{4}-\d{2}-\d{2}$/.test(dateTo)) parts.push(`DueDate le '${dateTo}'`); if(search){ const s=search.replace(/'/g,"''"); const or=[`contains(ItemNo,'${s}')`,`contains(ProductDescription,'${s}')`]; if(/^\d+$/.test(search)){ or.push(`DocumentNumber eq ${search}`); or.push(`AbsoluteEntry eq ${search}`); } const needle=search.toUpperCase(); const localMatchEntries=localRecs.filter(p=>{ if(p.sapAbsEntry==null)return false; if((p.batchNumber||'').toUpperCase().includes(needle))return true; const woNo=p.workOrderId!=null?(woByIdMap[p.workOrderId]||''):''; return woNo.toUpperCase().includes(needle); }).map(p=>p.sapAbsEntry); // Many SFG items here aren't SAP-batch-managed (no OBTN entry) — whoever // raises the Production Order directly in SAP just types the batch // number into the order's own Comments field instead. Fold in a match // there too (DocEntry IS AbsoluteEntry on this entity), or those orders // are only ever findable by scrolling the full unfiltered list. Capped // (TOP 40) — an overly broad/short term matching hundreds of Comments // rows would otherwise balloon this into a $filter of hundreds of // "AbsoluteEntry eq X" clauses and blow past SAP's own 2048-char query // string limit (SAP -207), breaking the search outright instead of // just being a bit narrower than ideal. if(s.length>=4){ try{ const oworRows=await hanaQuery(`SELECT TOP 40 "DocEntry" FROM ${DB(co)}."OWOR" WHERE "Comments" LIKE '%${s}%'`,co); oworRows.forEach(r=>{ if(r.DocEntry!=null) localMatchEntries.push(r.DocEntry); }); }catch(_e){ /* non-fatal — search just stays narrower */ } } // Same cap applied to the COMBINED set (local batch/WO matches + the // OWOR ones above) — belt and braces, since either source alone could // already be large on a broad term. const cappedEntries=[...new Set(localMatchEntries)].slice(0,60); cappedEntries.forEach(e=>or.push(`AbsoluteEntry eq ${e}`)); parts.push('('+or.join(' or ')+')'); } } let orders, total=null; if(restrictedMode||needsOrderTypeSql){ // At this company's scale (tens of thousands of Released orders) // neither a per-order Service Layer fetch nor SAP's own $filter (which // has no working any()/all() lambda support on ProductionOrderLines — // confirmed live against this SAP instance, -201 "Invalid symbol in // the filter condition") can express "not fully issued"/"not fully // received" as a real server-side condition. The old approach — fetch // up to 500 raw "Released" orders and filter in Node — silently // dropped everything past that cap: readyToIssue had ~7,000 real // candidates, readyToReceive ~7,600, readyToClose up to ~48,000, all // spread across ~62,000 Released orders, so only whatever happened to // be in the most recent 500 (by AbsoluteEntry) ever showed up — pages // appeared to have data scattered at random with long empty gaps, and // the total was wrong regardless. Built directly off the underlying // SQL tables instead, shared across all three modes — proper WHERE/ // EXISTS + COUNT + OFFSET/FETCH scales fine however large any of these // candidate sets grow, unlike fetching-then-filtering a bounded batch. // OWOR."Status" is SAP's own raw status code — verified live against // this company's data: 'R' = Released (e.g. DocEntry 109760, actively // in-progress, confirmed elsewhere in this codebase's own history); // 'L' = Closed (e.g. DocEntry 1/3, both have CmpltQty=PlannedQty AND a // CloseDate set). An earlier version of this query used 'L' here by // mistake — meaning it silently searched CLOSED orders instead of // Released ones, which is exactly why an already-SAP-closed order // could still surface on Issue for Production: a PWO that was // force-closed without full issuance still passes the "not fully // issued" check below regardless of being closed. const notFullyIssuedExists=`EXISTS (SELECT 1 FROM ${DB(co)}."WOR1" T1 WHERE T1."DocEntry"=T0."DocEntry" AND T1."IssueType"<>'B' AND T1."IssuedQty"'B' AND T1."IssuedQty">0)`; // readyToIssue/Receive/Close only ever care about Released orders // (that's what "ready" means); the needsOrderTypeSql-only case (plain // "View Orders" browsing with a Type filter whose entry set was too // large to embed in OData) maps from the actual status chip instead. const whereParts=(readyToIssue||readyToReceive||readyToClose)?[`T0."Status"='R'`] :status==='Planned'?[`T0."Status"='P'`] :status==='Released'?[`T0."Status"='R'`] :status==='Closed'?[`T0."Status"='L'`] :[]; if(allowedGroups.length){ const groupList=allowedGroups.map(g=>parseInt(g)).filter(n=>!isNaN(n)); whereParts.push(groupList.length ? `T0."ItemCode" IN (SELECT "ItemCode" FROM ${DB(co)}."OITM" WHERE "ItmsGrpCod" IN (${groupList.join(',')}))` : `1=0`); // allowedGroups set but somehow none parse to a real code — fail closed, not open } // Same Consumable/Regular split as the plain-list OData $filter above // (see effectiveOrderType/consumableEntries), applied here too since // Receipt/Close can also run with ?orderType= set — pushed into SQL for // the same reason: a post-fetch filter after OFFSET/FETCH has already // paginated would reproduce the exact sparse-page bug being fixed. if(effectiveOrderType==='consumable'){ whereParts.push(consumableEntries.length ? `T0."DocEntry" IN (${consumableEntries.join(',')})` : `1=0`); }else if(effectiveOrderType==='regular'&&consumableEntries.length){ whereParts.push(`T0."DocEntry" NOT IN (${consumableEntries.join(',')})`); } if(readyToIssue){ whereParts.push(notFullyIssuedExists); }else if(readyToReceive){ // Always: at least partially issued, and not yet fully received. // Additionally: fully issued, ONLY when Admin → "Require full // issuance before Receipt/Close" is actually on — when it's off, a // partially-issued order is legitimately receivable too (matches // the client's own full-or-partial acceptance). whereParts.push(anyIssuedExists); whereParts.push(`T0."CmpltQty"=T0."PlannedQty"`); } // YYYY-MM-DD only — matches 's own value format, and // guards these against being interpolated as anything other than a // plain date literal. const isPlainDate=v=>/^\d{4}-\d{2}-\d{2}$/.test(v); if(isPlainDate(dateFrom)) whereParts.push(`T0."DueDate">='${dateFrom}'`); if(isPlainDate(dateTo)) whereParts.push(`T0."DueDate"<='${dateTo}'`); if(search){ const s=search.replace(/'/g,"''"); const searchOr=[`T0."ItemCode" LIKE '%${s}%'`,`T0."ProdName" LIKE '%${s}%'`,`T0."Comments" LIKE '%${s}%'`]; if(/^\d+$/.test(search)){ searchOr.push(`T0."DocNum"=${search}`); searchOr.push(`T0."DocEntry"=${search}`); } const needle=search.toUpperCase(); const localMatchEntries=[...new Set(localRecs.filter(p=>{ if(p.sapAbsEntry==null)return false; if((p.batchNumber||'').toUpperCase().includes(needle))return true; const woNo=p.workOrderId!=null?(woByIdMap[p.workOrderId]||''):''; return woNo.toUpperCase().includes(needle); }).map(p=>p.sapAbsEntry))].slice(0,500); if(localMatchEntries.length) searchOr.push(`T0."DocEntry" IN (${localMatchEntries.join(',')})`); whereParts.push('('+searchOr.join(' OR ')+')'); } // whereParts can legitimately be empty here (needsOrderTypeSql with // status='All' and no other filters) — unlike restrictedMode proper, // which always has at least the Status='R' condition. const where=whereParts.length?whereParts.join(' AND '):'1=1'; try{ const countRows=await hanaQuery(`SELECT COUNT(*) AS n FROM ${DB(co)}."OWOR" T0 WHERE ${where}`,co); total=Number(countRows[0]?.n); if(isNaN(total)) total=null; }catch(e){ total=null; console.warn('[PRODUCTION-ORDERS] restrictedMode COUNT(*) failed — pager will show no total:',e.message); } const rows=await hanaQuery(` SELECT T0."DocEntry",T0."DocNum",T0."ItemCode",T0."ProdName",T0."PlannedQty",T0."CmpltQty",T0."Warehouse",T0."DueDate",T0."Status" FROM ${DB(co)}."OWOR" T0 WHERE ${where} ORDER BY T0."DocEntry" DESC OFFSET ${skip} ROWS FETCH NEXT ${top} ROWS ONLY`,co); const entries=rows.map(r=>r.DocEntry); // Per-line issued/planned, scoped to just this page's orders — enough // to compute the same _issueStatus/_receiptStatus badges the SAP- // fetched path uses everywhere else, kept consistent rather than // inventing a different signal just for this one list. let linesByEntry={}; if(entries.length){ try{ const lineRows=await hanaQuery(`SELECT "DocEntry","IssueType","IssuedQty","PlannedQty" FROM ${DB(co)}."WOR1" WHERE "DocEntry" IN (${entries.join(',')})`,co); lineRows.forEach(l=>{ (linesByEntry[l.DocEntry]||(linesByEntry[l.DocEntry]=[])).push(l); }); }catch(_e){} } orders=rows.map(r=>{ const lines=(linesByEntry[r.DocEntry]||[]).filter(l=>l.IssueType!=='B'); const allFull=lines.length?lines.every(l=>(Number(l.IssuedQty)||0)>=(Number(l.PlannedQty)||0)):true; const anyIssued=lines.some(l=>(Number(l.IssuedQty)||0)>0); const planned=Number(r.PlannedQty)||0, completed=Number(r.CmpltQty)||0; // readyToIssue/Receive/Close only ever query Status='R' rows, so this // was always safe to hardcode for them; needsOrderTypeSql can now // also return Planned/Closed rows, so map the real column instead. const statusMap={P:'boposPlanned',R:'boposReleased',L:'boposClosed'}; return { AbsoluteEntry:r.DocEntry, DocumentNumber:r.DocNum, ItemNo:r.ItemCode, ProductDescription:r.ProdName, PlannedQuantity:planned, CompletedQuantity:completed, ProductionOrderStatus:statusMap[r.Status]||'boposReleased', Warehouse:r.Warehouse, DueDate:r.DueDate, PostingDate:null, _issueStatus:allFull?'full':anyIssued?'partial':'', _receiptStatus:completed>=(planned||1)?'done':completed>0?'partial':'', }; }); }else{ const filter=parts.length?`&$filter=${parts.join(' and ')}`:''; // restrictedMode is always false here (see the branch above), so this // is a plain $top/$skip page straight off the real filtered query. const fetchTop=top; const fetchSkip=skip; // NOTE: Comments is NOT a selectable property on this Service Layer // entity (SAP -1000 "Property 'Comments' of 'ProductionOrder' is // invalid") even though the underlying OWOR table has the column — // fetched separately via HANA below instead. const result=await getSap().sapRequest('GET', `ProductionOrders?$select=AbsoluteEntry,DocumentNumber,ItemNo,ProductDescription,PlannedQuantity,CompletedQuantity,ProductionOrderStatus,Warehouse,DueDate,PostingDate,CustomerCode,ProductionOrderLines&$orderby=AbsoluteEntry desc&$top=${fetchTop}&$skip=${fetchSkip}${filter}`,null,co); // Issue/receipt status computed straight from SAP's own maintained // fields — no separate HANA query needed, and (importantly) both are // already return-aware: a "Return Components" posting decrements the // affected line's IssuedQuantity directly in SAP, so this reflects it // automatically instead of the old approach (summing IGE1 against the // finished good's PlannedQuantity) which compared unrelated units and // never accounted for returns at all. orders=(result?.value||[]).map(o=>{ // Backflush lines are consumed by SAP itself, never manually issued via // this portal (see [[issue-production-backflush-hidden]]) — their // IssuedQuantity legitimately stays 0 forever, so they must be excluded // here too or an order that's genuinely fully issued (on every line // that's ACTUALLY issuable) shows as stuck PARTIAL/PENDING forever. const lines=(o.ProductionOrderLines||[]).filter(l=>l.ItemType!=='pit_Resource'&&l.ProductionOrderIssueType!=='im_Backflush'); if(lines.length){ const allFull=lines.every(l=>(l.IssuedQuantity||0)>=(l.PlannedQuantity||0)); const anyIssued=lines.some(l=>(l.IssuedQuantity||0)>0); o._issueStatus=allFull?'full':anyIssued?'partial':''; }else{ o._issueStatus=''; } const planned=o.PlannedQuantity||1; const received=o.CompletedQuantity||0; o._receiptStatus=received>=planned?'done':received>0?'partial':''; delete o.ProductionOrderLines; return o; }); // readyToClose/readyToReceive/readyToIssue are all handled above (this // branch only runs when restrictedMode is false), so this is always // the plain, unrestricted list — total via a genuine $count query. // Total matching count (same filter, no paging) — drives page numbers // on the client. Wrapped so a count failure never breaks the list itself. try{ const countFilter=parts.length?`?$filter=${parts.join(' and ')}`:''; const c=await getSap().sapRequest('GET',`ProductionOrders/$count${countFilter}`,null,co); const n=Number(c); if(!isNaN(n)) total=n; }catch(e){ console.warn('[PRODUCTION-ORDERS] $count failed — pager will show no total:',e.message); } } // Batch No. + source Work Order No. — joined in from the SAME local // records/WO map already loaded above (for search-widening), by SAP // AbsoluteEntry, so every list/card view gets both for free without a // second round trip. try{ const batchByEntry={},refBatchByEntry={},woNoByEntry={}; localRecs.forEach(p=>{ if(p.sapAbsEntry==null)return; batchByEntry[p.sapAbsEntry]=p.batchNumber||''; // REF_BATCH_NUMBER (the PARENT order's batch, carried onto a "+ PWO" // component sub-order purely for reference — see createPwoForComponent() // in public/production.html) is only this order's OWN batch by // coincidence — SAP's own Comments field (checked below) is this // specific order's authoritative batch text when one was typed // there, and must win over the parent's breadcrumb reference. Kept // as the LAST-resort fallback only, for when neither this order's // own batchNumber nor its Comments have anything usable. refBatchByEntry[p.sapAbsEntry]=p.refBatchNumber||''; woNoByEntry[p.sapAbsEntry]=p.workOrderId!=null?(woByIdMap[p.workOrderId]||''):(p.refWoNo||''); }); // Fall back to SAP's own Comments field when there's no portal-tracked // batch — many of these SFG items aren't SAP-batch-managed, so whoever // raises the order directly in SAP just types the batch number there // instead (see the OWOR.Comments search-widening above). Without this // fallback, an order found ONLY via that Comments match would show a // blank Batch No. here, and — worse — get silently dropped again by // the client's own "type to filter loaded orders" re-filter, which // matches against this same BatchNumber field. Only accepted when // Comments actually LOOKS like a batch code (every space-separated // token is letters/digits/-/ only, AND at least one token contains a // digit — a real remark like "urgent rework needed" or "Please check // with QA" never has a digit anywhere) — otherwise Comments is just an // ordinary remark and showing it as "Batch No." would be misleading. // ANY number of internal spaces is allowed — verified live this // company's own convention includes multi-word phrasing (e.g. // "PDS NA2609165", and "SOL BB2609327 CPDA" — a 3-token batch note // that a single-space-only version of this check used to reject // outright, leaving Batch No. blank and unsearchable). A real // server-side search via Enter/numeric doc search still finds these // fine either way, since that path queries Comments directly rather // than relying on this display heuristic — which is what made earlier, // narrower versions of this check look like an inconsistent, // hard-to-explain "sometimes works" bug. const batchLike=v=>{ if(typeof v!=='string')return false; const s=v.trim(); if(s.length<5||s.length>40)return false; const tokens=s.split(/\s+/); if(!tokens.every(t=>/^[A-Z0-9\-\/]+$/i.test(t)))return false; return tokens.some(t=>/\d/.test(t)); }; const missingLocalBatch=orders.filter(o=>!batchByEntry[o.AbsoluteEntry]&&Number.isInteger(o.AbsoluteEntry)).map(o=>o.AbsoluteEntry); let commentsByEntry={}; if(missingLocalBatch.length){ try{ const rows=await hanaQuery(`SELECT "DocEntry","Comments" FROM ${DB(co)}."OWOR" WHERE "DocEntry" IN (${missingLocalBatch.join(',')})`,co); rows.forEach(r=>{ commentsByEntry[r.DocEntry]=r.Comments||''; }); }catch(_e){ /* non-fatal — falls through to blank below */ } } orders.forEach(o=>{ const commentsBatch=batchLike(commentsByEntry[o.AbsoluteEntry])?commentsByEntry[o.AbsoluteEntry].trim():''; o.BatchNumber=batchByEntry[o.AbsoluteEntry]||commentsBatch||refBatchByEntry[o.AbsoluteEntry]||''; o.WoNo=woNoByEntry[o.AbsoluteEntry]||''; }); }catch(_e){ /* non-fatal — Batch No./WO No. just show blank */ } // Consumable Orders are only visible to their own creator, an admin, or // someone holding 'approve' on production_order:consumable_create — drop // any others from the list entirely (not just hidden action buttons). try{ const consumableByEntry={}; localRecs.forEach(p=>{ if(p.sapAbsEntry!=null&&p.isConsumable) consumableByEntry[p.sapAbsEntry]=p; }); orders.forEach(o=>{ const c=consumableByEntry[o.AbsoluteEntry]; o.IsConsumable=!!c; o.Department=c?.department||''; }); orders=orders.filter(o=>{ const c=consumableByEntry[o.AbsoluteEntry]; return !c||canViewConsumableOrder(c,req); }); // Department scoping (Add-only users, see consumableDeptRestriction()) — // Release/Issue/Close holders and the order's own creator are already // exempt inside that function, so this only ever narrows an Add-only // viewer down to their own department(s). const deptSet=await consumableDeptRestriction(req); if(deptSet) orders=orders.filter(o=>{ const c=consumableByEntry[o.AbsoluteEntry]; return !c||c.createdBy===req.user?.username||deptSet.has(String(c.department||'')); }); // A Consumable-only user (holds no general production_order step at // all) must NEVER see a regular PWO here regardless of what orderType // the client asks for — this is a hard server-side floor, not just a // client-side default, so it can't be bypassed by querying // ?orderType=regular directly. if(isConsumableOnlyUser(req)) orders=orders.filter(o=>o.IsConsumable); // ?orderType=consumable|regular — lets the "View Orders" screen filter // the two apart instead of always showing them interleaved. else if(req.query.orderType==='consumable') orders=orders.filter(o=>o.IsConsumable); else if(req.query.orderType==='regular') orders=orders.filter(o=>!o.IsConsumable); }catch(_e){} // Item Group visibility restriction — LAST-RESORT fallback only. // restrictedMode pushed this into its own SQL WHERE above; the plain // list embedded it into the OData $filter above too whenever the // allowed set was small enough (groupFilterEmbedded). Only when NEITHER // of those could apply (a group-restricted user's allowed items number // more than can fit in one OData $filter string) does this old post- // fetch narrowing still run — same "page can come back shorter than // requested, total goes null" caveat as before, now the genuinely rare // case instead of the default for every restricted user. if(!restrictedMode&&!groupFilterEmbedded) try{ if(allowedGroups.length&&orders.length){ const codes=[...new Set(orders.map(o=>o.ItemNo).filter(Boolean))]; const list=codes.map(c=>`'${String(c).replace(/'/g,"''")}'`).join(','); const rows=await hanaQuery(`SELECT "ItemCode","ItmsGrpCod" FROM ${DB(co)}."OITM" WHERE "ItemCode" IN (${list})`,co); const grpByCode={};rows.forEach(r=>{grpByCode[r.ItemCode]=String(r.ItmsGrpCod);}); const allowSet=new Set(allowedGroups); orders=orders.filter(o=>allowSet.has(grpByCode[o.ItemNo])); if(total!=null) total=null; // the SAP $count above no longer matches this narrower, group-filtered set } }catch(e){ console.warn('[PRODUCTION-ORDERS] item-group visibility filter failed (non-fatal):',e.message); } res.json({success:true,data:orders,total}); }catch(err){res.status(500).json({success:false,message:err.message});} }); // ════════════════════════════════════════════════════════════════ // RECEIPTS HISTORY → GET /api/sap/receipts-history // Already-posted Receipt from Production documents (InventoryGenEntries, // BaseType 202, FG receipt lines = BaseLine IS NULL; Return Components have // BaseLine set and are excluded). Searchable by PWO No, receipt doc number, // item code/description, or batch. Read straight from OIGN/IGN1 (+OWOR for // the PWO No, +IBT1 for the real batch), so it works for every receipt. // ════════════════════════════════════════════════════════════════ router.get('/receipts-history', verifyToken, async(req,res)=>{ const co=cq(req); const top=Math.min(Number(req.query.top)||20,100); const skip=Number(req.query.skip)||0; const search=(req.query.search||'').trim(); try{ const db=DB(co); let where=`T1."BaseType"=202 AND T1."BaseLine" IS NULL`; // No Consumable-Order carve-out here anymore: Consumable Orders never go // through Receipt at all (workflow is Release → Issue → Close only, and // there is no consumable Receipt approval step), so there is nothing // Consumable-specific for this report to special-case. if(search){ const s=search.replace(/'/g,"''"); const ors=[`T1."ItemCode" LIKE '%${s}%'`,`T1."Dscription" LIKE '%${s}%'`,`T3."BatchNum" LIKE '%${s}%'`,`T0."U_BTCHNO" LIKE '%${s}%'`]; if(/^\d+$/.test(search)){ ors.push(`T0."DocNum"=${search}`); ors.push(`T2."DocNum"=${search}`); ors.push(`T1."BaseEntry"=${search}`); } where+=` AND (${ors.join(' OR ')})`; } const fromJoin=` FROM ${db}."OIGN" T0 INNER JOIN ${db}."IGN1" T1 ON T1."DocEntry"=T0."DocEntry" LEFT JOIN ${db}."OWOR" T2 ON T2."DocEntry"=T1."BaseEntry" LEFT JOIN ${db}."IBT1" T3 ON T3."BaseType"=59 AND T3."BaseEntry"=T0."DocEntry" AND T3."BaseLinNum"=T1."LineNum" WHERE ${where}`; const rows=await hanaQuery(` SELECT T0."DocEntry", T0."DocNum" AS "ReceiptDocNum", T0."DocDate", T1."LineNum", T1."ItemCode", T1."Dscription", T1."Quantity", T1."WhsCode", T1."BaseEntry", T1."TranType", T2."DocNum" AS "PWONum", COALESCE(T3."BatchNum", T0."U_BTCHNO") AS "Batch" ${fromJoin} ORDER BY T0."DocEntry" DESC, T1."LineNum" OFFSET ${skip} ROWS FETCH NEXT ${top} ROWS ONLY`,co); // Always computed (even unfiltered) so the pager can show "Page X of Y" // consistently — a plain COUNT(*) over these indexed join columns is // cheap enough not to need the search-only guard this used to have. let total=null; try{ const c=await hanaQuery(`SELECT COUNT(*) AS n ${fromJoin}`,co); total=c[0]?.n??null; }catch(e){ console.warn('[RECEIPTS-HISTORY] COUNT(*) failed — pager will show no total:',e.message); } res.json({success:true,data:rows.map(r=>({ docEntry:r.DocEntry, receiptDocNum:r.ReceiptDocNum, docDate:r.DocDate, lineNum:r.LineNum, itemCode:r.ItemCode, description:r.Dscription, quantity:Number(r.Quantity), warehouse:r.WhsCode, pwoAbsEntry:r.BaseEntry, pwoNum:r.PWONum, transType:r.TranType, batch:r.Batch||'', })), total}); }catch(err){res.status(500).json({success:false,message:err.message});} }); // ════════════════════════════════════════════════════════════════ // BATCH LOOKUP (OIBT — batch inventory by item + warehouse) // ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════ // RESOURCES (ORSC — Resource Master Data) // ════════════════════════════════════════════════════════════════ router.get('/lookup/resources', verifyToken, async(req,res)=>{ const co=cq(req); const q=(req.query.q||'').replace(/'/g,"''").toUpperCase(); // Try HANA first try{ const rows=await hanaQuery(` SELECT TOP 30 "VisResCode","ResName" FROM ${DB(co)}."ORSC" WHERE (UPPER("VisResCode") LIKE '%${q}%' OR UPPER("ResName") LIKE '%${q}%') ORDER BY "VisResCode"`,co); if(rows.length) return res.json({success:true,data:rows.map(r=>({ResCode:r.VisResCode,ResName:r.ResName}))}); }catch(e){console.warn('[SAP] HANA ORSC failed:',e.message);} // Fallback to Service Layer — fields are Code, VisCode, Name try{ const result=await getSap().sapRequest('GET',`Resources?$select=Code,VisCode,Name&$top=50`,null,co); const all=(result?.value||[]).filter(r=>(r.VisCode||r.Code||'').toUpperCase().includes(q)||(r.Name||'').toUpperCase().includes(q)); res.json({success:true,data:all.map(r=>({ResCode:r.VisCode||r.Code,ResName:r.Name}))}); }catch(e2){ console.warn('[SAP] SL Resources failed:',e2.message); res.json({success:true,data:[],warning:e2.message}); } }); router.get('/lookup/batches', verifyToken, async(req,res)=>{ const co=cq(req); const itemCode=(req.query.item||'').replace(/'/g,"''"); const whsCode=(req.query.warehouse||'').replace(/'/g,"''"); if(!itemCode) return res.json({success:true,data:[]}); try{ let where=`"ItemCode"='${itemCode}' AND "Quantity">0`; if(whsCode) where+=` AND "WhsCode"='${whsCode}'`; const rows=await hanaQuery(` SELECT "BatchNum","ItemCode","WhsCode","Quantity","ExpDate" FROM ${DB(co)}."OIBT" WHERE ${where} ORDER BY "ExpDate","BatchNum"`,co); res.json({success:true,data:rows.map(r=>({ BatchNumber:r.BatchNum,ItemCode:r.ItemCode,Warehouse:r.WhsCode, Quantity:Number(r.Quantity),ExpiryDate:r.ExpDate }))}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // GET /api/sap/lookup/batch-exists?batchNo=X — does this batch number already // exist ANYWHERE in SAP (any item), regardless of current stock level? Checks // OBTN (the batch master table — DistNumber is the batch number column), // unlike /lookup/batches (OIBT) which only sees batches with stock on hand // right now. Used by Batch Issuance Intimation's "Batch No. Required" // uniqueness check. router.get('/lookup/batch-exists', verifyToken, async(req,res)=>{ const co=cq(req); const batchNo=(req.query.batchNo||'').trim().replace(/'/g,"''"); if(!batchNo) return res.json({success:true,exists:false}); try{ const rows=await hanaQuery(` SELECT TOP 1 "ItemCode","itemName" FROM ${DB(co)}."OBTN" WHERE "DistNumber"='${batchNo}'`,co); res.json({success:true,exists:rows.length>0,itemCode:rows[0]?.ItemCode||null,itemName:rows[0]?.itemName||null}); }catch(e){res.json({success:true,exists:false,warning:e.message});} }); // GET /api/sap/lookup/batch-in-sap?batch=X — cross-reference search used by // Batch Issuance/Work Order/Verify Work Order's own search boxes: does this // batch number appear ANYWHERE in SAP, either as a real batch-managed number // (OBTN.DistNumber) or — how most of these SFG items actually record it, // since they aren't SAP-batch-managed — as free text in a Production Order's // own Comments field (OWOR.Comments)? Those Production Orders are then // created directly in SAP B1, never through this portal, so they'd otherwise // be invisible to every in-app search. Deliberately NOT wired into the // Production Order/Issue/Receipt/Close pages themselves (their own PWO // picker search already covers what they need) — this is only for // upstream/traceability lookups from Batch Issuance and Work Order. router.get('/lookup/batch-in-sap', verifyToken, async(req,res)=>{ const co=cq(req); const batch=(req.query.batch||'').trim().replace(/'/g,"''"); if(!batch||batch.length<4) return res.json({success:true,data:[]}); try{ const [obtnRows,oworRows]=await Promise.all([ hanaQuery(`SELECT TOP 5 "ItemCode","itemName","DistNumber" FROM ${DB(co)}."OBTN" WHERE "DistNumber" LIKE '%${batch}%'`,co).catch(()=>[]), hanaQuery(`SELECT TOP 5 "DocEntry","DocNum","ItemCode","ProdName","Comments" FROM ${DB(co)}."OWOR" WHERE "Comments" LIKE '%${batch}%'`,co).catch(()=>[]), ]); const data=[ ...obtnRows.map(r=>({source:'batch',itemCode:r.ItemCode,itemName:r.itemName,batch:r.DistNumber})), ...oworRows.map(r=>({source:'productionOrder',absEntry:r.DocEntry,docNum:r.DocNum,itemCode:r.ItemCode,itemName:r.ProdName,batch:r.Comments})), ]; res.json({success:true,data}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // ════════════════════════════════════════════════════════════════ // BATCH-MANAGED ITEM CLASSIFIER → GET /api/sap/batch-managed-items?codes=A,B // Returns which of the given item codes SAP itself tracks by batch // (OITM.ManBtchNum = 'Y'). Used by Batch Issuance Intimation to make Batch // No./MFG/EXP required per item automatically — mirrors the // /blood-bag-fg-items classifier pattern. // ════════════════════════════════════════════════════════════════ router.get('/batch-managed-items', verifyToken, async(req,res)=>{ const co=cq(req); const codes=(req.query.codes||'').split(',').map(s=>s.trim()).filter(Boolean).slice(0,500); if(!codes.length) return res.json({success:true,data:[]}); const list=codes.map(c=>`'${c.replace(/'/g,"''")}'`).join(','); try{ const rows=await hanaQuery(`SELECT "ItemCode" FROM ${DB(co)}."OITM" WHERE "ItemCode" IN (${list}) AND "ManBtchNum"='Y'`,co); res.json({success:true,data:rows.map(r=>r.ItemCode)}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // ════════════════════════════════════════════════════════════════ // BLOOD-BAG FG CLASSIFIER → GET /api/sap/blood-bag-fg-items?codes=A,B // Returns which of the given item codes are "Blood Bag finished goods": // item group 101 (FG BLOOD BAG) or 103 (FG EQUIPMENT) EXCEPT the two CAPD // machines. Used by Batch Issuance to auto-default a 3-year (Mfg+3yr) expiry. // ════════════════════════════════════════════════════════════════ router.get('/blood-bag-fg-items', verifyToken, async(req,res)=>{ const co=cq(req); const codes=(req.query.codes||'').split(',').map(s=>s.trim()).filter(Boolean).slice(0,500); if(!codes.length) return res.json({success:true,data:[]}); const list=codes.map(c=>`'${c.replace(/'/g,"''")}'`).join(','); try{ const rows=await hanaQuery(`SELECT "ItemCode" FROM ${DB(co)}."OITM" WHERE "ItemCode" IN (${list}) AND "ItmsGrpCod" IN (101,103) AND "ItemCode" NOT IN ('MEAPD20','m.CYCLER')`,co); res.json({success:true,data:rows.map(r=>r.ItemCode)}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // ════════════════════════════════════════════════════════════════ // SOLUTION-BATCH-LOCK CLASSIFIER → GET /api/sap/solution-batch-lock-items // Returns which of the given item codes are Blood Bag OR PD (CAPD) finished // goods — the products whose Work Order "Solution Batch Size" is locked once // it reaches the configured litre limit. Blood Bag = grp 101/103 minus the two // CAPD machines; PD = grp 102 or those two CAPD machines. (Union = grp 101/102/103.) // ════════════════════════════════════════════════════════════════ router.get('/solution-batch-lock-items', verifyToken, async(req,res)=>{ const co=cq(req); const codes=(req.query.codes||'').split(',').map(s=>s.trim()).filter(Boolean).slice(0,500); if(!codes.length) return res.json({success:true,data:[]}); const list=codes.map(c=>`'${c.replace(/'/g,"''")}'`).join(','); try{ const rows=await hanaQuery(`SELECT "ItemCode", CASE WHEN "ItmsGrpCod"=102 OR ("ItmsGrpCod"=103 AND "ItemCode" IN ('MEAPD20','m.CYCLER')) THEN 'PD' ELSE 'BB' END AS "T" FROM ${DB(co)}."OITM" WHERE "ItemCode" IN (${list}) AND ( ("ItmsGrpCod" IN (101,103) AND "ItemCode" NOT IN ('MEAPD20','m.CYCLER')) OR "ItmsGrpCod"=102 OR ("ItmsGrpCod"=103 AND "ItemCode" IN ('MEAPD20','m.CYCLER')))`,co); res.json({success:true,data:rows.map(r=>({code:r.ItemCode,type:r.T}))}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // ════════════════════════════════════════════════════════════════ // ISSUE FOR PRODUCTION → POST /api/sap/issue-production // Creates InventoryGenExits linked to a Production Order (BaseType 202) // ════════════════════════════════════════════════════════════════ router.post('/issue-production', verifyToken, async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const body=req.body; const co=cq(req); console.log('[ISSUE-PROD] Posting Issue for Production, Lines:',body.DocumentLines?.length); // Per-user item-group restriction: if this user has an allowed-groups list // (Admin → user → Issue Items), every issued line's item must belong to one // of those SAP Item Groups. Empty list = no restriction. Read fresh from DB. try{ const acting=await require('../services/hanaUsers').findById(req.user.id); const allowed=Array.isArray(acting?.issueItemGroups)?acting.issueItemGroups.map(String):[]; if(allowed.length){ const codes=[...new Set((body.DocumentLines||[]).map(l=>l.ItemCode).filter(Boolean))]; if(codes.length){ const list=codes.map(c=>`'${String(c).replace(/'/g,"''")}'`).join(','); const rows=await hanaQuery(`SELECT "ItemCode","ItmsGrpCod" FROM ${DB(co)}."OITM" WHERE "ItemCode" IN (${list})`,co); const grpByCode={};rows.forEach(r=>{grpByCode[r.ItemCode]=String(r.ItmsGrpCod);}); const allowSet=new Set(allowed); const bad=codes.filter(c=>!allowSet.has(grpByCode[c])); if(bad.length) return res.status(403).json({success:false,message:`You are not permitted to issue these item(s): ${bad.join(', ')} (item group not in your allowed list).`}); } } }catch(e){ console.warn('[ISSUE-PROD] item-group restriction check failed:',e.message); } // Stage sequencing: if this order is linked to a Work Order, "Issuance" // must either be the current pending step (stage 1, first pass), OR // already completed once and now sitting at "Receipt" (stage 2) — a // Shortage/Substitution deviation approved AFTER Issuance completed // legitimately needs a SECOND Issue for Production pass (the whole // point of "the concerned user issues it manually via Issue for // Production" — see [[production-deviation-workflow]]), so Issuance // can't be a one-shot-only gate. Checked BEFORE touching SAP either way. const baseEntry=parseInt(body.DocumentLines?.[0]?.BaseEntry); let linked=!isNaN(baseEntry)?await poStore().findBySapAbsEntry(baseEntry):null; linked=await flagOutOfPortalRelease(linked,co,req); if(!canActOnProductionOrder(linked,req,hasStepAssigned(req.user,'production_order:issuance'),'issuance')) return res.status(403).json({success:false,message:linked&&linked.isConsumable?'You are not permitted to issue this Consumable Order.':'You are not assigned to approval step: production_order:issuance'}); if(linked&&linked.stage!==1&&linked.stage!==2) return res.status(409).json({success:false,message:`Out of sequence: this order's next required step is "${linked.currentStep}", not Issuance`}); // Backflush lines are never manually issued — SAP is meant to consume // them itself (and in this DB, actually attempting it can hard-fail with // "[SAP -5002] Issue type cannot be backflush for serial or batch number // items"). The UI already hides these from the picker, but re-check // against SAP's live order data here too, since a client could submit // a line SAP still marks Backflush regardless of what the UI showed. if(!isNaN(baseEntry)&&Array.isArray(body.DocumentLines)&&body.DocumentLines.length){ try{ const poNow=await sap.sapRequest('GET',`ProductionOrders(${baseEntry})?$select=ProductionOrderLines`,null,co); const poLines=poNow?.ProductionOrderLines||[]; const backflushLineNums=new Set(poLines.filter(l=>l.ProductionOrderIssueType==='im_Backflush').map(l=>l.LineNumber)); const bad=body.DocumentLines.filter(l=>backflushLineNums.has(parseInt(l.BaseLine))); if(bad.length) return res.status(400).json({success:false,message:'This order contains Backflush-type item(s) which cannot be manually issued to SAP.'}); }catch(e){ console.warn('[ISSUE-PROD] backflush check failed:',e.message); } } // Set branch if not provided (default 2 = FACTORY) if(!body.BPL_IDAssignedToInvoice) body.BPL_IDAssignedToInvoice = parseInt(body.branchId) || 2; delete body.company; delete body.branchId; // Comments is silently dropped by SAP when sent in the CREATE payload // (same behavior already confirmed for Receipt from Production/other // marketing docs on this instance) — only a PATCH after the document // exists actually sticks. Captured here, stripped from the create body // (no point sending it, and Add() would just ignore it), applied below // once DocEntry is known. const remarksText=(body.Comments||'').toString().trim().slice(0,254); delete body.Comments; // Captured here (before ItemCode is stripped below, since SAP doesn't // want it on a production-order-based line) so the raw-material // qtyIssued calculation after a successful POST knows which SAP item was // actually issued on each line, and how much — only used when Admin → // System Settings → "Raw Material Qty Issued Source" is set to "Issue // for Production" (the default). const issuedItems=[]; if(Array.isArray(body.DocumentLines)){ body.DocumentLines=body.DocumentLines.map((line,idx)=>{ issuedItems.push({itemCode:line.ItemCode,baseLine:parseInt(line.BaseLine),quantity:parseFloat(line.Quantity)||0}); const clean={...line}; clean.BaseEntry=parseInt(clean.BaseEntry); clean.BaseLine=parseInt(clean.BaseLine); clean.BaseType=202; clean.Quantity=parseFloat(clean.Quantity)||0; // SAP rejects ItemCode when referencing a production order — it auto-derives from base doc delete clean.ItemCode; delete clean.ItemDescription; ['CostingCode','CostingCode2','CostingCode3','CostingCode4','CostingCode5','ProjectCode'].forEach(k=>{ if(clean[k]===''||clean[k]===null||clean[k]===undefined) delete clean[k]; }); if(!clean.WarehouseCode) delete clean.WarehouseCode; // BaseLineNumber is REQUIRED by Service Layer to link a // BatchNumbers row back to its own DocumentLines row (its index in // this array) — without it SAP rejects the whole document with // -4014 "Cannot add row without complete selection of batch/serial // numbers", especially once there's more than one line. if(Array.isArray(clean.BatchNumbers)){ clean.BatchNumbers=clean.BatchNumbers.filter(b=>b.BatchNumber && b.Quantity>0).map(b=>({...b,BaseLineNumber:idx})); if(!clean.BatchNumbers.length) delete clean.BatchNumbers; } else { delete clean.BatchNumbers; } if(Array.isArray(clean.SerialNumbers)){ clean.SerialNumbers=clean.SerialNumbers.filter(s=>s.InternalSerialNumber); if(!clean.SerialNumbers.length) delete clean.SerialNumbers; } else { delete clean.SerialNumbers; } // Portal-origin marker — same local Production Order tracking ID // already stamped on OWOR.U_ERP_SO_NO for this order, now also on // IGE1.U_ERP_SO_NO for every line of this Issue document. (Tried // Comments first — confirmed live that SAP silently drops it when // it's in the CREATE payload, only sticking via a later PATCH. // U_ERP_SO_NO is a genuine UDF, not a standard field SAP recomputes // on Add(), so it's included directly here instead — no // post-creation PATCH needed, since `linked` is already known // before this POST happens.) clean.U_ERP_SO_NO=linked?String(linked.id):''; return clean; }); } console.log('[ISSUE-PROD] Final payload:\n',JSON.stringify(body,null,2)); const result=await sap.sapRequest('POST','InventoryGenExits',body,co); console.log('[ISSUE-PROD] ✅ DocEntry:',result?.DocEntry,'DocNum:',result?.DocNum); // Remarks — see remarksText comment above for why this is a PATCH, not // part of the create payload. Non-fatal: the actual goods issue already // posted successfully regardless of whether this note sticks. if(remarksText&&result?.DocEntry!=null){ try{ await sap.sapRequest('PATCH',`InventoryGenExits(${result.DocEntry})`,{Comments:remarksText},co); } catch(e){ console.warn('[ISSUE-PROD] Comments PATCH failed (non-fatal):',e.message); } } // 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 actually being issued here) — so their own "Qty Issued" can't be // read live off the PO like Packing Material's is. Only when Admin → // System Settings → "Raw Material Qty Issued Source" is 'issue_for_production' // (the default): whenever a just-issued line's item matches a raw row's // solCode, prorate — (this issue's Quantity ÷ that item's // PlannedQuantity on the PO) × the row's own full Qty Req. — and add it // to that row's PERSISTED qtyIssued (cumulative across multiple Issue // for Production passes, e.g. a deviation top-up). Non-fatal: SAP's own // document is already posted at this point regardless. if(linked&&linked.workOrderId&&require('../services/appSettingsStore').woRawQtyIssuedSource()==='issue_for_production'){ try{ const woStore=require('../services/workOrderStore'); const wo=await woStore.findById(linked.workOrderId); const rawRows=Array.isArray(wo?.rawMaterials)?wo.rawMaterials:[]; const solCodes=new Set(rawRows.map(r=>r.solCode).filter(Boolean)); const relevant=issuedItems.filter(it=>it.itemCode&&solCodes.has(it.itemCode)&&it.quantity>0); if(wo&&relevant.length){ const poSap=await sap.sapRequest('GET',`ProductionOrders(${baseEntry})`,null,co); const poLines=poSap?.ProductionOrderLines||[]; let changed=false; for(const it of relevant){ const poLine=poLines.find(l=>l.LineNumber===it.baseLine); const planned=Number(poLine?.PlannedQuantity)||0; if(!planned) continue; const fraction=it.quantity/planned; rawRows.forEach(r=>{ if(r.solCode===it.itemCode){ const add=(parseFloat(r.qtyReq)||0)*fraction; r.qtyIssued=Math.round(((parseFloat(r.qtyIssued)||0)+add)*1000)/1000; changed=true; } }); } if(changed){ wo.rawMaterials=rawRows; await woStore.updateWorkOrder(wo.id,wo); } } }catch(e){ console.warn('[ISSUE-PROD] raw-material qtyIssued update failed (non-fatal):',e.message); } } if(linked&&linked.stage===1){ // Only advance on the FIRST pass — a second pass (stage already 2, // e.g. issuing a deviation-approved top-up) has nothing left to // advance; the order is already sitting at Receipt. try{ await poStore().advanceStage(linked.id,{action:'complete',stepKey:'issuance',by:req.user.username,byName:req.user.name||req.user.username}); } catch(e){ console.warn('[ISSUE-PROD] local stage advance failed (non-fatal):',e.message); } } res.json({success:true,data:result}); }catch(err){ const sapMsg=err.message||'Unknown SAP error'; console.error('[ISSUE-PROD] ❌',sapMsg); res.status(400).json({success:false,message:sapMsg}); } }); // ════════════════════════════════════════════════════════════════ // CONFIRM ISSUED OUTSIDE THE PORTAL → POST /api/sap/confirm-issued-outside-portal // Mirrors flagOutOfPortalRelease's philosophy (see its own long comment // above): an order Issued directly in SAP B1, bypassing this portal, must // NOT silently advance the local approval stage just because SAP's own // numbers already look done — the concerned user still needs to record // that in the portal. Unlike Release, though, Issuance can't just be a // harmless idempotent re-PATCH: posting InventoryGenExits again would // double-issue real stock that's already fully issued in SAP. So instead // of reposting anything, this endpoint re-verifies live against SAP that // every real component line genuinely IS already fully issued, and — only // if so — advances the local stage and leaves an audit trail entry, with // no new SAP document created at all (there's nothing left to post). // ════════════════════════════════════════════════════════════════ router.post('/confirm-issued-outside-portal', verifyToken, async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const co=cq(req); const absEntry=parseInt(req.body.absEntry); if(isNaN(absEntry)) return res.status(400).json({success:false,message:'absEntry is required'}); const linked=await poStore().findBySapAbsEntry(absEntry); if(!linked) return res.status(404).json({success:false,message:'This order is not tracked locally — nothing to confirm.'}); if(!canActOnProductionOrder(linked,req,hasStepAssigned(req.user,'production_order:issuance'),'issuance')) return res.status(403).json({success:false,message:'You are not assigned to approval step: production_order:issuance'}); if(linked.stage!==1) return res.status(409).json({success:false,message:`Out of sequence: this order's next required step is "${linked.currentStep}", not Issuance`}); const ord=await sap.sapRequest('GET',`ProductionOrders(${absEntry})?$select=ProductionOrderLines`,null,co); const lines=(ord?.ProductionOrderLines||[]).filter(l=>l.ItemType!=='pit_Resource'&&l.ProductionOrderIssueType!=='im_Backflush'); const notFullyIssued=lines.filter(l=>(l.IssuedQuantity||0)<(l.PlannedQuantity||0)); if(notFullyIssued.length) return res.status(409).json({success:false,message:`Not actually fully issued in SAP yet: ${notFullyIssued.map(l=>l.ItemNo).join(', ')}. Issue the remaining quantity first.`}); const updated=await poStore().advanceStage(linked.id,{action:'complete',stepKey:'issuance',by:req.user.username,byName:req.user.name||req.user.username,remarks:'Confirmed already fully issued directly in SAP B1 — no Issue for Production document was posted through the portal.'}); require('../services/auditStore').record({ userId:req.user?.id, username:req.user?.username, role:req.user?.role, method:req.method, action:'CONFIRM', entity:'ProductionOrder', entityId:String(linked.id), sub:'issued_outside_portal', path:req.originalUrl, status:200, ok:true, summary:`Production Order ${linked.sapDocNum||'#'+linked.id} (${linked.itemCode||''}) confirmed fully issued in SAP without a portal Issue for Production action — local stage advanced to Receipt.`, details:{sapAbsEntry:absEntry}, ip:req.ip, company:co, }).catch(()=>{}); res.json({success:true,data:updated}); }catch(err){res.status(400).json({success:false,message:err.message||'Unknown error'});} }); // GET /api/sap/production-orders/:absEntry/backflush-check?qty=X — pre-flight // check run from the Receipt from Production modal, right before Post, to // surface SAP's own "-5002 consumed quantity would cause inventory to fall // below zero" condition as a clear warning instead of a raw SAP error after // the user has already filled in the whole form. Backflush-type component // lines (ProductionOrderIssueType im_Backflush) are auto-consumed by SAP // when the Receipt posts — never manually issued — so their available stock // is never checked anywhere else in this flow. `qty` is the FG quantity // about to be received in THIS action (defaults to the order's full // remaining quantity); required consumption per line = BaseQuantity × qty, // same ratio SAP itself applies. router.get('/production-orders/:absEntry/backflush-check', verifyToken, async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const co=cq(req); const absEntry=parseInt(req.params.absEntry); if(isNaN(absEntry)) return res.status(400).json({success:false,message:'absEntry is required'}); const ord=await sap.sapRequest('GET',`ProductionOrders(${absEntry})?$select=PlannedQuantity,CompletedQuantity,ProductionOrderLines`,null,co); const remaining=Math.max(0,(ord?.PlannedQuantity||0)-(ord?.CompletedQuantity||0)); const qty=parseFloat(req.query.qty); const recvQty=(!isNaN(qty)&&qty>0)?qty:remaining; const backflushLines=(ord?.ProductionOrderLines||[]).filter(l=>l.ItemType!=='pit_Resource'&&l.ProductionOrderIssueType==='im_Backflush'&&l.ItemNo&&l.Warehouse); if(!backflushLines.length) return res.json({success:true,warnings:[]}); // SQL Server has no multi-column IN(...) — OR'd pair conditions instead. const sqlEsc=(v)=>String(v).replace(/'/g,"''"); const pairClauses=backflushLines.map(l=>`("ItemCode"='${sqlEsc(l.ItemNo)}' AND "WhsCode"='${sqlEsc(l.Warehouse)}')`).join(' OR '); const stockRows=await hanaQuery( `SELECT "ItemCode","WhsCode","OnHand" FROM ${DB(co)}."OITW" WHERE ${pairClauses}`,co ).catch(()=>[]); const stockMap={}; stockRows.forEach(r=>{ stockMap[`${r.ItemCode}||${r.WhsCode}`]=Number(r.OnHand)||0; }); const warnings=backflushLines.map(l=>{ const required=(Number(l.BaseQuantity)||0)*recvQty; const onHand=stockMap[`${l.ItemNo}||${l.Warehouse}`]||0; return {itemCode:l.ItemNo,itemName:l.ItemName||'',warehouse:l.Warehouse,required,onHand,shortfall:Math.max(0,required-onHand)}; }).filter(w=>w.shortfall>0.0001); res.json({success:true,warnings}); }catch(err){ console.warn('[BACKFLUSH-CHECK]',err.message); res.json({success:true,warnings:[],warning:err.message}); // non-fatal — never blocks the Receipt screen itself } }); // ════════════════════════════════════════════════════════════════ // RECEIPT FROM PRODUCTION → POST /api/sap/receipt-production // Creates InventoryGenEntries linked to a Production Order (BaseType 202) // This receives the finished product into inventory. // ════════════════════════════════════════════════════════════════ router.post('/receipt-production', verifyToken, async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const body=req.body; const co=cq(req); console.log('[RECEIPT-PROD] Posting Receipt from Production'); // Stage sequencing: if this order is linked to a Work Order, "Receipt" // must be the current pending step — checked BEFORE touching SAP. const baseEntry=parseInt(body.DocumentLines?.[0]?.BaseEntry); let linked=!isNaN(baseEntry)?await poStore().findBySapAbsEntry(baseEntry):null; linked=await flagOutOfPortalRelease(linked,co,req); if(!canActOnProductionOrder(linked,req,hasStepAssigned(req.user,'production_order:receipt'),'receipt')) return res.status(403).json({success:false,message:linked&&linked.isConsumable?'Consumable Orders do not go through Receipt — their workflow is Release → Issue → Close only.':'You are not assigned to approval step: production_order:receipt'}); if(linked&&linked.stage!==2) return res.status(409).json({success:false,message:`Out of sequence: this order's next required step is "${linked.currentStep}", not Receipt`}); // Full-issuance gate — admin-configurable (Settings → "Require full // issuance before Receipt/Close", default ON): the order moves to the // "Receipt" stage after the FIRST issue action even if it was only // partial (by design), so this is a separate, stricter check that every // real component's IssuedQuantity has actually reached its // PlannedQuantity in SAP before Receipt is allowed at all. if(appSettings.poRequireFullIssuance()&&!isNaN(baseEntry)){ const ord=await sap.sapRequest('GET',`ProductionOrders(${baseEntry})?$select=ProductionOrderLines`,null,co).catch(()=>null); // Backflush lines never get manually issued (see [[issue-production- // backflush-hidden]]) — excluded here too, or Receipt would be // permanently blocked on every order that has one. const lines=(ord?.ProductionOrderLines||[]).filter(l=>l.ItemType!=='pit_Resource'&&l.ProductionOrderIssueType!=='im_Backflush'); const notFullyIssued=lines.filter(l=>(l.IssuedQuantity||0)<(l.PlannedQuantity||0)); if(notFullyIssued.length) return res.status(409).json({success:false,message:`Cannot receipt: ${notFullyIssued.length} component(s) not yet fully issued (${notFullyIssued.map(l=>l.ItemNo).join(', ')}). Issue the remaining quantity first.`}); } // Full-quantity gate — admin-configurable (Settings → receiptRequireFullQty): // the total received across every warehouse line in this one Post Receipt // action must equal what's still outstanding on the order, blocking // partial/split-over-multiple-actions receipts. if(appSettings.receiptRequireFullQty()&&!isNaN(baseEntry)){ const order=await sap.sapRequest('GET',`ProductionOrders(${baseEntry})?$select=PlannedQuantity,CompletedQuantity`,null,co).catch(()=>null); if(order){ const remaining=Math.max(0,(order.PlannedQuantity||0)-(order.CompletedQuantity||0)); // By-product/process-loss lines (e.g. ICO10791) are a DIFFERENT // component's own BaseLine, not another warehouse split of the FG. // Admin-configurable (System Settings → receiptExcludeByProductFromTotal, // default ON): excluded here, matching the client's own recvTotal() // (public/receipt-production.html), which never counts them toward // the FG's remaining planned quantity — without this, a real // process-loss quantity entered alongside the FG's warehouses would // wrongly inflate the total and block an otherwise-exact receipt. const excludeByProduct=appSettings.receiptExcludeByProductFromTotal(); const total=(body.DocumentLines||[]).filter(l=>!excludeByProduct||!l.isByProduct).reduce((s,l)=>s+(parseFloat(l.Quantity)||0),0); if(Math.abs(total-remaining)>0.0001) return res.status(400).json({success:false,message:`Total receipt quantity (${total}) must equal the order's remaining planned quantity (${remaining})`}); } } const bplId=parseInt(body.BPL_IDAssignedToInvoice)||parseInt(body.branchId)||2; const docDate=body.DocDate, docDueDate=body.DocDueDate; const rawLines=Array.isArray(body.DocumentLines)?body.DocumentLines:[]; // "Batch Number Required" checkbox (OIGN.U_IS_BATCH_REQ, smallint 1/0). // When off, the frontend already omits BatchNumbers; we defensively strip // any that slipped through so no batch/expiry is recorded. const batchReq=body.batchReq!==false; // A receipt split across several warehouses is ONE InventoryGenEntries // document with one line per warehouse (all sharing the same batch and // MFG/EXP dates). The catch that made this look impossible earlier: each // BatchNumbers row MUST carry BaseLineNumber = its own DocumentLines // index, and the expiry field is "ExpiryDate" (NOT "ExpirationDate") — // without those SAP rejects the whole document with -4014 "Cannot add // row without complete selection of batch/serial numbers". const lineTransTypes=[]; const lines=rawLines.map((line,idx)=>{ const clean={...line}; clean.BaseEntry=parseInt(clean.BaseEntry); // The main FG line omits BaseLine (SAP derives it from the production // order's own item). A by-product/process-loss component — e.g. // ICO10791, a NEGATIVE-quantity line already on the Production Order // itself — needs its own BaseLine kept, since it's a distinct // component row, not the FG being received. if(clean.isByProduct) clean.BaseLine=parseInt(clean.BaseLine); else delete clean.BaseLine; delete clean.isByProduct; clean.BaseType=202; clean.Quantity=parseFloat(clean.Quantity)||0; delete clean.ItemCode; // SAP derives ItemCode from BaseEntry(+BaseLine) delete clean.ItemDescription; if(!clean.WarehouseCode) delete clean.WarehouseCode; lineTransTypes[idx]=(clean.TransactionType==='R'||clean.TransactionType==='Reject')?'R':'C'; delete clean.TransactionType; // set via HANA UPDATE after creation, not accepted on POST ['CostingCode','CostingCode2','CostingCode3','CostingCode4','CostingCode5'].forEach(k=>{ if(clean[k]===''||clean[k]===null||clean[k]===undefined) delete clean[k]; }); if(batchReq&&Array.isArray(clean.BatchNumbers)){ clean.BatchNumbers=clean.BatchNumbers.filter(b=>b.BatchNumber&&b.Quantity>0).map(b=>({...b,BaseLineNumber:idx})); if(!clean.BatchNumbers.length) delete clean.BatchNumbers; } else { delete clean.BatchNumbers; } // Portal-origin marker — same local Production Order tracking ID // already stamped on OWOR.U_ERP_SO_NO, now also on IGN1.U_ERP_SO_NO // for every line of this Receipt document. (Comments was tried first // — confirmed live SAP silently drops it when it's in the CREATE // payload. U_ERP_SO_NO is a genuine UDF, not a standard field SAP // recomputes on Add(), so it's included directly here instead.) clean.U_ERP_SO_NO=linked?String(linked.id):''; return clean; }); const payload={BPL_IDAssignedToInvoice:bplId,DocumentLines:lines,U_IS_BATCH_REQ:batchReq?1:0}; // When batch is required, SAP's validation also needs the batch number on // the header UDF U_BTCHNO (else it errors "-1116 (-30) Please fill the // batch no"). Fall back to the first line's batch if not sent explicitly. if(batchReq){ const headerBatch=(body.batchNo||lines?.[0]?.BatchNumbers?.[0]?.BatchNumber||'').toString().trim(); if(headerBatch) payload.U_BTCHNO=headerBatch; } if(docDate){payload.DocDate=docDate;payload.DocDueDate=docDueDate;} console.log('[RECEIPT-PROD] Final payload:\n',JSON.stringify(payload,null,2)); // Longer timeout than the usual 60s default: SAP auto-consumes any // Backflush-type components of THIS production order as part of posting // the Receipt itself (that's what "Backflush" means — SAP does it, not // a manual Issue via this portal), which can take noticeably longer than // a plain receipt, especially with several Backflush lines/batches. const result=await sap.sapRequest('POST','InventoryGenEntries',payload,co,true,null,180000); const docEntry=result?.DocEntry; console.log('[RECEIPT-PROD] ✅ DocEntry:',docEntry,'DocNum:',result?.DocNum); // TranType (Complete 'C' / Reject 'R') per line — Service Layer doesn't // accept it on POST, so it's stamped per LineNum via HANA after creation. if(docEntry){ for(let li=0;linull); if(ord) fullyReceived=(ord.CompletedQuantity||0)>=(ord.PlannedQuantity||0); } if(anyReject||fullyReceived){ await poStore().advanceStage(linked.id,{action:anyReject?'reject':'complete',stepKey:'receipt',by:req.user.username,byName:req.user.name||req.user.username}); } }catch(e){ console.warn('[RECEIPT-PROD] local stage advance failed (non-fatal):',e.message); } } // Autoclave Rejection (FG only) — informational counts, NOT part of SAP. // Written ONLY now that the SAP receipt is confirmed posted, and wrapped // non-fatally so an app-DB hiccup can never fail/undo a real SAP receipt. const autoclaveRows=req.body.autoclaveRejections; if(appSettings.receiptAutoclaveRejection()&&Array.isArray(autoclaveRows)&&autoclaveRows.some(n=>Number(n)>0)){ try{ const batchNumber=lines?.[0]?.BatchNumbers?.[0]?.BatchNumber||''; await require('../services/autoclaveRejectionStore').saveRejection({ absEntry:baseEntry, docEntry, docNum:result?.DocNum, itemCode:req.body.itemCode||'', batchNumber, rows:autoclaveRows, company:co, createdBy:req.user.username, createdByName:req.user.name||req.user.username, }); console.log('[RECEIPT-PROD] ✅ Autoclave rejection saved for DocEntry',docEntry); }catch(e){ console.warn('[RECEIPT-PROD] autoclave rejection save failed (non-fatal):',e.message); } } res.json({success:true,data:result}); }catch(err){ const sapMsg=err.message||'Unknown SAP error'; console.error('[RECEIPT-PROD] ❌',sapMsg); res.status(400).json({success:false,message:sapMsg}); } }); // ════════════════════════════════════════════════════════════════ // RETURN COMPONENTS → POST /api/sap/return-components // SAP's "Return Components" (Receipt from Production window): after Issue // for Production, defective component quantities go BACK into stock. // Mechanically an InventoryGenEntries (receipt) whose lines reference the // production order's COMPONENT rows — BaseType 202 + BaseLine present, the // inverse of the FG receipt above (which omits BaseLine so SAP derives the // parent item). SAP decrements the component line's issued quantity. // Corrective side-action: deliberately does NOT touch the local Work // Order-linked stage sequence. // ════════════════════════════════════════════════════════════════ router.post('/return-components', verifyToken, requireStepAssigned('production_order:return_components'), async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const body=req.body; const co=cq(req); console.log('[RETURN-COMP] Posting Return Components, Lines:',body.DocumentLines?.length); if(!body.BPL_IDAssignedToInvoice) body.BPL_IDAssignedToInvoice=parseInt(body.branchId)||2; delete body.company; delete body.branchId; if(Array.isArray(body.DocumentLines)){ body.DocumentLines=body.DocumentLines.map(line=>{ const clean={...line}; clean.BaseEntry=parseInt(clean.BaseEntry); clean.BaseLine=parseInt(clean.BaseLine); // component row — REQUIRED, this is what makes it a return clean.BaseType=202; clean.Quantity=parseFloat(clean.Quantity)||0; // Like issue: SAP derives the component ItemCode from BaseEntry+BaseLine delete clean.ItemCode; delete clean.ItemDescription; if(!clean.WarehouseCode) delete clean.WarehouseCode; ['CostingCode','CostingCode2','CostingCode3','CostingCode4','CostingCode5','ProjectCode'].forEach(k=>{ if(clean[k]===''||clean[k]===null||clean[k]===undefined) delete clean[k]; }); return clean; }).filter(l=>l.Quantity>0&&!isNaN(l.BaseLine)) // BaseLineNumber is REQUIRED by Service Layer to link a // BatchNumbers row back to its own DocumentLines row — computed // AFTER the filter above so it matches each line's FINAL position // in the array actually being sent, not its original index. .map((clean,idx)=>{ if(Array.isArray(clean.BatchNumbers)){ clean.BatchNumbers=clean.BatchNumbers.filter(b=>b.BatchNumber&&b.Quantity>0).map(b=>({...b,BaseLineNumber:idx})); if(!clean.BatchNumbers.length) delete clean.BatchNumbers; } else { delete clean.BatchNumbers; } return clean; }); } if(!body.DocumentLines?.length) return res.status(400).json({success:false,message:'No valid return lines (each needs a component line and a quantity > 0)'}); // Same SAP-side validation as the FG Receipt (see /receipt-production): // an InventoryGenEntries whose lines carry batch numbers ALSO needs the // header UDF U_BTCHNO filled, or SAP rejects the whole document with // -1116 (-30) "Please fill the batch no" even though every line already // has a valid BatchNumbers array. Header is a single field, so use the // first batch-carrying line's number — enough to satisfy the check. const firstBatch=body.DocumentLines.map(l=>l.BatchNumbers?.[0]?.BatchNumber).find(Boolean); if(firstBatch) body.U_BTCHNO=firstBatch; console.log('[RETURN-COMP] Final payload:\n',JSON.stringify(body,null,2)); const result=await sap.sapRequest('POST','InventoryGenEntries',body,co); console.log('[RETURN-COMP] ✅ DocEntry:',result?.DocEntry,'DocNum:',result?.DocNum); res.json({success:true,data:result}); }catch(err){ const sapMsg=err.message||'Unknown SAP error'; console.error('[RETURN-COMP] ❌',sapMsg); res.status(400).json({success:false,message:sapMsg}); } }); // ════════════════════════════════════════════════════════════════ // TRANSFER TO FINISHED GOODS → POST /api/sap/transfer-fg // Moves the received quantity from the production/receiving warehouse into // the Finished Goods warehouse via a SAP B1 Stock Transfer. Only meaningful // for orders linked to a Work Order (production_order:transfer_fg step) — // identified by our own local productionOrderId, since a Stock Transfer has // no "base document" link back to the Production Order the way Issue/ // Receipt do. // ════════════════════════════════════════════════════════════════ router.post('/transfer-fg', verifyToken, requireApprovalStep('production_order:transfer_fg', 'approve'), async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const co=cq(req); const { productionOrderId, itemCode, quantity, batchNumber, fromWarehouse, toWarehouse, comments } = req.body; let linked=productionOrderId?await poStore().findById(productionOrderId):null; if(!linked) return res.status(400).json({success:false,message:'productionOrderId is required and must be a valid linked Production Order'}); linked=await flagOutOfPortalRelease(linked,co,req); if(linked.stage!==3) return res.status(409).json({success:false,message:`Out of sequence: this order's next required step is "${linked.currentStep}", not Transfer to Finished Goods`}); if(!itemCode||!quantity||!fromWarehouse||!toWarehouse) return res.status(400).json({success:false,message:'itemCode, quantity, fromWarehouse and toWarehouse are required'}); const line={ ItemCode: itemCode, Quantity: parseFloat(quantity)||0, WarehouseCode: toWarehouse, FromWarehouseCode: fromWarehouse, }; if(batchNumber) line.BatchNumbers=[{BatchNumber:batchNumber, Quantity: parseFloat(quantity)||0}]; const payload={ FromWarehouse: fromWarehouse, ToWarehouse: toWarehouse, Comments: comments||`Transfer to Finished Goods — PO ${linked.sapDocNum||linked.id}`, StockTransferLines: [line], }; console.log('[TRANSFER-FG] Final payload:\n',JSON.stringify(payload,null,2)); const result=await sap.sapRequest('POST','StockTransfers',payload,co); console.log('[TRANSFER-FG] ✅ DocEntry:',result?.DocEntry,'DocNum:',result?.DocNum); await poStore().advanceStage(linked.id,{action:'complete',stepKey:'transfer_fg',by:req.user.username,byName:req.user.name||req.user.username}); res.json({success:true,data:result}); }catch(err){ const sapMsg=err.message||'Unknown SAP error'; console.error('[TRANSFER-FG] ❌',sapMsg); res.status(400).json({success:false,message:sapMsg}); } }); // ════════════════════════════════════════════════════════════════ // CLOSE PRODUCTION ORDER → POST /api/sap/production-order/:id/close // ════════════════════════════════════════════════════════════════ // Admin / System Admin: can Close (or Cancel, below) a Production Order at // ANY stage, bypassing the normal step-assignment gate AND every sequencing/ // quantity check below — an explicit, deliberate override for these two // roles, not a general permission ("admin" already bypasses requireStepAssigned // automatically; system_admin does not by default, so it's named here too). function isPoAdminOverride(req){ return ['admin','system_admin'].includes(req.user?.role); } // Consumable Order ("+ Consumable Order"): the workflow is Release → Issue → // Close ONLY — Receipt and Transfer to Finished Goods are never performed // for these (confirmed with the user), so the normal "stage must reach 4" // sequencing could NEVER be satisfied for one. Anyone holding 'approve' on // the dedicated production_order:consumable_close step may Close it at // whatever stage it's actually sitting at — same bypass shape as the admin // override below, just scoped to that one order instead of every PWO. No // creator bypass — the creator needs this step too, same as everyone else. function isConsumableCloser(linked, req){ return !!(linked && linked.isConsumable && hasStepPerm(req.user,'production_order:consumable_close','approve')); } router.post('/production-order/:id/close', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const id=parseInt(req.params.id); let linked=await poStore().findBySapAbsEntry(id); linked=await flagOutOfPortalRelease(linked,co,req); const adminOverride=isPoAdminOverride(req); const consumableCloser=isConsumableCloser(linked,req); if(!adminOverride&&!consumableCloser&&!hasStepAssigned(req.user,'production_order:close')) return res.status(403).json({success:false,message:'You are not assigned to approval step: production_order:close'}); const bypassGates=adminOverride||consumableCloser; if(!bypassGates){ // REJECTED (receipt posted with rejection lines) is a valid end-state that // still gets closed — only block when an IN_PROGRESS order hasn't reached // the Close stage yet. if(linked&&linked.status!=='REJECTED'&&linked.stage!==4) return res.status(409).json({success:false,message:`Out of sequence: this order's next required step is "${linked.currentStep}", not Close`}); // The check above only works when a local tracking record exists — if it // doesn't (e.g. the registration call right after SAP creation silently // failed), Issue/Receipt/Transfer completion can't be verified at all, // and Close would otherwise proceed unchecked. Admin-configurable // (Settings → "Close — require local tracking", default ON) since a // genuine legacy/external SAP order this portal never tracked would // also hit this and need the setting turned off to stay closeable. if(!linked&&appSettings.poCloseRequireTracking()) return res.status(409).json({success:false,message:'No local stage-tracking record found for this Production Order — Issue/Receipt/Transfer to FG completion cannot be verified, so it cannot be closed. If this is a legacy order from before this portal tracked it, an admin can turn off "Close — require local tracking" in System Settings.'}); // Same full-issuance gate as Receipt — a Production Order can reach // Close (stage 4, all four prior local steps marked complete) while // still having under-issued components in SAP, e.g. if issuance was // only ever partially done. REJECTED orders are exempt (nothing further // can be issued against a rejected receipt). if(appSettings.poRequireFullIssuance()&&(!linked||linked.status!=='REJECTED')){ const ord0=await sap.sapRequest('GET',`ProductionOrders(${id})?$select=ProductionOrderLines`,null,co).catch(()=>null); // Backflush lines never get manually issued — excluded here too, same as Receipt's gate above. const lines0=(ord0?.ProductionOrderLines||[]).filter(l=>l.ItemType!=='pit_Resource'&&l.ProductionOrderIssueType!=='im_Backflush'); const notFullyIssued=lines0.filter(l=>(l.IssuedQuantity||0)<(l.PlannedQuantity||0)); if(notFullyIssued.length) return res.status(409).json({success:false,message:`Cannot close: ${notFullyIssued.length} component(s) not yet fully issued (${notFullyIssued.map(l=>l.ItemNo).join(', ')}). Issue the remaining quantity first.`}); } // Full-quantity gate — admin-configurable (Settings → closeRequireFullQty): // Close only when Completed Qty = Planned Qty. Orders whose receipt was // REJECTED are exempt (they can never reach full qty by definition). if(appSettings.closeRequireFullQty()&&(!linked||linked.status!=='REJECTED')){ const ord=await sap.sapRequest('GET',`ProductionOrders(${id})?$select=PlannedQuantity,CompletedQuantity`,null,co); const planned=parseFloat(ord?.PlannedQuantity)||0, completed=parseFloat(ord?.CompletedQuantity)||0; if(Math.abs(completed-planned)>0.0001) return res.status(409).json({success:false,message:`Cannot close: Completed Qty (${completed}) must equal Planned Qty (${planned}). Receive the remaining ${planned-completed} first.`}); } } console.log(bypassGates?'[PROD] Override — closing Production Order:':'[PROD] Closing Production Order:',id,bypassGates?`(by ${req.user.username})`:''); const result=await sap.sapRequest('PATCH',`ProductionOrders(${id})`,{ProductionOrderStatus:'L'},co); console.log('[PROD] ✅ Closed:',id); if(linked){ try{ if(bypassGates) await poStore().adminForceClose(linked.id,{by:req.user.username,byName:req.user.name||req.user.username,remarks:req.body?.remarks||''}); else await poStore().advanceStage(linked.id,{action:'complete',stepKey:'close',by:req.user.username,byName:req.user.name||req.user.username}); } catch(e){ console.warn('[PROD] local stage advance failed (non-fatal):',e.message); } } res.json({success:true,data:result}); }catch(err){ console.error('[PROD] ❌ Close failed:',err.message); res.status(400).json({success:false,message:err.message}); } }); // ════════════════════════════════════════════════════════════════ // CANCEL PRODUCTION ORDER → POST /api/sap/production-order/:id/cancel // Admin/System Admin always bypass (isPoAdminOverride) at whatever stage // the order is currently in — same as Close's own override. A regular user // may also be granted this specifically via 'approve' on the dedicated // production_order:cancel step (Admin → Edit User → Approval Steps) — // previously this action had NO assignable step at all. SAP's own business // rules (e.g. refusing to cancel an order with quantity already received) // still apply and surface as a normal error below — this route only // removes the PORTAL's own gates, not SAP's. // ════════════════════════════════════════════════════════════════ router.post('/production-order/:id/cancel', verifyToken, (req,res,next)=>{ if(isPoAdminOverride(req)||hasStepPerm(req.user,'production_order:cancel','approve')) return next(); res.status(403).json({success:false,message:'You are not assigned to approval step: production_order:cancel'}); }, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const id=parseInt(req.params.id); const linked=await poStore().findBySapAbsEntry(id); console.log('[PROD] Admin override — cancelling Production Order:',id,`(by ${req.user.username})`); const result=await sap.sapRequest('PATCH',`ProductionOrders(${id})`,{ProductionOrderStatus:'boposCancelled'},co); console.log('[PROD] ✅ Cancelled:',id); if(linked){ try{ await poStore().adminForceCancel(linked.id,{by:req.user.username,byName:req.user.name||req.user.username,remarks:req.body?.remarks||''}); } catch(e){ console.warn('[PROD] local cancel-state update failed (non-fatal):',e.message); } } res.json({success:true,data:result}); }catch(err){ console.error('[PROD] ❌ Cancel failed:',err.message); res.status(400).json({success:false,message:err.message}); } }); // ════════════════════════════════════════════════════════════════ // BUDGET — UDO (SAP Service Layer) // ════════════════════════════════════════════════════════════════ // List all budgets via Service Layer router.get('/budget/list', verifyToken, async(req,res)=>{ const co=cq(req); try{ let all=[],url=`BUDGET?$select=DocEntry,DocNum,U_BUDGET,U_SUB_BUDGET,CreateDate&$orderby=DocEntry desc&$top=100`; while(url){ const result=await getSap().sapRequest('GET',url,null,co); all=all.concat(result?.value||[]); const next=result?.['@odata.nextLink']; url=next?next.replace(/^.*\/b1s\/v2\//,''):null; } res.json({success:true,data:all}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // Get single budget with lines via Service Layer router.get('/budget/:id', verifyToken, async(req,res)=>{ const co=cq(req); const id=parseInt(req.params.id); try{ const result=await getSap().sapRequest('GET',`BUDGET(${id})`,null,co); res.json({success:true,data:result}); }catch(e){res.status(404).json({success:false,message:e.message});} }); // Budget (Dim3) and Sub Budget (Dim4) lookups already exist via /lookup/costing-codes?dim=3 and dim=4 // Create/Update budget via SAP Service Layer UDO router.post('/budget', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const body=req.body; delete body.company; console.log('[BUDGET] Creating budget:', JSON.stringify(body,null,2)); const result=await sap.sapRequest('POST','BUDGET',body,co); console.log('[BUDGET] ✅ Created DocEntry:',result?.DocEntry); res.json({success:true,data:result}); }catch(err){ console.error('[BUDGET] ❌',err.message); res.status(400).json({success:false,message:err.message}); } }); // Update budget — PUT requires all fields, so fetch existing first and merge router.put('/budget/:id', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const id=parseInt(req.params.id); const body=req.body; delete body.company; const payload={ U_BUDGET:body.U_BUDGET, U_SUB_BUDGET:body.U_SUB_BUDGET||null, BUDGET1Collection:body.BUDGET1Collection||[], }; console.log('[BUDGET] Updating budget',id,'with',payload.BUDGET1Collection.length,'lines'); // Use PATCH with ReplaceCollectionsOnPatch header to delete removed lines const result=await sap.sapRequest('PATCH',`BUDGET(${id})`,payload,co,true,{'B1S-ReplaceCollectionsOnPatch':'true'}); console.log('[BUDGET] ✅ Updated DocEntry:',id); res.json({success:true,data:result}); }catch(err){ console.error('[BUDGET] ❌',err.message); res.status(400).json({success:false,message:err.message}); } }); // Delete budget router.delete('/budget/:id', verifyToken, async(req,res)=>{ try{ const sap=getSap(); const co=cq(req); const id=parseInt(req.params.id); console.log('[BUDGET] Deleting budget',id); await sap.sapRequest('DELETE',`BUDGET(${id})`,null,co); console.log('[BUDGET] ✅ Deleted DocEntry:',id); res.json({success:true}); }catch(err){ console.error('[BUDGET] ❌',err.message); res.status(400).json({success:false,message:err.message}); } }); // ════════════════════════════════════════════════════════════════ // DOCUMENTS VIEWER — Generic endpoint for all SAP document types // ════════════════════════════════════════════════════════════════ const DOC_TYPES={ 'Drafts':{select:'DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,DocTotal,DocCurrency,Comments,DocumentStatus,AttachmentEntry,ObjType',label:'Draft'}, 'PurchaseQuotations':{select:'DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,DocTotal,DocCurrency,Comments,DocumentStatus,NumAtCard,AttachmentEntry',label:'Purchase Quotation'}, 'PurchaseRequests':{select:'DocEntry,DocNum,DocDate,DocDueDate,DocCurrency,Comments,DocumentStatus,RequriedDate,AttachmentEntry',label:'Purchase Request'}, 'PurchaseOrders':{select:'DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,DocTotal,DocCurrency,Comments,DocumentStatus,BPL_IDAssignedToInvoice,AttachmentEntry',label:'Purchase Order'}, 'PurchaseDeliveryNotes':{select:'DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,DocTotal,DocCurrency,Comments,DocumentStatus,BPL_IDAssignedToInvoice,AttachmentEntry',label:'GRPO'}, 'PurchaseInvoices':{select:'DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,DocTotal,DocCurrency,Comments,DocumentStatus,BPL_IDAssignedToInvoice,AttachmentEntry',label:'AP Invoice'}, 'PurchaseCreditNotes':{select:'DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,DocTotal,DocCurrency,Comments,DocumentStatus,BPL_IDAssignedToInvoice,AttachmentEntry',label:'AP Credit Note'}, 'PurchaseReturns':{select:'DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,DocTotal,DocCurrency,Comments,DocumentStatus,BPL_IDAssignedToInvoice,AttachmentEntry',label:'Goods Return'}, 'Invoices':{select:'DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,DocTotal,DocCurrency,Comments,DocumentStatus,BPL_IDAssignedToInvoice,AttachmentEntry',label:'AR Invoice'}, 'CreditNotes':{select:'DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,DocTotal,DocCurrency,Comments,DocumentStatus,BPL_IDAssignedToInvoice,AttachmentEntry',label:'AR Credit Memo'}, 'Returns':{select:'DocEntry,DocNum,DocDate,DocDueDate,CardCode,CardName,DocTotal,DocCurrency,Comments,DocumentStatus,BPL_IDAssignedToInvoice,AttachmentEntry',label:'Return Notes'}, 'JournalEntries':{select:'JdtNum,Number,ReferenceDate,DueDate,Memo,Reference,Reference2,StornoToDate',label:'Journal Entry'}, }; // List documents router.get('/documents/:type', verifyToken, async(req,res)=>{ const co=cq(req); const type=req.params.type; const cfg=DOC_TYPES[type]; if(!cfg)return res.status(400).json({success:false,message:'Unknown document type: '+type}); const top=Number(req.query.top)||20; const skip=Number(req.query.skip)||0; const {q:search,bp,dateFrom,dateTo,status:docStatus}=req.query; const isJE=type==='JournalEntries'; try{ const filters=[]; if(search){ if(isJE)filters.push(`Number eq ${parseInt(search)||0}`); else filters.push(`DocNum eq ${parseInt(search)||0}`); } if(bp&&!isJE){ const safeBp=bp.replace(/'/g,"''"); filters.push(`contains(CardName,'${safeBp}')`); } if(dateFrom&&!isJE)filters.push(`DocDate ge '${dateFrom}'`); if(dateTo&&!isJE)filters.push(`DocDate le '${dateTo}'`); if(dateFrom&&isJE)filters.push(`ReferenceDate ge '${dateFrom}'`); if(dateTo&&isJE)filters.push(`ReferenceDate le '${dateTo}'`); if(docStatus&&!isJE){ const stMap={'O':'bost_Open','C':'bost_Close','L':'bost_Cancel'}; if(stMap[docStatus])filters.push(`DocumentStatus eq '${stMap[docStatus]}'`); } const filterStr=filters.length?`&$filter=${encodeURIComponent(filters.join(' and '))}`:''; const orderBy=isJE?'JdtNum desc':'DocEntry desc'; const result=await getSap().sapRequest('GET',`${type}?$select=${cfg.select}&$orderby=${orderBy}&$top=${top}&$skip=${skip}${filterStr}`,null,co); res.json({success:true,data:result?.value||[],label:cfg.label}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); // Single document detail router.get('/documents/:type/:id', verifyToken, async(req,res)=>{ const co=cq(req); const type=req.params.type; const id=req.params.id; const cfg=DOC_TYPES[type]; if(!cfg)return res.status(400).json({success:false,message:'Unknown document type'}); try{ const key=type==='JournalEntries'?id:`${id}`; const result=await getSap().sapRequest('GET',`${type}(${key})`,null,co); res.json({success:true,data:result}); }catch(e){res.status(404).json({success:false,message:e.message});} }); // Document lifecycle actions: Close / Cancel / Reopen / CreateCancellationDocument const DOC_ACTIONS={close:'Close',cancel:'Cancel',reopen:'Reopen','create-cancellation':'CreateCancellationDocument'}; router.post('/documents/:type/:id/:action', verifyToken, async(req,res)=>{ const co=cq(req); const {type,id,action}=req.params; if(!DOC_TYPES[type]) return res.status(400).json({success:false,message:'Unknown document type'}); const sapAction=DOC_ACTIONS[action]; if(!sapAction) return res.status(400).json({success:false,message:'Unknown action: '+action}); try{ const result=await getSap().sapRequest('POST',`${type}(${parseInt(id)})/${sapAction}`,{},co); res.json({success:true,data:result}); }catch(err){res.status(400).json({success:false,message:err.message});} }); // Get attachment by entry router.get('/attachments/:entry', verifyToken, async(req,res)=>{ const co=cq(req); try{ const result=await getSap().sapRequest('GET',`Attachments2(${parseInt(req.params.entry)})`,null,co); res.json({success:true,data:result}); }catch(e){res.status(404).json({success:false,message:e.message});} }); // Download attachment file router.get('/attachments/:entry/download/:lineId', verifyToken, async(req,res)=>{ const co=cq(req); const fs=require('fs'); const pathMod=require('path'); const sapAttachPath=process.env.SAP_ATTACHMENT_PATH||''; try{ const result=await getSap().sapRequest('GET',`Attachments2(${parseInt(req.params.entry)})`,null,co); const lines=result?.Attachments2_Lines||[]; const line=lines.find(l=>l.LineNum===parseInt(req.params.lineId))||lines[parseInt(req.params.lineId)]; if(!line) return res.status(404).json({success:false,message:'Attachment line not found'}); const fileName=`${line.FileName}.${line.FileExtension}`; const ext=(line.FileExtension||'').toLowerCase(); const mimeMap={pdf:'application/pdf',jpg:'image/jpeg',jpeg:'image/jpeg',png:'image/png',gif:'image/gif',doc:'application/msword',docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',xls:'application/vnd.ms-excel',xlsx:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',txt:'text/plain',csv:'text/csv'}; // Try multiple paths to find the file const pathsToTry=[ pathMod.join(line.SourcePath||'',fileName), // SAP SourcePath + filename pathMod.join(sapAttachPath,fileName), // ENV path + filename pathMod.join(sapAttachPath,line.FileName||'',fileName), // ENV path + subfolder + filename // Try without subfolder `${line.SourcePath}\\${fileName}`, `${sapAttachPath}\\${fileName}`, ]; // Mount share if UNC path if(sapAttachPath.startsWith('\\\\')|| sapAttachPath.startsWith('//')){ try{getSap().sanitizeFolderName;}catch(_e){}// just to ensure sap module loaded const {execSync}=require('child_process'); const parts=sapAttachPath.replace(/\\/g,'/').split('/').filter(Boolean); const shareRoot=`\\\\${parts[0]}\\${parts[1]}`; try{execSync(`net use "${shareRoot}" /persistent:no`,{stdio:'pipe',timeout:5000});}catch(_e){} } let found=false; for(const fp of pathsToTry){ console.log('[ATTACH-DL] Trying:',fp); if(fs.existsSync(fp)){ console.log('[ATTACH-DL] Found:',fp); res.setHeader('Content-Type',mimeMap[ext]||'application/octet-stream'); res.setHeader('Content-Disposition',`inline; filename="${fileName}"`); fs.createReadStream(fp).pipe(res); found=true; break; } } if(!found){ console.error('[ATTACH-DL] File not found. Tried:',pathsToTry); res.status(404).json({success:false,message:`File not found: ${fileName}`,paths:pathsToTry}); } }catch(e){ console.error('[ATTACH-DL] Error:',e.message); res.status(500).json({success:false,message:e.message}); } }); // ════════════════════════════════════════════════════════════════ // SAP APPROVAL REQUESTS // ════════════════════════════════════════════════════════════════ const OBJ_TYPE_MAP={'112':'Draft','13':'AR Invoice','14':'AR Credit Memo','18':'AP Invoice','19':'AP Credit Note','22':'Purchase Order','20':'Goods Receipt PO','21':'Goods Return','59':'Goods Issue','60':'Goods Receipt','46':'Blanket Agreement','17':'Order','15':'Delivery','16':'Return','1470000113':'Inventory Transfer'}; router.get('/approval-requests', verifyToken, async(req,res)=>{ const co=cq(req); const {status,objectType,originatorId,dateFrom,dateTo,bpCode,code}=req.query; // Default matches SAP Service Layer's own MaxPageSize (confirmed live: a // page is silently capped at 20 rows regardless of a higher $top) — see // public/sap-approvals.html's PAGE_SIZE. const top=Number(req.query.top)||20; const skip=Number(req.query.skip)||0; try{ // Admin → Users → "SAP Approval Types" restricts which document types a // user may even SEE here (not just a display filter — read fresh from // the DB every request, same reasoning as the approval-decision route's // credential lookup: this can change without the user re-logging in). // Empty = no restriction (sees every type, today's behavior). const acting=await require('../services/hanaUsers').findById(req.user.id); const allowedTypes=Array.isArray(acting?.sapApprovalTypes)?acting.sapApprovalTypes.map(String):[]; if(allowedTypes.length&&objectType&&!allowedTypes.includes(String(objectType))){ return res.json({success:true,data:[]}); // explicitly asked for a type they're not allowed to see } const filters=[]; if(status==='Pending')filters.push(`Status eq 'arsPending'`); else if(status==='Approved')filters.push(`Status eq 'arsApproved'`); else if(status==='Rejected')filters.push(`Status eq 'arsNotApproved'`); else if(status==='Generated')filters.push(`Status eq 'arsGenerated'`); else if(status==='Cancelled')filters.push(`Status eq 'arsCancelled'`); if(objectType)filters.push(`ObjectType eq '${objectType}'`); else if(allowedTypes.length)filters.push(`(${allowedTypes.map(t=>`ObjectType eq '${t}'`).join(' or ')})`); if(originatorId)filters.push(`OriginatorID eq ${parseInt(originatorId)}`); if(dateFrom)filters.push(`CreationDate ge '${dateFrom}'`); if(dateTo)filters.push(`CreationDate le '${dateTo}'`); if(code)filters.push(`Code eq ${parseInt(code)}`); const filterStr=filters.length?`&$filter=${encodeURIComponent(filters.join(' and '))}`:''; const result=await getSap().sapRequest('GET',`ApprovalRequests?$orderby=Code desc&$top=${top}&$skip=${skip}${filterStr}`,null,co); res.json({success:true,data:result?.value||[]}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); router.get('/approval-requests/:id', verifyToken, async(req,res)=>{ const co=cq(req); try{ const result=await getSap().sapRequest('GET',`ApprovalRequests(${parseInt(req.params.id)})`,null,co); res.json({success:true,data:result}); }catch(e){res.status(404).json({success:false,message:e.message});} }); // SAP's own auto-conversion of an approved Draft into its real document only // happens through the desktop client's post-approval hook — the same // client-only limitation as Approval Procedures triggering in the first // place (neither DI API nor Service Layer does this on their own, confirmed // live). So once a decision fully closes out an ApprovalRequests record // (every stage/approver satisfied → Status flips to arsApproved), the PATCH // handler below must post the underlying Draft itself — via Service Layer's // own dedicated DraftsService_SaveDraftToDocument action (confirmed live: // creates the real document cleanly). An earlier approach of manually // re-POSTing the Draft's stripped snapshot to its target entity hit spurious // "[SAP -2028] No matching records found" errors this built-in action // avoids entirely — and since SAP resolves the target entity from the // Draft's own ObjType internally, this works for ANY approved document // type, not just Purchase Request, with no per-type mapping needed. async function postApprovedDraft(approvalReq,co){ await getSap().sapRequest('POST','DraftsService_SaveDraftToDocument',{Document:{DocEntry:String(approvalReq.DraftEntry)}},co); console.log(`[SAP-APPROVAL] ✅ Draft ${approvalReq.DraftEntry} converted to a real document`); try{await getSap().sapRequest('DELETE',`Drafts(${approvalReq.DraftEntry})`,null,co);}catch(_e){} return {posted:true}; } router.patch('/approval-requests/:id', verifyToken, async(req,res)=>{ const co=cq(req); const id=parseInt(req.params.id); const body=req.body||{}; let sapPassword=body.sapPassword; delete body.company; delete body.sapPassword; // Only send ApprovalRequestDecisions — SAP rejects Status/Lines on PATCH const payload={}; if(body.ApprovalRequestDecisions){ // SAP infers the approver from the SL session — strip ApproverUserID payload.ApprovalRequestDecisions=body.ApprovalRequestDecisions.map(d=>{ const {ApproverUserID,...rest}=d; return rest; }); } if(body.Remarks) payload.Remarks=body.Remarks; // Prefer this portal user's own SAVED SAP login for the CURRENT company // (Admin → Users → "SAP Login", or Profile → My SAP Account) — read FRESH // from the DB every time, never from the JWT: the JWT only ever captured // ONE company's credentials at login time (see routes/auth.js's // buildPayload()), so it goes stale the moment credentials are // saved/changed without a re-login, and is simply wrong when approving in // a DIFFERENT company than whichever one was active at login. Only when // nothing is saved for this company does it fall back to the // manually-typed password from the approval dialog. let userCode; const saved=await require('../services/hanaUsers').getSapCredentialsForCompany(req.user.id,co); if(saved){ userCode=saved.sapUser; sapPassword=saved.sapPassword; } else { userCode=req.user?.sapUser; } if(!userCode) return res.status(400).json({success:false,message:'No SAP login saved for this portal user — set it under Admin → Users → "SAP Login", or Profile → My SAP Account.'}); if(!sapPassword) return res.status(400).json({success:false,message:'SAP password required'}); console.log('[SAP-APPROVAL] PATCH',id,JSON.stringify(payload),'as',userCode,saved?'(saved login)':'(typed password)'); // A full approve can be up to 4 SAP calls (decision PATCH, status GET, // SaveDraftToDocument, cleanup DELETE) — sapRequestAs() used to log in and // OUT fresh for every single one of those (confirmed the real cause of // "approval takes long"). runWithSapUser() + the shared sapRequest() below // instead log in ONCE (session is cached in sapServiceLayer.js's // _userSessions, module-level — later approvals by the same user reuse it // too, not just calls within this one request). const { runWithSapUser } = getSap(); try{ await runWithSapUser({sapUser:userCode,sapPassword},async()=>{ await getSap().sapRequest('PATCH',`ApprovalRequests(${id})`,payload,co); let posted=null; const isApprove=Array.isArray(payload.ApprovalRequestDecisions)&&payload.ApprovalRequestDecisions.some(d=>d.Status==='ardApproved'); if(isApprove){ const after=await getSap().sapRequest('GET',`ApprovalRequests(${id})`,null,co); if(after?.Status==='arsApproved'&&after?.DraftEntry){ try{ posted=await postApprovedDraft(after,co); } catch(e){ posted={posted:false,message:'Approved in SAP, but posting the document failed: '+e.message}; } } } res.json({success:true,data:{posted}}); }); }catch(e){res.status(400).json({success:false,message:e.message});} }); // ════════════════════════════════════════════════════════════════ // REPORTS — List and serve report files from configured path // ════════════════════════════════════════════════════════════════ const REPORT_PATH=process.env.REPORT_PATH||process.env.SAP_ATTACHMENT_PATH||''; // Run report queries router.get('/reports/run/:id', verifyToken, async(req,res)=>{ const co=cq(req); const id=req.params.id; const {ItemCode,Warehouse,DateFrom,DateTo}=req.query; try{ if(id==='inv-audit'){ const db=DB(co); const safeFrom=DateFrom||new Date().toISOString().slice(0,10); const safeTo=DateTo||new Date().toISOString().slice(0,10); const itemFilter=ItemCode?` AND A."ItemCode" LIKE '%${(ItemCode||'').replace(/'/g,"''")}%'`:''; const whsFilter=Warehouse?` AND A."Warehouse" = '${(Warehouse||'').replace(/'/g,"''")}'`:''; const itemFilterOB=ItemCode?` AND T."ItemCode" LIKE '%${(ItemCode||'').replace(/'/g,"''")}%'`:''; const whsFilterOB=Warehouse?` AND "Warehouse" = '${(Warehouse||'').replace(/'/g,"''")}'`:''; console.log('[REPORT] inv-audit from='+safeFrom+' to='+safeTo); // 1. Opening Balance (before DateFrom) const obSql=` SELECT U."U_Unit" AS "Unit", U."U_Sub_Group" AS "Sub Group", U."U_SKU" AS "SKU", T."ItemCode", U."ItemName", T."Warehouse" AS "Godown", U."SalPackMsr" AS "UOM", '${safeFrom}' AS "DocDate", 0 AS "DocTime", 'OB' AS "DocNum", CAST(SUM(T."InQty"-T."OutQty") AS DECIMAL(19,2)) AS "Quantity", CASE WHEN U."U_IsLitre"='Y' THEN CAST(SUM((T."InQty"-T."OutQty")*U."SalPackUn") AS DECIMAL(19,2)) ELSE 0 END AS "Oil Liter" FROM ${db}."OINM" T INNER JOIN ${db}."OITM" U ON U."ItemCode"=T."ItemCode" WHERE T."DocDate" < '${safeFrom}' ${itemFilterOB} ${whsFilterOB} GROUP BY U."U_Unit", U."U_Sub_Group", U."U_SKU", T."ItemCode", U."ItemName", T."Warehouse", U."SalPackMsr", U."U_IsLitre", U."SalPackUn" HAVING SUM(T."InQty"-T."OutQty") != 0`; // 2. Transactions in date range const txSql=` SELECT B."U_Unit" AS "Unit", B."U_Sub_Group" AS "Sub Group", B."U_SKU" AS "SKU", A."ItemCode", B."ItemName", A."Warehouse" AS "Godown", B."SalPackMsr" AS "UOM", CAST(A."DocDate" AS DATE) AS "DocDate", A."DocTime" AS "DocTime", CASE WHEN A."TransType"=67 THEN 'IM' WHEN A."TransType"=20 THEN 'PD' WHEN A."TransType"=59 THEN 'SI' WHEN A."TransType"=16 THEN 'RE' WHEN A."TransType"=15 THEN 'DL' WHEN A."TransType"=13 THEN 'IN' WHEN A."TransType"=10000071 THEN 'ST' WHEN A."TransType"=14 THEN 'CN' WHEN A."TransType"=18 THEN 'PU' WHEN A."TransType"=21 THEN 'PR' WHEN A."TransType"=19 THEN 'PT' WHEN A."TransType"=60 THEN 'SO' ELSE 'OT' END || '-' || CAST(A."BASE_REF" AS NVARCHAR(50)) AS "DocNum", CAST(SUM(A."InQty"-A."OutQty") AS DECIMAL(19,2)) AS "Quantity", CASE WHEN B."U_IsLitre"='Y' THEN CAST(SUM((A."InQty"-A."OutQty")*B."SalPackUn") AS DECIMAL(19,2)) ELSE 0 END AS "Oil Liter" FROM ${db}."OINM" A INNER JOIN ${db}."OITM" B ON A."ItemCode"=B."ItemCode" WHERE A."DocDate" BETWEEN '${safeFrom}' AND '${safeTo}' ${itemFilter} ${whsFilter} GROUP BY B."U_Unit", B."U_Sub_Group", B."U_SKU", A."ItemCode", B."ItemName", A."Warehouse", B."U_IsLitre", B."SalPackMsr", B."SalPackUn", A."TransType", A."BASE_REF", A."DocDate", A."DocTime" HAVING SUM(A."InQty"-A."OutQty") != 0`; const obRows=await hanaQuery(obSql,co); const txRows=await hanaQuery(txSql,co); const allRows=[...obRows,...txRows]; // Sort: by DocDate, Godown, ItemCode allRows.sort((a,b)=>{ const d1=String(a.DocDate||''),d2=String(b.DocDate||''); if(d1d2)return 1; const g1=a.Godown||'',g2=b.Godown||''; if(g1g2)return 1; return(a.ItemCode||'').localeCompare(b.ItemCode||''); }); const columns=['Godown','Unit','Sub Group','SKU','ItemCode','ItemName','UOM','DocDate','DocTime','DocNum','Quantity','Oil Liter']; allRows.forEach(r=>{ if(r.DocDate&&r.DocNum!=='OB')r.DocDate=new Date(r.DocDate).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'}); else if(r.DocNum==='OB')r.DocDate=''; r.Quantity=Number(r.Quantity||0).toFixed(2); r['Oil Liter']=Number(r['Oil Liter']||0).toFixed(2); }); res.json({success:true,data:{rows:allRows,columns}}); }else{ res.status(400).json({success:false,message:'Unknown report: '+id}); } }catch(e){ console.error('[REPORT] Error:',e.message); res.status(500).json({success:false,message:e.message}); } }); router.get('/reports/list', verifyToken, async(req,res)=>{ const fs=require('fs'); const pathMod=require('path'); const reportDir=req.query.path||REPORT_PATH; if(!reportDir)return res.json({success:true,data:[],warning:'REPORT_PATH not configured'}); try{ // Mount share if UNC if(reportDir.startsWith('\\\\')){ const parts=reportDir.replace(/\\/g,'/').split('/').filter(Boolean); const shareRoot=`\\\\${parts[0]}\\${parts[1]}`; try{require('child_process').execSync(`net use "${shareRoot}" /persistent:no`,{stdio:'pipe',timeout:5000});}catch(_e){} } const files=fs.readdirSync(reportDir).filter(f=>{ const ext=f.split('.').pop().toLowerCase(); return['rpt','pdf','xlsx','xls','docx','doc','csv'].includes(ext); }).map(f=>{ const stat=fs.statSync(pathMod.join(reportDir,f)); return{name:f,size:stat.size,modified:stat.mtime?.toISOString(),ext:f.split('.').pop().toLowerCase()}; }); res.json({success:true,data:files}); }catch(e){res.json({success:true,data:[],warning:e.message});} }); router.get('/reports/download/:filename', verifyToken, async(req,res)=>{ const fs=require('fs'); const pathMod=require('path'); const reportDir=req.query.path||REPORT_PATH; const filename=req.params.filename; if(!reportDir)return res.status(400).json({success:false,message:'REPORT_PATH not configured'}); const filePath=pathMod.join(reportDir,filename); try{ if(!fs.existsSync(filePath))return res.status(404).json({success:false,message:'File not found: '+filename}); const ext=filename.split('.').pop().toLowerCase(); const mimeMap={pdf:'application/pdf',rpt:'application/octet-stream',xlsx:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',xls:'application/vnd.ms-excel',docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',doc:'application/msword',csv:'text/csv',txt:'text/plain'}; res.setHeader('Content-Type',mimeMap[ext]||'application/octet-stream'); res.setHeader('Content-Disposition',ext==='pdf'?`inline; filename="${filename}"`:`attachment; filename="${filename}"`); fs.createReadStream(filePath).pipe(res); }catch(e){res.status(500).json({success:false,message:e.message});} }); // ════════════════════════════════════════════════════════════════ // ITEM MASTER — VIEW & UPDATE // ════════════════════════════════════════════════════════════════ // GET single item with warehouse info (expanded) router.get('/items/:itemCode/detail', verifyToken, async(req,res)=>{ const co=cq(req); try{ const rawCode = req.params.itemCode.replace(/'/g,"''"); const code = encodeURIComponent(rawCode); // Step 1: fetch item WITHOUT $expand (some SAP versions reject it) const result = await getSap().sapRequest('GET', `Items('${code}')`, null, co); // Step 2: fetch warehouse stock from HANA directly try{ const whRows = await hanaQuery(` SELECT W."WhsCode", W."WhsName", I."OnHand", I."IsCommited", I."OnOrder" FROM ${DB(co)}."OITW" I INNER JOIN ${DB(co)}."OWHS" W ON W."WhsCode" = I."WhsCode" WHERE I."ItemCode" = '${rawCode.replace(/'/g,"''")}' AND I."OnHand" != 0`,co); result.ItemWarehouseInfoCollection = whRows.map(r=>({ WarehouseCode : r.WhsCode, WarehouseName : r.WhsName, InStock : Number(r.OnHand) || 0, Committed : Number(r.IsCommited)|| 0, OnOrder : Number(r.OnOrder) || 0, })); }catch(whErr){ console.warn('[ITEM-DETAIL] Warehouse HANA query failed (non-fatal):', whErr.message); result.ItemWarehouseInfoCollection = []; } res.json({success:true, data:result}); }catch(err){ res.status(404).json({success:false, message:err.message}); } }); // GET item list with pagination and filters router.get('/items', verifyToken, async(req,res)=>{ const co=cq(req); const {q,groupCode,warehouse,status,top=20,skip=0} = req.query; try{ const filters=[]; if(q){ const safeQ=q.replace(/'/g,"''"); filters.push(`(contains(ItemCode,'${safeQ}') or contains(ItemName,'${safeQ}'))`); } if(groupCode) filters.push(`ItemsGroupCode eq ${parseInt(groupCode)}`); if(warehouse) filters.push(`ItemWarehouseInfoCollection/any(w:w/WarehouseCode eq '${warehouse.replace(/'/g,"''")}')`); if(status==='active') filters.push(`Frozen eq 'tNO'`); if(status==='inactive') filters.push(`Frozen eq 'tYES'`); const filterStr=filters.length?`&$filter=${encodeURIComponent(filters.join(' and '))}`:''; const result=await getSap().sapRequest('GET', `Items?$select=ItemCode,ItemName,ItemsGroupCode,InventoryItem,SalesItem,PurchaseItem,Frozen,InventoryUOM,DefaultWarehouse&$orderby=ItemCode&$top=${top}&$skip=${skip}${filterStr}`,null,co); res.json({success:true,data:result?.value||[],count:result?.['@odata.count']||result?.value?.length||0}); }catch(err){res.json({success:true,data:[],warning:err.message});} }); // PATCH update item (differential update) router.patch('/items/:itemCode', verifyToken, async(req,res)=>{ const co=cq(req); try{ const payload=cleanItemPayload(req.body); delete payload.ItemCode; // Cannot update key field delete payload.company; const code=encodeURIComponent(req.params.itemCode.replace(/'/g,"''")); console.log('[ITEM] PATCH item:',req.params.itemCode); const result=await getSap().sapRequest('PATCH',`Items('${code}')`,payload,co); res.json({success:true,data:result}); }catch(err){ console.error('[ITEM] ❌ PATCH failed:',err.message); res.status(400).json({success:false,message:err.message}); } }); // POST Cancel item (set to inactive / cancel in SAP) router.post('/items/:itemCode/cancel', verifyToken, async(req,res)=>{ const co=cq(req); try{ const code=encodeURIComponent(req.params.itemCode.replace(/'/g,"''")); // SAP Items don't have a Cancel action like documents — we PATCH Frozen=tYES await getSap().sapRequest('PATCH',`Items('${code}')`,{Frozen:'tYES'},co); res.json({success:true,message:'Item cancelled (set to inactive)'}); }catch(err){ console.error('[ITEM] ❌ Cancel failed:',err.message); res.status(400).json({success:false,message:err.message}); } }); // GET last item code by prefix (for auto-numbering) //router.get('/lookup/last-item-code', verifyToken, async(req,res)=>{ //const co=cq(req); //const prefix=(req.query.prefix||'').toUpperCase().trim(); //if(!prefix) return res.status(400).json({success:false,message:'Prefix required'}); //const prefixLen=prefix.length; //const safePrefix=prefix.replace(/'/g,"''"); // try{ //const strictSql=` //SELECT TOP 1 "ItemCode" // FROM ${DB(co)}."OITM" //WHERE UPPER("ItemCode") LIKE '${safePrefix}%' // AND LEN("ItemCode") >= ${prefixLen + 4} // AND PATINDEX('%[^0-9]%', SUBSTRING("ItemCode", ${prefixLen + 1}, LEN("ItemCode") - ${prefixLen})) = 0 // ORDER BY CAST(SUBSTRING("ItemCode", ${prefixLen + 1}, 20) AS BIGINT) DESC`; // const strictRows=await hanaQuery(strictSql); //if(strictRows.length){ //const lastCode=strictRows[0].ItemCode; //const numMatch=lastCode.match(new RegExp(`^${prefix}(\\d+)$`,'i')); //if(numMatch){ //const lastNum=parseInt(numMatch[1]); // return res.json({success:true,lastCode,lastNumber:lastNum,nextNumber:lastNum+1,prefix}); //} //} // return res.json({success:true,lastCode:null,lastNumber:0,nextNumber:1,prefix}); // }catch(e){ // return res.json({success:true,lastCode:null,lastNumber:0,nextNumber:1,prefix,warning:e.message}); // } //}); router.get('/lookup/last-item-code', verifyToken, async(req,res)=>{ const co=cq(req); const prefix=(req.query.prefix||'').toUpperCase().trim(); if(!prefix) return res.status(400).json({success:false,message:'Prefix required'}); const prefixLen=prefix.length; const safePrefix=prefix.replace(/'/g,"''"); // The digit-width Create Item's own auto-numbering pads every code to // (see public/Itemcreationform.html's ITEM_TYPE_CONFIG padLen — 5 for // every series-based prefix today). Used below as an EXACT length match, // not a minimum — a code longer than this (e.g. a stray 6-digit entry // typed directly in SAP, outside this form entirely) is a different, // unrelated numbering scheme, not "further along" the same sequence, so // it must not count toward "the last code used." Without this, ORDER BY // …AS BIGINT DESC would let one anomalously long code always win as the // max, however far outside the real series it actually is — no prefix // gets special-cased, this is the same rule for RM/PK/ICO alike. const digitWidth = 5; // Admin-configurable floor (System Settings → Item Code Series). const floorMap=appSettings.itemCodeSeriesFloor(); const floor=floorMap[prefix]||0; try{ // No TOP-N cap and no ORDER BY here anymore — every matching code is // pulled into a Set so BOTH branches below can check "is this exact // number already taken", not just find the single numeric max. That // distinction matters once a floor is set: a real but unrelated higher // code elsewhere in the series (e.g. one entered directly in SAP, // outside this form) is just one more taken slot to step over, not a // reason to jump the whole suggestion past it — see the floor branch. const strictSql = ` SELECT "ItemCode" FROM ${DB(co)}."OITM" WHERE UPPER("ItemCode") LIKE '${safePrefix}%' AND LEN("ItemCode") = ${prefixLen + digitWidth} AND PATINDEX('%[^0-9]%', SUBSTRING("ItemCode", ${prefixLen + 1}, LEN("ItemCode") - ${prefixLen})) = 0`; const rows=await hanaQuery(strictSql,co); const taken=new Set(); rows.forEach(r=>{ const n=parseInt(String(r.ItemCode).slice(prefixLen)); if(!isNaN(n)) taken.add(n); }); if(floor>0){ // Walk up from the floor itself, one number at a time, skipping any // that's already a real item — finds the first genuinely free code // AT OR AFTER the floor, respecting real gaps in between instead of // silently continuing after wherever the global max happens to be. let next=floor; while(taken.has(next)) next++; const lastCode=next>floor?`${prefix}${String(next-1).padStart(digitWidth,'0')}`:null; return res.json({success:true,lastCode,lastNumber:next-1,nextNumber:next,prefix,source:'floor_walk'}); } if(taken.size){ const lastNum=Math.max(...taken); return res.json({success:true,lastCode:`${prefix}${String(lastNum).padStart(digitWidth,'0')}`,lastNumber:lastNum,nextNumber:lastNum+1,prefix,source:'hana_strict'}); } return res.json({success:true,lastCode:null,lastNumber:0,nextNumber:1,prefix,source:'none'}); }catch(e){ return res.json({success:true,lastCode:null,lastNumber:0,nextNumber:1,prefix,source:'error',warning:e.message}); } }); // ════════════════════════════════════════════════════════════════ // PRODUCTION DEVIATIONS — raised AFTER Issue for Production, without // stopping the run: Shortage (top up qty of the same item), Substitution // (issue a different item instead), Damage/Loss (write off a damaged qty, // optionally to the admin-configured scrap warehouse). The SAP posting (if // any) always happens immediately on raise; QA approval (when Admin → // System Settings → "Deviation — Require QA Approval" is on) is a // post-hoc sign-off on the RECORD, never a gate on the goods movement. // ════════════════════════════════════════════════════════════════ // SAP quirk, live-verified 2026-08-06: PATCHing a ProductionOrderLines row's // PlannedQuantity to EXACTLY 0 is silently ignored — SAP resets it back to a // BOM-derived default (BaseQuantity × parent's PlannedQuantity) instead of // actually storing 0. Any other value, including 0.001, persists correctly. // Used wherever a line's remaining quantity is deliberately being closed out // to "nothing left" — a negligible non-zero floor still reads as 0 anywhere // this app rounds to 3 decimals for display, but actually sticks in SAP. const SAP_PLANNED_QTY_ZERO_FLOOR=0.0001; function zeroSafeQty(v){ return v<=0?SAP_PLANNED_QTY_ZERO_FLOOR:v; } // Adds a Substitution's replacement item as a real component line on the // Production Order — or, if that item is ALREADY a line there (from an // earlier deviation, or any other reason), bumps its PlannedQuantity instead // of appending a duplicate. Live-verified 2026-08-06: SAP silently // consolidates/renumbers lines when the same ItemNo is added twice via // separate PATCH calls, so working WITH that (reuse) instead of against it // (assume a fresh distinct line every time) is what keeps LineNumber // tracking reliable. // // In the SAME PATCH, also shrinks the REPLACED item's own line (oldLineNum) // by `quantity` — the whole point of a Substitution is that the old item // isn't available for that amount anymore, so its remaining (unissued) // quantity must come down too, or the store could still issue an item that // doesn't actually have stock. Clamped so it never drops below that line's // own live IssuedQuantity (can't un-issue something already physically // gone). Returns the FINAL LineNumber the substitute item landed on. Shared // by the immediate (non-deferred) raise path and the deferred approval-time // apply path — must behave identically in both. async function applySubstitutionLine(sap,sapAbsEntry,oldLineNum,newItemCode,quantity,warehouse,co){ const orderNow=await sap.sapRequest('GET',`ProductionOrders(${sapAbsEntry})?$select=ProductionOrderLines`,null,co); const rawLines=orderNow.ProductionOrderLines||[]; const oldTarget=oldLineNum!=null?rawLines.find(l=>l.LineNumber===parseInt(oldLineNum)):null; if(oldLineNum!=null&&!oldTarget) throw new Error(`Replaced component line ${oldLineNum} was not found on the Production Order`); const existingLines=rawLines.map(l=>{ const clean={ ItemNo:l.ItemNo, BaseQuantity:l.BaseQuantity, PlannedQuantity:l.PlannedQuantity, ItemType:l.ItemType, ProductionOrderIssueType:l.ProductionOrderIssueType, Warehouse:l.Warehouse, VisualOrder:l.VisualOrder, LineNumber:l.LineNumber, }; if(oldTarget&&l.LineNumber===oldTarget.LineNumber){ clean.PlannedQuantity=zeroSafeQty(Math.max(l.IssuedQuantity||0,(l.PlannedQuantity||0)-quantity)); } return clean; }); const already=existingLines.find(l=>l.ItemNo===newItemCode); let lines,resultLineNum; if(already){ lines=existingLines.map(l=>l.LineNumber===already.LineNumber?{...l,PlannedQuantity:(l.PlannedQuantity||0)+quantity}:l); resultLineNum=already.LineNumber; console.log('[DEVIATION-SUBSTITUTION] Item already a component (line',resultLineNum,') — bumping PlannedQuantity by',quantity); }else{ const newLine={ItemNo:newItemCode, BaseQuantity:quantity, PlannedQuantity:quantity, ItemType:'pit_Item', ProductionOrderIssueType:'im_Manual', Warehouse:warehouse, VisualOrder:existingLines.length}; lines=[...existingLines,newLine]; console.log('[DEVIATION-SUBSTITUTION] Adding new component line:',JSON.stringify(newLine)); } if(oldTarget) console.log('[DEVIATION-SUBSTITUTION] Shrinking replaced line',oldTarget.LineNumber,'PlannedQuantity by',quantity,'(floor',oldTarget.IssuedQuantity||0,')'); await sap.sapRequest('PATCH',`ProductionOrders(${sapAbsEntry})`,{ProductionOrderLines:lines},co); if(!already){ const knownLineNums=new Set(existingLines.map(l=>l.LineNumber)); const orderAfter=await sap.sapRequest('GET',`ProductionOrders(${sapAbsEntry})?$select=ProductionOrderLines`,null,co); const added=(orderAfter.ProductionOrderLines||[]).find(l=>l.ItemNo===newItemCode&&!knownLineNums.has(l.LineNumber)); if(!added) throw new Error('Added the substitute item as a component, but could not locate its new line afterward. Check the Production Order in SAP.'); resultLineNum=added.LineNumber; } return resultLineNum; } // GET /api/sap/deviations?sapAbsEntry=... — list, optionally scoped to one order. router.get('/deviations', verifyToken, requireStepAssigned('production_order:deviation'), async(req,res)=>{ try{ const { sapAbsEntry, company } = req.query; const data=await devStore().listDeviations({ sapAbsEntry: sapAbsEntry?parseInt(sapAbsEntry):undefined, company }); res.json({success:true,data}); }catch(err){res.status(500).json({success:false,message:err.message});} }); // GET /api/sap/deviations/report — every deviation across every Production // Order, for the standalone "Deviations" menu (public/deviations.html). // Deliberately gated by verifyToken only, NOT requireStepAssigned — access is // controlled purely by the 'production-deviations' module toggle (Admin → // Users), same soft-gate pattern as the other report-style pages (Inventory // Status Report, Inventory Posting List), since this is a read-only overview // and not the actual raise/approve workflow (which stays step-gated above). router.get('/deviations/report', verifyToken, async(req,res)=>{ try{ const { company } = req.query; const data=await devStore().listDeviations({ company }); res.json({success:true,data}); }catch(err){res.status(500).json({success:false,message:err.message});} }); router.post('/deviations', verifyToken, requireApprovalStep('production_order:deviation','add'), async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const co=cq(req); const b=req.body||{}; const type=(b.type||'').toUpperCase(); if(!devStore().TYPES.includes(type)) return res.status(400).json({success:false,message:`type must be one of ${devStore().TYPES.join(', ')}`}); const enabledTypes=appSettings.deviationEnabledTypes(); if(!enabledTypes.includes(type)) return res.status(403).json({success:false,message:`The "${type}" deviation type is disabled in Admin → System Settings`}); const sapAbsEntry=parseInt(b.sapAbsEntry); if(isNaN(sapAbsEntry)) return res.status(400).json({success:false,message:'sapAbsEntry is required'}); if(!b.itemCode) return res.status(400).json({success:false,message:'itemCode is required'}); const quantity=parseFloat(b.quantity)||0; if(quantity<=0) return res.status(400).json({success:false,message:'quantity must be > 0'}); if(!(b.reason||'').trim()) return res.status(400).json({success:false,message:'A reason is required to raise a deviation'}); if(type==='SUBSTITUTION'&&!b.newItemCode) return res.status(400).json({success:false,message:'newItemCode is required for a Substitution'}); // Deviations only make sense once the order is actually past Issuance — // if it's linked to this app's local Production Order lifecycle, enforce // that; unlinked orders (created outside the app) can't be checked, so // they're allowed through. const linked=await poStore().findBySapAbsEntry(sapAbsEntry); if(linked&&linked.stage<2) return res.status(409).json({success:false,message:`This order hasn't completed Issuance yet (currently "${linked.currentStep}") — a deviation only applies once production has started.`}); const bplId=parseInt(b.branchId)||2; let posted=false, sapDocEntry=null, sapDocNumPosted='', sapDocObject='', addedLineNum=null, postIntended=false, lineApplied=false; const qaRequired=appSettings.deviationRequireQaApproval(); // Defer mode: the Production Order is NOT touched at all while raised — // it only sits as a PENDING record. Only once QA approves does the // component actually get added/updated (PATCH /deviations/:id/approve), // after which the concerned user issues it manually via Issue for // Production. Damage/Loss never touches a component line either way — // it gets its own manual "Post to SAP" once approved (POST /deviations/ // :id/post-damage). Only meaningful when QA approval is actually // required — with no approval step, there'd be nothing to defer to. const defer=appSettings.deviationDeferIssueUntilApproval()&&qaRequired; // Batch Numbers — if the item being issued/transferred is batch-managed, // SAP rejects the document entirely with -4014 "Cannot add row without // complete selection of batch/serial numbers" unless a BatchNumbers row // is attached. A single batch may not have enough qty in stock, so — same // as Issue for Production — the frontend can split across MULTIPLE // batches; batchNumbers is simply [] for non-batch-managed items. const batchNumbers=(Array.isArray(b.batchNumbers)?b.batchNumbers:[]) .map(x=>({BatchNumber:String(x.BatchNumber||'').trim(),Quantity:parseFloat(x.Quantity)||0})) .filter(x=>x.BatchNumber&&x.Quantity>0); if(batchNumbers.length){ const batchTotal=batchNumbers.reduce((s,x)=>s+x.Quantity,0); if(Math.abs(batchTotal-quantity)>0.001) return res.status(400).json({success:false,message:`Batch quantities (${batchTotal}) must add up to the deviation quantity (${quantity})`}); } if(type==='SHORTAGE'){ if(b.lineNum==null) return res.status(400).json({success:false,message:'lineNum (the component\'s own BaseLine on the Production Order) is required for a Shortage top-up'}); if(!b.warehouse) return res.status(400).json({success:false,message:'warehouse is required'}); if(defer){ // Nothing touches SAP at raise time at all — the Production Order // must not reflect a still-PENDING deviation. The PlannedQuantity // bump happens later, only once QA approves (PATCH /deviations/:id/ // approve), then the user issues it manually via Issue for // Production. console.log('[DEVIATION-SHORTAGE] Deferred — recorded as pending, Production Order not touched yet'); posted=false; lineApplied=false; }else{ const line={BaseEntry:sapAbsEntry,BaseLine:parseInt(b.lineNum),BaseType:202,Quantity:quantity,WarehouseCode:b.warehouse}; // BaseLineNumber is REQUIRED to link EACH BatchNumbers row back to its // own DocumentLines row (index 0 here — always a single line) — without // it SAP rejects the whole document with -4014, same fix as elsewhere // in this file (Issue for Production, Receipt from Production). if(batchNumbers.length) line.BatchNumbers=batchNumbers.map(x=>({...x,BaseLineNumber:0})); const payload={ BPL_IDAssignedToInvoice:bplId, Comments:`Deviation (Shortage) — PO ${b.sapDocNum||sapAbsEntry}: ${b.reason}`.slice(0,254), DocumentLines:[line], }; console.log('[DEVIATION-SHORTAGE] Payload:\n',JSON.stringify(payload,null,2)); const result=await sap.sapRequest('POST','InventoryGenExits',payload,co); posted=true; lineApplied=true; sapDocEntry=result?.DocEntry; sapDocNumPosted=result?.DocNum; sapDocObject='InventoryGenExits'; console.log('[DEVIATION-SHORTAGE] ✅ DocEntry:',sapDocEntry,'DocNum:',sapDocNumPosted); } } else if(type==='SUBSTITUTION'){ if(!b.warehouse) return res.status(400).json({success:false,message:'warehouse is required'}); if(b.lineNum==null) return res.status(400).json({success:false,message:'lineNum (the replaced component\'s own line on the Production Order) is required — its remaining quantity must be reduced when the substitute is added'}); if(defer){ // Nothing touches SAP at raise time — the substitute item must not // appear as a component until QA actually approves this. Which line // it lands on (reuse an existing one for the same item, or add a // new one) is decided later, at approval time. console.log('[DEVIATION-SUBSTITUTION] Deferred — recorded as pending, Production Order not touched yet'); posted=false; lineApplied=false; }else{ // The substitute item usually isn't a BOM component of the order, so // it has no BaseLine to issue against yet. Verified live // (2026-08-06): SAP B1 DOES allow adding a new ProductionOrderLines // row to an already-RELEASED order via PATCH — so a real component // line is added first (ItemNo=newItemCode), then issued the SAME way // as Shortage (BaseEntry/BaseLine/BaseType:202). This makes the // substitute item show up as a real component on the Production // Order in SAP, and its IssuedQuantity tracks correctly — not just a // Comments-field mention. addedLineNum=await applySubstitutionLine(sap,sapAbsEntry,b.lineNum,b.newItemCode,quantity,b.warehouse,co); const line={BaseEntry:sapAbsEntry,BaseLine:addedLineNum,BaseType:202,Quantity:quantity,WarehouseCode:b.warehouse}; if(batchNumbers.length) line.BatchNumbers=batchNumbers.map(x=>({...x,BaseLineNumber:0})); const payload={ BPL_IDAssignedToInvoice:bplId, Comments:`Deviation (Substitution) — PO ${b.sapDocNum||sapAbsEntry}: replacing ${b.itemCode} with ${b.newItemCode}. ${b.reason}`.slice(0,254), DocumentLines:[line], }; console.log('[DEVIATION-SUBSTITUTION] Payload:\n',JSON.stringify(payload,null,2)); const result=await sap.sapRequest('POST','InventoryGenExits',payload,co); posted=true; lineApplied=true; sapDocEntry=result?.DocEntry; sapDocNumPosted=result?.DocNum; sapDocObject='InventoryGenExits'; console.log('[DEVIATION-SUBSTITUTION] ✅ Line',addedLineNum,'DocEntry:',sapDocEntry,'DocNum:',sapDocNumPosted); } } else if(type==='ADDITION'){ if(!b.warehouse) return res.status(400).json({success:false,message:'warehouse is required'}); if(defer){ // Nothing touches SAP at raise time — same as a deferred Substitution, // the new item must not appear as a component until QA approves. console.log('[DEVIATION-ADDITION] Deferred — recorded as pending, Production Order not touched yet'); posted=false; lineApplied=false; }else{ // Identical to Substitution's own "add a new component line" step, // just with no old item being replaced — applySubstitutionLine() // already treats a null oldLineNum as "nothing to shrink", so it's // reused as-is rather than duplicated. addedLineNum=await applySubstitutionLine(sap,sapAbsEntry,null,b.itemCode,quantity,b.warehouse,co); const line={BaseEntry:sapAbsEntry,BaseLine:addedLineNum,BaseType:202,Quantity:quantity,WarehouseCode:b.warehouse}; if(batchNumbers.length) line.BatchNumbers=batchNumbers.map(x=>({...x,BaseLineNumber:0})); const payload={ BPL_IDAssignedToInvoice:bplId, Comments:`Deviation (Addition) — PO ${b.sapDocNum||sapAbsEntry}: adding ${b.itemCode}. ${b.reason}`.slice(0,254), DocumentLines:[line], }; console.log('[DEVIATION-ADDITION] Payload:\n',JSON.stringify(payload,null,2)); const result=await sap.sapRequest('POST','InventoryGenExits',payload,co); posted=true; lineApplied=true; sapDocEntry=result?.DocEntry; sapDocNumPosted=result?.DocNum; sapDocObject='InventoryGenExits'; console.log('[DEVIATION-ADDITION] ✅ Line',addedLineNum,'DocEntry:',sapDocEntry,'DocNum:',sapDocNumPosted); } } else if(type==='DAMAGE'){ const postToSap=!!b.postToSap; postIntended=postToSap; if(postToSap){ if(!b.warehouse) return res.status(400).json({success:false,message:'warehouse (where the damaged stock currently sits) is required'}); const dest=(b.destWarehouse||appSettings.deviationScrapWarehouse()||'').trim(); if(!dest) return res.status(400).json({success:false,message:'No scrap warehouse configured — set one in Admin → System Settings, or pick a destination warehouse.'}); if(defer){ // Record the intent (postIntended, already set above) but don't post // — the concerned user posts it manually via POST /deviations/:id/ // post-damage once QA approves. console.log('[DEVIATION-DAMAGE] Deferred — write-off recorded as pending, not posted yet'); posted=false; }else{ const line={ItemCode:b.itemCode,Quantity:quantity,WarehouseCode:dest,FromWarehouseCode:b.warehouse}; // StockTransfers' BatchNumbers don't need BaseLineNumber (matches the // already-working Transfer to Finished Goods endpoint's shape). if(batchNumbers.length) line.BatchNumbers=batchNumbers; const payload={ FromWarehouse:b.warehouse, ToWarehouse:dest, Comments:`Deviation (Damage/Loss) — PO ${b.sapDocNum||sapAbsEntry}: ${b.reason}`.slice(0,254), StockTransferLines:[line], }; const damageSeries=appSettings.deviationDamageSeries(); if(damageSeries!=null) payload.Series=damageSeries; console.log('[DEVIATION-DAMAGE] Payload:\n',JSON.stringify(payload,null,2)); const result=await sap.sapRequest('POST','StockTransfers',payload,co); posted=true; sapDocEntry=result?.DocEntry; sapDocNumPosted=result?.DocNum; sapDocObject='StockTransfers'; console.log('[DEVIATION-DAMAGE] ✅ DocEntry:',sapDocEntry,'DocNum:',sapDocNumPosted); } } // else: informational only — no SAP posting (e.g. damage discovered before the item was ever issued). } const saved=await devStore().createDeviation({ productionOrderId:linked?linked.id:null, sapAbsEntry, sapDocNum:b.sapDocNum||'', type, itemCode:b.itemCode, itemName:b.itemName||'', newItemCode:b.newItemCode||'', newItemName:b.newItemName||'', quantity, warehouse:b.warehouse||'', destWarehouse:type==='DAMAGE'?(b.destWarehouse||appSettings.deviationScrapWarehouse()||''):'', batchNumbers, lineNum:type==='SHORTAGE'?b.lineNum:((type==='SUBSTITUTION'||type==='ADDITION')?addedLineNum:null), oldLineNum:type==='SUBSTITUTION'?b.lineNum:null, lineApplied, postIntended, postedToSap:posted, sapDocEntry, sapDocNumPosted, sapDocObject, reason:b.reason.trim(), raisedBy:req.user.username, raisedName:req.user.name||req.user.username, qaRequired, company:co||b.company||'', }); res.json({success:true,data:saved}); try{ require('../services/notifyStore').notify({ stepFullKey:'production_order:deviation', title:`Deviation raised — PO ${b.sapDocNum||sapAbsEntry} (${type})`, lines:[['Type',type],['Item',b.itemCode],['Qty',String(quantity)],['Reason',b.reason],['Raised By',req.user.name||req.user.username]], url:`${process.env.APP_BASE_URL||''}/production`, excludeUsernames:[req.user.username], }); }catch(e){console.warn('[DEVIATION] notify failed (non-fatal):',e.message);} }catch(err){ const sapMsg=err.message||'Unknown SAP error'; console.error('[DEVIATION] ❌',sapMsg); res.status(400).json({success:false,message:sapMsg}); } }); // Physically deletes a Substitution's REPLACED item's line from the // Production Order in SAP via DI API — Service Layer has no way to do this // at all (verified live: PATCHing the ProductionOrderLines array with a line // omitted is silently ignored, the line stays). Only meaningful once that // line has already been shrunk to SAP's zero-floor by an APPROVED // Substitution. Irreversible in SAP (there's no "undelete" for a document // line). Shared by the manual button's route (below) and the automatic path // (POST /deviations approve-time apply, when Admin → "Deviation — Show // 'Remove from SAP' Button" is OFF). Never throws — always resolves to // {success, message}, since the automatic caller treats a "can't remove yet" // outcome as a normal, silent no-op, not an error worth surfacing. async function attemptRemoveOldLine(existing, co, devStore, getSap){ if(existing.type!=='SUBSTITUTION') return {success:false,message:'Only Substitution deviations have a replaced line to remove'}; if(existing.qaStatus!=='APPROVED') return {success:false,message:`Only an APPROVED deviation's replaced line can be removed (currently ${existing.qaStatus})`}; if(!existing.lineApplied) return {success:false,message:'The substitution has not been applied to the Production Order yet'}; if(existing.oldLineNum==null) return {success:false,message:'No replaced-line number was recorded on this deviation'}; if(existing.oldLineRemoved) return {success:false,message:'Already removed'}; try{ // Safety check, live against SAP (never trust the DB snapshot for this): // a line that's had ANY real quantity physically issued against it must // never be deleted — that would destroy the audit record of stock that // genuinely left the warehouse, not just "clean up" an unused line. Only // a line that was truly never issued against is safe to remove outright. const sap=getSap(); if(!sap) return {success:false,message:'SAP service not ready'}; const order=await sap.sapRequest('GET',`ProductionOrders(${existing.sapAbsEntry})?$select=ProductionOrderLines`,null,co); const oldLine=(order.ProductionOrderLines||[]).find(l=>l.LineNumber===parseInt(existing.oldLineNum)); if(!oldLine) return {success:false,message:`Line ${existing.oldLineNum} was not found on the Production Order — it may already be gone`}; if((oldLine.IssuedQuantity||0)>0.001) return {success:false,message:`Cannot remove — ${oldLine.ItemNo} has ${oldLine.IssuedQuantity} unit(s) already physically issued against this line. Deleting it would destroy that audit record. Only a line with nothing ever issued against it can be removed this way.`}; const result=await require('../services/diApiService').removeProductionOrderLine(existing.sapAbsEntry, existing.oldLineNum, co); console.log(`[DEVIATION-REMOVE-OLD-LINE] ✅ Deviation #${existing.id}`,JSON.stringify(result)); await devStore().markOldLineRemoved(existing.id); return {success:true}; }catch(err){ const msg=err.message||'Unknown DI API error'; console.error(`[DEVIATION-REMOVE-OLD-LINE] ❌ Deviation #${existing.id}:`,msg); return {success:false,message:msg}; } } router.post('/deviations/:id/remove-old-line', verifyToken, requireApprovalStep('production_order:deviation','approve'), async(req,res)=>{ try{ const existing=await devStore().findById(req.params.id); if(!existing) return res.status(404).json({success:false,message:'Deviation not found'}); const co=existing.company||cq(req); const r=await attemptRemoveOldLine(existing,co,devStore,getSap); if(!r.success) return res.status(400).json({success:false,message:r.message}); const updated=await devStore().findById(req.params.id); res.json({success:true,data:updated}); }catch(err){ res.status(500).json({success:false,message:err.message}); } }); // Manual "Post to SAP" for a Damage/Loss deviation raised while Defer Issue // Until Approval was on — Damage has no Issue-for-Production equivalent // screen, so the concerned user posts the write-off from here once QA has // approved (or immediately, if QA approval isn't required at all). router.post('/deviations/:id/post-damage', verifyToken, requireApprovalStep('production_order:deviation','add'), async(req,res)=>{ try{ const sap=getSap(); if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'}); const existing=await devStore().findById(req.params.id); if(!existing) return res.status(404).json({success:false,message:'Deviation not found'}); if(existing.type!=='DAMAGE') return res.status(400).json({success:false,message:'Only Damage/Loss deviations can be posted this way'}); if(!existing.postIntended) return res.status(400).json({success:false,message:'This deviation was never intended to post an SAP write-off'}); if(existing.postedToSap) return res.status(409).json({success:false,message:'Already posted to SAP'}); if(existing.qaRequired&&existing.qaStatus!=='APPROVED') return res.status(409).json({success:false,message:`QA approval is required before posting (currently ${existing.qaStatus})`}); if(!existing.destWarehouse) return res.status(400).json({success:false,message:'No scrap/destination warehouse was recorded on this deviation'}); const co=existing.company||cq(req); const batchNumbers=Array.isArray(existing.batchNumbers)?existing.batchNumbers:[]; const line={ItemCode:existing.itemCode,Quantity:existing.quantity,WarehouseCode:existing.destWarehouse,FromWarehouseCode:existing.warehouse}; if(batchNumbers.length) line.BatchNumbers=batchNumbers; const payload={ FromWarehouse:existing.warehouse, ToWarehouse:existing.destWarehouse, Comments:`Deviation (Damage/Loss) — PO ${existing.sapDocNum||existing.sapAbsEntry}: ${existing.reason}`.slice(0,254), StockTransferLines:[line], }; const damageSeries=appSettings.deviationDamageSeries(); if(damageSeries!=null) payload.Series=damageSeries; console.log('[DEVIATION-DAMAGE-POST] Payload:\n',JSON.stringify(payload,null,2)); const result=await sap.sapRequest('POST','StockTransfers',payload,co); console.log('[DEVIATION-DAMAGE-POST] ✅ DocEntry:',result?.DocEntry,'DocNum:',result?.DocNum); const updated=await devStore().markPosted(req.params.id,{docEntry:result?.DocEntry,docNum:result?.DocNum,docObject:'StockTransfers'}); res.json({success:true,data:updated}); }catch(err){ const sapMsg=err.message||'Unknown SAP error'; console.error('[DEVIATION-DAMAGE-POST] ❌',sapMsg); res.status(400).json({success:false,message:sapMsg}); } }); // Shared by both the reject flow (PATCH /:id/approve) and the standalone // retry (POST /:id/retry-reversal) — reverses whatever an already-REJECTED // deviation still needs reversed. Best-effort: never throws, always resolves // to {updated, reversal}; a failure just leaves the deviation un-reversed // with reversalError set, so someone can retry again later (e.g. once a // transient SAP problem — like the missing-company-context bug that used to // silently block every SAP-user's login check — has actually been fixed). async function reverseDeviation(existing, remarks, co, devStore, getSap){ const sap=getSap(); const comments=`Reversal of Deviation #${existing.id} (${existing.type}) rejected by QA: ${(remarks||'').trim()}`.slice(0,254); const batchNumbers=Array.isArray(existing.batchNumbers)?existing.batchNumbers:[]; if(existing.postedToSap){ try{ if(!sap) throw new Error('SAP service not ready'); let result, docObject; if(existing.type==='SHORTAGE'){ if(existing.lineNum==null) throw new Error('Original component line number was not recorded — cannot auto-reverse; adjust stock manually in SAP'); const line={BaseEntry:existing.sapAbsEntry,BaseLine:parseInt(existing.lineNum),BaseType:202,Quantity:existing.quantity,WarehouseCode:existing.warehouse}; if(batchNumbers.length) line.BatchNumbers=batchNumbers.map(x=>({...x,BaseLineNumber:0})); const payload={BPL_IDAssignedToInvoice:2,Comments:comments,DocumentLines:[line]}; // U_BTCHNO is a MANDATORY header UDF on every InventoryGenEntries in // this SAP setup — required even for non-batch items, not just when // BatchNumbers are present (verified live 2026-08-06: SAP rejects // with -1116(-30) "Please fill the batch no" otherwise). payload.U_BTCHNO=batchNumbers.length?batchNumbers[0].BatchNumber:'N/A'; docObject='InventoryGenEntries'; result=await sap.sapRequest('POST',docObject,payload,co); }else if(existing.type==='SUBSTITUTION'||existing.type==='ADDITION'){ // Same BaseLine-linked shape as Shortage — the substitute/added item // was added as a real component line (see POST /deviations), so this // correctly decrements that line's own IssuedQuantity too, not just // physical stock. (Addition never sets oldLineNum, so the "restore // the replaced item's own line" step below simply never applies to it.) if(existing.lineNum==null) throw new Error('The item\'s component line number was not recorded — cannot auto-reverse; adjust stock manually in SAP'); const line={BaseEntry:existing.sapAbsEntry,BaseLine:parseInt(existing.lineNum),BaseType:202,Quantity:existing.quantity,WarehouseCode:existing.warehouse}; if(batchNumbers.length) line.BatchNumbers=batchNumbers.map(x=>({...x,BaseLineNumber:0})); const payload={BPL_IDAssignedToInvoice:2,Comments:comments,DocumentLines:[line]}; payload.U_BTCHNO=batchNumbers.length?batchNumbers[0].BatchNumber:'N/A'; docObject='InventoryGenEntries'; result=await sap.sapRequest('POST',docObject,payload,co); }else if(existing.type==='DAMAGE'){ if(!existing.destWarehouse) throw new Error('Original scrap/destination warehouse was not recorded — cannot auto-reverse; transfer stock back manually in SAP'); const line={ItemCode:existing.itemCode,Quantity:existing.quantity,WarehouseCode:existing.warehouse,FromWarehouseCode:existing.destWarehouse}; if(batchNumbers.length) line.BatchNumbers=batchNumbers; const payload={FromWarehouse:existing.destWarehouse,ToWarehouse:existing.warehouse,Comments:comments,StockTransferLines:[line]}; const damageSeries=appSettings.deviationDamageSeries(); if(damageSeries!=null) payload.Series=damageSeries; docObject='StockTransfers'; result=await sap.sapRequest('POST',docObject,payload,co); } // Substitution also shrank the REPLACED item's own line when this was // applied — restore that too, or the old item stays permanently // reduced even after its replacement was undone. if(existing.type==='SUBSTITUTION'&&existing.oldLineNum!=null){ const orderNow=await sap.sapRequest('GET',`ProductionOrders(${existing.sapAbsEntry})?$select=ProductionOrderLines`,null,co); const targetOldLine=parseInt(existing.oldLineNum); const lines=(orderNow.ProductionOrderLines||[]).map(l=>({ ItemNo:l.ItemNo, BaseQuantity:l.BaseQuantity, PlannedQuantity:l.LineNumber===targetOldLine?(l.PlannedQuantity||0)+existing.quantity:l.PlannedQuantity, ItemType:l.ItemType, ProductionOrderIssueType:l.ProductionOrderIssueType, Warehouse:l.Warehouse, VisualOrder:l.VisualOrder, LineNumber:l.LineNumber, })); await sap.sapRequest('PATCH',`ProductionOrders(${existing.sapAbsEntry})`,{ProductionOrderLines:lines},co); console.log(`[DEVIATION-REVERSAL] ✅ Deviation #${existing.id} — restored replaced item's line`,targetOldLine,'by',existing.quantity); } if(result){ console.log(`[DEVIATION-REVERSAL] ✅ Deviation #${existing.id} reversed via ${docObject} DocEntry:`,result.DocEntry,'DocNum:',result.DocNum); const updated=await devStore().recordReversal(existing.id,{reversed:true,docEntry:result.DocEntry,docNum:result.DocNum,docObject}); return {updated,reversal:{success:true,docNum:result.DocNum,docObject}}; } return {updated:existing,reversal:null}; }catch(revErr){ const msg=revErr.message||'Unknown SAP error'; console.error(`[DEVIATION-REVERSAL] ❌ Deviation #${existing.id}:`,msg); const updated=await devStore().recordReversal(existing.id,{reversed:false,error:msg}); return {updated,reversal:{success:false,message:msg}}; } } // Only reachable for a LEGACY deviation raised under the OLD deferred // design (before this feature required approval to touch the Production // Order at all) that's still somehow sitting PENDING — its line change DID // already happen (lineApplied backfilled true for those rows at startup; // see services/deviationStore.js bootstrap()). A deviation created under // the CURRENT design is never lineApplied while PENDING, so this branch is // simply unreachable for it — nothing was ever added, so rejecting has // nothing to undo. if(existing.lineApplied && ['SHORTAGE','SUBSTITUTION'].includes(existing.type)){ try{ if(!sap) throw new Error('SAP service not ready'); const orderNow=await sap.sapRequest('GET',`ProductionOrders(${existing.sapAbsEntry})?$select=ProductionOrderLines`,null,co); const targetLineNum=parseInt(existing.lineNum); const target=(orderNow.ProductionOrderLines||[]).find(l=>l.LineNumber===targetLineNum); if(!target) throw new Error(`Component line ${targetLineNum} was not found on the Production Order — adjust it manually in SAP`); const oldLineNum=existing.type==='SUBSTITUTION'&&existing.oldLineNum!=null?parseInt(existing.oldLineNum):null; const lines=(orderNow.ProductionOrderLines||[]).map(l=>{ if(l.LineNumber===targetLineNum){ // Always subtract back off exactly what THIS deviation added, never // zero the whole line — Substitution's line may be shared with // other deviations for the same item (SAP consolidates // same-ItemNo lines rather than keeping duplicates — see POST // /deviations), or may already have been a real BOM/WO component // with its own legitimate quantity before this deviation touched // it. A delta subtract is correct in both cases. return {ItemNo:l.ItemNo,BaseQuantity:l.BaseQuantity,PlannedQuantity:zeroSafeQty(Math.max(0,(l.PlannedQuantity||0)-existing.quantity)), ItemType:l.ItemType,ProductionOrderIssueType:l.ProductionOrderIssueType,Warehouse:l.Warehouse,VisualOrder:l.VisualOrder,LineNumber:l.LineNumber}; } if(oldLineNum!=null&&l.LineNumber===oldLineNum){ // Restore the replaced item's own line by the same amount that was // taken off it when this Substitution was applied. return {ItemNo:l.ItemNo,BaseQuantity:l.BaseQuantity,PlannedQuantity:(l.PlannedQuantity||0)+existing.quantity, ItemType:l.ItemType,ProductionOrderIssueType:l.ProductionOrderIssueType,Warehouse:l.Warehouse,VisualOrder:l.VisualOrder,LineNumber:l.LineNumber}; } return {ItemNo:l.ItemNo,BaseQuantity:l.BaseQuantity,PlannedQuantity:l.PlannedQuantity, ItemType:l.ItemType,ProductionOrderIssueType:l.ProductionOrderIssueType,Warehouse:l.Warehouse,VisualOrder:l.VisualOrder,LineNumber:l.LineNumber}; }); await sap.sapRequest('PATCH',`ProductionOrders(${existing.sapAbsEntry})`,{ProductionOrderLines:lines},co); console.log(`[DEVIATION-REVERSAL] ✅ Deviation #${existing.id} — reverted deferred line change on line`,targetLineNum,oldLineNum!=null?`and restored old line ${oldLineNum}`:''); const updated=await devStore().recordReversal(existing.id,{reversed:true,docObject:'ProductionOrderLines'}); return {updated,reversal:{success:true,docObject:'ProductionOrderLines'}}; }catch(revErr){ const msg=revErr.message||'Unknown SAP error'; console.error(`[DEVIATION-REVERSAL] ❌ Deviation #${existing.id}:`,msg); const updated=await devStore().recordReversal(existing.id,{reversed:false,error:msg}); return {updated,reversal:{success:false,message:msg}}; } } return {updated:existing,reversal:null}; } // Applies a Shortage/Substitution's Production Order change for the FIRST // time — called only once QA approves (never at raise time; see POST // /deviations). Shared by the approve handler and its retry endpoint. async function applyDeviationLine(existing, co, devStore, getSap){ const sap=getSap(); if(!sap){ await devStore().markLineApplyError(existing.id,'SAP service not ready'); return {success:false,message:'SAP service not ready'}; } try{ let finalLineNum; if(existing.type==='SHORTAGE'){ if(existing.lineNum==null) throw new Error('Component line number was not recorded — cannot apply; adjust the Production Order manually in SAP'); const orderNow=await sap.sapRequest('GET',`ProductionOrders(${existing.sapAbsEntry})?$select=ProductionOrderLines`,null,co); const targetLineNum=parseInt(existing.lineNum); if(!(orderNow.ProductionOrderLines||[]).some(l=>l.LineNumber===targetLineNum)) throw new Error(`Component line ${targetLineNum} was not found on the Production Order — cannot top up its quantity`); const lines=(orderNow.ProductionOrderLines||[]).map(l=>({ ItemNo:l.ItemNo, BaseQuantity:l.BaseQuantity, PlannedQuantity:l.LineNumber===targetLineNum?(l.PlannedQuantity||0)+existing.quantity:l.PlannedQuantity, ItemType:l.ItemType, ProductionOrderIssueType:l.ProductionOrderIssueType, Warehouse:l.Warehouse, VisualOrder:l.VisualOrder, LineNumber:l.LineNumber, })); await sap.sapRequest('PATCH',`ProductionOrders(${existing.sapAbsEntry})`,{ProductionOrderLines:lines},co); finalLineNum=targetLineNum; console.log(`[DEVIATION-APPLY] ✅ Deviation #${existing.id} — bumped PlannedQuantity on line`,finalLineNum,'by',existing.quantity); }else if(existing.type==='SUBSTITUTION'){ finalLineNum=await applySubstitutionLine(sap,existing.sapAbsEntry,existing.oldLineNum,existing.newItemCode,existing.quantity,existing.warehouse,co); console.log(`[DEVIATION-APPLY] ✅ Deviation #${existing.id} — substitute item now on line`,finalLineNum); }else if(existing.type==='ADDITION'){ finalLineNum=await applySubstitutionLine(sap,existing.sapAbsEntry,null,existing.itemCode,existing.quantity,existing.warehouse,co); console.log(`[DEVIATION-APPLY] ✅ Deviation #${existing.id} — new item now on line`,finalLineNum); }else{ return {success:true}; // DAMAGE never touches a line — nothing to apply } await devStore().markLineApplied(existing.id,{lineNum:finalLineNum}); // Auto-remove the replaced item's now-superseded line, when the admin // setting says to skip the manual "Remove from SAP" button entirely. // Best-effort and silent either way — attemptRemoveOldLine() safely // no-ops (never throws) when it's not actually removable yet (e.g. real // IssuedQuantity already sits against it), which is a normal outcome // here, not a failure worth surfacing on top of a successful apply. if(existing.type==='SUBSTITUTION'&&!appSettings.deviationShowRemoveOldLineButton()){ const reloaded=await devStore().findById(existing.id); const r=await attemptRemoveOldLine(reloaded,co,devStore,getSap); if(r.success) console.log(`[DEVIATION-APPLY] ✅ Deviation #${existing.id} — old line auto-removed`); else console.log(`[DEVIATION-APPLY] Deviation #${existing.id} — old line not auto-removed:`,r.message); } return {success:true,lineNum:finalLineNum}; }catch(applyErr){ const msg=applyErr.message||'Unknown SAP error'; console.error(`[DEVIATION-APPLY] ❌ Deviation #${existing.id}:`,msg); await devStore().markLineApplyError(existing.id,msg); return {success:false,message:msg}; } } router.patch('/deviations/:id/approve', verifyToken, requireApprovalStep('production_order:deviation','approve'), async(req,res)=>{ try{ const existing=await devStore().findById(req.params.id); if(!existing) return res.status(404).json({success:false,message:'Deviation not found'}); if(existing.qaStatus!=='PENDING') return res.status(409).json({success:false,message:`This deviation is already ${existing.qaStatus}`}); const approve=req.body.approve!==false; if(!approve && !String(req.body.remarks||'').trim()) return res.status(400).json({success:false,message:'A reason is required to reject a deviation'}); let updated=await devStore().approveDeviation(req.params.id,{ by:req.user.username, byName:req.user.name||req.user.username, remarks:req.body.remarks||'', approve, }); const co=existing.company||cq(req); let reversal=null, applyResult=null; if(approve){ // Approving a still-not-applied Shortage/Substitution/Addition is the // moment the component actually gets added/updated on the Production // Order — never before. Best-effort: a failure here does NOT undo the // approval itself (QA's decision stands); it's surfaced via // lineApplyError and can be retried via POST /deviations/:id/retry-apply. if(!existing.lineApplied && ['SHORTAGE','SUBSTITUTION','ADDITION'].includes(existing.type)){ applyResult=await applyDeviationLine(existing,co,devStore,getSap); updated=await devStore().findById(req.params.id); } }else{ // Rejecting reverses whatever this deviation actually did — production // wasn't blocked when it was raised, but QA saying "this shouldn't // have happened this way" must not leave the stock adjustment // standing. Best-effort: a reversal failure does NOT undo the // rejection itself — it's surfaced via reversalError, and can be // retried later via POST /deviations/:id/retry-reversal. const r=await reverseDeviation(existing,req.body.remarks,co,devStore,getSap); updated=r.updated; reversal=r.reversal; } res.json({success:true,data:updated,reversal,applyResult}); try{ require('../services/notifyStore').notify({ stepFullKey:'production_order:deviation', title:`Deviation ${approve?'Approved':'Rejected'} — PO ${existing.sapDocNum||existing.sapAbsEntry} (${existing.type})`, lines:[['Type',existing.type],['Item',existing.itemCode],['Qty',String(existing.quantity)],['Raised By',existing.raisedName||existing.raisedBy],[approve?'Approved By':'Rejected By',req.user.name||req.user.username],['Remarks',req.body.remarks||'']], url:`${process.env.APP_BASE_URL||''}/production`, excludeUsernames:[req.user.username], }); }catch(e){console.warn('[DEVIATION] approve/reject notify failed (non-fatal):',e.message);} }catch(err){res.status(500).json({success:false,message:err.message});} }); // Retries applying a Shortage/Substitution's Production Order change after // approval, if it failed the first time. Only meaningful for an already- // APPROVED deviation that hasn't been applied yet. router.post('/deviations/:id/retry-apply', verifyToken, requireApprovalStep('production_order:deviation','approve'), async(req,res)=>{ try{ const existing=await devStore().findById(req.params.id); if(!existing) return res.status(404).json({success:false,message:'Deviation not found'}); if(existing.qaStatus!=='APPROVED') return res.status(409).json({success:false,message:'Only an APPROVED deviation can be applied'}); if(existing.lineApplied) return res.status(409).json({success:false,message:'Already applied'}); const co=existing.company||cq(req); const applyResult=await applyDeviationLine(existing,co,devStore,getSap); const updated=await devStore().findById(req.params.id); res.json({success:true,data:updated,applyResult}); }catch(err){res.status(500).json({success:false,message:err.message});} }); // Retries a reversal that failed the first time (e.g. a transient SAP // problem, like the per-company SAP-login context not being sent — fixed // 2026-08-06 — that used to make EVERY reversal fail with "you have not set // your SAP User ID" regardless of what was actually saved). Only meaningful // for a deviation that's already REJECTED and still not reversed. router.post('/deviations/:id/retry-reversal', verifyToken, requireApprovalStep('production_order:deviation','approve'), async(req,res)=>{ try{ const existing=await devStore().findById(req.params.id); if(!existing) return res.status(404).json({success:false,message:'Deviation not found'}); if(existing.qaStatus!=='REJECTED') return res.status(409).json({success:false,message:'Only a REJECTED deviation can have its reversal retried'}); if(existing.reversed) return res.status(409).json({success:false,message:'Already reversed'}); const co=existing.company||cq(req); const r=await reverseDeviation(existing,existing.qaRemarks,co,devStore,getSap); res.json({success:true,data:r.updated,reversal:r.reversal}); }catch(err){res.status(500).json({success:false,message:err.message});} }); module.exports = router;