406 lines
18 KiB
JavaScript
Executable File
406 lines
18 KiB
JavaScript
Executable File
/** @odoo-module **/
|
||
|
||
/**
|
||
* 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;
|
||
|
||
// Initialization
|
||
const INIT = [ESC, 0x40];
|
||
|
||
// Alignment commands
|
||
const ALIGN_LEFT = [ESC, 0x61, 0x00];
|
||
const ALIGN_CENTER = [ESC, 0x61, 0x01];
|
||
const ALIGN_RIGHT = [ESC, 0x61, 0x02];
|
||
|
||
// Text emphasis
|
||
const BOLD_ON = [ESC, 0x45, 0x01];
|
||
const BOLD_OFF = [ESC, 0x45, 0x00];
|
||
const UNDERLINE_ON = [ESC, 0x2D, 0x01];
|
||
const UNDERLINE_OFF= [ESC, 0x2D, 0x00];
|
||
|
||
// Paper control
|
||
const FEED_LINE = [LF];
|
||
const CUT_PAPER = [GS, 0x56, 0x00];
|
||
|
||
export class EscPosGenerator {
|
||
constructor() {
|
||
this.commands = [];
|
||
this.characterSet = 'CP437'; // Default character set
|
||
this.colWidth = 32; // 58 mm paper = 32 chars @ 12 cpi
|
||
}
|
||
|
||
// ─── Low-level ESC/POS helpers ──────────────────────────────────────────
|
||
|
||
initialize() { return new Uint8Array(INIT); }
|
||
|
||
setAlignment(alignment) {
|
||
switch ((alignment || '').toLowerCase()) {
|
||
case 'center': return new Uint8Array(ALIGN_CENTER);
|
||
case 'right': return new Uint8Array(ALIGN_RIGHT);
|
||
default: return new Uint8Array(ALIGN_LEFT);
|
||
}
|
||
}
|
||
|
||
setTextSize(width = 1, height = 1) {
|
||
width = Math.max(1, Math.min(8, width));
|
||
height = Math.max(1, Math.min(8, height));
|
||
const sizeValue = ((width - 1) << 4) | (height - 1);
|
||
return new Uint8Array([GS, 0x21, sizeValue]);
|
||
}
|
||
|
||
setEmphasis(bold = false, underline = false) {
|
||
const cmds = [];
|
||
cmds.push(...(bold ? BOLD_ON : BOLD_OFF));
|
||
cmds.push(...(underline ? UNDERLINE_ON : UNDERLINE_OFF));
|
||
return new Uint8Array(cmds);
|
||
}
|
||
|
||
feedAndCut(lines = 3) {
|
||
const cmds = [];
|
||
for (let i = 0; i < lines; i++) cmds.push(...FEED_LINE);
|
||
cmds.push(...CUT_PAPER);
|
||
cmds.push(...INIT); // Force printer buffer flush and reset
|
||
return new Uint8Array(cmds);
|
||
}
|
||
|
||
/**
|
||
* Encode text to bytes using the configured character set.
|
||
*/
|
||
encodeText(text) {
|
||
if (!text) return new Uint8Array(0);
|
||
|
||
const result = new Uint8Array(text.length);
|
||
const charSet = (this.characterSet || 'CP437').toUpperCase();
|
||
|
||
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,'á':0xA0,'í':0xA1,'ó':0xA2,
|
||
'ú':0xA3,'ñ':0xA4,'Ñ':0xA5,'ß':0xE1,'µ':0xE6,'±':0xF1,'°':0xF8,'€':0xEE,
|
||
};
|
||
const cp850Map = {
|
||
...cp437Map, 'ø':0x9B,'Ø':0x9D,'×':0x9E,'®':0xA9,'€':0xD5,
|
||
};
|
||
|
||
const activeMap = (charSet === 'CP850' || charSet === 'CP858' || charSet === 'CP852')
|
||
? cp850Map : cp437Map;
|
||
|
||
for (let i = 0; i < text.length; i++) {
|
||
const ch = text[i];
|
||
const code = ch.charCodeAt(0);
|
||
if (code <= 127) {
|
||
result[i] = code;
|
||
} else if (activeMap[ch] !== undefined) {
|
||
result[i] = activeMap[ch];
|
||
} else {
|
||
const norm = ch.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||
result[i] = (norm.length > 0 && norm.charCodeAt(0) <= 127)
|
||
? norm.charCodeAt(0) : 0x3F; // '?'
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Emit a single line of text (with optional formatting) followed by LF.
|
||
*/
|
||
addLine(text, options = {}) {
|
||
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, '.');
|
||
}
|
||
// 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}`;
|
||
}
|
||
|
||
/**
|
||
* 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 cmds = [];
|
||
const W = this.colWidth;
|
||
|
||
// ── Initialize ────────────────────────────────────────────────────
|
||
cmds.push(...this.initialize());
|
||
|
||
// ── Header ────────────────────────────────────────────────────────
|
||
if (receiptData.headerData) {
|
||
const h = receiptData.headerData;
|
||
if (h.companyName) {
|
||
cmds.push(...this.addLine(h.companyName, { align: 'center', bold: true, width: 2, height: 2 }));
|
||
}
|
||
if (h.address) {
|
||
// Long addresses should wrap — print as-is (printer wraps automatically)
|
||
cmds.push(...this.addLine(h.address, { align: 'center' }));
|
||
}
|
||
if (h.phone) {
|
||
cmds.push(...this.addLine(h.phone, { align: 'center' }));
|
||
}
|
||
if (h.taxId) {
|
||
cmds.push(...this.addLine(`NPWP: ${h.taxId}`, { align: 'center' }));
|
||
}
|
||
cmds.push(...this.addLine(this.divider()));
|
||
}
|
||
|
||
// ── Order info ────────────────────────────────────────────────────
|
||
if (receiptData.orderData) {
|
||
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}`));
|
||
// Table name — printed after cashier, matching pos_restaurant ReceiptHeader
|
||
if (o.tableName) cmds.push(...this.addLine(o.tableName, { bold: true, align: 'center' }));
|
||
if (o.customer) cmds.push(...this.addLine(`Customer: ${o.customer}`));
|
||
cmds.push(...this.addLine(this.divider()));
|
||
}
|
||
|
||
// ── Line items ────────────────────────────────────────────────────
|
||
if (receiptData.lines && receiptData.lines.length > 0) {
|
||
receiptData.lines.forEach(line => {
|
||
const name = String(line.productName || '');
|
||
const qty = line.quantity || 0;
|
||
const qtyStr = qty % 1 === 0 ? qty.toFixed(0) : qty.toFixed(2);
|
||
|
||
if (receiptData.isBasicReceipt) {
|
||
// ── Basic receipt / table checker ──────────────────────────
|
||
// Show only qty + product name (no price/total), matching
|
||
// Odoo's basic_receipt behaviour (vals.price = false).
|
||
// Format: "2 Mie Ayam Geprek"
|
||
const qtyLabel = qtyStr.padEnd(3);
|
||
const nameMaxLen = W - qtyLabel.length;
|
||
const displayName = name.length > nameMaxLen
|
||
? name.substring(0, nameMaxLen - 1) + '.'
|
||
: name;
|
||
cmds.push(...this.addLine(qtyLabel + displayName, { bold: true, height: 2 }));
|
||
} else {
|
||
// ── Full receipt ───────────────────────────────────────────
|
||
// Line 1: product name
|
||
const displayName = name.length > W ? name.substring(0, W - 1) + '.' : name;
|
||
cmds.push(...this.addLine(displayName, { bold: true, height: 2 }));
|
||
|
||
// Line 2: qty x unitPrice = total (right-aligned)
|
||
const priceStr = this.formatAmount(line.price || 0);
|
||
const totalStr = this.formatAmount(line.total || 0);
|
||
const middle = `${qtyStr}x ${priceStr}`;
|
||
let itemLine = middle + totalStr.padStart(W - middle.length);
|
||
if (itemLine.length > W) itemLine = totalStr.padStart(W);
|
||
cmds.push(...this.addLine(itemLine, { bold: true, height: 2 }));
|
||
}
|
||
|
||
// Combo sub-lines detail printing (shown on both basic/checker and full receipt modes)
|
||
if (line.comboLines && line.comboLines.length > 0) {
|
||
line.comboLines.forEach(sub => {
|
||
const subName = String(sub.productName || '');
|
||
const subQty = sub.quantity || 0;
|
||
const subQtyStr = subQty % 1 === 0 ? subQty.toFixed(0) : subQty.toFixed(2);
|
||
|
||
// Format: " - 1x Mie Goreng"
|
||
const prefix = ` - ${subQtyStr}x `;
|
||
const maxSubLen = W - prefix.length;
|
||
|
||
const displayName = subName.length > maxSubLen
|
||
? subName.substring(0, maxSubLen - 1) + '.'
|
||
: subName;
|
||
cmds.push(...this.addLine(prefix + displayName, { bold: true }));
|
||
});
|
||
}
|
||
|
||
// Note line (customer note / kitchen note) — word-wrapped to prevent truncation
|
||
if (line.note) {
|
||
const rawNote = String(line.note);
|
||
const rawLines = rawNote.split('\n');
|
||
const wrappedLines = [];
|
||
const maxNoteWidth = W - 2;
|
||
|
||
rawLines.forEach(rawLine => {
|
||
const words = rawLine.split(/\s+/);
|
||
let currentLine = '';
|
||
for (const word of words) {
|
||
if (!word) continue;
|
||
if ((currentLine + (currentLine ? ' ' : '') + word).length <= maxNoteWidth) {
|
||
currentLine += (currentLine ? ' ' : '') + word;
|
||
} else {
|
||
if (currentLine) {
|
||
wrappedLines.push(currentLine);
|
||
}
|
||
let tempWord = word;
|
||
while (tempWord.length > maxNoteWidth) {
|
||
wrappedLines.push(tempWord.substring(0, maxNoteWidth));
|
||
tempWord = tempWord.substring(maxNoteWidth);
|
||
}
|
||
currentLine = tempWord;
|
||
}
|
||
}
|
||
if (currentLine) {
|
||
wrappedLines.push(currentLine);
|
||
}
|
||
});
|
||
|
||
wrappedLines.forEach((l, i) => {
|
||
const prefix = i === 0 ? '* ' : ' ';
|
||
cmds.push(...this.addLine(prefix + l, { bold: true }));
|
||
});
|
||
}
|
||
});
|
||
cmds.push(...this.addLine(this.divider()));
|
||
}
|
||
|
||
// ── Totals ────────────────────────────────────────────────────────
|
||
// Hidden on basic receipt — matches t-if="!props.basic_receipt" in order_receipt.xml
|
||
if (!receiptData.isBasicReceipt && receiptData.totals) {
|
||
const t = receiptData.totals;
|
||
|
||
if (t.subtotal !== undefined) {
|
||
cmds.push(...this.addLine(this.twoCol('Subtotal:', this.formatAmount(t.subtotal))));
|
||
}
|
||
if (t.discount !== undefined && t.discount > 0) {
|
||
cmds.push(...this.addLine(this.twoCol('Discount:', this.formatAmount(t.discount))));
|
||
}
|
||
if (t.tax !== undefined && t.tax !== 0) {
|
||
cmds.push(...this.addLine(this.twoCol('Tax:', this.formatAmount(t.tax))));
|
||
}
|
||
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 }));
|
||
}
|
||
cmds.push(...this.addLine(this.divider()));
|
||
}
|
||
|
||
// ── Payment ───────────────────────────────────────────────────────
|
||
// Hidden on basic receipt — matches t-if="!props.basic_receipt" in order_receipt.xml
|
||
if (!receiptData.isBasicReceipt && receiptData.paymentData) {
|
||
const p = receiptData.paymentData;
|
||
if (p.method) {
|
||
cmds.push(...this.addLine(`Payment: ${p.method}`));
|
||
}
|
||
if (p.amount !== undefined) {
|
||
cmds.push(...this.addLine(this.twoCol('Paid:', this.formatAmount(p.amount))));
|
||
}
|
||
if (p.change !== undefined && p.change > 0) {
|
||
cmds.push(...this.addLine(this.twoCol('Change:', this.formatAmount(p.change)), { bold: true }));
|
||
}
|
||
cmds.push(...this.addLine(this.divider()));
|
||
}
|
||
|
||
// ── Footer ────────────────────────────────────────────────────────
|
||
// Skip footer on basic receipt (table checker) — matches pos_custom_receipt behaviour
|
||
if (!receiptData.isBasicReceipt && receiptData.footerData) {
|
||
const f = receiptData.footerData;
|
||
if (f.message) {
|
||
cmds.push(...this.addLine(f.message, { align: 'center' }));
|
||
}
|
||
if (f.barcode) {
|
||
cmds.push(...this.addLine(f.barcode, { align: 'center' }));
|
||
}
|
||
}
|
||
|
||
// Feed and cut (set feed to 6 lines to ensure the footer clears the tear bar)
|
||
cmds.push(...this.feedAndCut(6));
|
||
|
||
return new Uint8Array(cmds);
|
||
}
|
||
|
||
/**
|
||
* Helper: combine multiple Uint8Arrays into one.
|
||
*/
|
||
static combineArrays(arrays) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
export default EscPosGenerator;
|