diff --git a/api/src/controllers/sales.controller.js b/api/src/controllers/sales.controller.js
index 540714e62..7cc13d6e7 100644
--- a/api/src/controllers/sales.controller.js
+++ b/api/src/controllers/sales.controller.js
@@ -696,6 +696,14 @@ class SalesController extends BaseController {
return !isApprovedFor(token, 'discount_apply', req.user && req.user._id);
}
+ canOverrideCounterPrice(req, payload = {}) {
+ if (!req.user?._id) return false;
+ const token = payload.price_override_approval_token || payload.approval_token;
+ return (
+ canPos(req.user, 'price_override') || isApprovedFor(token, 'price_override', req.user._id)
+ );
+ }
+
async buildSaleContext(req) {
const { branch_id, branch_name } = this.resolveBranchContext(req);
const user = req.user || {};
@@ -798,6 +806,7 @@ class SalesController extends BaseController {
// Build Context for Service
const context = await this.buildSaleContext(req);
+ context.allowCounterPriceOverride = this.canOverrideCounterPrice(req, payload);
// Delegate core Add / Hold business logic to salesService.processSale,
// which is a line-by-line port of PHP sales_model::salesInsertUpdate.
@@ -1001,6 +1010,7 @@ class SalesController extends BaseController {
// salesInsertUpdate parity for $process = 'Edit').
// Enrich the context with branch-specific settings via the service
context = await salesService.enrichSaleContext(context);
+ context.allowCounterPriceOverride = this.canOverrideCounterPrice(req, payload);
const result = payload.paid_bill_correction
? await require('../services/paid-bill-edit').amend(req, payload, context)
diff --git a/api/tests/unit/controllers/sales.controller.test.js b/api/tests/unit/controllers/sales.controller.test.js
index 66b198f69..d8fdb6765 100644
--- a/api/tests/unit/controllers/sales.controller.test.js
+++ b/api/tests/unit/controllers/sales.controller.test.js
@@ -361,6 +361,53 @@ describe('SalesController', () => {
});
describe('create', () => {
+ test.each([true, false])(
+ 'counter price permission comes from staff access (%s)',
+ async (allowed) => {
+ const res = mockRes();
+ await ctrl.create(
+ mockReq({
+ user: adminUser({
+ usertype: 'cashier',
+ role: 'cashier',
+ access: { pos: { price_override: allowed } },
+ }),
+ body: {
+ allowCounterPriceOverride: true,
+ items: [{ item_id: VALID_ID, item_quantity: 1 }],
+ },
+ }),
+ res,
+ mockNext()
+ );
+ expect(salesService.processSale.mock.calls[0][3].allowCounterPriceOverride).toBe(allowed);
+ }
+ );
+
+ test('price approval must be signed, current and bound to this cashier and action', () => {
+ const { signApproval } = require('../../../src/utils/approval-token.util');
+ const req = mockReq({
+ user: adminUser({
+ usertype: 'cashier',
+ role: 'cashier',
+ access: { pos: { price_override: false } },
+ }),
+ });
+ for (const [claims, allowed] of [
+ [{ action: 'price_override', cashier_user_id: VALID_ID }, true],
+ [{ action: 'discount_apply', cashier_user_id: VALID_ID }, false],
+ [{ action: 'price_override', cashier_user_id: 'other' }, false],
+ [{ action: 'price_override', cashier_user_id: VALID_ID, exp: Date.now() - 1000 }, false],
+ ]) {
+ expect(
+ ctrl.canOverrideCounterPrice(req, { price_override_approval_token: signApproval(claims) })
+ ).toBe(allowed);
+ }
+ expect(ctrl.canOverrideCounterPrice(req, { price_override_approval_token: 'forged' })).toBe(
+ false
+ );
+ });
+
test('200 success', async () => {
const res = mockRes();
const next = mockNext();
diff --git a/api/tests/unit/services/pricing-authority.test.js b/api/tests/unit/services/pricing-authority.test.js
index 223e53972..2febe0326 100644
--- a/api/tests/unit/services/pricing-authority.test.js
+++ b/api/tests/unit/services/pricing-authority.test.js
@@ -27,6 +27,16 @@ test('inclusive water stays 30 including tax, including after quantity changes',
test('a channel label does not authorize an arbitrary price', () => {
expect(() => quote({ channel: 'online', submitted: 60 })).toThrow(/does not match/);
});
+test('counter override cannot bypass an outlet, price list or other channel', () => {
+ for (const context of [
+ { channel: 'online' },
+ { outlet: { id: 'room', markup_percent: 20 } },
+ { priceList: { _id: 'wholesale', percent_off: 10 } },
+ { venue: { code: 'hotel', price_adjust_percent: 20 } },
+ ]) {
+ expect(() => quote({ ...context, allowCounterPriceOverride: true, submitted: 70 })).toThrow();
+ }
+});
test('configured outlet and customer prices are enforced', () => {
expect(quote({ outlet: { id: 'room', markup_percent: 20 }, submitted: 54 }).source).toBe(
'outlet'
diff --git a/api/tests/unit/services/sale.service.test.js b/api/tests/unit/services/sale.service.test.js
index 08d212c46..ca30c087f 100644
--- a/api/tests/unit/services/sale.service.test.js
+++ b/api/tests/unit/services/sale.service.test.js
@@ -235,6 +235,36 @@ describe('SalesService', () => {
});
describe('fixed-price admission', () => {
+ test.each([true, false])(
+ 'sale-only cart price respects host permission (%s)',
+ async (allowed) => {
+ const item = makeItemDoc();
+ mockItemRepositoryInstance.findItemById.mockResolvedValue(item);
+ const result = await salesService.processSale(
+ makeSaleData({
+ items: [makeItemPayload({ sale_inline_item_price: '70' })],
+ sales_total: '140',
+ allowCounterPriceOverride: true,
+ }),
+ '',
+ 'Add',
+ makeContext({ allowCounterPriceOverride: allowed })
+ );
+ expect(result.status).toBe(allowed);
+ expect(item.selling_price).toBe(100);
+ if (allowed) {
+ const saved = salesRepository.create.mock.calls[0][0];
+ expect(saved.items[0].pricing).toMatchObject({
+ selling_price: 70,
+ catalogue_price: 100,
+ source: 'counter_override',
+ });
+ } else {
+ expect(salesRepository.create).not.toHaveBeenCalled();
+ }
+ }
+ );
+
test.each(['undefined', 'null', '', ' ', null, undefined])(
'desktop missing inline override %j uses and validates the selling-price field',
async (inline) => {
diff --git a/docs/sale-only-price-edit.md b/docs/sale-only-price-edit.md
new file mode 100644
index 000000000..86e66dc0e
--- /dev/null
+++ b/docs/sale-only-price-edit.md
@@ -0,0 +1,26 @@
+# Sale-only cart price edits
+
+Desktop counter checkout now supplies the authenticated cashier's existing
+`price_override` permission to pricing admission. A signed, unexpired manager
+approval for the same cashier can also authorize the change. The cart editor
+passes that approval separately from discount approval. Payload permission flags
+are not trusted. Outlet, price-list and other channel rules remain enforced.
+Persisted historical pricing snapshots remain immutable through this path.
+
+The override is saved in the existing sale line and pricing snapshot; the item
+catalogue price is not updated. This fixes new counter sales and new lines, not
+historical bill repricing. Existing sale audit handling is unchanged.
+
+Synchronization uses the existing branch-scoped `sales` collection in the API
+and Gateway, and the bundled sync agent's realtime sales lane. No new collection
+or transport fields are introduced. Other authorized tills and the web receive
+the saved sale price through that lane; future sales still use the catalogue
+price. Approval tokens are request credentials, not shared business settings.
+No Gateway or agent change is needed. Live offline/reconnection propagation has
+not been exercised for this patch.
+
+Validation: 365 API controller, sale-service and pricing tests; two frontend
+regression tests. Cases cover sale-only 100-to-70 pricing without catalogue
+mutation, denied staff, forged/expired/wrong-cashier/wrong-action approvals and
+channel-specific price enforcement. ESLint reports no errors (existing warnings
+remain). Physical checkout on the client's updated installation is still pending.
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 7360a140a..d48115c55 100644
--- a/frontend/modules/items.html
+++ b/frontend/modules/items.html
@@ -221,8 +221,8 @@
It
Check before printing Fit whole page Actual size Previous page Next page
Print shelf labels Paper Total labels Pages in this batch Scale Actual size · 100%
-
Choose the printer in the browser print dialog. Use matching paper, 100% scale, no browser margins and no headers or footers.
From label Labels in this batch
-
Print one test label Large selections can be printed in batches of up to 2,000 labels.
+ Printer Choose a printer Print method Printer driver (A4 or label printer) 80 mm receipt printer (ESC/POS) For an 80 mm receipt printer, use label width up to 72 mm (the printable area). Preview shows an 80 mm roll with 4 mm unprintable sides and a 72 mm printable area. Printer feed and cutter margins vary. Each label is cut separately.
Refresh printers Print directly to the selected printer at the label size shown above.
Choose the printer in the browser print dialog. Use matching paper, 100% scale, no browser margins and no headers or footers.
From label Labels in this batch
+ Print one test label 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/print-settings.js b/frontend/static/script/js/core/print-settings.js
index 8cfc78204..10f41342d 100644
--- a/frontend/static/script/js/core/print-settings.js
+++ b/frontend/static/script/js/core/print-settings.js
@@ -64,6 +64,14 @@
'' + (sale && desktop() ? select({ exact: PosnicPro.i18n.t('lang_print_mode_exact', 'Exact design'), fast: PosnicPro.i18n.t('lang_print_mode_fast', 'Fast receipt'), driver: PosnicPro.i18n.t('lang_print_mode_driver', 'Windows driver (advanced)') }, value.printMode || 'exact', 'data-setting="mode" aria-label="Print mode" data-t-aria-label="lang_print_mode"') + '' + esc(PosnicPro.i18n.t('lang_print_mode_help', 'Fast uses standard text layout. Graphics or unsupported text use Exact design.')) + ' ' : '—') + ' ' +
(sale && desktop() ? '' + esc(PosnicPro.i18n.t('lang_print_sample', 'Print sample')) + ' ' : '') + (sale && index ? '× ' : '') + ' ';
}
+ function modeDetails() {
+ if (!desktop()) return '';
+ return '' +
+ '
' + esc(PosnicPro.i18n.t('lang_print_mode_exact', 'Exact design')) + ' — ' + esc(PosnicPro.i18n.t('lang_print_exact_route', 'Thermal paper: HTML layout rendered as an image, sent directly using ESC/POS. Preserves logos, QR codes, barcodes and alignment. No PDF conversion.')) + '
' +
+ '
' + esc(PosnicPro.i18n.t('lang_print_mode_fast', 'Fast receipt')) + ' — ' + esc(PosnicPro.i18n.t('lang_print_fast_route', 'Thermal paper: standard text sent directly using ESC/POS, without image or PDF rendering. Graphics or unsupported characters automatically use Exact design instead.')) + '
' +
+ '
' + esc(PosnicPro.i18n.t('lang_print_mode_driver', 'Windows driver (advanced)')) + ' — ' + esc(PosnicPro.i18n.t('lang_print_driver_route', 'HTML layout printed through the installed Windows printer driver. Preserves graphics; size and speed depend on the driver. PDF is a fallback only after a retry-safe failure.')) + '
' +
+ '
' + esc(PosnicPro.i18n.t('lang_print_modes_scope', 'These modes apply to sales receipts. Sheet-size receipts use the driver path. Invoices and quotations print as PDF; shelf labels have their own printer settings. Compare sample prints on your printer before saving a mode.')) + '
';
+ }
function languageSettings() {
var words = { '': PosnicPro.i18n.t('lang_item_original_language_print', 'Original item name') };
(window.PosnicItemText ? window.PosnicItemText.languages : []).forEach(function (code) {
@@ -77,7 +85,7 @@
host.html('' + esc(PosnicPro.i18n.t('lang_print_printers_paper', 'Printers & paper')) + ' ' + esc(PosnicPro.i18n.t('lang_print_this_device', 'Saved on this computer')) + '
' +
esc(desktop() ? PosnicPro.i18n.t('lang_print_device_help', 'Choose a printer and paper for each document. Other computers keep their own choices.') : PosnicPro.i18n.t('lang_print_browser_help', 'Paper choices are saved in this browser. Choose the printer and copies in the browser print dialog.')) + '
' + esc(PosnicPro.i18n.t('lang_print_document', 'Document')) + ' ' + esc(PosnicPro.i18n.t('lang_printer', 'Printer')) + ' ' + esc(PosnicPro.i18n.t('lang_print_paper_size', 'Paper size')) + ' ' + esc(PosnicPro.i18n.t('lang_print_mode', 'Print mode')) + ' ' + esc(PosnicPro.i18n.t('lang_print_copies', 'Copies')) + ' ' +
state.sales.map(function (v, i) { return row('sales', v, i); }).join('') + row('invoice', state.invoice) + row('quotation', state.quotation) +
- '
' + languageSettings() + '' + esc(PosnicPro.i18n.t('lang_print_refresh_printers', 'Refresh printers')) + ' ' +
+ '
' + modeDetails() + languageSettings() + '' + esc(PosnicPro.i18n.t('lang_print_refresh_printers', 'Refresh printers')) + ' ' +
(desktop() ? '' + esc(PosnicPro.i18n.t('lang_print_add_sales_printer', 'Add sales printer')) + ' ' : '') +
'
');
features(featureState);
diff --git a/frontend/static/script/js/core/shelf-labels.js b/frontend/static/script/js/core/shelf-labels.js
index 3f811f7e2..f7f56cf05 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($('').val('').text(PosnicPro.i18n.t('lang_choose_a_printer', 'Choose a printer')));
+ try {
+ var printers = await printer.list();
+ if ($('#sl-paper').val() !== paper) return;
+ printers.forEach(function (p) { $('#sl-printer').append($(' ').val(p.name).text(p.displayName || p.name)); });
+ if (printers.some(function (p) { return p.name === saved; })) $('#sl-printer').val(saved);
+ try { if (paper === 'roll' && root.localStorage.getItem('posnic.shelf-label-method.' + paper) === 'thermal') { $('#sl-print-method').val('thermal'); $('#sl-thermal-help').prop('hidden', false); } } catch (_) {}
+ } catch (_) { $('#sl-status').text(PosnicPro.i18n.t('lang_could_not_load_printers_check_the_connecti', 'Could not load printers. Check the connection and refresh printers.')); }
+ }
function init() {
if (!root.jQuery || !root.document) return;
$(root.document).on('click', '[data-sl-step]', function () { setStep(Number(this.getAttribute('data-sl-step'))); });
@@ -372,7 +421,9 @@
$(root.document).on('click', '#sl-prev,#sl-next', function () { state.page += this.id === 'sl-next' ? 1 : -1; refresh(); });
$(root.document).on('click', '#sl-test', function () { print(true); });
$(root.document).on('click', '#sl-print', function () { print(false); });
- $(root.document).on('change', '#sl-paper', function () { $('#sl-start').val(1); refresh(); });
+ $(root.document).on('change', '#sl-paper', function () { $('#sl-start').val(1); refresh(); loadPrinters(); });
+ $(root.document).on('click', '#sl-refresh-printers', loadPrinters);
+ $(root.document).on('change', '#sl-print-method', function () { $('#sl-thermal-help').prop('hidden', this.value !== 'thermal'); refresh(); });
$(root).on('resize', function () { if (state.step > 0) refresh(); });
}
init();
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/frontend/static/script/js/modules/js/sales.js b/frontend/static/script/js/modules/js/sales.js
index a8fe35a63..01163d78b 100644
--- a/frontend/static/script/js/modules/js/sales.js
+++ b/frontend/static/script/js/modules/js/sales.js
@@ -4109,6 +4109,10 @@ PosnicPro.sales.guardDiscountApproval = function (params, proceed, checkedPendin
}
var data;
try { data = JSON.parse(params.data); } catch (e) { proceed(); return; }
+ if (data && PosnicPro.sales.lineEdit._priceApprovalToken) {
+ data.price_override_approval_token = PosnicPro.sales.lineEdit._priceApprovalToken;
+ params.data = JSON.stringify(data);
+ }
if (!data || !PosnicPro.sales._manualDiscountOn(data)) { proceed(); return; }
if (PosnicPro.posCan('discount_apply')) {
var pos = PosnicPro.userACL && PosnicPro.userACL.pos;
@@ -6733,7 +6737,10 @@ PosnicPro.sales.lineEdit = {
_id: null,
open: function (itemId) {
if (PosnicPro.local.get('sale_quick_edit') === 'disable') { return; }
- var go = function () { PosnicPro.sales.lineEdit._show(itemId); };
+ var go = function (approval) {
+ PosnicPro.sales.lineEdit._priceApprovalToken = approval && approval.approval_token;
+ PosnicPro.sales.lineEdit._show(itemId);
+ };
if (PosnicPro.posCan && !(PosnicPro.posCan('price_override') && PosnicPro.posCan('discount_apply'))) {
PosnicPro.requireManagerApproval('price_override',
{ prompt: 'Editing a line needs a manager\'s approval.' }, go);
diff --git a/languages/_english.json b/languages/_english.json
index e2f6c093b..d82bd0394 100644
--- a/languages/_english.json
+++ b/languages/_english.json
@@ -37,6 +37,7 @@
"lang_6_hours": "6 Hours",
"lang_7_days": "7 days",
"lang_80_mm": "80 mm",
+ "lang_80_mm_receipt_printer_esc_pos": "80 mm receipt printer (ESC/POS)",
"lang_Addvariants": "Add",
"lang_a4_sheet": "A4 sheet",
"lang_a4_sheet_multiple_labels": "A4 sheet · multiple labels",
@@ -715,6 +716,7 @@
"lang_choose_a_name_to_get_started_prices_and_st": "Choose a name to get started. Prices and staff access are optional.",
"lang_choose_a_pdf_markdown_or_text_file": "Choose a PDF, Markdown, or text file.",
"lang_choose_a_preference": "Choose a preference",
+ "lang_choose_a_printer": "Choose a printer",
"lang_choose_a_provider": "Choose a provider",
"lang_choose_a_referrer": "Choose a referrer",
"lang_choose_a_supplier": "Choose a supplier",
@@ -891,6 +893,7 @@
"lang_could_not_load_modifier_groups": "Could not load modifier groups.",
"lang_could_not_load_operation": "Could not load operation.",
"lang_could_not_load_price_lists": "Could not load price lists.",
+ "lang_could_not_load_printers_check_the_connecti": "Could not load printers. Check the connection and refresh printers.",
"lang_could_not_load_products": "Could not load products.",
"lang_could_not_load_quotation_pricing_settings": "Could not load quotation pricing settings. Please try again.",
"lang_could_not_load_quotes_try_again": "Could not load quotes - try again.",
@@ -1725,6 +1728,7 @@
"lang_food_or_drink_you_prepare": "Food or drink you prepare",
"lang_footer_content": "Footer Content",
"lang_footer_picture_file": "Footer picture file",
+ "lang_for_an_80_mm_receipt_printer_use_label_wid": "For an 80 mm receipt printer, use label width up to 72 mm (the printable area). Preview shows an 80 mm roll with 4 mm unprintable sides and a 72 mm printable area. Printer feed and cutter margins vary. Each label is cut separately.",
"lang_for_example_diwali_offer": "For example: Diwali offer",
"lang_for_example_garden_2": "For example, Garden 2",
"lang_for_example_keep_explanations_short_and_ca": "For example: keep explanations short and call each location an outlet.",
@@ -2251,7 +2255,6 @@
"lang_language": "Language",
"lang_lapsed_no_purchase": "Lapsed (no purchase)",
"lang_large": "Large",
- "lang_large_selections_can_be_printed_in_batches": "Large selections can be printed in batches of up to 2,000 labels.",
"lang_largest_balance_first": "Largest balance first",
"lang_last_30_days": "Last 30 days",
"lang_last_7_days": "Last 7 days",
@@ -3264,17 +3267,23 @@
"lang_print_browser_help": "Paper choices are saved in this browser. Choose the printer and copies in the browser print dialog.",
"lang_print_copies": "Copies",
"lang_print_device_help": "Choose a printer and paper for each document. Other computers keep their own choices.",
+ "lang_print_directly_to_the_selected_printer_at": "Print directly to the selected printer at the label size shown above.",
"lang_print_document": "Document",
+ "lang_print_driver_route": "HTML layout printed through the installed Windows printer driver. Preserves graphics; size and speed depend on the driver. PDF is a fallback only after a retry-safe failure.",
+ "lang_print_exact_route": "Thermal paper: HTML layout rendered as an image, sent directly using ESC/POS. Preserves logos, QR codes, barcodes and alignment. No PDF conversion.",
"lang_print_failed": "Print failed",
+ "lang_print_fast_route": "Thermal paper: standard text sent directly using ESC/POS, without image or PDF rendering. Graphics or unsupported characters automatically use Exact design instead.",
"lang_print_flow": "Print flow",
"lang_print_labels": "Print labels",
"lang_print_load_failed": "Could not read printers. Refresh to try again.",
"lang_print_maximum_character_set_product_name": "Print Maximum Character set product name",
+ "lang_print_method": "Print method",
"lang_print_mode": "Print mode",
"lang_print_mode_driver": "Windows driver (advanced)",
"lang_print_mode_exact": "Exact design",
"lang_print_mode_fast": "Fast receipt",
"lang_print_mode_help": "Fast uses standard text layout. Graphics or unsupported text use Exact design.",
+ "lang_print_modes_scope": "These modes apply to sales receipts. Sheet-size receipts use the driver path. Invoices and quotations print as PDF; shelf labels have their own printer settings. Compare sample prints on your printer before saving a mode.",
"lang_print_on_the_till_roll": "Print on the till roll",
"lang_print_one_test_label": "Print one test label",
"lang_print_paper_size": "Paper size",
@@ -3316,6 +3325,7 @@
"lang_printed_on_some_printers": "Sent to",
"lang_printer": "Printer",
"lang_printer_and_paper_are_set_per_till": "Printer and paper are set per till.",
+ "lang_printer_driver_a4_or_label_printer": "Printer driver (A4 or label printer)",
"lang_printer_name": "Printer Name",
"lang_printer_settings": "Printer Settings",
"lang_printers_scanners_scales": "Printers, scanners & scales",
@@ -4264,6 +4274,7 @@
"lang_start_recount": "Start recount",
"lang_start_recount_to_review_and_apply_actual_s": "Start recount to review and apply actual stock quantities. Completed adjustments appear in Inventory logs; saved draft worksheets appear below.",
"lang_start_with_a_name_we_ll_help_you_get_it_re": "Start with a name. We’ll help you get it ready to sell.",
+ "lang_start_with_one_test_label_the_batch_starts": "Start with one test label. The batch starts at 5; increase it when you are ready (maximum 2,000).",
"lang_start_with_realistic_products_customers_an": "Start with realistic products, customers and sample sales. You can remove the samples later when you are ready for real billing.",
"lang_starter_shop_settings": "Starter shop settings",
"lang_state": "State",
diff --git a/languages/ne.json b/languages/ne.json
index 5804c0238..9a47629d0 100644
--- a/languages/ne.json
+++ b/languages/ne.json
@@ -4595,5 +4595,17 @@
"lang_room_charge_rate": "{0}: छुटपछिको र करअघिको खानाको रकमको {1}%।",
"lang_room_charge_final": "अन्तिम बिलमा समावेश हुन्छ।",
"lang_room_orders_enable_help": "सेटिङ → रेस्टुरेन्ट → होटल र अन्य स्थलमा कर्मचारीका लागि कोठाको अर्डर सक्षम गर्नुहोस्।",
- "lang_room_venue": "स्थल"
+ "lang_room_venue": "स्थल",
+ "lang_80_mm_receipt_printer_esc_pos": "८० मिमि रसिद प्रिन्टर (ESC/POS)",
+ "lang_choose_a_printer": "प्रिन्टर छान्नुहोस्",
+ "lang_could_not_load_printers_check_the_connecti": "प्रिन्टरहरू लोड गर्न सकिएन। जडान जाँचेर प्रिन्टर सूची फेरि लोड गर्नुहोस्।",
+ "lang_for_an_80_mm_receipt_printer_use_label_wid": "८० मिमि रसिद प्रिन्टरमा लेबलको चौडाइ ७२ मिमिसम्म राख्नुहोस्। पूर्वावलोकनमा ८० मिमि रोलका दुवैतिर ४ मिमि छाप्न नमिल्ने भाग र ७२ मिमि छापिने क्षेत्र देखिन्छ। कागज अगाडि बढ्ने र काटिने ठाउँ प्रिन्टरअनुसार फरक हुन्छ। प्रत्येक लेबल छुट्टै काटिन्छ।",
+ "lang_print_directly_to_the_selected_printer_at": "माथि देखाइएको लेबल आकारमा छानिएको प्रिन्टरमा सिधै छाप्नुहोस्।",
+ "lang_printer_driver_a4_or_label_printer": "प्रिन्टर ड्राइभर (A4 वा लेबल प्रिन्टर)",
+ "lang_start_with_one_test_label_the_batch_starts": "पहिले एउटा परीक्षण लेबल छाप्नुहोस्। सुरुमा एक पटकमा ५ लेबल छापिन्छन्; तयार भएपछि संख्या बढाउनुहोस् (अधिकतम २,०००)।",
+ "lang_print_method": "छपाइ विधि",
+ "lang_print_exact_route": "थर्मल कागज: HTML ढाँचा चित्रमा रूपान्तरण गरी ESC/POS मार्फत सिधै पठाइन्छ। लोगो, QR कोड, बारकोड र पङ्क्तिबद्धता जोगिन्छ। PDF मा रूपान्तरण हुँदैन।",
+ "lang_print_fast_route": "थर्मल कागज: चित्र वा PDF नबनाई सामान्य पाठ ESC/POS मार्फत सिधै पठाइन्छ। चित्र वा असमर्थित अक्षर भए स्वतः Exact design प्रयोग हुन्छ।",
+ "lang_print_driver_route": "HTML ढाँचा जडान गरिएको Windows प्रिन्टर ड्राइभरबाट छापिन्छ। चित्र जोगिन्छन्; आकार र गति ड्राइभरमा निर्भर हुन्छन्। सुरक्षित रूपमा पुनः प्रयास गर्न मिल्ने त्रुटि भए मात्र PDF विकल्प प्रयोग हुन्छ।",
+ "lang_print_modes_scope": "यी विधिहरू बिक्री रसिदका लागि हुन्। पानामा छापिने रसिदले ड्राइभर प्रयोग गर्छन्। इनभ्वाइस र कोटेसन PDF बाट छापिन्छन्; शेल्फ लेबलका आफ्नै प्रिन्टर सेटिङ छन्। विधि सुरक्षित गर्नुअघि आफ्नो प्रिन्टरमा परीक्षण छपाइ तुलना गर्नुहोस्।"
}
diff --git a/languages/nl.json b/languages/nl.json
index f2f22de97..bb235c760 100644
--- a/languages/nl.json
+++ b/languages/nl.json
@@ -5028,5 +5028,17 @@
"lang_room_charge_rate": "{0}: {1}% van het eten na kortingen, vóór belasting.",
"lang_room_charge_final": "Inbegrepen op de eindafrekening.",
"lang_room_orders_enable_help": "Schakel kamerbestellingen voor medewerkers in Instellingen → Restaurant → Hotels en andere locaties in.",
- "lang_room_venue": "Locatie"
+ "lang_room_venue": "Locatie",
+ "lang_80_mm_receipt_printer_esc_pos": "80 mm-bonprinter (ESC/POS)",
+ "lang_choose_a_printer": "Kies een printer",
+ "lang_could_not_load_printers_check_the_connecti": "Printers konden niet worden geladen. Controleer de verbinding en vernieuw de printerlijst.",
+ "lang_for_an_80_mm_receipt_printer_use_label_wid": "Gebruik voor een 80 mm-bonprinter een labelbreedte van maximaal 72 mm (het afdrukbare gebied). Het voorbeeld toont een rol van 80 mm met aan beide zijden 4 mm die niet kan worden bedrukt en een afdrukbaar gebied van 72 mm. De papierinvoer en snijmarges verschillen per printer. Elk label wordt afzonderlijk afgesneden.",
+ "lang_print_directly_to_the_selected_printer_at": "Druk rechtstreeks af op de geselecteerde printer met het hierboven getoonde labelformaat.",
+ "lang_printer_driver_a4_or_label_printer": "Printerstuurprogramma (A4- of labelprinter)",
+ "lang_start_with_one_test_label_the_batch_starts": "Begin met één testlabel. De batch begint met 5 labels; verhoog het aantal wanneer u klaar bent (maximaal 2.000).",
+ "lang_print_method": "Afdrukmethode",
+ "lang_print_exact_route": "Thermisch papier: de HTML-opmaak wordt als afbeelding weergegeven en rechtstreeks via ESC/POS verstuurd. Behoudt logo’s, QR-codes, barcodes en uitlijning. Geen PDF-conversie.",
+ "lang_print_fast_route": "Thermisch papier: standaardtekst wordt rechtstreeks via ESC/POS verstuurd, zonder afbeelding of PDF. Afbeeldingen of niet-ondersteunde tekens gebruiken automatisch Exact design.",
+ "lang_print_driver_route": "De HTML-opmaak wordt via het geïnstalleerde Windows-printerstuurprogramma afgedrukt. Afbeeldingen blijven behouden; formaat en snelheid hangen af van het stuurprogramma. PDF wordt alleen gebruikt na een fout waarbij opnieuw proberen veilig is.",
+ "lang_print_modes_scope": "Deze modi gelden voor verkoopbonnen. Bonnen op vellen gebruiken het stuurprogramma. Facturen en offertes worden als PDF afgedrukt; schaplabels hebben eigen printerinstellingen. Vergelijk testafdrukken op uw printer voordat u een modus opslaat."
}
diff --git a/src/hardware-manager.js b/src/hardware-manager.js
index cb8eb0e58..dcf417387 100644
--- a/src/hardware-manager.js
+++ b/src/hardware-manager.js
@@ -557,6 +557,7 @@ class HardwareManager {
const started = Date.now();
diagnostics?.record('print.request', { trace, format: options.pageSize, printer: options.printerName, copies: options.copies, bytes: Buffer.byteLength(String(htmlContent || '')) });
try {
+ if (options.exactPageSize && !options.printerName) throw new Error('Choose a printer before printing labels.');
// Designed rolls use the same raw queue as Hardware Manager's test.
// A text-only thermal driver may accept HTML/GDI and still feed blank paper.
// Render the complete design before submitting; never fall back after RAW acceptance.
@@ -598,8 +599,14 @@ class HardwareManager {
};
// Get pageSize from options or default to 80mm (3inch)
- const sizeKey = (options.pageSize || '80mm').toLowerCase();
- const pageSize = paperSizes[sizeKey] || paperSizes['80mm'];
+ const customSize = options.pageSize && typeof options.pageSize === 'object';
+ if (customSize && (!Number.isInteger(options.pageSize.width) || !Number.isInteger(options.pageSize.height) ||
+ options.pageSize.width < 15000 || options.pageSize.width > 300000 || options.pageSize.height < 15000 || options.pageSize.height > 1000000)) {
+ throw new Error('Invalid custom print dimensions.');
+ }
+ const sizeKey = customSize ? 'custom' : (options.pageSize || '80mm').toLowerCase();
+ const pageSize = customSize ? { width: options.pageSize.width, height: options.pageSize.height,
+ windowWidth: Math.ceil(options.pageSize.width / 25400 * 96) } : { ...(paperSizes[sizeKey] || paperSizes['80mm']) };
/*
* Where the document is going to be loaded from, decided before the
@@ -675,7 +682,8 @@ class HardwareManager {
printBackground: true,
scaleFactor: 100,
margins: { marginType: 'none' },
- pageSize: pageSize,
+ pageSize: { width: pageSize.width, height: pageSize.height },
+ ...(customSize ? { landscape: options.landscape === true } : {}),
copies: Math.max(1, parseInt(options.copies, 10) || 1)
};
@@ -689,11 +697,22 @@ class HardwareManager {
diagnostics.record('print.result', { trace, diagnosticOnly: true, submitted: false, stage: 'captured' });
return { success: false, diagnosticOnly: true, error: 'Receipt captured for diagnostics. Nothing was sent to the printer.' };
}
+ // Shelf sheets retain their physical layout through PDF at actual size.
+ // This is the primary A4 path, never a retry after an ambiguous submission.
+ if (options.exactPageSize && pageSize.width === 210000 && pageSize.height === 297000) {
+ const pdf = await printWindow.webContents.printToPDF({
+ pageSize: 'A4', preferCSSPageSize: true, printBackground: true,
+ margins: { top: 0, bottom: 0, left: 0, right: 0 }
+ });
+ return await require('./print-pdf').printPdfDocument(pdf, {
+ printerName: deviceName, paperSize: 'a4', copies: 1
+ });
+ }
diagnostics?.record('print.submit', { trace, printer: deviceName, format: sizeKey });
let result = await this._sendPrintJob(printWindow, printOpts);
diagnostics?.record('print.result', { trace, success: result.success, message: result.error, retryable: result.retryable, submitted: result.submitted });
- if (!result.success && result.retryable !== false) {
+ if (!result.success && result.retryable !== false && !options.exactPageSize) {
console.warn('Print failed with receipt page size, retrying with printer defaults:', result.error || 'unknown');
const fallbackOpts = {
silent: printOpts.silent,
@@ -705,7 +724,7 @@ class HardwareManager {
result = await this._sendPrintJob(printWindow, fallbackOpts);
}
- if (!result.success && result.retryable !== false) {
+ if (!result.success && result.retryable !== false && !options.exactPageSize) {
console.warn('Electron print failed, retrying through PDF fallback:', result.error || 'unknown');
result = await this._printViaPdfFallback(printWindow, deviceName, options);
}
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();
+ });
+}
diff --git a/tests/sale-price-approval.test.js b/tests/sale-price-approval.test.js
new file mode 100644
index 000000000..2b4b2ecc5
--- /dev/null
+++ b/tests/sale-price-approval.test.js
@@ -0,0 +1,18 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const source = fs.readFileSync('frontend/static/script/js/modules/js/sales.js', 'utf8');
+test('sale-only price approval reaches checkout without requiring a discount', () => {
+ const PosnicPro = { sales: { lineEdit: { _priceApprovalToken: 'signed-price-approval' }, _manualDiscountOn: () => false } };
+ const start = source.indexOf('PosnicPro.sales.guardDiscountApproval = function');
+ const end = source.indexOf('/*********** START - ADD NEW SALES', start);
+ vm.runInNewContext(source.slice(start, end), { PosnicPro });
+ const params = { data: JSON.stringify({ items: [{ sale_inline_item_price: 70 }] }) };
+ let proceeded = false;
+ PosnicPro.sales.guardDiscountApproval(params, () => { proceeded = true; });
+ assert.equal(proceeded, true);
+ assert.equal(JSON.parse(params.data).price_override_approval_token, 'signed-price-approval');
+ assert.equal(JSON.parse(params.data).items[0].sale_inline_item_price, 70);
+});
diff --git a/tests/shelf-label-desktop.test.js b/tests/shelf-label-desktop.test.js
new file mode 100644
index 000000000..ed70e2d09
--- /dev/null
+++ b/tests/shelf-label-desktop.test.js
@@ -0,0 +1,45 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const source = fs.readFileSync(require('node:path').join(__dirname,'../src/hardware-manager.js'),'utf8');
+function fixture(result = {success:true}) {
+ const submitted=[], pdfs=[]; let windows=0;
+ class BrowserWindow {
+ constructor() { windows++; this.webContents={executeJavaScript:async()=>{},printToPDF:async()=>Buffer.from("%PDF-labels")}; }
+ async loadURL() {} isDestroyed(){return false;} destroy(){}
+ }
+ const method = source.slice(source.indexOf(' async printHTML('),source.indexOf(' async getDefaultPrinter()'));
+ const object=vm.runInNewContext('({' + method + '})',{BrowserWindow,Buffer,console:{log(){},warn(){},error(){}},hardenPrintWindow(){},prepareDocument(){},fitDocument(){},require:(name)=>name==='crypto'?{randomUUID:()=> 'test-trace'}:({printPdfDocument:async (bytes,options)=>{pdfs.push({bytes,options});return result;}})});
+ object._resolvePrintRoute=()=>({url:'data:text/html,hello',secure:false});
+ object._resolvePrinterName=async n=>n;
+ object._waitForPrintPage=async()=>{};
+ object._sendPrintJob=async (_w,options)=>{submitted.push(options);return result;};
+ object._printViaPdfFallback=async()=>{throw Error('Unexpected fallback');};
+ return {object,submitted,pdfs,windows:()=>windows};
+}
+test('exact label driver printing passes micron dimensions and never retries using driver defaults',async()=>{
+ const f=fixture({success:false,retryable:true,error:'Unsupported paper'});
+ const r=await f.object.printHTML('Label
',{printerName:'Labels',pageSize:{width:72000,height:33000},exactPageSize:true,silent:true});
+ assert.equal(r.success,false); assert.equal(f.submitted.length,1);
+ assert.deepEqual(JSON.parse(JSON.stringify(f.submitted[0].pageSize)),{width:72000,height:33000});
+ assert.equal(f.submitted[0].landscape,false); assert.equal(f.submitted[0].deviceName,'Labels');
+});
+test('invalid custom label sizes and missing printer names fail before opening a print window',async()=>{
+ for(const options of [{pageSize:{width:0,height:33000},printerName:'Labels'}, {pageSize:{width:72000,height:33000}}]){
+ const f=fixture(); const r=await f.object.printHTML('Label
',{...options,exactPageSize:true});
+ assert.equal(r.success,false);assert.equal(f.windows(),0);assert.equal(f.submitted.length,0);
+ }
+});
+
+test('A4 shelf sheets go directly through actual-size PDF printing, without an HTML job first',async()=>{
+ const f=fixture();const r=await f.object.printHTML('Labels
',{printerName:'Brother',pageSize:{width:210000,height:297000},exactPageSize:true});
+ assert.equal(r.success,true);assert.equal(f.submitted.length,0);assert.equal(f.pdfs.length,1);
+ assert.equal(f.pdfs[0].options.printerName,'Brother');assert.equal(f.pdfs[0].options.paperSize,'a4');
+});
+
+test('diagnostic capture never submits an A4 shelf sheet',async()=>{
+ const f=fixture(); f.object.diagnostic=()=>({record(){},holdPrint:()=>true,setPreview(){}});
+ const r=await f.object.printHTML('Labels
',{printerName:'Brother',pageSize:{width:210000,height:297000},exactPageSize:true});
+ assert.equal(r.success,false);assert.equal(f.pdfs.length,0);assert.equal(f.submitted.length,0);
+});
diff --git a/tests/shelf-labels-dom.test.js b/tests/shelf-labels-dom.test.js
index a679b9d99..9bea06498 100644
--- a/tests/shelf-labels-dom.test.js
+++ b/tests/shelf-labels-dom.test.js
@@ -49,7 +49,7 @@ test('the real designer previews A4, prints the same sheets and restores its lay
const { dom, w, $, getPrint, next, selectAll } = setup();
w.PosnicShelfLabels.open(); await tick();
assert.equal($('#sl-products input[type=checkbox]:checked').length, 0);
- await selectAll(); next();
+ await selectAll(); $('#sl-batch').val(30); next();
$('#sl-paper').val('a4').trigger('change');
assert.equal($('#sl-sample .sl-label').length, 1);
next();
@@ -179,3 +179,84 @@ test('design controls work without legacy direct event bindings and restore the
assert.equal($('#sl-products input[type=checkbox]:checked').length, 0);
dom.window.close();
});
+
+
+test('desktop labels use a chosen printer and exact roll dimensions without a browser dialog', async () => {
+ const { dom, w, $, next, getPrint } = setup();
+ const sent = [];
+ w.electronAPI = { printer: {
+ list: async () => [{ name: 'POS-80C' }, { name: 'Brother A4' }],
+ print: async (html, options) => { sent.push({ html, options }); return { success: true }; }
+ } };
+ w.PosnicShelfLabels.open('0'); await tick();
+ assert.equal($('#sl-batch').val(), '5');
+ assert.equal($('#sl-desktop-printer').prop('hidden'), false);
+ next(); $('#sl-paper').val('roll').trigger('change'); await tick(); next(); next();
+ await w.PosnicShelfLabels.print(true);
+ assert.equal(sent.length, 0, 'must not borrow the receipt/default printer');
+ $('#sl-printer').val('POS-80C');
+ await w.PosnicShelfLabels.print(true);
+ assert.equal(sent.length, 1);
+ assert.equal(sent[0].options.printerName, 'POS-80C');
+ assert.deepEqual(JSON.parse(JSON.stringify(sent[0].options.pageSize)), { width: 63000, height: 33000 });
+ assert.equal(sent[0].options.exactPageSize, true);
+ assert.equal(sent[0].options.silent, true);
+ assert.equal(sent[0].options.landscape, false);
+ assert.equal(getPrint(), undefined);
+ assert.equal(new JSDOM(sent[0].html).window.document.querySelectorAll('.sl-label').length, 1);
+ assert.match($('#sl-status').text(), /Sent 1 label/);
+ assert.equal(w.localStorage.getItem('posnic.shelf-label-printer.roll'), 'POS-80C');
+ dom.window.close();
+});
+
+test('desktop A4 keeps sheet dimensions and does not retry a failed submission through browser printing', async () => {
+ const { dom, w, $, next, selectAll, getPrint } = setup();
+ const sent = [];
+ w.electronAPI = { printer: { list: async () => [{ name: 'Brother A4' }], print: async (html, options) => {
+ sent.push({html, options}); return { success: false, error: 'Printer offline' };
+ } } };
+ w.PosnicShelfLabels.open(); await tick(); await selectAll(); next();
+ $('#sl-paper').val('a4').trigger('change'); await tick(); next(); next();
+ $('#sl-printer').val('Brother A4');
+ await w.PosnicShelfLabels.print(false);
+ assert.equal(sent.length, 1);
+ assert.deepEqual(JSON.parse(JSON.stringify(sent[0].options.pageSize)), { width: 210000, height: 297000 });
+ assert.equal(new JSDOM(sent[0].html).window.document.querySelectorAll('.sl-label').length, 5);
+ assert.match($('#sl-status').text(), /Printer offline/);
+ assert.equal(getPrint(), undefined);
+ dom.window.close();
+});
+
+
+test('thermal labels explicitly opt into raw raster printing and stop at the first failed label', async () => {
+ const { dom, w, $, next } = setup(); const sent = [];
+ w.electronAPI = { printer: { list: async () => [{ name: 'POS-80C' }], print: async (html, options) => {
+ sent.push({html,options}); return sent.length === 1 ? {success:true} : {success:false,error:'Disconnected'};
+ } } };
+ w.PosnicShelfLabels.open('0'); await tick(); next();
+ $('#sl-paper').val('roll').trigger('change'); await tick();
+ $('#sl-products input[type=number]').first().val(3).trigger('change');
+ next(); next(); $('#sl-printer').val('POS-80C'); $('#sl-print-method').val('thermal');
+ await w.PosnicShelfLabels.print(false);
+ assert.equal(sent.length,2);
+ assert.equal(sent[0].options.thermalRaster,true);
+ assert.equal(sent[0].options.pageSize,'80mm');
+ assert.match(sent[0].html,/data-receipt-design="80"/);
+ assert.equal(new JSDOM(sent[0].html).window.document.querySelectorAll('.sl-label').length,1);
+ assert.match($('#sl-status').text(),/1 earlier label/);
+ dom.window.close();
+});
+
+test('thermal preview shows roll edges and keeps print content within the print head', async () => {
+ const { dom, w, $, next } = setup(); const sent=[];
+ w.electronAPI={printer:{list:async()=>[{name:'POS-80C'}],print:async(html,options)=>{sent.push({html,options});return {success:true};}}};
+ w.PosnicShelfLabels.open('0'); await tick(); next();
+ $('#sl-paper').val('roll').trigger('change'); await tick(); $('#sl-width').val(72);
+ next(); next(); $('#sl-printer').val('POS-80C'); $('#sl-print-method').val('thermal').trigger('change');
+ assert.match($('#sl-print-sample').attr('aria-label'),/80 mm roll, 72 mm printable/);
+ assert.match($('#sl-print-sample style').text(),/justify-content:flex-start/);
+ await w.PosnicShelfLabels.print(true);
+ assert.match(sent[0].html,/justify-content:flex-start/);
+ assert.match(sent[0].html,/width:72mm/);
+ dom.window.close();
+});
diff --git a/tests/tools/print-one-shelf-label.cjs b/tests/tools/print-one-shelf-label.cjs
new file mode 100644
index 000000000..f0209b019
--- /dev/null
+++ b/tests/tools/print-one-shelf-label.cjs
@@ -0,0 +1,43 @@
+const fs = require('fs');
+const path = require('path');
+const { app, BrowserWindow } = require('electron');
+const ROOT = path.resolve(__dirname, '../..');
+const argv = process.argv.slice(2);
+const printer = argv[argv.indexOf('--printer') + 1];
+const paper = argv.includes('--a4') ? 'a4' : 'roll';
+if (!argv.includes('--printer') || !printer) throw Error('Pass --printer with an explicit printer name');
+app.setPath('userData', path.join(ROOT, 'qa/shelf-print-runtime'));
+app.whenReady().then(async () => {
+ const { JSDOM } = require('jsdom');
+ const { HardwareManager } = require('../../src/hardware-manager');
+ const labels = require('../../frontend/static/script/js/core/shelf-labels');
+ const dom = new JSDOM('', {runScripts:'outside-only'});
+ dom.window.eval(fs.readFileSync(path.join(ROOT,'frontend/static/script/js/JsBarcode.all.min.js'),'utf8'));
+ const barcode=dom.window.JsBarcode;
+ dom.window.HTMLCanvasElement.prototype.getContext = () => ({ measureText: text => ({ width: String(text).length * 6 }) });
+ const s = {paper, width: paper === 'a4' ? 63 : 72, height:33, margin:5, gap:2, start:1,
+ font:'Arial',fontSize:12,align:'center',fontOptions:'',ink:'#000000',background:'#ffffff',
+ guides:paper==='a4',barcode:true,format:'CODE128',barWidth:1.4,barHeight:38,barMargin:3,top:3,bottom:3,left:3,right:3,
+ barText:true,textMargin:2,currency:'GBP',shop:argv.includes('--raw') ? 'DIRECT THERMAL TEST' : 'LOCAL PRINT TEST'};
+ const item={name:'Epoxy Fix Lock Stick Bond',barcode_id:'5034753047741',label_price:1.50,label_currency_digits:2};
+ const g=labels.geometry(s);
+ const count=paper==='a4' ? (argv.includes('--full-sheet') ? g.capacity : 5) : 1;
+ const cells=Array(g.capacity).fill(null); for(let i=0;i'+(argv.includes('--raw') ? '' : '')+labels.sheet(dom.window.document,cells,s,barcode).outerHTML+(argv.includes('--raw') ? '
' : '')+'