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

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/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() ? '' : '') + (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(''); 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($('