refactor: include state code in receipt address and optimize ESC/POS generator codebase
This commit is contained in:
parent
458404ba7a
commit
d3a3633359
@ -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<Uint8Array>} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 || ''
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user