refactor: include state code in receipt address and optimize ESC/POS generator codebase

This commit is contained in:
Suherdy Yacob 2026-05-29 10:36:44 +07:00
parent 458404ba7a
commit d3a3633359
2 changed files with 232 additions and 302 deletions

View File

@ -2,410 +2,333 @@
/** /**
* ESC/POS Command Generator * ESC/POS Command Generator
* *
* Generates ESC/POS command sequences for thermal printers. * Generates ESC/POS command sequences for thermal printers.
* Supports text formatting, alignment, sizing, and receipt generation. * 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 // ESC/POS Command Constants
const ESC = 0x1B; const ESC = 0x1B;
const GS = 0x1D; const GS = 0x1D;
const LF = 0x0A; const LF = 0x0A;
const CR = 0x0D;
// Initialization // Initialization
const INIT = [ESC, 0x40]; const INIT = [ESC, 0x40];
// Alignment commands // Alignment commands
const ALIGN_LEFT = [ESC, 0x61, 0x00]; const ALIGN_LEFT = [ESC, 0x61, 0x00];
const ALIGN_CENTER = [ESC, 0x61, 0x01]; const ALIGN_CENTER = [ESC, 0x61, 0x01];
const ALIGN_RIGHT = [ESC, 0x61, 0x02]; const ALIGN_RIGHT = [ESC, 0x61, 0x02];
// Text emphasis // Text emphasis
const BOLD_ON = [ESC, 0x45, 0x01]; const BOLD_ON = [ESC, 0x45, 0x01];
const BOLD_OFF = [ESC, 0x45, 0x00]; const BOLD_OFF = [ESC, 0x45, 0x00];
const UNDERLINE_ON = [ESC, 0x2D, 0x01]; const UNDERLINE_ON = [ESC, 0x2D, 0x01];
const UNDERLINE_OFF = [ESC, 0x2D, 0x00]; const UNDERLINE_OFF= [ESC, 0x2D, 0x00];
// Paper control // Paper control
const FEED_LINE = [LF]; const FEED_LINE = [LF];
const CUT_PAPER = [GS, 0x56, 0x00]; 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 { export class EscPosGenerator {
constructor() { constructor() {
this.commands = []; this.commands = [];
this.characterSet = 'CP437'; // Default character set this.characterSet = 'CP437'; // Default character set
this.colWidth = 32; // 58 mm paper = 32 chars @ 12 cpi
} }
/** // ─── Low-level ESC/POS helpers ──────────────────────────────────────────
* Initialize printer with default settings
* @returns {Uint8Array} Initialization command sequence initialize() { return new Uint8Array(INIT); }
*/
initialize() {
return new Uint8Array(INIT);
}
/**
* Set text alignment
* @param {string} alignment - 'left', 'center', or 'right'
* @returns {Uint8Array} Alignment command sequence
*/
setAlignment(alignment) { setAlignment(alignment) {
switch (alignment.toLowerCase()) { switch ((alignment || '').toLowerCase()) {
case 'left': case 'center': return new Uint8Array(ALIGN_CENTER);
return new Uint8Array(ALIGN_LEFT); case 'right': return new Uint8Array(ALIGN_RIGHT);
case 'center': default: return new Uint8Array(ALIGN_LEFT);
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) { 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)); height = Math.max(1, Math.min(8, height));
const sizeValue = ((width - 1) << 4) | (height - 1);
// 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;
return new Uint8Array([GS, 0x21, sizeValue]); 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) { setEmphasis(bold = false, underline = false) {
const commands = []; const cmds = [];
cmds.push(...(bold ? BOLD_ON : BOLD_OFF));
if (bold) { cmds.push(...(underline ? UNDERLINE_ON : UNDERLINE_OFF));
commands.push(...BOLD_ON); return new Uint8Array(cmds);
} else {
commands.push(...BOLD_OFF);
}
if (underline) {
commands.push(...UNDERLINE_ON);
} else {
commands.push(...UNDERLINE_OFF);
}
return new Uint8Array(commands);
} }
/**
* 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) { feedAndCut(lines = 3) {
const commands = []; const cmds = [];
for (let i = 0; i < lines; i++) cmds.push(...FEED_LINE);
// Feed lines cmds.push(...CUT_PAPER);
for (let i = 0; i < lines; i++) { return new Uint8Array(cmds);
commands.push(...FEED_LINE);
}
// Cut paper
commands.push(...CUT_PAPER);
return new Uint8Array(commands);
} }
/** /**
* Encode text to bytes using the configured character set * Encode text to bytes using the configured character set.
* @param {string} text - Text to encode
* @returns {Uint8Array} Encoded text bytes
*/ */
encodeText(text) { encodeText(text) {
if (!text) { if (!text) return new Uint8Array(0);
return new Uint8Array(0);
} const result = new Uint8Array(text.length);
const result = new Uint8Array(text.length);
const charSet = (this.characterSet || 'CP437').toUpperCase(); const charSet = (this.characterSet || 'CP437').toUpperCase();
// CP437 mapping for common characters above 127
const cp437Map = { const cp437Map = {
'ü': 0x81, 'é': 0x82, 'â': 0x83, 'ä': 0x84, 'à': 0x85, 'å': 0x86, 'ç': 0x87, 'ê': 0x88, 'ë': 0x89, 'è': 0x8A, 'ï': 0x8B, 'î': 0x8C, 'ì': 0x8D, 'Ä': 0x8E, 'Å': 0x8F, 'ü':0x81,'é':0x82,'â':0x83,'ä':0x84,'à':0x85,'å':0x86,'ç':0x87,'ê':0x88,
'É': 0x90, 'æ': 0x91, 'Æ': 0x92, 'ô': 0x93, 'ö': 0x94, 'ò': 0x95, 'û': 0x96, 'ù': 0x97, 'ÿ': 0x98, 'Ö': 0x99, 'Ü': 0x9A, '¢': 0x9B, '£': 0x9C, '¥': 0x9D, '₧': 0x9E, 'ƒ': 0x9F, 'ë':0x89,'è':0x8A,'ï':0x8B,'î':0x8C,'ì':0x8D,'Ä':0x8E,'Å':0x8F,'É':0x90,
'á': 0xA0, 'í': 0xA1, 'ó': 0xA2, 'ú': 0xA3, 'ñ': 0xA4, 'Ñ': 0xA5, 'ª': 0xA6, 'º': 0xA7, '¿': 0xA8, '⌐': 0xA9, '¬': 0xAA, '½': 0xAB, '¼': 0xAC, '¡': 0xAD, '«': 0xAE, '»': 0xAF, 'æ':0x91,'Æ':0x92,'ô':0x93,'ö':0x94,'ò':0x95,'û':0x96,'ù':0x97,'ÿ':0x98,
'ß': 0xE1, 'Γ': 0xE2, 'π': 0xE3, 'Σ': 0xE4, 'σ': 0xE5, 'µ': 0xE6, 'τ': 0xE7, 'Φ': 0xE8, 'Θ': 0xE9, 'Ω': 0xEA, 'δ': 0xEB, '∞': 0xEC, 'φ': 0xED, 'ε': 0xEE, '∩': 0xEF, 'Ö':0x99,'Ü':0x9A,'¢':0x9B,'£':0x9C,'¥':0x9D,'á':0xA0,'í':0xA1,'ó':0xA2,
'≡': 0xF0, '±': 0xF1, '≥': 0xF2, '≤': 0xF3, '⌠': 0xF4, '⌡': 0xF5, '÷': 0xF6, '≈': 0xF7, '°': 0xF8, '∙': 0xF9, '·': 0xFA, '√': 0xFB, 'ⁿ': 0xFC, '²': 0xFD, '■': 0xFE, 'ú':0xA3,'ñ':0xA4,'Ñ':0xA5,'ß':0xE1,'µ':0xE6,'±':0xF1,'°':0xF8,'€':0xEE,
'€': 0xEE // Mapped to approximate char or standard CP437 fallback
}; };
// CP850/CP858 mapping for common characters
const cp850Map = { const cp850Map = {
'ü': 0x81, 'é': 0x82, 'â': 0x83, 'ä': 0x84, 'à': 0x85, 'å': 0x86, 'ç': 0x87, 'ê': 0x88, 'ë': 0x89, 'è': 0x8A, 'ï': 0x8B, 'î': 0x8C, 'ì': 0x8D, 'Ä': 0x8E, 'Å': 0x8F, ...cp437Map, 'ø':0x9B,'Ø':0x9D,'×':0x9E,'®':0xA9,'€':0xD5,
'É': 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
}; };
// Select active lookup map const activeMap = (charSet === 'CP850' || charSet === 'CP858' || charSet === 'CP852')
let activeMap = cp437Map; ? cp850Map : cp437Map;
if (charSet === 'CP850' || charSet === 'CP858' || charSet === 'CP852') {
activeMap = cp850Map;
}
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text.length; i++) {
const char = text[i]; const ch = text[i];
const code = char.charCodeAt(0); const code = ch.charCodeAt(0);
if (code <= 127) { if (code <= 127) {
result[i] = code; result[i] = code;
} else if (activeMap[char] !== undefined) { } else if (activeMap[ch] !== undefined) {
result[i] = activeMap[char]; result[i] = activeMap[ch];
} else { } else {
// Normalize to strip accents as a general fallback const norm = ch.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const normalized = char.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); result[i] = (norm.length > 0 && norm.charCodeAt(0) <= 127)
if (normalized.length > 0 && normalized.charCodeAt(0) <= 127) { ? norm.charCodeAt(0) : 0x3F; // '?'
result[i] = normalized.charCodeAt(0);
} else {
result[i] = 0x3F; // '?' in ASCII
}
} }
} }
return result; return result;
} }
/** /**
* Add a line of text with optional formatting * Emit a single line of text (with optional formatting) followed by LF.
* @param {string} text - Text to add
* @param {Object} options - Formatting options
* @returns {Uint8Array} Formatted text command sequence
*/ */
addLine(text, options = {}) { addLine(text, options = {}) {
const commands = []; const cmds = [];
if (options.align) cmds.push(...this.setAlignment(options.align));
// Apply alignment if (options.width || options.height) cmds.push(...this.setTextSize(options.width || 1, options.height || 1));
if (options.align) { if (options.bold !== undefined || options.underline !== undefined)
commands.push(...this.setAlignment(options.align)); 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, '.');
} }
// Format with 2 decimals and thousands dots
// Apply text size const [intPart, decPart] = rounded.toFixed(2).split('.');
if (options.width || options.height) { const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
commands.push(...this.setTextSize(options.width || 1, options.height || 1)); return `${formattedInt},${decPart}`;
}
// 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);
} }
/** /**
* Generate complete receipt from Odoo receipt data * Build a right-justified two-column line (label left, value right).
* @param {Object} receiptData - Odoo receipt structure * Total width = this.colWidth.
* @returns {Uint8Array} Complete ESC/POS command sequence *
* @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) { generateReceipt(receiptData) {
const commands = []; const cmds = [];
const W = this.colWidth;
// Initialize printer
commands.push(...this.initialize()); // ── Initialize ────────────────────────────────────────────────────
cmds.push(...this.initialize());
// Header section
// ── Header ────────────────────────────────────────────────────────
if (receiptData.headerData) { if (receiptData.headerData) {
const header = receiptData.headerData; const h = receiptData.headerData;
if (h.companyName) {
if (header.companyName) { cmds.push(...this.addLine(h.companyName, { align: 'center', bold: true, width: 2, height: 2 }));
commands.push(...this.addLine(header.companyName, {
align: 'center',
width: 2,
height: 2,
bold: true
}));
} }
if (h.address) {
if (header.address) { // Long addresses should wrap — print as-is (printer wraps automatically)
commands.push(...this.addLine(header.address, { align: 'center' })); cmds.push(...this.addLine(h.address, { align: 'center' }));
} }
if (h.phone) {
if (header.phone) { cmds.push(...this.addLine(h.phone, { align: 'center' }));
commands.push(...this.addLine(header.phone, { align: 'center' }));
} }
if (h.taxId) {
if (header.taxId) { cmds.push(...this.addLine(`NPWP: ${h.taxId}`, { align: 'center' }));
commands.push(...this.addLine(`Tax ID: ${header.taxId}`, { align: 'center' }));
} }
cmds.push(...this.addLine(this.divider()));
// Separator line
commands.push(...this.addLine(''.padEnd(48, '-'), { align: 'center' }));
} }
// Order information // ── Order info ────────────────────────────────────────────────────
if (receiptData.orderData) { if (receiptData.orderData) {
const order = receiptData.orderData; const o = receiptData.orderData;
if (o.orderName) cmds.push(...this.addLine(`Order: ${o.orderName}`));
if (order.orderName) { if (o.date) cmds.push(...this.addLine(`Date: ${o.date}`));
commands.push(...this.addLine(`Order: ${order.orderName}`, { bold: true })); 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()));
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, '-')));
} }
// Line items // ── Line items ────────────────────────────────────────────────────
if (receiptData.lines && receiptData.lines.length > 0) { if (receiptData.lines && receiptData.lines.length > 0) {
// Header for items
commands.push(...this.addLine('Item Qty Price Total', { bold: true }));
receiptData.lines.forEach(line => { receiptData.lines.forEach(line => {
// Product name (truncate if too long) const name = String(line.productName || '');
let productName = line.productName || ''; const qty = line.quantity || 0;
if (productName.length > 24) { const unitPrice = line.price || 0;
productName = productName.substring(0, 21) + '...'; 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);
} }
cmds.push(...this.addLine(itemLine));
// 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(this.divider()));
commands.push(...this.addLine(''.padEnd(48, '-')));
} }
// Totals section // ── Totals ────────────────────────────────────────────────────────
if (receiptData.totals) { if (receiptData.totals) {
const totals = receiptData.totals; const t = receiptData.totals;
if (totals.subtotal !== undefined) { if (t.subtotal !== undefined) {
const subtotalLine = `Subtotal:`.padEnd(40) + (totals.subtotal || 0).toFixed(2).padStart(8); cmds.push(...this.addLine(this.twoCol('Subtotal:', this.formatAmount(t.subtotal))));
commands.push(...this.addLine(subtotalLine));
} }
if (t.discount !== undefined && t.discount > 0) {
if (totals.discount !== undefined && totals.discount > 0) { cmds.push(...this.addLine(this.twoCol('Discount:', this.formatAmount(t.discount))));
const discountLine = `Discount:`.padEnd(40) + (totals.discount || 0).toFixed(2).padStart(8);
commands.push(...this.addLine(discountLine));
} }
if (t.tax !== undefined && t.tax !== 0) {
if (totals.tax !== undefined) { cmds.push(...this.addLine(this.twoCol('Tax:', this.formatAmount(t.tax))));
const taxLine = `Tax:`.padEnd(40) + (totals.tax || 0).toFixed(2).padStart(8);
commands.push(...this.addLine(taxLine));
} }
if (t.total !== undefined) {
if (totals.total !== undefined) { // Double-width/height for the grand total line
const totalLine = `TOTAL:`.padEnd(40) + (totals.total || 0).toFixed(2).padStart(8); const totalLabel = 'TOTAL:';
commands.push(...this.addLine(totalLine, { bold: true, width: 2, height: 2 })); 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 }));
} }
cmds.push(...this.addLine(this.divider()));
commands.push(...this.addLine(''.padEnd(48, '-')));
} }
// Payment information // ── Payment ───────────────────────────────────────────────────────
if (receiptData.paymentData) { if (receiptData.paymentData) {
const payment = receiptData.paymentData; const p = receiptData.paymentData;
if (p.method) {
if (payment.method) { cmds.push(...this.addLine(`Payment: ${p.method}`));
commands.push(...this.addLine(`Payment Method: ${payment.method}`));
} }
if (p.amount !== undefined) {
if (payment.amount !== undefined) { cmds.push(...this.addLine(this.twoCol('Paid:', this.formatAmount(p.amount))));
const amountLine = `Amount Paid:`.padEnd(40) + (payment.amount || 0).toFixed(2).padStart(8);
commands.push(...this.addLine(amountLine));
} }
if (p.change !== undefined && p.change > 0) {
if (payment.change !== undefined && payment.change > 0) { cmds.push(...this.addLine(this.twoCol('Change:', this.formatAmount(p.change)), { bold: true }));
const changeLine = `Change:`.padEnd(40) + (payment.change || 0).toFixed(2).padStart(8);
commands.push(...this.addLine(changeLine, { bold: true }));
} }
cmds.push(...this.addLine(this.divider()));
commands.push(...this.addLine(''.padEnd(48, '-')));
} }
// Footer section // ── Footer ────────────────────────────────────────────────────────
if (receiptData.footerData) { if (receiptData.footerData) {
const footer = receiptData.footerData; const f = receiptData.footerData;
if (f.message) {
if (footer.message) { cmds.push(...this.addLine(f.message, { align: 'center' }));
commands.push(...this.addLine(footer.message, { align: 'center' }));
} }
if (f.barcode) {
if (footer.barcode) { cmds.push(...this.addLine(f.barcode, { align: 'center' }));
// 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' }));
} }
} }
// Feed and cut // Feed and cut
commands.push(...this.feedAndCut(4)); cmds.push(...this.feedAndCut(4));
return new Uint8Array(commands); return new Uint8Array(cmds);
} }
/** /**
* Helper method to combine multiple Uint8Arrays * Helper: combine multiple Uint8Arrays into one.
* @param {Array<Uint8Array>} arrays - Arrays to combine
* @returns {Uint8Array} Combined array
*/ */
static combineArrays(arrays) { static combineArrays(arrays) {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); const total = arrays.reduce((s, a) => s + a.length, 0);
const result = new Uint8Array(totalLength); const result = new Uint8Array(total);
let offset = 0; let offset = 0;
for (const arr of arrays) { result.set(arr, offset); offset += arr.length; }
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result; return result;
} }
} }

View File

@ -636,9 +636,16 @@ patch(PosPrinterService.prototype, {
const config = order.config || pos?.config || {}; const config = order.config || pos?.config || {};
// ── Header ───────────────────────────────────────────────────────────── // ── 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 = { const headerData = {
companyName: company.name || config.name || 'Receipt', companyName: company.name || config.name || 'Receipt',
address: [company.street, company.city, company.zip].filter(Boolean).join(', '), address: addrParts.join(', '),
phone: company.phone || '', phone: company.phone || '',
taxId: company.vat || '' taxId: company.vat || ''
}; };