From 69929d14f24a0c62b582dd26fa921bd005da3ad5 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sat, 17 Oct 2026 15:15:10 +0530 Subject: [PATCH 1/7] Fix SKU values in detailed sales report and export --- .../script/js/modules/js/report_sales.js | 4 +-- tests/report-sales-sku.test.js | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tests/report-sales-sku.test.js diff --git a/frontend/static/script/js/modules/js/report_sales.js b/frontend/static/script/js/modules/js/report_sales.js index 15fb556ca..846ac6e5e 100644 --- a/frontend/static/script/js/modules/js/report_sales.js +++ b/frontend/static/script/js/modules/js/report_sales.js @@ -159,7 +159,7 @@ PosnicPro.salereport = { tax_value = val.tax + '% (' + (updatePrice * (val.tax / 100)).toFixed(2) + ')'; } - var lineitem = '' + val.item_name + '' + val.barcode_id + '' + currency + ' ' + price + '' + val.item_quantity + '' + discountDisplay + '' + tax_value + '' + currency + ' ' + val.total_amount + ''; + var lineitem = '' + val.item_name + '' + $('').text(val.item_sku == null ? '' : val.item_sku).html() + '' + currency + ' ' + price + '' + val.item_quantity + '' + discountDisplay + '' + tax_value + '' + currency + ' ' + val.total_amount + ''; $("#" + row._id + 'reportaction').children('tbody').append(lineitem); } $('span.number').number(true, 2); @@ -177,7 +177,7 @@ PosnicPro.salereport = { $(data[key].items).each(function (key, value) { var name = value.item_name; - var sku = value.barcode_id; + var sku = value.item_sku == null ? '' : value.item_sku; var quantity = value.item_quantity; var discount = (value.item_discount > 0) ? value.item_discount : value.item_discount_percentage; var discountSign = (value.item_discount > 0) ? currency : '%'; diff --git a/tests/report-sales-sku.test.js b/tests/report-sales-sku.test.js new file mode 100644 index 000000000..954e54816 --- /dev/null +++ b/tests/report-sales-sku.test.js @@ -0,0 +1,36 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); + +for (const [label, fields, expected] of [ + ['SKU without barcode', {item_sku:'CANDLE-24'}, 'CANDLE-24'], + ['different SKU and barcode', {item_sku:'PEN-10',barcode_id:'123456'}, 'PEN-10'], + ['missing SKU', {barcode_id:'123456'}, ''], + ['null SKU', {item_sku:null}, ''], + ['empty SKU', {item_sku:''}, ''], + ['leading zeros', {item_sku:'000123'}, '000123'], + ['SKU containing markup', {item_sku:'SKU'}, 'SKU'], +]) { + test(`detailed sales display and CSV: ${label}`, () => { + const w = new JSDOM('
', {runScripts:'outside-only'}).window; + const $ = require('jquery')(w); w.$ = $; + $.fn.number = function () { return this; }; + const requests = []; let exported; + w.PosnicPro = {get:(params,cb)=>requests.push(cb), paging:()=>{}, convertDate:v=>v,local:{get:()=> '£'}, JSONToCSVConvertor:rows=>exported=rows}; + const src = fs.readFileSync(path.join(__dirname,'../frontend/static/script/js/modules/js/report_sales.js'),'utf8'); + w.eval(src.slice(0,src.indexOf('$(document).ready'))); + const response = {type:'success',data:{total:1,total_pages:1,current_page:1,per_page:5,list:[{_id:'sale1',sales_id:'S1',string_date:'2026/10/17',customer_name:'Walk-in',customer_phone:'',number_of_items:1,items_total:35,items:[{item_name:'Candles',item_price:35,item_quantity:1,total_amount:35,...fields}]}]}}; + w.PosnicPro.salereport.salereportTable(); requests.shift()(response); + const cell = $('.export-item-barcode-id'); + assert.equal(cell.text(),expected); + assert.equal(cell.children().length,0); + w.PosnicPro.salereport.salereportTable('salereportexport'); + w.PosnicPro.salereport.salereportTable=()=>{}; + w.PosnicPro.instantreport.instantreportTable=()=>{}; + requests.shift()(response); + assert.equal(exported[0].SKU,expected); + w.close(); + }); +} From 9e72fdb6cac9a8ca840163ebf42244767be90aac Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sun, 18 Oct 2026 10:14:05 +0530 Subject: [PATCH 2/7] Print shelf labels directly with exact paper sizes and smaller batches --- docs/shelf-label-printing.md | 31 +++++++ frontend/modules/items.html | 4 +- .../static/script/js/core/shelf-labels.js | 75 ++++++++++++++--- src/hardware-manager.js | 29 +++++-- tests/shelf-label-desktop.test.js | 45 ++++++++++ tests/shelf-labels-dom.test.js | 83 ++++++++++++++++++- tests/tools/print-one-shelf-label.cjs | 43 ++++++++++ tests/tools/print-receipt-mode.cjs | 27 ++++++ 8 files changed, 317 insertions(+), 20 deletions(-) create mode 100644 docs/shelf-label-printing.md create mode 100644 tests/shelf-label-desktop.test.js create mode 100644 tests/tools/print-one-shelf-label.cjs create mode 100644 tests/tools/print-receipt-mode.cjs diff --git a/docs/shelf-label-printing.md b/docs/shelf-label-printing.md new file mode 100644 index 000000000..2ec729645 --- /dev/null +++ b/docs/shelf-label-printing.md @@ -0,0 +1,31 @@ +# Shelf-label desktop printing + +The shelf-label wizard now selects a local printer before submission. A missing selection is an error; it never borrows the receipt or kitchen printer. Printer choices are remembered separately for A4 and roll paper on this computer. + +- Initial batch: 5 labels. A single test always submits one label. The 2,000-label maximum remains available only when entered by the operator. +- A4: render the same label HTML to an A4 PDF, then submit at actual size to the selected printer. Do not open the browser print dialog on desktop. +- 80 mm receipt printers: explicitly choose the ESC/POS method. Labels may be up to 72 mm wide, matching the 576-dot printable area. Each label uses the existing raster renderer and durable raw queue, with one cut per label. A batch stops on the first failed/uncertain submission and reports the number previously sent. +- Other label printers: driver printing receives explicit micron dimensions, no margins, 100% scale and no additional landscape rotation. Exact-size jobs never retry using a driver's default paper size. +- Browser-only installations retain the system print dialog because they do not have the desktop printer bridge. + +## Local data and synchronization + +Product names, prices and barcodes still come from the existing tenant-scoped Items API, including label pricing. This change does not modify business data or its sync lanes. Printer names, print methods and layout preferences remain local to this installation/browser profile: another till may have different hardware. Print jobs are never synchronized or replayed on another till. No Gateway or sync-agent rollout is required. + +## Validation (18 October 2026) + +37 targeted shelf-label and desktop-PDF tests passed. Real POS-80C output was submitted using the existing raw queue; the operator confirmed correct horizontal orientation and improved readability on the larger sample. The dotted cutting guide was then removed for the thermal sample. Barcode scanning has not yet been physically verified. + +The operator subsequently confirmed Brother DCP-T710W output with a photograph of all five sample labels, arranged in three columns. The test was configured for A4 with 63 x 33 mm labels, 5 mm sheet margins and 2 mm gaps, allowing 24 positions (three columns by eight rows). Only five positions were requested. The photograph confirms readable output and cutting guides, but does not establish measured dimensions or barcode scan reliability. Both an Electron HTML job and an actual-size PDF job had been submitted, so the photograph alone does not identify which submission produced this sheet. A separate direct GDI connection-test page was not specifically confirmed. + +The frontend production build also passed, with existing Sass deprecation warnings. The installed application and final packaged installer still require verification with these source changes. + +These are source changes, not yet installed on the client's machine or included in a released handover. + +## Follow-up design and receipt modes + +The full 24-label A4 sheet was physically confirmed by the operator. Product names and prices are larger; roll content starts 1 mm from the label top instead of being vertically centred. The thermal preview shows the 80 mm paper around the 72 mm print head. Feed/cutter margins depend on the printer and are not promised as an exact screen match. Existing saved design preferences are preserved. + +The three existing receipt modes remain available under Settings > Printers & paper: Exact design (raster), Fast receipt (text with Exact fallback for unsupported content), and Windows driver. Explicit physical tests were submitted using the actual receipt designer and HardwareManager. Exact and Fast returned success; Windows driver reported no matching spooler job and was not retried. The operator subsequently confirmed all three physical samples. The driver spooler check was a false negative; no duplicate was submitted. + +126 targeted tests passed, including receipt modes, printer settings, receipt designer, shelf labels and PDF printing. diff --git a/frontend/modules/items.html b/frontend/modules/items.html index 92099786f..d329f3365 100644 --- a/frontend/modules/items.html +++ b/frontend/modules/items.html @@ -220,8 +220,8 @@

It

Sample label

+

Choose the printer in the browser print dialog. Use matching paper, 100% scale, no browser margins and no headers or footers.

+

Start with one test label. The batch starts at 5; increase it when you are ready (maximum 2,000).

diff --git a/frontend/static/script/js/core/shelf-labels.js b/frontend/static/script/js/core/shelf-labels.js index 3f811f7e2..df97fe1c2 100644 --- a/frontend/static/script/js/core/shelf-labels.js +++ b/frontend/static/script/js/core/shelf-labels.js @@ -50,10 +50,10 @@ 'padding:' + (s.paper === 'a4' ? s.margin : 0) + 'mm;display:grid;grid-template-columns:repeat(' + g.columns + ',' + s.width + 'mm);' + 'grid-template-rows:repeat(' + g.rows + ',' + s.height + 'mm);gap:' + (s.paper === 'a4' ? s.gap : 0) + 'mm;break-after:page;overflow:hidden}' + '.sl-sheet:last-child{break-after:auto}.sl-label{width:' + s.width + 'mm;height:' + s.height + 'mm;padding:1mm;overflow:hidden;' + - 'display:flex;flex-direction:column;justify-content:center;gap:1px;border:' + (s.guides ? '.15mm dashed #aaa' : 'none') + ';font-family:' + s.font + ';font-size:' + s.fontSize + 'px;text-align:' + s.align + + 'display:flex;flex-direction:column;justify-content:' + ((s.previewPaper || s.paper) === 'roll' ? 'flex-start' : 'center') + ';gap:1px;border:' + (s.guides ? '.15mm dashed #aaa' : 'none') + ';font-family:' + s.font + ';font-size:' + s.fontSize + 'px;text-align:' + s.align + ';font-weight:' + (s.fontOptions?.includes('bold') ? '700' : '400') + ';font-style:' + (s.fontOptions?.includes('italic') ? 'italic' : 'normal') + ';color:' + s.ink + ';background:' + s.background + ';print-color-adjust:exact;-webkit-print-color-adjust:exact}' + - '.sl-label p{margin:0;line-height:1.15;overflow-wrap:anywhere}.sl-name{font-weight:600;font-size:115%}.sl-price{font-weight:700;font-size:220%}.sl-barcode{display:block;max-width:100%;max-height:45%;' + + '.sl-label p{margin:0;line-height:1.15;overflow-wrap:anywhere}.sl-name{font-weight:700;font-size:135%}.sl-price{font-weight:700;font-size:260%}.sl-barcode{display:block;flex-shrink:1;min-height:0;max-width:100%;max-height:45%;' + 'margin-left:' + (s.align === 'left' ? '0' : 'auto') + ';margin-right:' + (s.align === 'right' ? '0' : 'auto') + '}.sl-foot{font-size:75%}'; } function validateDesign(s) { @@ -143,8 +143,10 @@ // Scope preview rules: printing's html/body and @page rules never affect the application. style.textContent = allCss.slice(allCss.indexOf('.sl-sheet')).replace(/(^|})(\.sl-[^{]+)/g, '$1' + host + ' $2'); var $host = $(host).empty().append(style, page); + var thermal = s.paper === 'roll' && $('#sl-print-method').val() === 'thermal'; + var previewWidth = thermal ? 80 : g.width; var available = $host.parent().width() || 400; - var scale = Math.min(1.4, available / (g.width * 96 / 25.4)); + var scale = Math.min(1.4, available / (previewWidth * 96 / 25.4)); if (s.paper === 'a4' && host !== '#sl-sample') { if (host === '#sl-preview' && $('#sl-preview-zoom').val() === 'actual') scale = Math.min(1, scale); else { @@ -152,8 +154,9 @@ scale = Math.min(scale, availableHeight / (g.height * 96 / 25.4)); } } - $(page).css({ transform: 'scale(' + scale + ')', transformOrigin: 'top left' }); - $host.css({ width: g.width * 96 / 25.4 * scale + 'px', height: g.height * 96 / 25.4 * scale + 'px' }); + $(page).css({ transform: 'scale(' + scale + ')', transformOrigin: 'top left', marginLeft: thermal ? 4 * 96 / 25.4 * scale + 'px' : 0 }); + $host.css({ width: previewWidth * 96 / 25.4 * scale + 'px', height: g.height * 96 / 25.4 * scale + 'px', background: '#fff' }); + $host.attr('aria-label', thermal ? '80 mm roll, 72 mm printable width. Printer feed and cutter margins vary.' : 'Label print preview'); return page; } function refresh() { @@ -176,7 +179,7 @@ design: { font: s.font, fontSize: s.fontSize, align: s.align, ink: s.ink, background: s.background, format: s.format, barWidth: s.barWidth, barHeight: s.barHeight, barMargin: s.barMargin, textMargin: s.textMargin, top: s.top, bottom: s.bottom, left: s.left, right: s.right, fontOptions: s.fontOptions } })); } catch (_) { /* Optional storage. */ } if (state.step === 1) { var sampleGeometry = geometry(Object.assign({}, s, { paper: 'roll' })); - try { mountPreview('#sl-sample', [entries[0].item], Object.assign({}, s, { paper: 'roll' }), sampleGeometry); } + try { mountPreview('#sl-sample', [entries[0].item], Object.assign({}, s, { paper: 'roll', previewPaper: s.paper }), sampleGeometry); } catch (e) { $('#sl-sample').empty().append($('

').text(e.message || String(e))); } } if (state.step < 2) return; @@ -191,7 +194,7 @@ $('#sl-prev').prop('disabled', state.page === 0); $('#sl-next').prop('disabled', state.page === result.pages - 1); $('#sl-job-paper').text(s.paper === 'a4' ? 'A4 · 210 × 297 mm' : s.width + ' × ' + s.height + ' mm'); $('#sl-job-total').text(result.total); $('#sl-job-pages').text(result.pages); - $('#sl-print,#sl-test').prop('disabled', state.loading); + $('#sl-print,#sl-test').prop('disabled', state.loading || state.printing); } catch (e) { error(e); } } function setStep(step) { @@ -295,7 +298,7 @@ state.total = 0; state.totalPages = 1; state.productPage = 1; // The wizard owns its visible search/category filters. Tenant scope remains enforced by the Items API. state.filters = {}; - $('#sl-search').val(''); $('#sl-category').val(''); $('#sl-from').val(1); $('#sl-batch').val(2000); $('#sl-copies').val(1); + $('#sl-search').val(''); $('#sl-category').val(''); $('#sl-from').val(1); $('#sl-batch').val(5); $('#sl-copies').val(1); $('#sl-width').val(Math.round(Number($('#document_width').val()) * 25.4 * 10) / 10); $('#sl-height').val(Math.round(Number($('#document_height').val()) * 25.4 * 10) / 10); try { var saved = JSON.parse(root.localStorage.getItem('posnic.shelf-label-layout') || 'null'); @@ -314,7 +317,7 @@ } } } catch (_) { /* Use current controls if storage is unavailable. */ } - $('#view_print_lable').modal('show'); setStep(0); renderRows(); + $('#view_print_lable').modal('show'); setStep(0); renderRows(); loadPrinters(); var scopeToken = state.token; root.PosnicPro.get({ url: 'categories/getCategoryAjaxList', data: 'query=' }, function (response) { if (!$('#view_print_lable').hasClass('show') || !response) return; @@ -329,7 +332,8 @@ remember([item], true); state.viewIds = [String(item._id)]; state.total = 1; state.loading = false; renderRows(); }).catch(function (e) { if (state.token === scopeToken) { state.loading = false; state.loadError = e; refresh(); } }); } - function print(test) { + async function print(test) { + if (state.printing) return false; try { if (state.step !== 3) throw Error(t('lang_sl_review_first', 'Review the labels before printing.')); if (state.loading) throw Error(t('lang_sl_wait_loading', 'Wait for products to finish loading.')); @@ -337,10 +341,55 @@ var s = settings(), g = geometry(s), entries = chosen(), from = Number($('#sl-from').val()), amount = test ? 1 : Number($('#sl-batch').val()); var first = slots(entries, g, 0, from, amount), html = ''; for (var i = 0; i < first.pages; i++) html += sheet(root.document, slots(entries, g, i, from, amount).cells, s, root.JsBarcode).outerHTML; - root.printJS({ printable: html, type: 'raw-html', style: css(s, g), scanStyles: false, documentTitle: t('lang_shelf_label_products', 'Shelf label products') }); + var printer = root.electronAPI && root.electronAPI.printer; + if (printer && printer.print) { + var name = $('#sl-printer').val(); + if (!name) throw Error('Choose a shelf-label printer.'); + var thermal = $('#sl-print-method').val() === 'thermal'; + if (thermal && (s.paper !== 'roll' || s.width > 72)) throw Error('For the 80 mm receipt printer, choose Label roll and a label width of 72 mm or less.'); + state.printing = true; + $('#sl-print,#sl-test').prop('disabled', true); + $('#sl-status').text('Sending labels to printer…'); + var documents = thermal ? Array.from({ length: first.pages }, function (_, page) { + var labelHtml = sheet(root.document, slots(entries, g, page, from, amount).cells, s, root.JsBarcode).outerHTML; + return '

' + labelHtml + '
'; + }) : [html]; + for (var job = 0; job < documents.length; job++) { + var result = await printer.print('' + documents[job] + '', { + printerName: name, pageSize: thermal ? '80mm' : { width: Math.round(g.width * 1000), height: Math.round(g.height * 1000) }, + thermalRaster: thermal, exactPageSize: true, silent: true, copies: 1, landscape: false, + printBackground: true, margins: { marginType: 'none' } + }); + if (!result || !result.success) throw Error((result && result.error || 'Labels could not be sent to the printer.') + + (thermal ? ' ' + job + ' earlier label(s) were sent; check the printer before retrying.' : '')); + } + try { root.localStorage.setItem('posnic.shelf-label-printer.' + s.paper, name); root.localStorage.setItem('posnic.shelf-label-method.' + s.paper, thermal ? 'thermal' : 'driver'); } catch (_) {} + $('#sl-status').text('Sent ' + first.amount + ' label(s) to ' + name + '. Check the printed output.'); + } else { + root.printJS({ printable: html, type: 'raw-html', style: css(s, g), scanStyles: false, documentTitle: t('lang_shelf_label_products', 'Shelf label products') }); + } } catch (e) { error(e); } + finally { state.printing = false; $('#sl-print,#sl-test').prop('disabled', state.loading || state.printing); } return false; } + async function loadPrinters() { + var printer = root.electronAPI && root.electronAPI.printer; + var desktop = !!(printer && printer.print && printer.list); + $('#sl-desktop-printer').prop('hidden', !desktop); + $('#sl-browser-print-help').prop('hidden', desktop); + $('#sl-print-method').val('driver'); $('#sl-thermal-help').prop('hidden', true); + if (!desktop) return; + var paper = $('#sl-paper').val(), saved = ''; + try { saved = root.localStorage.getItem('posnic.shelf-label-printer.' + paper) || ''; } catch (_) {} + $('#sl-printer').empty().append($('