inventory transfer
SAP-ERP Portal CI/CD / build (push) Successful in 3m14s

This commit is contained in:
John
2026-10-06 11:40:00 +05:30
parent 1a596217b7
commit affbb077c8
10 changed files with 176 additions and 125 deletions
+4 -4
View File
@@ -6,7 +6,7 @@ NODE_ENV=production
# any origin back (fine on a private LAN — this is today's default). Once
# this app has a real public domain, set it, e.g.:
# CORS_ALLOWED_ORIGINS=https://portal.mitraindustries.com
CORS_ALLOWED_ORIGINS=https://erp.miplapp.in
CORS_ALLOWED_ORIGINS=
# NOTE: The following app settings moved OUT of .env and are now edited from
# the Admin panel (System Settings tab), persisted in the app DB table
# ZAPP_SETTINGS (see services/appSettingsStore.js): BOM approval levels, skip
@@ -18,19 +18,19 @@ APP_SQL_SERVER_NAME=SAPSVR
APP_SQL_PORT=1433
APP_SQL_USER=SAP-ERP-USER
APP_SQL_PASSWORD=Admin@1234
APP_SQL_DATABASE=SAP-ERP-DEMO
APP_SQL_DATABASE=SAP-ERP
# ─── SAP SQL Direct Connection ───────────────────────────
SQL_HOST=192.9.205.132
SQL_SERVER_NAME=SAPSVR
SQL_PORT=1433
SQL_USER=sa
SQL_PASSWORD=Edp2sYstem
SQL_DATABASE=Test_MI-NewDB2
SQL_DATABASE=MI-NewDB2
# SQL_ENCRYPT=true ← uncomment only if SQL Server enforces encryption
# ─── SAP B1 Service Layer ─────────────────────────────────
SAP_B1_SERVER=https://192.9.205.132:50000
SAP_B1_COMPANY=Test_MI-NewDB2
SAP_B1_COMPANY=MI-NewDB2
SAP_B1_USER=manager
SAP_B1_PASSWORD=Mipl@123
SAP_DI_DB_TYPE=11
+56
View File
@@ -0,0 +1,56 @@
// migrate-transfer-fg-stage.js
// One-time data fix for removing "Transfer to Finished Goods" from the
// Production Order workflow (services/productionOrderStore.js's STEPS/
// STEP_KEYS shortened from 5 steps to 4 — Release, Issuance, Receipt,
// Close). Stages 0-3 (nothing done .. Receipt done) keep the exact same
// numeric meaning either way, since Transfer to FG used to sit AFTER all of
// them. Only stage >= 4 (Transfer to FG done, and/or Closed) needs
// shifting down by 1 to land on the new, shorter numbering:
// old stage 4 (Transfer done, Close pending) -> new stage 3 (ready to Close)
// old stage 5 (Closed) -> new stage 4 (Closed)
//
// Usage:
// node migrate-transfer-fg-stage.js <companyDB> (dry run — shows what WOULD change)
// node migrate-transfer-fg-stage.js <companyDB> --apply (actually runs the UPDATE)
'use strict';
require('dotenv').config();
const { getPool } = require('./services/sqlPool');
async function main() {
const companyDB = process.argv[2];
const apply = process.argv.includes('--apply');
if (!companyDB) {
console.error('Usage: node migrate-transfer-fg-stage.js <companyDB> [--apply]');
process.exit(1);
}
const pool = await getPool(companyDB);
const chk = await pool.request().query(`SELECT COUNT(*) c FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME='ZPRODUCTION_ORDERS'`);
if (!chk.recordset[0].c) {
console.log(`[${companyDB}] No ZPRODUCTION_ORDERS table — nothing to migrate.`);
process.exit(0);
}
const before = await pool.request().query(`SELECT STAGE, STATUS, COUNT(*) c FROM ZPRODUCTION_ORDERS WHERE STAGE>=4 GROUP BY STAGE, STATUS ORDER BY STAGE`);
console.log(`[${companyDB}] Rows with STAGE>=4 (will shift down by 1):`);
console.log(JSON.stringify(before.recordset, null, 1));
if (!before.recordset.length) {
console.log(`[${companyDB}] Nothing to migrate — no rows with STAGE>=4.`);
process.exit(0);
}
if (!apply) {
console.log(`[${companyDB}] DRY RUN — no changes made. Re-run with --apply to actually update.`);
process.exit(0);
}
const result = await pool.request().query(`UPDATE ZPRODUCTION_ORDERS SET STAGE = STAGE - 1 WHERE STAGE >= 4`);
console.log(`[${companyDB}] ✅ Updated ${result.rowsAffected[0]} row(s).`);
const after = await pool.request().query(`SELECT STAGE, STATUS, COUNT(*) c FROM ZPRODUCTION_ORDERS GROUP BY STAGE, STATUS ORDER BY STAGE`);
console.log(`[${companyDB}] Full stage distribution after migration:`);
console.log(JSON.stringify(after.recordset, null, 1));
process.exit(0);
}
main().catch(e => { console.error('ERR', e.message); process.exit(1); });
-1
View File
@@ -1316,7 +1316,6 @@ const NOTIFY_TARGET_OPTS = [
{ v:'production_order:release', l:'Production Order — Release' },
{ v:'production_order:issuance', l:'Production Order — Issuance (Issue for Production)' },
{ v:'production_order:receipt', l:'Production Order — Receipt from Production' },
{ v:'production_order:transfer_fg', l:'Production Order — Transfer to Finished Goods' },
{ v:'production_order:close', l:'Production Order — Close' },
{ v:'production_order:deviation', l:'Production Order — Deviation (Shortage/Substitution/Damage)' },
{ v:'module:production-batch-issuance', l:'Batch Issuance Intimation — New Intimation' },
+3 -3
View File
@@ -238,19 +238,19 @@ ol.steps b{color:var(--text)}
<summary><span class="sec-ico">🏭</span> 8. Production Order <span class="chev">›</span></summary>
<div class="sec-bd">
<p class="lead">A Production Order is created from an approved Work Order and moves through a fixed sequence in SAP.</p>
<div class="flow"><span class="step">Create</span><span class="arrow">→</span><span class="step">Release</span><span class="arrow">→</span><span class="step">Issuance</span><span class="arrow">→</span><span class="step">Receipt</span><span class="arrow">→</span><span class="step">Transfer to FG</span><span class="arrow">→</span><span class="step badge b-green">Close</span></div>
<div class="flow"><span class="step">Create</span><span class="arrow">→</span><span class="step">Release</span><span class="arrow">→</span><span class="step">Issuance</span><span class="arrow">→</span><span class="step">Receipt</span><span class="arrow">→</span><span class="step badge b-green">Close</span></div>
<ol class="steps">
<li>Open <b>Production Order</b>, pick the approved Work Order to create from (or <b>Manual Entry</b>, if you're permitted to create without a Work Order). A Work Order can only ever produce <b>one</b> Production Order — once it has one, it no longer appears in this picker.</li>
<li>Components that exist in both the item's SAP BOM and the Work Order's approved materials are pre-filled with the <b>Work Order's approved quantity</b>, not SAP's plain BOM-ratio calculation — it already reflects any real overage/measured adjustment approved for this batch. Any Work Order item that isn't in the standard BOM at all is added as an extra component automatically.</li>
<li><b>Release</b> the order once it's created — this makes it available for issuance.</li>
<li>Go to <b>Issue</b> to post the raw material issuance (see section 9 for the full flow).</li>
<li>Go to <b>Receipt</b> to receive the finished quantity into stock (section 10).</li>
<li>Once fully received, <b>Transfer to Finished Goods</b> then <b>Close</b> the order.</li>
<li>Once fully received, <b>Close</b> the order. (Moving finished goods into their own warehouse is a separate <b>Inventory Transfer</b> — see section 5 — not a required step here.)</li>
<li>Open any order's detail to see the <b>QA — Approved vs Issued</b> comparison: BOM Planned vs Work Order Approved vs actually Issued, with mismatches highlighted. A component with no entry in the Work Order at all is flagged <b>NOT IN WORK ORDER</b>, and one that also isn't a real ingredient anywhere in the item's BOM is flagged <b>NOT IN BOM</b> too — so QA can catch anything added directly on the Production Order before releasing/closing. (Solution containers, e.g. a chemical solution that's approved via its own raw ingredients, are exempt from this flag — that's expected, not an error.)</li>
<li><b>Raise Deviation</b> — once an order has completed Issuance, you can raise a <b>Shortage</b> (extra quantity of the same item needed), <b>Substitution</b> (replace one component with another), <b>Damage/Loss</b> (write off a damaged quantity) or <b>Addition</b> (add a genuinely new item that was never on the order at all) with a reason. Any SAP write-off for a Damage/Loss deviation posts automatically — there's no separate checkbox to trigger it. If your admin requires QA approval, the deviation sits <b>Pending</b> until QA approves or rejects it; approving applies the change to the Production Order, rejecting reverses anything it already did. Whoever raised it and whoever holds the Deviation approval step both get an email at every stage — raised, approved, and rejected. See the <b>Deviations</b> section next for a single list of every deviation across every order.</li>
<li><b>Cancel Order</b> (if granted) — bypasses all normal Close checks to cancel an order outright. This is irreversible and only available to admins or whoever's been specifically granted the Cancel approval step.</li>
</ol>
<div class="warn"><b>Optional — Pre-PWO Store Review:</b> if your admin has turned this on, creating from a Work Order no longer writes to SAP straight away. It first stages a <b>Pre-PWO</b> in the portal (same components table, editable, nothing sent to SAP yet) — find it under the new <b>Pre-PWO</b> tab. You must <b>Share it with Store</b>, who can adjust quantities or add/remove/substitute components outright, then revert it back to you. This is a <b>hard gate</b>: the order cannot be pushed to SAP until Store has completed that review — there's no way to skip ahead. Once pushed, the Pre-PWO locks and the normal Release → Issuance → Receipt → Transfer to FG → Close sequence above continues as usual.</div>
<div class="warn"><b>Optional — Pre-PWO Store Review:</b> if your admin has turned this on, creating from a Work Order no longer writes to SAP straight away. It first stages a <b>Pre-PWO</b> in the portal (same components table, editable, nothing sent to SAP yet) — find it under the new <b>Pre-PWO</b> tab. You must <b>Share it with Store</b>, who can adjust quantities or add/remove/substitute components outright, then revert it back to you. This is a <b>hard gate</b>: the order cannot be pushed to SAP until Store has completed that review — there's no way to skip ahead. Once pushed, the Pre-PWO locks and the normal Release → Issuance → Receipt → Close sequence above continues as usual.</div>
<div class="tip"><b>Manual Entry item restriction (if set):</b> an admin can limit which SAP Item Groups you're allowed to originate a Manual Entry Production Order for (Admin → user → <b>Manual PWO Item Groups</b>) — separate from the Issue Items restriction. If it's set, the Product No. search only shows items in your permitted groups, and the server independently re-checks on submit either way. No restriction set = you can create for any item, as before.</div>
<div class="tip"><b>Warehouse per component:</b> each row in the Components table has its own Warehouse dropdown (both a regular Production Order and a Consumable Order) — set it per component, not once for the whole order, since different components can come from different warehouses.</div>
<div class="tip"><b>Item Group visibility (if restricted):</b> if you're restricted to certain Item Groups (Admin → Issue Items), the Production Order list only shows orders whose main product is in your allowed groups — same restriction that already applied to issuing, now applied to what you can even see in the list.</div>
+22 -50
View File
@@ -249,14 +249,13 @@ const CAN_RELEASE_PO=hasStepPerm('production_order:release','approve');
function hasStepAssigned(fullKey){return ['view','add','edit','approve','delete'].some(p=>hasStepPerm(fullKey,p));}
const CAN_ISSUE_PO=hasStepAssigned('production_order:issuance');
const CAN_RECEIPT_PO=hasStepAssigned('production_order:receipt');
const CAN_TRANSFER_FG=hasStepPerm('production_order:transfer_fg','approve');
const CAN_CLOSE_PO=hasStepAssigned('production_order:close');
// A user who holds ANY Consumable Order step but NONE of the general
// production_order steps must see ONLY Consumable Orders in View Orders —
// mirrors routes/sap.js's isConsumableOnlyUser() (also enforced server-side
// there, so this is UI-only: hiding the pointless Type toggle and forcing
// the default, not the actual security boundary).
const CONSUMABLE_ONLY_USER=CAN_ANY_CONSUMABLE&&!(CAN_CREATE_PO||CAN_MANUAL_CREATE_PO||CAN_CREATE_PWO_FROM_COMPONENT||CAN_RELEASE_PO||CAN_ISSUE_PO||CAN_RECEIPT_PO||CAN_TRANSFER_FG||CAN_CLOSE_PO);
const CONSUMABLE_ONLY_USER=CAN_ANY_CONSUMABLE&&!(CAN_CREATE_PO||CAN_MANUAL_CREATE_PO||CAN_CREATE_PWO_FROM_COMPONENT||CAN_RELEASE_PO||CAN_ISSUE_PO||CAN_RECEIPT_PO||CAN_CLOSE_PO);
// Admin / System Admin: can Close OR Cancel a Production Order at ANY stage,
// bypassing every normal gate below (step assignment, full-issuance,
// full-qty, sequencing) — a deliberate override for these two roles only.
@@ -827,53 +826,18 @@ function renderPager(){
</div>`;
}
function wirePoCards(){
document.querySelectorAll('[data-po]').forEach(c=>c.onclick=function(e){if(e.target.closest('[data-issue]')||e.target.closest('[data-receipt]')||e.target.closest('[data-close]')||e.target.closest('[data-transferfg]'))return;openDetail(parseInt(this.dataset.po));});
document.querySelectorAll('[data-po]').forEach(c=>c.onclick=function(e){if(e.target.closest('[data-issue]')||e.target.closest('[data-receipt]')||e.target.closest('[data-close]'))return;openDetail(parseInt(this.dataset.po));});
document.querySelectorAll('[data-issue]').forEach(b=>b.onclick=function(e){e.stopPropagation();window.location.href=`/issue-production#order=${this.dataset.issue}&company=${encodeURIComponent(_co)}`;});
document.querySelectorAll('[data-receipt]').forEach(b=>b.onclick=function(e){e.stopPropagation();window.location.href=`/receipt-production#order=${this.dataset.receipt}&company=${encodeURIComponent(_co)}`;});
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=function(e){
e.stopPropagation();window.location.href=`/close-production#order=${this.dataset.close}&company=${encodeURIComponent(_co)}`;
});
document.querySelectorAll('[data-transferfg]').forEach(b=>b.onclick=function(e){e.stopPropagation();openTransferFG(parseInt(this.dataset.transferfg));});
}
// ═══════════════════════════════════════
// TRANSFER TO FINISHED GOODS — new step, only for orders linked to a Work
// Order (see _localByEntry). No existing page handles this, so it's a
// small self-contained modal here rather than a whole new page.
// ═══════════════════════════════════════
function openTransferFG(entry){
const linked=_localByEntry[entry];
const o=_orders.find(x=>x.AbsoluteEntry===entry);
if(!linked||!o){toast('Order not found','err');return;}
const modal=document.getElementById('detail-modal'),inner=document.getElementById('modal-inner');
modal.classList.add('show');
const whOpts=_wh.map(w=>`<option value="${esc(w.code)}">${esc(w.code)} - ${esc(w.name)}</option>`).join('');
inner.innerHTML=`<div class="modal-hd"><div class="modal-hd-title">Transfer to Finished Goods — ${esc(o.ItemNo)}</div><button class="modal-close" id="m-close">X</button></div>
<div class="modal-bd">
<div class="fg"><label class="fl">From Warehouse (production/receiving)</label><select class="fi" id="tfg-from">${whOpts}</select></div>
<div class="fg"><label class="fl">To Warehouse (finished goods)</label><select class="fi" id="tfg-to">${whOpts}</select></div>
<div class="fi-row">
<div class="fg"><label class="fl">Quantity</label><input class="fi fi-mono" id="tfg-qty" type="number" min="0" step="0.001" value="${o.CompletedQuantity||o.PlannedQuantity||0}" style="text-align:right"/></div>
<div class="fg"><label class="fl">Batch Number</label><input class="fi fi-mono" id="tfg-batch" value="${esc(linked.batchNumber||'')}"/></div>
</div>
<button class="btn b-planned" id="tfg-submit" style="width:100%"><span id="tfg-sp" style="display:none" class="sp"></span> Transfer</button>
</div>`;
q('#tfg-from').value=linked.warehouse||o.Warehouse||'';
q('#tfg-to').value=linked.fgWarehouse||'';
q('#m-close').onclick=()=>modal.classList.remove('show');
q('#tfg-submit').onclick=async()=>{
const from=q('#tfg-from').value,to=q('#tfg-to').value,qty=parseFloat(q('#tfg-qty').value)||0,batch=q('#tfg-batch').value.trim();
if(!from||!to){toast('From/To warehouse required','err');return;}
if(from===to){toast('From and To warehouse must differ','err');return;}
if(!qty){toast('Quantity required','err');return;}
if(!requireSapLogin())return;
const btn=q('#tfg-submit'),sp=q('#tfg-sp');btn.disabled=true;sp.style.display='inline-block';
try{
await api('POST','/sap/transfer-fg',{productionOrderId:linked.id,itemCode:o.ItemNo,quantity:qty,batchNumber:batch,fromWarehouse:from,toWarehouse:to,company:_co});
toast('Transferred to Finished Goods');modal.classList.remove('show');loadOrders();
}catch(e){toast(e.message,'err');btn.disabled=false;sp.style.display='none';}
};
}
// NOTE: "Transfer to Finished Goods" used to be its own step here (a small
// self-contained modal, openTransferFG()) between Receipt and Close.
// Removed — it's now a plain, standalone Inventory Transfer
// (/inventory-transfer), decoupled from any specific Production Order and
// no longer gating Close at all.
function renderOrderList(){
let list=_orders;
@@ -1208,12 +1172,12 @@ async function openDetail(entry){
${(st==='Released'||st==='Closed')&&fullyIssued&&_localByEntry[entry]&&_localByEntry[entry].stage<2&&(d._localIsConsumable?canIssueConsumable:CAN_ISSUE_PO)?`<div style="margin-top:14px"><div class="alert al-amber" style="margin-bottom:8px;font-size:11px">This order shows fully issued in SAP${st==='Closed'?' (and already Closed)':''}, but Issuance was never recorded through this portal — click below to catch it up so it appears in Verify Work Order.</div><button class="btn" style="width:100%;background:rgba(6,182,212,.1);color:#06b6d4;border:1px solid rgba(6,182,212,.3)" id="m-catchup-issue"><span id="m-catchup-sp" style="display:none" class="sp"></span> Catch Up Issuance</button></div>`:''}
${st==='Released'&&!fullyIssued&&(d._localIsConsumable?canIssueConsumable:CAN_ISSUE_PO)?`<div style="margin-top:14px"><button class="btn" style="width:100%;background:rgba(6,182,212,.1);color:#06b6d4;border:1px solid rgba(6,182,212,.3)" id="m-issue">${d._localIsConsumable?'Issue Consumable':'Issue for Production'}</button></div>`:''}
${st==='Released'&&!d._localIsConsumable&&fullyIssued&&(d.CompletedQuantity||0)<(d.PlannedQuantity||1)&&CAN_RECEIPT_PO?`<div style="margin-top:8px"><button class="btn" style="width:100%;background:rgba(13,187,138,.1);color:var(--green);border:1px solid rgba(13,187,138,.3)" id="m-receipt">Receipt from Production</button></div>`:''}
${st==='Released'&&(d.CompletedQuantity||0)>=(d.PlannedQuantity||1)&&_localByEntry[entry]&&_localByEntry[entry].stage===3&&CAN_TRANSFER_FG?`<div style="margin-top:8px"><button class="btn" style="width:100%;background:rgba(159,90,253,.1);color:var(--purple);border:1px solid rgba(159,90,253,.3)" id="m-transferfg">Transfer to Finished Goods</button></div>`:''}
${st==='Released'&&!d._localIsConsumable&&(d.CompletedQuantity||0)>=(d.PlannedQuantity||1)&&_localByEntry[entry]&&_localByEntry[entry].stage===2&&CAN_CLOSE_PO?`<div style="margin-top:14px"><div class="alert al-amber" style="margin-bottom:8px;font-size:11px">This order shows fully received in SAP, but Receipt was never recorded through this portal — click below to catch it up so it can be Closed.</div><button class="btn" style="width:100%;background:rgba(13,187,138,.1);color:var(--green);border:1px solid rgba(13,187,138,.3)" id="m-catchup-receipt"><span id="m-catchup-recv-sp" style="display:none" class="sp"></span> Catch Up Receipt</button></div>`:''}
${(()=>{
const normalCloseOk=(d.CompletedQuantity||0)>=(d.PlannedQuantity||1)&&(!_localByEntry[entry]||_localByEntry[entry].stage>=4)&&CAN_CLOSE_PO;
const normalCloseOk=(d.CompletedQuantity||0)>=(d.PlannedQuantity||1)&&(!_localByEntry[entry]||_localByEntry[entry].stage>=3)&&CAN_CLOSE_PO;
// Consumable Order workflow is Release → Issue → Close only —
// Receipt/Transfer to FG never happen, so "stage>=4"/"fully
// received" can NEVER be true for one. Anyone holding 'approve' on
// Receipt never happens, so "stage>=3"/"fully received" can NEVER
// be true for one. Anyone holding 'approve' on
// the dedicated Consumable Order — Close step can close it at
// whatever stage it's actually at (mirrors routes/sap.js's
// isConsumableCloser()). No creator bypass.
@@ -1245,6 +1209,17 @@ async function openDetail(entry){
}catch(e){toast(e.message||'Failed','err');btn.disabled=false;if(sp)sp.style.display='none';}
};
}
if(q('#m-catchup-receipt')){
q('#m-catchup-receipt').onclick=async function(){
if(!requireSapLogin())return;
const btn=this,sp=q('#m-catchup-recv-sp');btn.disabled=true;if(sp)sp.style.display='inline-block';
try{
await api('POST','/sap/confirm-received-outside-portal',{absEntry:entry,company:_co});
toast('Receipt caught up — ready to Close');
openDetail(entry);
}catch(e){toast(e.message||'Failed','err');btn.disabled=false;if(sp)sp.style.display='none';}
};
}
if(q('#m-receipt')){
q('#m-receipt').onclick=()=>{window.location.href=`/receipt-production#order=${entry}&company=${encodeURIComponent(_co)}`;};
}
@@ -1267,9 +1242,6 @@ async function openDetail(entry){
}catch(e){toast(e.message||'Failed','err');btn.disabled=false;}
};
}
if(q('#m-transferfg')){
q('#m-transferfg').onclick=function(){ modal.classList.remove('show'); openTransferFG(entry); };
}
if(q('#m-raise-dev')){
q('#m-raise-dev').onclick=openDevModal;
}
+4 -3
View File
@@ -55,9 +55,10 @@ router.get('/pending', verifyToken, async (req, res) => {
// Issue/Receipt/Close each have their own dedicated page (deep-linked
// via #order=<AbsoluteEntry>&company=<co>, same hash convention
// production.html's own action buttons already use to get there).
// Release and Transfer to Finished Goods have no standalone page of
// their own — those stay on production.html's detail popup.
const STAGE_CARD_HREF = { release: '/production', issuance: '/issue-production', receipt: '/receipt-production', transfer_fg: '/production', close: '/close-production' };
// Release has no standalone page of its own — stays on production.html's
// detail popup. Transfer to Finished Goods is no longer part of this
// chain at all (see services/productionOrderStore.js's STEPS).
const STAGE_CARD_HREF = { release: '/production', issuance: '/issue-production', receipt: '/receipt-production', close: '/close-production' };
// Consumable Orders (Release → Issue → Close only, no Receipt/Transfer to
// FG) are gated by their OWN dedicated steps, never the general
// production_order:* ones — mirrors services/productionOrderStore.js's
+1 -1
View File
@@ -69,7 +69,7 @@ router.get('/summary', verifyToken, async (req, res) => {
GROUP BY T1.ItemCode ORDER BY qty DESC`);
// ── Portal workflow funnel (local tracking, point-in-time) ───────────
const STEPS = ['Release', 'Issuance', 'Receipt from Production', 'Transfer to Finished Goods', 'Close'];
const STEPS = ['Release', 'Issuance', 'Receipt from Production', 'Close'];
let stages = STEPS.map((label, i) => ({ stage: i, label, count: 0 }));
let rejected = 0;
try {
+72 -59
View File
@@ -1646,7 +1646,7 @@ function canViewConsumableOrder(linked, req){
// 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'];
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: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;
@@ -2861,6 +2861,60 @@ router.post('/confirm-issued-outside-portal', verifyToken, async(req,res)=>{
}catch(err){res.status(400).json({success:false,message:err.message||'Unknown error'});}
});
// ════════════════════════════════════════════════════════════════
// CONFIRM RECEIVED (+ TRANSFERRED) OUTSIDE THE PORTAL
// → POST /api/sap/confirm-received-outside-portal
// Same philosophy as confirm-issued-outside-portal above: an order fully
// Received directly in SAP B1 (its own Receipt from Production screen, DI
// API, Excel import, whatever) — bypassing this portal's own Receipt
// action entirely — leaves the local stage stuck at "Receipt" forever,
// blocking Close with "next required step is Receipt from Production".
// There's no ordinary path to record it after the fact: the portal's own
// Receipt screen only lists orders SAP still shows a remaining quantity
// for. Jumps straight to stage 3 (ready for Close, the chain's last stage
// before Close itself — see STEPS in services/productionOrderStore.js)
// once SAP independently confirms the order's full planned quantity is
// actually complete. Gated on the CLOSE step, not Receipt — that's who's
// actually blocked by this and asking for it, and by definition already
// carries this order's final sign-off authority.
// Consumable Orders never have a Receipt stage at all (Release → Issue →
// Close only — see CONSUMABLE_STEP_FOR), so this doesn't apply to them.
// ════════════════════════════════════════════════════════════════
router.post('/confirm-received-outside-portal', verifyToken, async(req,res)=>{
try{
const sap=getSap();
if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'});
const co=cq(req);
const absEntry=parseInt(req.body.absEntry);
if(isNaN(absEntry)) return res.status(400).json({success:false,message:'absEntry is required'});
const linked=await poStore().findBySapAbsEntry(absEntry);
if(!linked) return res.status(404).json({success:false,message:'This order is not tracked locally — nothing to confirm.'});
if(linked.isConsumable) return res.status(400).json({success:false,message:'Consumable Orders do not have a Receipt stage — this does not apply.'});
if(!hasStepPerm(req.user,'production_order:close','approve'))
return res.status(403).json({success:false,message:'You are not assigned "approve" on approval step: production_order:close'});
if(linked.stage>=3) return res.json({success:true,data:linked,message:'Already caught up.'});
if(linked.stage<2)
return res.status(409).json({success:false,message:`Out of sequence: this order's next required step is "${linked.currentStep}" — catch that up first.`});
const ord=await sap.sapRequest('GET',`ProductionOrders(${absEntry})?$select=PlannedQuantity,CompletedQuantity`,null,co);
const planned=Number(ord?.PlannedQuantity)||0, completed=Number(ord?.CompletedQuantity)||0;
if(!(planned>0&&completed>=planned))
return res.status(409).json({success:false,message:`SAP shows this order is not yet fully received (${completed} of ${planned}) — receive the remaining quantity first.`});
const updated=await poStore().catchUpStage(linked.id,3,{by:req.user.username,byName:req.user.name||req.user.username,remarks:req.body?.remarks||'Confirmed fully received directly in SAP B1 — no Receipt action was posted through the portal.'});
console.log('[PROD] Caught up local stage to Close-ready for',absEntry,'by',req.user.username);
require('../services/auditStore').record({
userId:req.user?.id, username:req.user?.username, role:req.user?.role,
method:req.method, action:'CONFIRM', entity:'ProductionOrder', entityId:String(linked.id),
sub:'received_outside_portal', path:req.originalUrl, status:200, ok:true,
summary:`Production Order ${linked.sapDocNum||'#'+linked.id} (${linked.itemCode||''}) confirmed fully received in SAP without a portal Receipt action — local stage advanced to Close.`,
details:{sapAbsEntry:absEntry,planned,completed}, ip:req.ip, company:co,
}).catch(()=>{});
res.json({success:true,data:updated});
}catch(err){
console.error('[PROD] ❌ Catch-up (received outside portal) failed:',err.message);
res.status(400).json({success:false,message:err.message||'Unknown error'});
}
});
// GET /api/sap/production-orders/:absEntry/backflush-check?qty=X — pre-flight
// check run from the Receipt from Production modal, right before Post, to
// surface SAP's own "-5002 consumed quantity would cause inventory to fall
@@ -3206,56 +3260,12 @@ router.post('/return-components', verifyToken, requireStepAssigned('production_o
}
});
// ════════════════════════════════════════════════════════════════
// 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});
}
});
// NOTE: "Transfer to Finished Goods" used to live here as its own gated
// step (POST /transfer-fg, production_order:transfer_fg) between Receipt
// and Close. Removed — it's now a plain, standalone Inventory Transfer
// (public/inventory-transfer.html / POST /api/inventory-transfer),
// decoupled from any specific Production Order, doable whenever, and no
// longer gates Close at all. See services/productionOrderStore.js's STEPS.
// ════════════════════════════════════════════════════════════════
// CLOSE PRODUCTION ORDER → POST /api/sap/production-order/:id/close
@@ -3275,6 +3285,9 @@ function isPoAdminOverride(req){ return ['admin','system_admin'].includes(req.us
// 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.
// Still necessary even with Transfer to FG removed: a Consumable Order's
// stage stops at 2 (Issuance done) and never organically reaches 3
// (Receipt-then-Close-ready) since it has no Receipt stage at all.
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)=>{
@@ -3293,19 +3306,19 @@ router.post('/production-order/:id/close', verifyToken, async(req,res)=>{
// 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)
if(linked&&linked.status!=='REJECTED'&&linked.stage!==3)
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.
// failed), Issue/Receipt 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.'});
return res.status(409).json({success:false,message:'No local stage-tracking record found for this Production Order — Issue/Receipt 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
// Close (stage 3, all three 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).
+5 -2
View File
@@ -74,8 +74,11 @@ const BUILTIN_STEPS = [
{ workflow: 'production_order', key: 'release', label: 'Release', order: 3 },
{ workflow: 'production_order', key: 'issuance', label: 'Issuance', order: 4 },
{ workflow: 'production_order', key: 'receipt', label: 'Receipt from Production', order: 5 },
{ workflow: 'production_order', key: 'transfer_fg', label: 'Transfer to Finished Goods', order: 6 },
{ workflow: 'production_order', key: 'close', label: 'Close', order: 7 },
// Transfer to Finished Goods is no longer part of this sequential chain —
// it's a plain Inventory Transfer (see public/inventory-transfer.html),
// decoupled from any specific Production Order, so it no longer gates or
// even appears in this workflow at all.
{ workflow: 'production_order', key: 'close', label: 'Close', order: 6 },
// Corrective side-action, NOT part of the sequential stage chain above:
// after Issuance, defective component quantities can be returned to stock
// from the Receipt from Production page (SAP's "Return Components").
+9 -2
View File
@@ -14,14 +14,21 @@ const TABLE = `[dbo].[ZPRODUCTION_ORDERS]`;
// Ordered workflow steps AFTER creation (STAGE = number of completed steps).
// Index-aligned with STEP_KEYS and services/approvalStepsStore.js's
// workflow:'production_order' keys (minus 'create', which is a separate gate).
// "Transfer to Finished Goods" USED to be a required step here (between
// Receipt and Close) — removed: it's now a plain, standalone Inventory
// Transfer (public/inventory-transfer.html), decoupled from any specific
// Production Order, doable whenever, never gating Close. A one-time data
// migration (see migrate-transfer-fg-stage.js) renumbered every existing
// order's STAGE to match this shortened chain — old stage 4/5 (transfer
// done / closed) became new stage 3/4; stages 0-3 were already numerically
// identical since transfer_fg sat AFTER them.
const STEPS = [
'Release',
'Issuance',
'Receipt from Production',
'Transfer to Finished Goods',
'Close',
];
const STEP_KEYS = ['release', 'issuance', 'receipt', 'transfer_fg', 'close'];
const STEP_KEYS = ['release', 'issuance', 'receipt', 'close'];
let _conn = null;
async function getConn() {