Files
sap-erp/routes/sap.js
T
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

4109 lines
237 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});}
});
// ════════════════════════════════════════════════════════════════
// 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);
res.json({success:true,data:result?.value||[]});
}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`);
res.json({success:true,data:rows});
}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||'';
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();
// "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());
const readyToReceive=req.query.readyToReceive==='1'&&status==='Released'&&appSettings.poRequireFullIssuance();
const restrictedMode=readyToClose||readyToReceive;
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(...)`.
const parts=[];
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(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);
[...new Set(localMatchEntries)].forEach(e=>or.push(`AbsoluteEntry eq ${e}`));
parts.push('('+or.join(' or ')+')');
}
const filter=parts.length?`&$filter=${parts.join(' and ')}`:'';
const fetchTop=restrictedMode?500:top;
const fetchSkip=restrictedMode?0:skip;
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.
let 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;
});
let total=null;
if(readyToClose){
orders=orders.filter(o=>{
const issueOk=!appSettings.poRequireFullIssuance()||o._issueStatus==='full'||o._issueStatus==='';
const receiptOk=!appSettings.closeRequireFullQty()||o._receiptStatus==='done';
return issueOk&&receiptOk;
});
total=orders.length;
orders=orders.slice(skip,skip+top);
}else if(readyToReceive){
// Fully issued, not yet fully received — the actual candidates for
// Receipt from Production. (poRequireFullIssuance is already known
// true here, per readyToReceive's own condition above.)
orders=orders.filter(o=>(o._issueStatus==='full'||o._issueStatus==='')&&o._receiptStatus!=='done');
total=orders.length;
orders=orders.slice(skip,skip+top);
}else{
// 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){}
}
// 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={},woNoByEntry={};
localRecs.forEach(p=>{
if(p.sapAbsEntry==null)return;
batchByEntry[p.sapAbsEntry]=p.batchNumber||'';
woNoByEntry[p.sapAbsEntry]=p.workOrderId!=null?(woByIdMap[p.workOrderId]||''):'';
});
orders.forEach(o=>{ o.BatchNumber=batchByEntry[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){}
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);
// Total is only computed when searching — an unfiltered COUNT over the
// whole (very large) receipt history would needlessly slow the default view.
let total=null;
if(search){ try{ const c=await hanaQuery(`SELECT COUNT(*) AS n ${fromJoin}`,co); total=c[0]?.n??null; }catch(_e){} }
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});}
});
// ════════════════════════════════════════════════════════════════
// 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;
// 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);
// 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});
}
});
// ════════════════════════════════════════════════════════════════
// 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');
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 (Admin / System Admin only) → POST /api/sap/production-order/:id/cancel
// No approval-step gate at all (unlike Close above) — this is a brand new
// action with no normal-user path, deliberately restricted to these two
// roles only, at whatever stage the order is currently in. 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)) return next();
res.status(403).json({success:false,message:'Admin or System Admin role required to cancel a Production Order.'});
}, 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,"''");
const minDigits = prefix === 'PK' ? 4 : (prefix === 'ICO' ? 5 : 4);
//HARDCODED: For ICO, only use 158xx series
let seriesWhere = '';
if(prefix === 'ICO'){
seriesWhere = ` AND SUBSTRING("ItemCode", 4, 3) = '158'`; // Force 158xx series
}
try{
const strictSql = `
SELECT TOP 100 "ItemCode"
FROM ${DB(co)}."OITM"
WHERE UPPER("ItemCode") LIKE '${safePrefix}%'
AND LEN("ItemCode") >= ${prefixLen + minDigits}
AND PATINDEX('%[^0-9]%', SUBSTRING("ItemCode", ${prefixLen + 1}, LEN("ItemCode") - ${prefixLen})) = 0
${seriesWhere}
ORDER BY CAST(SUBSTRING("ItemCode", ${prefixLen + 1}, 20) AS BIGINT) DESC`;
const strictRows=await hanaQuery(strictSql,co);
// Admin-configurable floor (System Settings → Item Code Series) — lets
// an admin force the next suggested code past a range where a mistaken/
// out-of-order entry already exists, instead of the suggestion silently
// continuing right after that bad entry. Only ever pushes the number UP
// (max with whatever SAP actually has) — never causes a collision with
// a real existing code.
const floorMap=appSettings.itemCodeSeriesFloor();
const floor=floorMap[prefix]||0;
if(strictRows.length){
const lastCode=strictRows[0].ItemCode;
const numMatch=lastCode.match(new RegExp(`^${prefix}(\\d+)$`,'i'));
if(numMatch){
const lastNum=Math.max(parseInt(numMatch[1]),floor-1);
return res.json({success:true,lastCode,lastNumber:lastNum,nextNumber:lastNum+1,prefix,source:'hana_strict'});
}
}
return res.json({success:true,lastCode:null,lastNumber:Math.max(0,floor-1),nextNumber:Math.max(1,floor),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});}
});
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==='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'?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'){
// Same BaseLine-linked shape as Shortage — the substitute 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.
if(existing.lineNum==null) throw new Error('The substitute 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{
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 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'].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;