From d3a3633359df6bb9de279f747cbaf7066358517c Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Fri, 29 May 2026 10:36:44 +0700 Subject: [PATCH] refactor: include state code in receipt address and optimize ESC/POS generator codebase --- static/src/js/escpos_generator.js | 525 ++++++++++++--------------- static/src/js/pos_receipt_printer.js | 9 +- 2 files changed, 232 insertions(+), 302 deletions(-) diff --git a/static/src/js/escpos_generator.js b/static/src/js/escpos_generator.js index 06f4798..c0eaf86 100755 --- a/static/src/js/escpos_generator.js +++ b/static/src/js/escpos_generator.js @@ -2,410 +2,333 @@ /** * ESC/POS Command Generator - * + * * Generates ESC/POS command sequences for thermal printers. * Supports text formatting, alignment, sizing, and receipt generation. + * + * Column width: 32 chars (58 mm paper) — standard for small Bluetooth thermal printers. */ // ESC/POS Command Constants const ESC = 0x1B; -const GS = 0x1D; -const LF = 0x0A; -const CR = 0x0D; +const GS = 0x1D; +const LF = 0x0A; // Initialization const INIT = [ESC, 0x40]; // Alignment commands -const ALIGN_LEFT = [ESC, 0x61, 0x00]; +const ALIGN_LEFT = [ESC, 0x61, 0x00]; const ALIGN_CENTER = [ESC, 0x61, 0x01]; -const ALIGN_RIGHT = [ESC, 0x61, 0x02]; +const ALIGN_RIGHT = [ESC, 0x61, 0x02]; // Text emphasis -const BOLD_ON = [ESC, 0x45, 0x01]; -const BOLD_OFF = [ESC, 0x45, 0x00]; +const BOLD_ON = [ESC, 0x45, 0x01]; +const BOLD_OFF = [ESC, 0x45, 0x00]; const UNDERLINE_ON = [ESC, 0x2D, 0x01]; -const UNDERLINE_OFF = [ESC, 0x2D, 0x00]; +const UNDERLINE_OFF= [ESC, 0x2D, 0x00]; // Paper control const FEED_LINE = [LF]; const CUT_PAPER = [GS, 0x56, 0x00]; -// Character sets -const CHARSET_USA = [ESC, 0x52, 0x00]; -const CHARSET_FRANCE = [ESC, 0x52, 0x01]; -const CHARSET_GERMANY = [ESC, 0x52, 0x02]; -const CHARSET_UK = [ESC, 0x52, 0x03]; -const CHARSET_DENMARK = [ESC, 0x52, 0x04]; -const CHARSET_SWEDEN = [ESC, 0x52, 0x05]; -const CHARSET_ITALY = [ESC, 0x52, 0x06]; -const CHARSET_SPAIN = [ESC, 0x52, 0x07]; - export class EscPosGenerator { constructor() { - this.commands = []; + this.commands = []; this.characterSet = 'CP437'; // Default character set + this.colWidth = 32; // 58 mm paper = 32 chars @ 12 cpi } - /** - * Initialize printer with default settings - * @returns {Uint8Array} Initialization command sequence - */ - initialize() { - return new Uint8Array(INIT); - } + // ─── Low-level ESC/POS helpers ────────────────────────────────────────── + + initialize() { return new Uint8Array(INIT); } - /** - * Set text alignment - * @param {string} alignment - 'left', 'center', or 'right' - * @returns {Uint8Array} Alignment command sequence - */ setAlignment(alignment) { - switch (alignment.toLowerCase()) { - case 'left': - return new Uint8Array(ALIGN_LEFT); - case 'center': - return new Uint8Array(ALIGN_CENTER); - case 'right': - return new Uint8Array(ALIGN_RIGHT); - default: - return new Uint8Array(ALIGN_LEFT); + switch ((alignment || '').toLowerCase()) { + case 'center': return new Uint8Array(ALIGN_CENTER); + case 'right': return new Uint8Array(ALIGN_RIGHT); + default: return new Uint8Array(ALIGN_LEFT); } } - /** - * Set text size - * @param {number} width - Width multiplier (1-8) - * @param {number} height - Height multiplier (1-8) - * @returns {Uint8Array} Text size command sequence - */ setTextSize(width = 1, height = 1) { - // Validate input ranges - width = Math.max(1, Math.min(8, width)); + width = Math.max(1, Math.min(8, width)); height = Math.max(1, Math.min(8, height)); - - // ESC/POS uses 0-7 for size (0 = normal, 7 = 8x) - const widthValue = width - 1; - const heightValue = height - 1; - - // Combine width and height into single byte - // High nibble = width, low nibble = height - const sizeValue = (widthValue << 4) | heightValue; - + const sizeValue = ((width - 1) << 4) | (height - 1); return new Uint8Array([GS, 0x21, sizeValue]); } - /** - * Set text emphasis (bold and underline) - * @param {boolean} bold - Enable bold - * @param {boolean} underline - Enable underline - * @returns {Uint8Array} Emphasis command sequence - */ setEmphasis(bold = false, underline = false) { - const commands = []; - - if (bold) { - commands.push(...BOLD_ON); - } else { - commands.push(...BOLD_OFF); - } - - if (underline) { - commands.push(...UNDERLINE_ON); - } else { - commands.push(...UNDERLINE_OFF); - } - - return new Uint8Array(commands); + const cmds = []; + cmds.push(...(bold ? BOLD_ON : BOLD_OFF)); + cmds.push(...(underline ? UNDERLINE_ON : UNDERLINE_OFF)); + return new Uint8Array(cmds); } - /** - * Feed paper and cut - * @param {number} lines - Number of lines to feed before cutting (default: 3) - * @returns {Uint8Array} Feed and cut command sequence - */ feedAndCut(lines = 3) { - const commands = []; - - // Feed lines - for (let i = 0; i < lines; i++) { - commands.push(...FEED_LINE); - } - - // Cut paper - commands.push(...CUT_PAPER); - - return new Uint8Array(commands); + const cmds = []; + for (let i = 0; i < lines; i++) cmds.push(...FEED_LINE); + cmds.push(...CUT_PAPER); + return new Uint8Array(cmds); } /** - * Encode text to bytes using the configured character set - * @param {string} text - Text to encode - * @returns {Uint8Array} Encoded text bytes + * Encode text to bytes using the configured character set. */ encodeText(text) { - if (!text) { - return new Uint8Array(0); - } - - const result = new Uint8Array(text.length); + if (!text) return new Uint8Array(0); + + const result = new Uint8Array(text.length); const charSet = (this.characterSet || 'CP437').toUpperCase(); - - // CP437 mapping for common characters above 127 + const cp437Map = { - 'ü': 0x81, 'é': 0x82, 'â': 0x83, 'ä': 0x84, 'à': 0x85, 'å': 0x86, 'ç': 0x87, 'ê': 0x88, 'ë': 0x89, 'è': 0x8A, 'ï': 0x8B, 'î': 0x8C, 'ì': 0x8D, 'Ä': 0x8E, 'Å': 0x8F, - 'É': 0x90, 'æ': 0x91, 'Æ': 0x92, 'ô': 0x93, 'ö': 0x94, 'ò': 0x95, 'û': 0x96, 'ù': 0x97, 'ÿ': 0x98, 'Ö': 0x99, 'Ü': 0x9A, '¢': 0x9B, '£': 0x9C, '¥': 0x9D, '₧': 0x9E, 'ƒ': 0x9F, - 'á': 0xA0, 'í': 0xA1, 'ó': 0xA2, 'ú': 0xA3, 'ñ': 0xA4, 'Ñ': 0xA5, 'ª': 0xA6, 'º': 0xA7, '¿': 0xA8, '⌐': 0xA9, '¬': 0xAA, '½': 0xAB, '¼': 0xAC, '¡': 0xAD, '«': 0xAE, '»': 0xAF, - 'ß': 0xE1, 'Γ': 0xE2, 'π': 0xE3, 'Σ': 0xE4, 'σ': 0xE5, 'µ': 0xE6, 'τ': 0xE7, 'Φ': 0xE8, 'Θ': 0xE9, 'Ω': 0xEA, 'δ': 0xEB, '∞': 0xEC, 'φ': 0xED, 'ε': 0xEE, '∩': 0xEF, - '≡': 0xF0, '±': 0xF1, '≥': 0xF2, '≤': 0xF3, '⌠': 0xF4, '⌡': 0xF5, '÷': 0xF6, '≈': 0xF7, '°': 0xF8, '∙': 0xF9, '·': 0xFA, '√': 0xFB, 'ⁿ': 0xFC, '²': 0xFD, '■': 0xFE, - '€': 0xEE // Mapped to approximate char or standard CP437 fallback + 'ü':0x81,'é':0x82,'â':0x83,'ä':0x84,'à':0x85,'å':0x86,'ç':0x87,'ê':0x88, + 'ë':0x89,'è':0x8A,'ï':0x8B,'î':0x8C,'ì':0x8D,'Ä':0x8E,'Å':0x8F,'É':0x90, + 'æ':0x91,'Æ':0x92,'ô':0x93,'ö':0x94,'ò':0x95,'û':0x96,'ù':0x97,'ÿ':0x98, + 'Ö':0x99,'Ü':0x9A,'¢':0x9B,'£':0x9C,'¥':0x9D,'á':0xA0,'í':0xA1,'ó':0xA2, + 'ú':0xA3,'ñ':0xA4,'Ñ':0xA5,'ß':0xE1,'µ':0xE6,'±':0xF1,'°':0xF8,'€':0xEE, }; - - // CP850/CP858 mapping for common characters const cp850Map = { - 'ü': 0x81, 'é': 0x82, 'â': 0x83, 'ä': 0x84, 'à': 0x85, 'å': 0x86, 'ç': 0x87, 'ê': 0x88, 'ë': 0x89, 'è': 0x8A, 'ï': 0x8B, 'î': 0x8C, 'ì': 0x8D, 'Ä': 0x8E, 'Å': 0x8F, - 'É': 0x90, 'æ': 0x91, 'Æ': 0x92, 'ô': 0x93, 'ö': 0x94, 'ò': 0x95, 'û': 0x96, 'ù': 0x97, 'ÿ': 0x98, 'Ö': 0x99, 'Ü': 0x9A, 'ø': 0x9B, '£': 0x9C, 'Ø': 0x9D, '×': 0x9E, 'ƒ': 0x9F, - 'á': 0xA0, 'í': 0xA1, 'ó': 0xA2, 'ú': 0xA3, 'ñ': 0xA4, 'Ñ': 0xA5, 'ª': 0xA6, 'º': 0xA7, '¿': 0xA8, '®': 0xA9, '¬': 0xAA, '½': 0xAB, '¼': 0xAC, '¡': 0xAD, '«': 0xAE, '»': 0xAF, - 'ß': 0xE1, 'µ': 0xE6, '±': 0xF1, '÷': 0xF6, '°': 0xF8, '²': 0xFD, - '€': 0xD5 // CP858/CP850 Euro symbol or placeholder + ...cp437Map, 'ø':0x9B,'Ø':0x9D,'×':0x9E,'®':0xA9,'€':0xD5, }; - // Select active lookup map - let activeMap = cp437Map; - if (charSet === 'CP850' || charSet === 'CP858' || charSet === 'CP852') { - activeMap = cp850Map; - } + const activeMap = (charSet === 'CP850' || charSet === 'CP858' || charSet === 'CP852') + ? cp850Map : cp437Map; for (let i = 0; i < text.length; i++) { - const char = text[i]; - const code = char.charCodeAt(0); - + const ch = text[i]; + const code = ch.charCodeAt(0); if (code <= 127) { result[i] = code; - } else if (activeMap[char] !== undefined) { - result[i] = activeMap[char]; + } else if (activeMap[ch] !== undefined) { + result[i] = activeMap[ch]; } else { - // Normalize to strip accents as a general fallback - const normalized = char.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); - if (normalized.length > 0 && normalized.charCodeAt(0) <= 127) { - result[i] = normalized.charCodeAt(0); - } else { - result[i] = 0x3F; // '?' in ASCII - } + const norm = ch.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + result[i] = (norm.length > 0 && norm.charCodeAt(0) <= 127) + ? norm.charCodeAt(0) : 0x3F; // '?' } } - return result; } /** - * Add a line of text with optional formatting - * @param {string} text - Text to add - * @param {Object} options - Formatting options - * @returns {Uint8Array} Formatted text command sequence + * Emit a single line of text (with optional formatting) followed by LF. */ addLine(text, options = {}) { - const commands = []; - - // Apply alignment - if (options.align) { - commands.push(...this.setAlignment(options.align)); + const cmds = []; + if (options.align) cmds.push(...this.setAlignment(options.align)); + if (options.width || options.height) cmds.push(...this.setTextSize(options.width || 1, options.height || 1)); + if (options.bold !== undefined || options.underline !== undefined) + cmds.push(...this.setEmphasis(options.bold || false, options.underline || false)); + + cmds.push(...this.encodeText(text)); + cmds.push(...FEED_LINE); + + // Reset formatting after every line + cmds.push(...this.setTextSize(1, 1)); + cmds.push(...this.setEmphasis(false, false)); + return new Uint8Array(cmds); + } + + // ─── Number formatting ─────────────────────────────────────────────────── + + /** + * Format a raw number for thermal receipt printing. + * + * Rules: + * - Use thousands separators (dot for ID locale style) + * - Strip trailing ".00" — currencies like IDR have 0 decimal places + * and a value like 29970000.00 should print as "29.970.000" + * - Keep up to 2 decimal places if non-zero + * + * @param {number} value + * @returns {string} + */ + formatAmount(value) { + if (typeof value !== 'number' || isNaN(value)) return '0'; + const rounded = Math.round(value * 100) / 100; + // Detect if the value is a whole number (IDR-style) + const isWhole = (rounded % 1 === 0); + if (isWhole) { + // Format as integer with thousands dots: 29970000 → "29.970.000" + return rounded.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.'); } - - // Apply text size - if (options.width || options.height) { - commands.push(...this.setTextSize(options.width || 1, options.height || 1)); - } - - // Apply emphasis - if (options.bold !== undefined || options.underline !== undefined) { - commands.push(...this.setEmphasis(options.bold || false, options.underline || false)); - } - - // Add text - commands.push(...this.encodeText(text)); - - // Add line feed - commands.push(...FEED_LINE); - - // Reset formatting - commands.push(...this.setTextSize(1, 1)); - commands.push(...this.setEmphasis(false, false)); - - return new Uint8Array(commands); + // Format with 2 decimals and thousands dots + const [intPart, decPart] = rounded.toFixed(2).split('.'); + const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '.'); + return `${formattedInt},${decPart}`; } /** - * Generate complete receipt from Odoo receipt data - * @param {Object} receiptData - Odoo receipt structure - * @returns {Uint8Array} Complete ESC/POS command sequence + * Build a right-justified two-column line (label left, value right). + * Total width = this.colWidth. + * + * @param {string} label + * @param {string} value + * @returns {string} + */ + twoCol(label, value) { + const w = this.colWidth; + const v = String(value); + const l = String(label); + if (l.length + v.length >= w) { + // Not enough room — put value on next implicit line via truncation + return l.substring(0, w - v.length - 1).padEnd(w - v.length) + v; + } + return l + v.padStart(w - l.length); + } + + /** + * Divider line (full width). + */ + divider() { + return ''.padEnd(this.colWidth, '-'); + } + + // ─── Main receipt generator ────────────────────────────────────────────── + + /** + * Generate complete receipt from structured data. + * + * Expected receiptData shape: + * { + * headerData: { companyName, address, phone, taxId } + * orderData: { orderName, date, cashier, customer } + * lines: [{ productName, quantity, price, total }, ...] + * totals: { subtotal, tax, discount, total } + * paymentData: { method, amount, change } + * footerData: { message, barcode } + * } */ generateReceipt(receiptData) { - const commands = []; - - // Initialize printer - commands.push(...this.initialize()); - - // Header section + const cmds = []; + const W = this.colWidth; + + // ── Initialize ──────────────────────────────────────────────────── + cmds.push(...this.initialize()); + + // ── Header ──────────────────────────────────────────────────────── if (receiptData.headerData) { - const header = receiptData.headerData; - - if (header.companyName) { - commands.push(...this.addLine(header.companyName, { - align: 'center', - width: 2, - height: 2, - bold: true - })); + const h = receiptData.headerData; + if (h.companyName) { + cmds.push(...this.addLine(h.companyName, { align: 'center', bold: true, width: 2, height: 2 })); } - - if (header.address) { - commands.push(...this.addLine(header.address, { align: 'center' })); + if (h.address) { + // Long addresses should wrap — print as-is (printer wraps automatically) + cmds.push(...this.addLine(h.address, { align: 'center' })); } - - if (header.phone) { - commands.push(...this.addLine(header.phone, { align: 'center' })); + if (h.phone) { + cmds.push(...this.addLine(h.phone, { align: 'center' })); } - - if (header.taxId) { - commands.push(...this.addLine(`Tax ID: ${header.taxId}`, { align: 'center' })); + if (h.taxId) { + cmds.push(...this.addLine(`NPWP: ${h.taxId}`, { align: 'center' })); } - - // Separator line - commands.push(...this.addLine(''.padEnd(48, '-'), { align: 'center' })); + cmds.push(...this.addLine(this.divider())); } - - // Order information + + // ── Order info ──────────────────────────────────────────────────── if (receiptData.orderData) { - const order = receiptData.orderData; - - if (order.orderName) { - commands.push(...this.addLine(`Order: ${order.orderName}`, { bold: true })); - } - - if (order.date) { - commands.push(...this.addLine(`Date: ${order.date}`)); - } - - if (order.cashier) { - commands.push(...this.addLine(`Cashier: ${order.cashier}`)); - } - - if (order.customer) { - commands.push(...this.addLine(`Customer: ${order.customer}`)); - } - - commands.push(...this.addLine(''.padEnd(48, '-'))); + const o = receiptData.orderData; + if (o.orderName) cmds.push(...this.addLine(`Order: ${o.orderName}`)); + if (o.date) cmds.push(...this.addLine(`Date: ${o.date}`)); + if (o.cashier) cmds.push(...this.addLine(`Cashier: ${o.cashier}`)); + if (o.customer) cmds.push(...this.addLine(`Customer: ${o.customer}`)); + cmds.push(...this.addLine(this.divider())); } - - // Line items + + // ── Line items ──────────────────────────────────────────────────── if (receiptData.lines && receiptData.lines.length > 0) { - // Header for items - commands.push(...this.addLine('Item Qty Price Total', { bold: true })); - receiptData.lines.forEach(line => { - // Product name (truncate if too long) - let productName = line.productName || ''; - if (productName.length > 24) { - productName = productName.substring(0, 21) + '...'; + const name = String(line.productName || ''); + const qty = line.quantity || 0; + const unitPrice = line.price || 0; + const total = line.total || 0; + + // Line 1: product name (truncate to full width) + const displayName = name.length > W ? name.substring(0, W - 1) + '.' : name; + cmds.push(...this.addLine(displayName, { bold: false })); + + // Line 2: qty x unitPrice = total (right-aligned) + const priceStr = this.formatAmount(unitPrice); + const totalStr = this.formatAmount(total); + const qtyStr = `${qty % 1 === 0 ? qty.toFixed(0) : qty.toFixed(2)}x`; + // Build: " 2x 14.985.000 29.970.000" + const middle = `${qtyStr} ${priceStr}`; + let itemLine = middle + totalStr.padStart(W - middle.length); + if (itemLine.length > W) { + // fallback: just right-align total + itemLine = totalStr.padStart(W); } - - // Format line with proper spacing - const qty = (line.quantity || 0).toFixed(2).padStart(6); - const price = (line.price || 0).toFixed(2).padStart(8); - const total = (line.total || 0).toFixed(2).padStart(8); - - commands.push(...this.addLine(productName.padEnd(24))); - commands.push(...this.addLine(`${' '.repeat(24)}${qty}${price}${total}`)); + cmds.push(...this.addLine(itemLine)); }); - - commands.push(...this.addLine(''.padEnd(48, '-'))); + cmds.push(...this.addLine(this.divider())); } - - // Totals section + + // ── Totals ──────────────────────────────────────────────────────── if (receiptData.totals) { - const totals = receiptData.totals; - - if (totals.subtotal !== undefined) { - const subtotalLine = `Subtotal:`.padEnd(40) + (totals.subtotal || 0).toFixed(2).padStart(8); - commands.push(...this.addLine(subtotalLine)); + const t = receiptData.totals; + + if (t.subtotal !== undefined) { + cmds.push(...this.addLine(this.twoCol('Subtotal:', this.formatAmount(t.subtotal)))); } - - if (totals.discount !== undefined && totals.discount > 0) { - const discountLine = `Discount:`.padEnd(40) + (totals.discount || 0).toFixed(2).padStart(8); - commands.push(...this.addLine(discountLine)); + if (t.discount !== undefined && t.discount > 0) { + cmds.push(...this.addLine(this.twoCol('Discount:', this.formatAmount(t.discount)))); } - - if (totals.tax !== undefined) { - const taxLine = `Tax:`.padEnd(40) + (totals.tax || 0).toFixed(2).padStart(8); - commands.push(...this.addLine(taxLine)); + if (t.tax !== undefined && t.tax !== 0) { + cmds.push(...this.addLine(this.twoCol('Tax:', this.formatAmount(t.tax)))); } - - if (totals.total !== undefined) { - const totalLine = `TOTAL:`.padEnd(40) + (totals.total || 0).toFixed(2).padStart(8); - commands.push(...this.addLine(totalLine, { bold: true, width: 2, height: 2 })); + if (t.total !== undefined) { + // Double-width/height for the grand total line + const totalLabel = 'TOTAL:'; + const totalValue = this.formatAmount(t.total); + // At 2x width the printer uses half the chars per line (W/2 = 16) + const halfW = Math.floor(W / 2); + const totalLine = totalLabel + totalValue.padStart(halfW - totalLabel.length); + cmds.push(...this.addLine(totalLine, { bold: true, width: 2, height: 2 })); } - - commands.push(...this.addLine(''.padEnd(48, '-'))); + cmds.push(...this.addLine(this.divider())); } - - // Payment information + + // ── Payment ─────────────────────────────────────────────────────── if (receiptData.paymentData) { - const payment = receiptData.paymentData; - - if (payment.method) { - commands.push(...this.addLine(`Payment Method: ${payment.method}`)); + const p = receiptData.paymentData; + if (p.method) { + cmds.push(...this.addLine(`Payment: ${p.method}`)); } - - if (payment.amount !== undefined) { - const amountLine = `Amount Paid:`.padEnd(40) + (payment.amount || 0).toFixed(2).padStart(8); - commands.push(...this.addLine(amountLine)); + if (p.amount !== undefined) { + cmds.push(...this.addLine(this.twoCol('Paid:', this.formatAmount(p.amount)))); } - - if (payment.change !== undefined && payment.change > 0) { - const changeLine = `Change:`.padEnd(40) + (payment.change || 0).toFixed(2).padStart(8); - commands.push(...this.addLine(changeLine, { bold: true })); + if (p.change !== undefined && p.change > 0) { + cmds.push(...this.addLine(this.twoCol('Change:', this.formatAmount(p.change)), { bold: true })); } - - commands.push(...this.addLine(''.padEnd(48, '-'))); + cmds.push(...this.addLine(this.divider())); } - - // Footer section + + // ── Footer ──────────────────────────────────────────────────────── if (receiptData.footerData) { - const footer = receiptData.footerData; - - if (footer.message) { - commands.push(...this.addLine(footer.message, { align: 'center' })); + const f = receiptData.footerData; + if (f.message) { + cmds.push(...this.addLine(f.message, { align: 'center' })); } - - if (footer.barcode) { - // Note: Barcode printing would require additional ESC/POS commands - // For now, just print the barcode value as text - commands.push(...this.addLine(footer.barcode, { align: 'center' })); + if (f.barcode) { + cmds.push(...this.addLine(f.barcode, { align: 'center' })); } } - + // Feed and cut - commands.push(...this.feedAndCut(4)); - - return new Uint8Array(commands); + cmds.push(...this.feedAndCut(4)); + + return new Uint8Array(cmds); } /** - * Helper method to combine multiple Uint8Arrays - * @param {Array} arrays - Arrays to combine - * @returns {Uint8Array} Combined array + * Helper: combine multiple Uint8Arrays into one. */ static combineArrays(arrays) { - const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); - const result = new Uint8Array(totalLength); - let offset = 0; - - for (const arr of arrays) { - result.set(arr, offset); - offset += arr.length; - } - + const total = arrays.reduce((s, a) => s + a.length, 0); + const result = new Uint8Array(total); + let offset = 0; + for (const arr of arrays) { result.set(arr, offset); offset += arr.length; } return result; } } diff --git a/static/src/js/pos_receipt_printer.js b/static/src/js/pos_receipt_printer.js index 3032dab..fd51950 100755 --- a/static/src/js/pos_receipt_printer.js +++ b/static/src/js/pos_receipt_printer.js @@ -636,9 +636,16 @@ patch(PosPrinterService.prototype, { const config = order.config || pos?.config || {}; // ── Header ───────────────────────────────────────────────────────────── + // Build address the same way the Odoo receipt template does + const addrParts = [ + company.street, + company.city, + company.state_id?.code, + company.zip, + ].filter(Boolean); const headerData = { companyName: company.name || config.name || 'Receipt', - address: [company.street, company.city, company.zip].filter(Boolean).join(', '), + address: addrParts.join(', '), phone: company.phone || '', taxId: company.vat || '' };