sale order
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s

This commit is contained in:
John
2026-10-05 18:45:17 +05:30
parent e725a6571b
commit eead8f5ffd
129 changed files with 14252 additions and 452 deletions
+148 -17
View File
@@ -188,10 +188,12 @@ let _issueGroups=null; // this user's allowed Item Groups for issuing (null =
let _loadingDetail=false;
let _submitting=false;
let _success=null;
let _remarks=''; // free-text note for this Issue document — saved to SAP as its Comments
let _readyOrders=[];
let _readyLoading=false;
let _modalOpen=false;
let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search
let _dateFrom='',_dateTo=''; // Due Date range, sent to the server (see loadReadyOrders())
// A user who can act on Consumable Orders but does NOT hold the general
// Issuance approval step must never see (or be able to submit for) a normal
// PWO here — force the list to Consumable-only for them, with no toggle
@@ -234,9 +236,17 @@ async function loadReadyOrders(resetPage){
_readyLoading=true;render();
const searchQ=_search.trim()?`&search=${encodeURIComponent(_search.trim())}`:'';
try{
const r=await api('GET',`/sap/production-orders?status=Released&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}${_viewOrderType?'&orderType='+_viewOrderType:''}`);
// readyToIssue=1 tells the server to filter OUT already-fully-issued
// orders itself, BEFORE paginating — it used to be filtered here
// client-side, AFTER the server had already handed back one fixed page
// of raw "Released" orders, which meant a page whose raw orders all
// happened to be already fully issued rendered completely empty while
// the pager still counted every Released order regardless — real data
// ended up scattered across seemingly random pages with long gaps.
const dateQ=(_dateFrom?`&dateFrom=${_dateFrom}`:'')+(_dateTo?`&dateTo=${_dateTo}`:'');
const r=await api('GET',`/sap/production-orders?status=Released&readyToIssue=1&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}${dateQ}${_viewOrderType?'&orderType='+_viewOrderType:''}`);
_rawCount=(r.data||[]).length;
_readyOrders=(r.data||[]).filter(o=>o._issueStatus!=='full');
_readyOrders=r.data||[];
_total=(typeof r.total==='number')?r.total:null;
}catch(_e){_readyOrders=[];_rawCount=0;_total=null;}
_readyLoading=false;render();
@@ -247,7 +257,7 @@ async function loadBranches(){try{const r=await api('GET',`/sap/lookup/branches?
async function selectOrder(absEntry){
_modalOpen=true;_loadingDetail=true;_selectedOrder=null;_lines=[];_removedLines=[];render();
_modalOpen=true;_loadingDetail=true;_selectedOrder=null;_lines=[];_removedLines=[];_remarks='';render();
try{
const r=await api('GET',`/sap/production-orders/${absEntry}?company=${encodeURIComponent(_co)}`);
const d=r.data||r;
@@ -315,6 +325,38 @@ async function selectOrder(absEntry){
await Promise.all(_lines.map(l=>loadBatches(l)));
}catch(e){toast(e.message,'err');}
_loadingDetail=false;render();
loadStockForLines();
}
// "Available" on-hand stock (OITW) shown next to each component — same
// pattern/endpoint as New Production Order's own Available Qty column
// (public/production.html's loadStockForLines()/stockFor()), grouped/
// fetched per distinct warehouse among the current lines (usually just one
// call). Non-blocking: patches _stockMap then patches just the modal, never
// gates anything — this is informational only, not a hard stock check.
let _stockMap={}; // `${itemCode}|${warehouse}` -> on-hand qty
async function loadStockForLines(){
const groups={};
_lines.forEach(l=>{
if(!l.itemCode||l.itemType==='pit_Resource')return;
const wh=l.warehouse||'';
(groups[wh]=groups[wh]||new Set()).add(l.itemCode.trim().toUpperCase());
});
const entries=Object.entries(groups);
if(!entries.length)return;
await Promise.all(entries.map(async([wh,codesSet])=>{
const codes=[...codesSet];
try{
const r=await api('GET',`/sap/lookup/item-stock?codes=${encodeURIComponent(codes.join(','))}${wh?'&warehouse='+encodeURIComponent(wh):''}&company=${encodeURIComponent(_co)}`);
if(r.success&&r.data)codes.forEach(c=>{_stockMap[`${c}|${wh}`]=r.data[c]||0;});
}catch(_e){/* non-fatal — Available just stays blank for these */}
}));
if(_modalOpen&&_selectedOrder)renderModal();
}
function stockFor(l){
const wh=l.warehouse||'';
const key=`${(l.itemCode||'').trim().toUpperCase()}|${wh}`;
return key in _stockMap ? _stockMap[key] : null;
}
async function loadBatches(line){
@@ -355,11 +397,13 @@ async function doIssue(){
_submitting=true;render();
const bplId=parseInt(q('#sel-branch')?.value)||2;
const postDate=q('#inp-postdate')?.value||'';
const remarks=(_remarks||'').trim();
const payload={
company:_co,
branchId:bplId,
};
if(postDate){payload.DocDate=postDate;payload.DocDueDate=postDate;}
if(remarks){payload.Comments=remarks;}
payload.DocumentLines=issuable.map(l=>{
const dl={BaseEntry:_selectedOrder.AbsoluteEntry,BaseLine:l.lineNum,BaseType:202,ItemCode:l.itemCode,Quantity:l.issueQty,WarehouseCode:l.warehouse};
if(l.costingCode) dl.CostingCode=l.costingCode;
@@ -376,13 +420,29 @@ async function doIssue(){
_submitting=false;render();
}
// Order is already 100% issued in SAP (confirmed live, not just assumed from
// what's displayed) but the portal's own approval chain never recorded it —
// see the long comment on POST /sap/confirm-issued-outside-portal for why
// this can't just silently auto-advance and why it's not a repost.
async function doConfirmIssued(){
if(!_selectedOrder)return;
if(!requireSapLogin())return;
_submitting=true;render();
try{
await api('POST','/sap/confirm-issued-outside-portal',{absEntry:_selectedOrder.AbsoluteEntry,company:_co});
_success={confirmed:true};
toast('Issuance confirmed — Receipt is now unblocked');
}catch(e){toast(e.message||'Failed','err');}
_submitting=false;render();
}
// ═══════════════════════════════════════
// RENDER
// ═══════════════════════════════════════
function render(){
try{ _doRender(); }catch(e){ console.error('Render error:',e); document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Render error: ${e.message}</div>`; }
}
function closeIssModal(){_modalOpen=false;_selectedOrder=null;_lines=[];_removedLines=[];_success=null;render();loadReadyOrders();}
function closeIssModal(){_modalOpen=false;_selectedOrder=null;_lines=[];_removedLines=[];_success=null;_remarks='';render();loadReadyOrders();}
function _doRender(){
const app=document.getElementById('app');app.innerHTML='';
@@ -418,11 +478,15 @@ function _doRender(){
<div class="po-top"><span class="po-code">${esc(o.ItemNo)}</span>${o._issueStatus==='partial'?'<span style="font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(245,166,35,.12);color:var(--amber)">PARTIAL</span>':''}${o.IsConsumable?'<span style="font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(159,90,253,.14);color:var(--purple)">CONSUMABLE</span>':''}</div>
<div class="po-icon">📦</div>
<div class="po-name" title="${esc(o.ProductDescription||'')}">${esc(o.ProductDescription||'-')}</div>
<div class="po-meta"><span>#${o.DocumentNumber||o.AbsoluteEntry} · Qty <b>${o.PlannedQuantity}</b></span><span>WH <b>${esc(o.Warehouse||'-')}</b></span></div>
<div class="po-meta"><span>#${o.DocumentNumber||o.AbsoluteEntry} · Qty <b>${o.PlannedQuantity}</b></span><span>WH <b>${esc(o.Warehouse||'-')}</b>${o.DueDate?` · Due <b>${new Date(o.DueDate).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'})}</b>`:''}</span></div>
${(o.WoNo||o.BatchNumber)?`<div class="po-meta"><span>${o.WoNo?`WO <b>${esc(o.WoNo)}</b>`:''}${o.WoNo&&o.BatchNumber?' · ':''}${o.BatchNumber?`Batch <b>${esc(o.BatchNumber)}</b>`:''}</span></div>`:''}
</div>`).join('')}</div>`;
const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px">
<input class="fi fi-mono" id="rp-search" value="${esc(_search)}" placeholder="Search item code, description, document no, order no, WO no or batch no…" style="font-size:12px;flex:1"/>
const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap">
<input class="fi fi-mono" id="rp-search" value="${esc(_search)}" placeholder="Search item code, description, document no, order no, WO no or batch no…" style="font-size:12px;flex:1;min-width:220px"/>
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;align-self:center">Due</span>
<input class="fi" id="rp-datefrom" type="date" value="${esc(_dateFrom)}" style="font-size:12px"/>
<span style="font-size:11px;color:var(--text3);align-self:center">to</span>
<input class="fi" id="rp-dateto" type="date" value="${esc(_dateTo)}" style="font-size:12px"/>
<button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--teal);color:#fff;white-space:nowrap">Search</button>
</div>`;
const typeRow=CONSUMABLE_ONLY_USER
@@ -431,7 +495,7 @@ function _doRender(){
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-right:2px">Type</span>
${[['','All'],['regular','Regular PWO'],['consumable','Consumable']].map(([v,l])=>`<button class="btn" style="padding:5px 11px;font-size:11px;${_viewOrderType===v?'background:rgba(159,90,253,.15);color:var(--purple);border:1px solid rgba(159,90,253,.4)':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vot="${v}">${l}</button>`).join('')}
</div>`:'';
c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready for Issuance<span class="right">${_readyOrders.length}</span></div><div class="card-bd">${searchRow}${typeRow}${gridBody}${renderPager()}</div>`;
c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready for Issuance<span class="right">${_total!=null?_total:_readyOrders.length}</span></div><div class="card-bd">${searchRow}${typeRow}${gridBody}${renderPager()}</div>`;
app.appendChild(c2b);
renderModal();
@@ -440,6 +504,8 @@ function _doRender(){
setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=async function(){_co=this.value;await Promise.all([loadWH(),loadBranches(),loadVarieties()]);loadReadyOrders(true);});
q('#rp-search')&&(q('#rp-search').oninput=function(){_search=this.value;});
q('#rp-datefrom')&&(q('#rp-datefrom').onchange=function(){_dateFrom=this.value;loadReadyOrders(true);});
q('#rp-dateto')&&(q('#rp-dateto').onchange=function(){_dateTo=this.value;loadReadyOrders(true);});
q('#rp-search')&&(q('#rp-search').onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();loadReadyOrders(true);}});
q('#rp-load')&&(q('#rp-load').onclick=()=>loadReadyOrders(true));
document.querySelectorAll('[data-vot]').forEach(b=>b.onclick=function(){_viewOrderType=this.dataset.vot;loadReadyOrders(true);});
@@ -460,7 +526,17 @@ function renderModal(){
bg.classList.add('show');
if(_success){
inner.innerHTML=`<div class="modal-hd"><span>Issue for Production — Posted</span><button class="modal-close" id="iss-modal-x">✕</button></div>
inner.innerHTML=_success.confirmed
?`<div class="modal-hd"><span>Issuance Confirmed</span><button class="modal-close" id="iss-modal-x">✕</button></div>
<div class="modal-bd"><div class="suc-box">
<div class="suc-icon">✓</div>
<div class="suc-title">Recorded in the Portal</div>
<p style="font-size:13px;color:var(--text3)">Production Order #${esc(String(_selectedOrder?.DocumentNumber||''))} — Issuance approval recorded. Nothing new was posted to SAP (it was already fully issued there); Receipt is now unblocked.</p>
<div class="btn-bar" style="justify-content:center;margin-top:12px">
<button class="btn b-new" id="btn-new">+ New Issue</button>
</div>
</div></div>`
:`<div class="modal-hd"><span>Issue for Production — Posted</span><button class="modal-close" id="iss-modal-x">✕</button></div>
<div class="modal-bd"><div class="suc-box">
<div class="suc-icon">✓</div>
<div class="suc-title">Issue Posted!</div>
@@ -498,7 +574,13 @@ function renderModal(){
if(!inactive&&l.batchLoading){
batchHtml='<div class="batch-loading"><span class="sp" style="width:10px;height:10px;border-color:rgba(245,166,35,.25);border-top-color:var(--amber)"></span> Loading batches...</div>';
}else if(!inactive&&l.batches.length){
const batchTotal0=l.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
const batchOk0=Math.abs(batchTotal0-l.issueQty)<=0.001;
batchHtml=`<div class="batch-sec"><div class="batch-title">Batches <span class="batch-auto">(auto-selected)</span></div>
<div class="batch-total-row" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;padding:6px 8px;border-radius:6px;border:1px solid ${batchOk0?'rgba(13,187,138,.35)':'rgba(220,38,38,.4)'};background:${batchOk0?'rgba(13,187,138,.08)':'rgba(220,38,38,.08)'}">
<span style="font-size:11px;font-weight:700;color:${batchOk0?'var(--green)':'#dc2626'}">Total Qty</span>
<span id="batch-total-${i}" data-required="${l.issueQty}" style="font-size:12px;font-weight:700;color:${batchOk0?'var(--green)':'#dc2626'}" title="${batchOk0?'':`Must equal Issue Qty (${l.issueQty}) — this item's available stock in this warehouse may be short`}">${batchTotal0}${batchOk0?'':` / ${l.issueQty} required`}</span>
</div>
${l.batches.map((b,bi)=>{
const sel=l.selectedBatches.find(s=>s.BatchNumber===b.BatchNumber);
const qty=sel?sel.Quantity:0;
@@ -524,6 +606,7 @@ function renderModal(){
<div class="comp-field"><span class="comp-fl">Planned</span><span class="comp-fv">${l.plannedQty}</span></div>
<div class="comp-field"><span class="comp-fl">Issued</span><span class="comp-fv">${l.issuedQty}</span></div>
<div class="comp-field"><span class="comp-fl">Remaining</span><span class="comp-fv" style="color:${l.remainingQty>0?'var(--amber)':'var(--green)'}">${l.remainingQty}</span></div>
${(()=>{const avail=stockFor(l);const short=avail!=null&&avail<l.remainingQty-0.0001;return `<div class="comp-field"><span class="comp-fl">Available</span><span class="comp-fv" style="color:${avail==null?'var(--text3)':short?'var(--red)':'var(--green)'}" title="${avail==null?'Loading…':short?'Short of Remaining qty':'On hand ≥ Remaining qty'}">${avail==null?'…':avail}</span></div>`;})()}
<div class="comp-field"><span class="comp-fl">Issue Qty *</span><input class="comp-inp" data-i="${i}" data-f="issueQty" type="number" min="0" step="0.001" value="${l.issueQty}" ${inactive?'disabled':''}/></div>
<div class="comp-field" style="grid-column:span 2"><span class="comp-fl">Warehouse</span><select class="comp-inp" data-i="${i}" data-f="warehouse" style="padding:2px 4px" ${inactive?'disabled':''}>${whOpts}</select></div>
</div>
@@ -551,10 +634,22 @@ function renderModal(){
// user just has nothing left THEY'RE permitted to act on. Never claim
// "fully issued" when that's true — it's misleading and could make
// someone think the order needs no further attention from anyone.
// All lines fully issued in SAP, but the portal's own approval chain never
// recorded it (stage still 1) — this order was issued directly in SAP B1,
// bypassing the portal entirely. Nothing left to post (re-posting would
// double-issue real stock), so offer a "Confirm" action that just records
// the approval + advances the local stage — see /sap/confirm-issued-outside-portal.
const needsConfirm=allIssued&&!_hasHiddenPending&&d._localStage===1;
const footer=allIssued
?(_hasHiddenPending
?`<div class="alert al-amber" style="margin:0">Nothing left for you to issue in your assigned item group(s) — but this order still has other components pending in a different item group. Someone with access to those groups still needs to issue them.</div>
<div class="btn-bar" style="margin-top:8px"><button class="btn b-cancel" id="btn-clear" style="flex:1">Select Another Order</button></div>`
:needsConfirm
?`<div class="alert al-green" style="margin:0">All components have been fully issued for this production order in SAP — but this wasn't recorded as an Issuance approval in the portal, so Receipt is still blocked. Someone assigned to this order's Issuance step needs to confirm it before Receipt can proceed.</div>
<div class="btn-bar" style="margin-top:8px">
<button class="btn b-issue" id="btn-confirm-issued" ${_submitting?'disabled':''}><span id="sp-confirm" style="display:${_submitting?'inline-block':'none'}" class="sp"></span> Confirm Issued in SAP</button>
<button class="btn b-cancel" id="btn-clear">Select Another Order</button>
</div>`
:`<div class="alert al-green" style="margin:0">All components have been fully issued for this production order.</div>
<div class="btn-bar" style="margin-top:8px"><button class="btn b-cancel" id="btn-clear" style="flex:1">Select Another Order</button></div>`)
:`<div class="btn-bar" style="margin-top:8px">
@@ -562,6 +657,11 @@ function renderModal(){
<button class="btn b-cancel" id="btn-clear">Clear</button>
</div>`;
// Preserve the modal's own scroll position across a rebuild — e.g.
// changing a component's Warehouse re-renders this whole block (batches
// depend on it), and without this the modal would jump back to the top
// every time, losing your place among a long component list.
const savedBdScroll=inner.querySelector('.modal-bd')?.scrollTop||0;
inner.innerHTML=`
<div class="modal-hd">
<span style="font-family:var(--mono);color:var(--teal)">${esc(d.ItemNo)}</span>
@@ -581,16 +681,22 @@ function renderModal(){
${removedStrip}
${toolbar}
${_lines.length?linesHtml:'<div class="empty-msg">No component lines</div>'}
${!allIssued?`<div class="fg" style="margin-top:10px"><label class="fl">Remarks</label><input class="fi" id="inp-remarks" type="text" maxlength="254" placeholder="Optional note for this Issue document" value="${esc(_remarks)}"/></div>`:''}
${footer}
</div>`;
const newBd=inner.querySelector('.modal-bd');
if(newBd)newBd.scrollTop=savedBdScroll;
setTimeout(()=>{
q('#iss-modal-x').onclick=closeIssModal;
wireLineInputs();
wireBatchInputs();
wireSelectionControls();
q('#btn-issue')&&(q('#btn-issue').onclick=doIssue);
q('#btn-confirm-issued')&&(q('#btn-confirm-issued').onclick=doConfirmIssued);
q('#btn-clear')&&(q('#btn-clear').onclick=closeIssModal);
q('#inp-remarks')&&(q('#inp-remarks').oninput=function(){_remarks=this.value;});
},0);
}
@@ -611,7 +717,7 @@ function wireSelectionControls(){
l._savedQty=l.issueQty;
l.issueQty=0;
}
render();
renderModal();
};
});
document.querySelectorAll('[data-remove-i]').forEach(el=>{
@@ -619,21 +725,21 @@ function wireSelectionControls(){
const i=parseInt(this.dataset.removeI);
const removed=_lines.splice(i,1)[0];
if(removed)_removedLines.push(removed);
render();
renderModal();
};
});
q('#btn-select-all')&&(q('#btn-select-all').onclick=()=>{
_lines.forEach(l=>{ if(l.remainingQty>0&&!l.selected){ l.selected=true; l.issueQty=l._savedQty!=null?l._savedQty:l.remainingQty; l._savedQty=null; if(l.batches.length)autoSelectBatches(l); } });
render();
renderModal();
});
q('#btn-select-none')&&(q('#btn-select-none').onclick=()=>{
_lines.forEach(l=>{ if(l.remainingQty>0&&l.selected){ l._savedQty=l.issueQty; l.selected=false; l.issueQty=0; } });
render();
renderModal();
});
q('#btn-restore-removed')&&(q('#btn-restore-removed').onclick=()=>{
_lines.push(..._removedLines);
_removedLines=[];
render();
renderModal();
});
}
@@ -645,11 +751,15 @@ function wireLineInputs(){
_lines[i].warehouse=this.value;
// Reload batches for new warehouse
_lines[i].batches=[];_lines[i].selectedBatches=[];
loadBatches(_lines[i]).then(()=>render());
// renderModal() only — NOT the full page render(), which rebuilds
// the "Orders Ready" grid too and resets window scroll on top of
// the modal's own scroll, doubling the jump-to-top effect.
loadBatches(_lines[i]).then(()=>renderModal());
loadStockForLines();
}else if(f==='issueQty'){
_lines[i].issueQty=parseFloat(this.value)||0;
if(_lines[i].batches.length) autoSelectBatches(_lines[i]);
render();
renderModal();
}else if(f==='costingCode'){
_lines[i].costingCode=this.value;return;
}
@@ -670,6 +780,27 @@ function wireBatchInputs(){
else if(qty>0) line.selectedBatches.push({BatchNumber:bn,Quantity:qty});
// Remove zeros
line.selectedBatches=line.selectedBatches.filter(b=>b.Quantity>0);
// Patch the Total Qty badge directly instead of a full render() — same
// convention as every other live input in this app (avoids losing
// focus/scroll position mid-edit). See the sibling batch-total-row
// markup this mirrors.
const badge=document.getElementById(`batch-total-${li}`);
if(badge){
const total=line.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
const required=parseFloat(badge.dataset.required)||0;
const ok=Math.abs(total-required)<=0.001;
const color=ok?'var(--green)':'#dc2626';
badge.textContent=ok?String(total):`${total} / ${required} required`;
badge.style.color=color;
badge.title=ok?'':`Must equal Issue Qty (${required}) — this item's available stock in this warehouse may be short`;
const row=badge.closest('.batch-total-row');
if(row){
row.style.borderColor=ok?'rgba(13,187,138,.35)':'rgba(220,38,38,.4)';
row.style.background=ok?'rgba(13,187,138,.08)':'rgba(220,38,38,.08)';
const label=row.querySelector('span');
if(label) label.style.color=color;
}
}
};
});
}
@@ -723,6 +854,6 @@ window.addEventListener('hashchange',()=>{
}
});
</script>
<script src="/sidebar.js?v=25"></script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>