Files
sap-erp/routes/sap.js
T
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

4737 lines
279 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.reqQty-0.0001);
if(short.length){
const msg=short.map(x=>`${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"<T1."PlannedQty")`;
// "Something has actually been issued" — an order that's Released but
// hasn't had ANY component issued yet must never show as ready to
// receive, regardless of the full-issuance setting (receiving nothing
// issued makes no sense). Mirrors receipt-production.html's own
// _issueStatus==='full'||'partial' client check, now enforced here too.
const anyIssuedExists=`EXISTS (SELECT 1 FROM ${DB(co)}."WOR1" T1 WHERE T1."DocEntry"=T0."DocEntry" AND T1."IssueType"<>'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"`);
if(appSettings.poRequireFullIssuance()) whereParts.push(`NOT ${notFullyIssuedExists}`);
}else if(readyToClose){
// Each half only applies when its own admin setting actually
// requires it — matches the original in-Node predicate exactly
// (readyToClose's own gate already guarantees at least one is on).
if(appSettings.poRequireFullIssuance()) whereParts.push(`NOT ${notFullyIssuedExists}`);
if(appSettings.closeRequireFullQty()) whereParts.push(`T0."CmpltQty">=T0."PlannedQty"`);
}
// YYYY-MM-DD only — matches <input type=date>'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;li<lineTransTypes.length;li++){
try{
await hanaQuery(`UPDATE ${DB(co)}."IGN1" SET "TranType" = '${lineTransTypes[li]}' WHERE "DocEntry" = ${docEntry} AND "BaseType" = 202 AND "LineNum" = ${li}`,co);
}catch(sqlErr){
console.warn(`[RECEIPT-PROD] ⚠ TranType HANA update failed for line ${li} (non-fatal):`,sqlErr.message);
}
}
}
// Batch Mfr/Exp dates: SAP does NOT apply the line-level BatchNumbers
// dates to a NEWLY-created batch master (it defaults them to the system
// date). So after the receipt posts, patch the batch master
// (BatchNumberDetails) with the user's real ManufacturingDate/
// ExpirationDate. Non-fatal — a failure here never undoes the receipt.
if(docEntry&&batchReq){
try{
const b=lines?.[0]?.BatchNumbers?.[0]||{};
const batchNo=(body.batchNo||b.BatchNumber||'').toString().trim();
const itemCode=(body.itemCode||'').toString().trim();
const mfg=b.ManufacturingDate, exp=b.ExpiryDate;
if(batchNo&&(mfg||exp)){
let filter=`Batch eq '${batchNo.replace(/'/g,"''")}'`;
if(itemCode) filter+=` and ItemCode eq '${itemCode.replace(/'/g,"''")}'`;
const bm=await sap.sapRequest('GET',`BatchNumberDetails?$filter=${filter}&$select=DocEntry&$orderby=DocEntry desc`,null,co);
const rec=bm?.value?.[0];
if(rec){
const patch={};
if(mfg) patch.ManufacturingDate=mfg;
if(exp) patch.ExpirationDate=exp;
await sap.sapRequest('PATCH',`BatchNumberDetails(${rec.DocEntry})`,patch,co);
console.log(`[RECEIPT-PROD] ✅ Batch ${batchNo} dates set (mfg=${mfg||'-'}, exp=${exp||'-'})`);
}else{
console.warn(`[RECEIPT-PROD] ⚠ Batch master not found for ${batchNo} — dates not updated`);
}
}
}catch(e){ console.warn('[RECEIPT-PROD] ⚠ Batch date sync failed (non-fatal):',e.message); }
}
if(linked){
try{
// If ANY line was a Rejection, treat the whole step as a reject
// (batch needs rework) rather than a completed "Receipt" step.
const anyReject=lineTransTypes.includes('R');
// Only advance past "Receipt" once the order is ACTUALLY fully
// received in SAP — a partial receipt (allowed whenever Admin →
// "Require full quantity before Receipt" is off) must leave the
// local stage sitting at Receipt so a second/third pass for the
// remaining quantity is still accepted. Without this check, the
// very first partial receipt unconditionally jumped local stage to
// "Transfer to Finished Goods", permanently locking out any further
// Receipt posting for that order even though SAP itself still shows
// a real remaining quantity — the exact "Out of sequence: next
// required step is Transfer to Finished Goods, not Receipt" error.
let fullyReceived=true;
if(!anyReject&&!isNaN(baseEntry)){
const ord=await sap.sapRequest('GET',`ProductionOrders(${baseEntry})?$select=PlannedQuantity,CompletedQuantity`,null,co).catch(()=>null);
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(d1<d2)return -1;if(d1>d2)return 1;
const g1=a.Godown||'',g2=b.Godown||'';
if(g1<g2)return -1;if(g1>g2)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;