Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/modules/expiryReport.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
<div class="col-md-3 mb-3"><label for="expiry_report_sort"><lang class="lang_sort">Sort</lang></label><select id="expiry_report_sort" class="form-control"><option value="asc" data-t="lang_earliest_expiry_first">Earliest expiry first</option><option value="desc" data-t="lang_latest_expiry_first">Latest expiry first</option></select></div>
<div class="col-md-2 mb-3"><button class="btn btn-primary" type="submit"><lang class="lang_refresh">Refresh</lang></button></div></form>
<p id="expiry_report_summary" role="status"></p><div class="table-responsive"><table class="table"><thead><tr><th><lang class="lang_itemname_title">Item Name</lang></th><th><lang class="lang_sku">SKU</lang></th><th><lang class="lang_expiry_date">Expiry Date</lang></th><th><lang class="lang_quantity">Quantity</lang></th><th><lang class="lang_status">Status:</lang></th></tr></thead><tbody id="expiry_report_rows"></tbody></table></div>
<div class="d-flex justify-content-between align-items-center"><button type="button" class="btn btn-outline-primary" id="expiry_report_previous"><lang class="lang_previous">Previous</lang></button><span id="expiry_report_page"></span><button type="button" class="btn btn-outline-primary" id="expiry_report_next"><lang class="lang_next">Next</lang></button></div>
<div id="expiry_report_paging" class=" justify-content-between align-items-center d-none"><button type="button" class="btn btn-outline-primary" id="expiry_report_previous"><lang class="lang_previous">Previous</lang></button><span id="expiry_report_page" class="mx-auto"></span><button type="button" class="btn btn-outline-primary" id="expiry_report_next"><lang class="lang_next">Next</lang></button></div>
</div></div></div></div>
6 changes: 4 additions & 2 deletions frontend/static/script/js/modules/js/item-settings-report.js
Original file line number Diff line number Diff line change
Expand Up @@ -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').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) {
Expand All @@ -47,8 +48,9 @@
});
if (!d.rows.length) $('<tr>').append($('<td colspan="5">').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-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(); } });
}
};
Expand Down
4 changes: 2 additions & 2 deletions frontend/static/script/js/modules/js/lowstock.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,15 +168,15 @@ PosnicPro.lowstockitems = {
};
var html = '';
if (pages > 1) {
html += btn(p - 1, '&laquo;', p <= 1);
if (p > 1) { html += btn(p - 1, '&laquo;', 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++) {
html += '<span class="q-pg-num">' + btn(n, n, false, n === p ? 'btn-primary-rgba' : 'btn-secondary-rgba') + '</span>';
}
}
html += '<span class="q-pg-count">' + label + '</span>';
if (pages > 1) { html += btn(p + 1, '&raquo;', p >= pages); }
if (p < pages) { html += btn(p + 1, '&raquo;', false); }
$('#lowstockitems_list_paging').html(html);
},
goPage: function (n) {
Expand Down
77 changes: 42 additions & 35 deletions frontend/static/script/js/modules/js/sale-extensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,39 +71,48 @@
var escaped=message.slice(0,300).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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',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() {
if(document.getElementById('sale-clear-dialog') || dialog || !cart().length)return;
Expand Down Expand Up @@ -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);});
Expand All @@ -226,4 +234,3 @@
})();



49 changes: 49 additions & 0 deletions tests/sale-extension-stock-refresh.test.js
Original file line number Diff line number Diff line change
@@ -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('<div id="sales_new">'+Array.from({length:count},(_,i)=>`<div class="wsk-cp" id="${id(i+1)}"><span class="wsk-cp-stock">23 in stock</span></div>`).join('')+'</div>');
let branch='shop',clears=0;const requests=[],alerts=[];
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};
}
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'));
});
Loading