4109 lines
237 KiB
JavaScript
4109 lines
237 KiB
JavaScript
// 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;
|