From a13fa92abf45275c23b0dbb3931bee90e979c209 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sat, 10 Oct 2026 11:55:50 +0530 Subject: [PATCH 1/4] Refresh visible sales stock and resume saved products with targeted reads --- .../script/js/modules/js/sale-extensions.js | 76 ++++++++++--------- tests/sale-extension-stock-refresh.test.js | 49 ++++++++++++ 2 files changed, 91 insertions(+), 34 deletions(-) create mode 100644 tests/sale-extension-stock-refresh.test.js diff --git a/frontend/static/script/js/modules/js/sale-extensions.js b/frontend/static/script/js/modules/js/sale-extensions.js index a8dc05db4..8a846a5f9 100644 --- a/frontend/static/script/js/modules/js/sale-extensions.js +++ b/frontend/static/script/js/modules/js/sale-extensions.js @@ -71,39 +71,48 @@ var escaped=message.slice(0,300).replace(/&/g,'&').replace(//g,'>'); PosnicPro.alert('success',escaped,3500); } - function refreshStock(done) { - if(!PosnicPro.sales.loadBillingCatalogue){done?.(false);return;} + // Read only the visible/saved products; never reload the whole catalogue. + async function freshProducts(ids) { + var unique = Array.from(new Set(ids)), result = new Map(), cursor = 0; + await Promise.all(Array.from({length:Math.min(6,unique.length)}, async function () { + while(cursor < unique.length) { + var id=unique[cursor++]; + var item=await new Promise(function(resolve) { + PosnicPro.get('items/'+id,function(response){resolve(response?.type==='success'?response.data:null);},function(){resolve(null);}); + }); + result.set(id,item); + } + })); + return result; + } + var stockRefreshSequence=0; + async function refreshStock(done) { + var sequence=++stockRefreshSequence, branch=String(PosnicPro.local?.get('branch_id_set')||''); PosnicPro.sales.itemCache?.clear(); - var branch=String(PosnicPro.local?.get('branch_id_set')||''); - PosnicPro.sales.loadBillingCatalogue(function(response){ - if(branch!==String(PosnicPro.local?.get('branch_id_set')||'') || !active()){done?.(false);return;} - if(response?.type!=='success'){done?.(false);PosnicPro.alert('error', PosnicPro.i18n.t('lang_stock_display_could_not_refresh_refresh_sa', 'Stock display could not refresh. Refresh Sales before checking availability.'));return;} - var items=new Map(),families={},pending=1,ok=true; - function settled(){if(--pending===0)done?.(ok);} - response.data.forEach(function(item){items.set(String(item.id||item.item_id),item);if(item.variant_group_id)(families[item.variant_group_id]||(families[item.variant_group_id]=[])).push(item);}); - if(PosnicPro.sales.itemsMenu)PosnicPro.sales.itemsMenu._families=families; - document.querySelectorAll('#sales_new .wsk-cp').forEach(function(tile){ - var rows=tile.dataset.variantGroup?families[tile.dataset.variantGroup]:[items.get(tile.id)]; - if(!rows || !rows[0]){ - var missingStock=tile.querySelector('.wsk-cp-stock'); - if(missingStock && /^[a-f0-9]{24}$/i.test(tile.id) && PosnicPro.get){ - pending++;missingStock.textContent='Updating stock…'; - function failed(){ok=false;if(tile.isConnected)missingStock.textContent='Stock unavailable';settled();} - PosnicPro.get('items/'+tile.id,function(response){ - if(branch!==String(PosnicPro.local?.get('branch_id_set')||'') || !tile.isConnected){ok=false;settled();return;} - if(response?.type!=='success'){failed();return;} - var quantity=Number(response.data?.available_quantity); - missingStock.textContent=Number.isFinite(quantity)?quantity+' in stock':'Stock unavailable'; - if(!Number.isFinite(quantity))ok=false;settled(); - },failed); - } - return; - } - var tracked=rows.filter(function(r){return r.track_inventory===true || r.track_inventory==='true';}),stock=tile.querySelector('.wsk-cp-stock'); - if(stock)stock.textContent=tracked.length?tracked.reduce(function(sum,r){return sum+(Number(r.available_quantity)||0);},0)+' in stock':''; + var families=PosnicPro.sales.itemsMenu?._families || {}; + var targets=Array.from(document.querySelectorAll('#sales_new .wsk-cp')).map(function(tile){ + var rows=tile.dataset.variantGroup?families[tile.dataset.variantGroup]||[]:[]; + return {tile:tile,stock:tile.querySelector('.wsk-cp-stock'),ids:tile.dataset.variantGroup?rows.map(function(r){return String(r.id||r.item_id);}):[tile.id]}; + }).filter(function(t){return t.stock && t.ids.length && t.ids.every(function(id){return /^[a-f0-9]{24}$/i.test(id);});}); + targets.forEach(function(t){t.stock.textContent='Updating stock...';}); + var ok=true; + try { + var items=await freshProducts(targets.flatMap(function(t){return t.ids;})); + if(sequence!==stockRefreshSequence || branch!==String(PosnicPro.local?.get('branch_id_set')||'') || !active()){done?.(false);return;} + targets.forEach(function(t){ + if(!t.tile.isConnected)return; + var rows=t.ids.map(function(id){return items.get(id);}); + if(rows.some(function(r){return !r || !Number.isFinite(Number(r.available_quantity));})) {t.stock.textContent='Stock unavailable';ok=false;return;} + var tracked=rows.filter(function(r){return r.track_inventory===true || r.track_inventory==='true';}); + t.stock.textContent=tracked.length?tracked.reduce(function(sum,r){return sum+Number(r.available_quantity);},0)+' in stock':''; + if(t.tile.dataset.variantGroup) families[t.tile.dataset.variantGroup]=rows.map(function(r,i){return Object.assign({},r,{id:t.ids[i]});}); }); - settled(); - },true); + } catch(error) { + ok=false; + if(sequence===stockRefreshSequence)targets.forEach(function(t){if(t.tile.isConnected)t.stock.textContent='Stock unavailable';}); + } + if(!ok)PosnicPro.alert('error','Stock display could not refresh. Try Refresh stock again.'); + done?.(ok); } function confirmClear() { if(document.getElementById('sale-clear-dialog') || dialog || !cart().length)return; @@ -200,10 +209,9 @@ if(input.context && JSON.stringify(input.context).length>1024)throw Error('Invalid sale context.'); restoring=true; try{ - var response=lines.length?await new Promise(function(resolve){PosnicPro.sales.loadBillingCatalogue(resolve,true);}):{type:'success',data:[]}; - if(response.type!=='success')throw Error('Products could not be loaded. Try again.'); + var products=await freshProducts(lines.map(function(line){return line.productId;})); if(branch!==String(PosnicPro.local.get('branch_id_set')||'') || (lines.length && cart().length) || selected?.id!==id)throw Error('The shop or current sale changed. Try again.'); - var items=lines.map(function(line){var item=response.data.find(function(p){return String(p.id||p.item_id)===line.productId;});if(!item)throw Error('A saved product is unavailable. Review the basket before resuming.');return Object.assign({},item,{available_quantity:Number(item.available_quantity)||0,id:line.productId,item_quantity:line.quantityMilli/1000,selling_price:line.sellingPrice,mrp_price:line.sellingPrice,discount_amount:0,discount_percentage:0,_modifiersResolved:true,_priceAsked:true});}); + var items=lines.map(function(line){var item=products.get(line.productId);if(!item)throw Error('A saved product is unavailable. Review the basket before resuming.');return Object.assign({},item,{available_quantity:Number(item.available_quantity)||0,id:line.productId,item_quantity:line.quantityMilli/1000,selling_price:line.sellingPrice,mrp_price:line.sellingPrice,discount_amount:0,discount_percentage:0,_modifiersResolved:true,_priceAsked:true});}); if(!/^#\/?sales\/new(?:$|\?)/.test(location.hash)){ location.hash='#/sales/new'; await new Promise(function(resolve,reject){var until=Date.now()+8000;function ready(){var scanner=document.getElementById('sales_new_item_name');if(scanner?.getClientRects().length && PosnicPro.sales.SaleAction==='add')return resolve();if(Date.now()>until)return reject(Error('Open Sales and try resuming again.'));setTimeout(ready,50);}setTimeout(ready,50);}); diff --git a/tests/sale-extension-stock-refresh.test.js b/tests/sale-extension-stock-refresh.test.js new file mode 100644 index 000000000..8af084570 --- /dev/null +++ b/tests/sale-extension-stock-refresh.test.js @@ -0,0 +1,49 @@ +'use strict'; +const test=require('node:test'); +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const {JSDOM}=require('jsdom'); +const source=fs.readFileSync(require.resolve('../frontend/static/script/js/modules/js/sale-extensions.js'),'utf8'); +const id=n=>String(n).padStart(24,'0'); +function setup(count=1){ + const dom=new JSDOM('
'+Array.from({length:count},(_,i)=>`
23 in stock
`).join('')+'
'); + let branch='shop',clears=0;const requests=[],alerts=[]; + const PosnicPro={local:{get:()=>branch},sales:{itemCache:{clear(){clears++;}},itemsMenu:{_families:{}}},get(url,ok,fail){requests.push({url,ok,fail});},alert(...args){alerts.push(args);}}; + const code=source.slice(source.indexOf(' async function freshProducts'),source.indexOf(' function confirmClear')); + const api=new Function('PosnicPro','document','active',code+'return {refreshStock,freshProducts};')(PosnicPro,dom.window.document,()=>true); + return {...api,dom,PosnicPro,requests,alerts,setBranch:b=>branch=b,get clears(){return clears;},stock:()=>dom.window.document.querySelector('.wsk-cp-stock').textContent}; +} +test('refresh fetches visible stock without the removed catalogue loader',async()=>{ + const s=setup();const p=s.refreshStock();assert.equal(s.clears,1);assert.equal(s.stock(),'Updating stock...'); + assert.equal(s.requests[0].url,'items/'+id(1));s.requests[0].ok({type:'success',data:{available_quantity:16,track_inventory:true}});await p; + assert.equal(s.stock(),'16 in stock');assert.equal(s.requests.length,1); +}); +test('new refresh wins over an older response',async()=>{ + const s=setup();const first=s.refreshStock(),second=s.refreshStock(); + s.requests[1].ok({type:'success',data:{available_quantity:15,track_inventory:true}});await second; + s.requests[0].ok({type:'success',data:{available_quantity:23,track_inventory:true}});await first;assert.equal(s.stock(),'15 in stock'); +}); +test('refresh failure is visible and never reports success',async()=>{ + const s=setup();let result;const p=s.refreshStock(ok=>result=ok);s.requests[0].fail();await p; + assert.equal(result,false);assert.equal(s.stock(),'Stock unavailable');assert.equal(s.alerts.length,1); +}); +test('branch change and removed tiles cannot receive stale updates',async()=>{ + const s=setup();const p=s.refreshStock();s.setBranch('other');s.requests[0].ok({type:'success',data:{available_quantity:16,track_inventory:true}});await p; + assert.notEqual(s.stock(),'16 in stock'); +}); +test('fresh reads deduplicate ids, bound concurrency, and reach saved off-page products',async()=>{ + const s=setup(0);const p=s.freshProducts([...Array.from({length:8},(_,i)=>id(i+1)),id(8)]); + assert.equal(s.requests.length,6); + for(let i=0;i<8;i++){s.requests[i].ok({type:'success',data:{id:id(i+1),available_quantity:i}});await new Promise(setImmediate);} + assert.equal((await p).size,8);assert.equal(s.requests.length,8); +}); +test('variant family stock and picker rows are both refreshed',async()=>{ + const s=setup();const tile=s.dom.window.document.querySelector('.wsk-cp');tile.dataset.variantGroup='family'; + s.PosnicPro.sales.itemsMenu._families.family=[{id:id(1)},{id:id(2)}]; + const p=s.refreshStock();s.requests[0].ok({type:'success',data:{available_quantity:2,track_inventory:true}});s.requests[1].ok({type:'success',data:{available_quantity:3,track_inventory:true}});await p; + assert.equal(s.stock(),'5 in stock');assert.equal(s.PosnicPro.sales.itemsMenu._families.family[1].available_quantity,3); +}); +test('saved basket restore no longer depends on full catalogue loading',()=>{ + assert.ok(!source.includes('loadBillingCatalogue')); + assert.ok(source.includes('var products=await freshProducts(lines.map')); +}); From e67555cb0ac681e21ef9af11cdb2a009beb1644d Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sat, 10 Oct 2026 01:32:12 +0530 Subject: [PATCH 2/4] Hide unavailable inventory pagination controls --- frontend/modules/expiryReport.html | 2 +- .../static/script/js/modules/js/item-settings-report.js | 6 ++++-- frontend/static/script/js/modules/js/lowstock.js | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/modules/expiryReport.html b/frontend/modules/expiryReport.html index cbdf27f6f..a18e72194 100644 --- a/frontend/modules/expiryReport.html +++ b/frontend/modules/expiryReport.html @@ -8,5 +8,5 @@

Item NameSKUExpiry DateQuantityStatus:
-
+
diff --git a/frontend/static/script/js/modules/js/item-settings-report.js b/frontend/static/script/js/modules/js/item-settings-report.js index c7344ab0d..62c140eae 100644 --- a/frontend/static/script/js/modules/js/item-settings-report.js +++ b/frontend/static/script/js/modules/js/item-settings-report.js @@ -34,6 +34,7 @@ var days = Number($('#expiry_report_days').val()); if (!Number.isInteger(days) || days < 0 || days > 365 || $('#expiry_report_days').val() === '') { failed(); return; } $('#expiry_report_summary').text(PosnicPro.i18n.t('lang_loading', 'Loading…')); + $('#expiry_report_paging').addClass('d-none'); $('#expiry_report_previous,#expiry_report_next').prop('disabled', true); $('#expiry_report_rows').empty(); PosnicPro.get({url:'items/expiryReport', data:{ page:next, days:days, search:$('#expiry_report_search').val(), status:$('#expiry_report_status').val(), sort:$('#expiry_report_sort').val() }}, function (r) { @@ -47,8 +48,9 @@ }); if (!d.rows.length) $('').append($('').text(PosnicPro.i18n.t('lang_no_products_expiry_review', 'No products to review.'))).appendTo('#expiry_report_rows'); $('#expiry_report_page').text(page + ' / ' + Math.max(1, Math.ceil(d.total / 25))); - $('#expiry_report_previous').prop('disabled', page <= 1); - $('#expiry_report_next').prop('disabled', page * 25 >= d.total); + $('#expiry_report_paging').toggleClass('d-none', d.total <= 25); + $('#expiry_report_previous').toggle(page > 1).prop('disabled', page <= 1); + $('#expiry_report_next').toggle(page * 25 < d.total).prop('disabled', page * 25 >= d.total); }, function () { if (ticket === request) { $('#expiry_report_summary').text(''); failed(); } }); } }; diff --git a/frontend/static/script/js/modules/js/lowstock.js b/frontend/static/script/js/modules/js/lowstock.js index 6cda7a1f2..58f0bdd17 100644 --- a/frontend/static/script/js/modules/js/lowstock.js +++ b/frontend/static/script/js/modules/js/lowstock.js @@ -168,7 +168,7 @@ PosnicPro.lowstockitems = { }; var html = ''; if (pages > 1) { - html += btn(p - 1, '«', p <= 1); + if (p > 1) { html += btn(p - 1, '«', false); } var end = Math.min(pages, Math.max(1, p - 2) + 4); var start = Math.max(1, end - 4); for (var n = start; n <= end; n++) { @@ -176,7 +176,7 @@ PosnicPro.lowstockitems = { } } html += '' + label + ''; - if (pages > 1) { html += btn(p + 1, '»', p >= pages); } + if (p < pages) { html += btn(p + 1, '»', false); } $('#lowstockitems_list_paging').html(html); }, goPage: function (n) { From dc8bda298d59470c132b4c0fccffe0c1147589a2 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sat, 10 Oct 2026 01:34:00 +0530 Subject: [PATCH 3/4] Hide single-page footer despite theme flex override --- frontend/modules/expiryReport.html | 2 +- frontend/static/script/js/modules/js/item-settings-report.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/modules/expiryReport.html b/frontend/modules/expiryReport.html index a18e72194..07b53660c 100644 --- a/frontend/modules/expiryReport.html +++ b/frontend/modules/expiryReport.html @@ -8,5 +8,5 @@

Item NameSKUExpiry DateQuantityStatus:
-
+
diff --git a/frontend/static/script/js/modules/js/item-settings-report.js b/frontend/static/script/js/modules/js/item-settings-report.js index 62c140eae..f0734f874 100644 --- a/frontend/static/script/js/modules/js/item-settings-report.js +++ b/frontend/static/script/js/modules/js/item-settings-report.js @@ -34,7 +34,7 @@ var days = Number($('#expiry_report_days').val()); if (!Number.isInteger(days) || days < 0 || days > 365 || $('#expiry_report_days').val() === '') { failed(); return; } $('#expiry_report_summary').text(PosnicPro.i18n.t('lang_loading', 'Loading…')); - $('#expiry_report_paging').addClass('d-none'); + $('#expiry_report_paging').removeClass('d-flex').addClass('d-none'); $('#expiry_report_previous,#expiry_report_next').prop('disabled', true); $('#expiry_report_rows').empty(); PosnicPro.get({url:'items/expiryReport', data:{ page:next, days:days, search:$('#expiry_report_search').val(), status:$('#expiry_report_status').val(), sort:$('#expiry_report_sort').val() }}, function (r) { @@ -48,7 +48,7 @@ }); if (!d.rows.length) $('').append($('').text(PosnicPro.i18n.t('lang_no_products_expiry_review', 'No products to review.'))).appendTo('#expiry_report_rows'); $('#expiry_report_page').text(page + ' / ' + Math.max(1, Math.ceil(d.total / 25))); - $('#expiry_report_paging').toggleClass('d-none', d.total <= 25); + $('#expiry_report_paging').toggleClass('d-flex', d.total > 25).toggleClass('d-none', d.total <= 25); $('#expiry_report_previous').toggle(page > 1).prop('disabled', page <= 1); $('#expiry_report_next').toggle(page * 25 < d.total).prop('disabled', page * 25 >= d.total); }, function () { if (ticket === request) { $('#expiry_report_summary').text(''); failed(); } }); From 05a98ce38c6b7706e14c4df9e65161fad873d59d Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sat, 10 Oct 2026 12:09:49 +0530 Subject: [PATCH 4/4] Preserve translated stock refresh errors --- frontend/static/script/js/modules/js/sale-extensions.js | 3 +-- tests/sale-extension-stock-refresh.test.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/static/script/js/modules/js/sale-extensions.js b/frontend/static/script/js/modules/js/sale-extensions.js index 8a846a5f9..f526c32da 100644 --- a/frontend/static/script/js/modules/js/sale-extensions.js +++ b/frontend/static/script/js/modules/js/sale-extensions.js @@ -111,7 +111,7 @@ ok=false; if(sequence===stockRefreshSequence)targets.forEach(function(t){if(t.tile.isConnected)t.stock.textContent='Stock unavailable';}); } - if(!ok)PosnicPro.alert('error','Stock display could not refresh. Try Refresh stock again.'); + if(!ok)PosnicPro.alert('error',PosnicPro.i18n.t('lang_stock_display_could_not_refresh_refresh_sa','Stock display could not refresh. Refresh Sales before checking availability.')); done?.(ok); } function confirmClear() { @@ -234,4 +234,3 @@ })(); - diff --git a/tests/sale-extension-stock-refresh.test.js b/tests/sale-extension-stock-refresh.test.js index 8af084570..49922fda7 100644 --- a/tests/sale-extension-stock-refresh.test.js +++ b/tests/sale-extension-stock-refresh.test.js @@ -8,7 +8,7 @@ const id=n=>String(n).padStart(24,'0'); function setup(count=1){ const dom=new JSDOM('
'+Array.from({length:count},(_,i)=>`
23 in stock
`).join('')+'
'); let branch='shop',clears=0;const requests=[],alerts=[]; - const PosnicPro={local:{get:()=>branch},sales:{itemCache:{clear(){clears++;}},itemsMenu:{_families:{}}},get(url,ok,fail){requests.push({url,ok,fail});},alert(...args){alerts.push(args);}}; + const PosnicPro={i18n:{t:(_key,text)=>text},local:{get:()=>branch},sales:{itemCache:{clear(){clears++;}},itemsMenu:{_families:{}}},get(url,ok,fail){requests.push({url,ok,fail});},alert(...args){alerts.push(args);}}; const code=source.slice(source.indexOf(' async function freshProducts'),source.indexOf(' function confirmClear')); const api=new Function('PosnicPro','document','active',code+'return {refreshStock,freshProducts};')(PosnicPro,dom.window.document,()=>true); return {...api,dom,PosnicPro,requests,alerts,setBranch:b=>branch=b,get clears(){return clears;},stock:()=>dom.window.document.querySelector('.wsk-cp-stock').textContent};