feat: update pos_receipt_printer to support Odoo 19 receipt structure and element selectors

This commit is contained in:
Suherdy Yacob 2026-05-29 09:03:57 +07:00
parent 4ee383c672
commit d4416d9e66

View File

@ -630,25 +630,24 @@ patch(PosPrinterService.prototype, {
}; };
// Parse order info // Parse order info
// Odoo 19: order reference is in .pos-receipt-vat, date in #order-date, cashier in .cashier
const orderData = { const orderData = {
orderName: getText('.pos-receipt-order-name') || getText('.order-name') || getText('.pos-receipt-order-data') || '', orderName: getText('.pos-receipt-vat') || getText('.pos-receipt-order-name') || getText('.order-name') || getText('.pos-receipt-order-data') || '',
date: getText('.pos-receipt-date') || new Date().toLocaleString(), date: getText('#order-date') || getText('.pos-receipt-date') || new Date().toLocaleString(),
cashier: getText('.pos-receipt-cashier') || getText('.cashier') || '', cashier: getText('.cashier') || getText('.pos-receipt-cashier') || '',
customer: getText('.pos-receipt-customer') || getText('.customer') || null customer: getText('.pos-receipt-customer') || getText('.customer') || null
}; };
// Parse order lines - try multiple selectors // Parse order lines
// Odoo 19: each order line is an <li class="orderline"> element
console.log('[BluetoothPrint] Searching for order lines...'); console.log('[BluetoothPrint] Searching for order lines...');
let lineElements = getAll('.orderline'); let lineElements = getAll('li.orderline');
if (lineElements.length === 0) {
lineElements = getAll('.orderline');
}
if (lineElements.length === 0) { if (lineElements.length === 0) {
lineElements = getAll('.pos-receipt-orderline'); lineElements = getAll('.pos-receipt-orderline');
} }
if (lineElements.length === 0) {
lineElements = getAll('tr.orderline');
}
if (lineElements.length === 0) {
lineElements = getAll('.pos-orderline');
}
if (lineElements.length === 0) { if (lineElements.length === 0) {
// Try to find any table rows // Try to find any table rows
lineElements = getAll('tbody tr'); lineElements = getAll('tbody tr');
@ -657,26 +656,75 @@ patch(PosPrinterService.prototype, {
console.log('[BluetoothPrint] Found', lineElements.length, 'line elements'); console.log('[BluetoothPrint] Found', lineElements.length, 'line elements');
const lines = lineElements.map((line, index) => { const lines = lineElements.map((line, index) => {
console.log(`[BluetoothPrint] Processing line ${index}:`, line.outerHTML.substring(0, 200)); console.log(`[BluetoothPrint] Processing line ${index}:`, line.outerHTML.substring(0, 300));
const productName = line.querySelector('.product-name, td:first-child, .pos-receipt-left-align')?.textContent.trim() || ''; // Odoo 19 orderline structure:
const qtyText = line.querySelector('.qty, .quantity')?.textContent.trim() || '1'; // div.product-name
const priceText = line.querySelector('.price, .price-unit')?.textContent.trim() || '0'; // span.qty ← quantity (e.g. "999")
const totalText = line.querySelector('.price-total, .total, td:last-child, .pos-receipt-right-align')?.textContent.trim() || '0'; // span.text-wrap ← product name only
// div.product-price.price ← line total (formatted currency, e.g. "Rp 0.00")
// ul.info-list
// li.price-per-unit ← "unit_price / UoM" (only shown when qty != 1)
console.log(`[BluetoothPrint] Line ${index} raw data:`, { productName, qtyText, priceText, totalText }); // Get qty from the dedicated .qty span
const qtySpan = line.querySelector('.qty');
const qtyText = qtySpan ? qtySpan.textContent.trim() : '1';
// Parse numbers (remove currency symbols and commas) // Get product name from span.text-wrap (excludes the qty span)
const nameSpan = line.querySelector('.product-name .text-wrap');
let productName = '';
if (nameSpan) {
productName = nameSpan.textContent.trim();
} else {
// Fallback: get .product-name text then strip the qty prefix
const productNameEl = line.querySelector('.product-name');
if (productNameEl) {
const fullText = productNameEl.textContent.trim();
const qtyPrefix = qtyText;
productName = fullText.startsWith(qtyPrefix)
? fullText.slice(qtyPrefix.length).trim()
: fullText;
}
}
// Line total: div.product-price.price contains the formatted currency string
const priceTotalEl = line.querySelector('.product-price.price, .product-price');
const priceTotalText = priceTotalEl ? priceTotalEl.textContent.trim() : '0';
// Unit price: li.price-per-unit contains "unit_price / UoM"
// Only shown when qty != 1; format: "Rp 1,234.56 / Unit"
const priceUnitEl = line.querySelector('.price-per-unit');
const priceUnitText = priceUnitEl ? priceUnitEl.textContent.trim() : '';
console.log(`[BluetoothPrint] Line ${index} raw data:`, { productName, qtyText, priceTotalText, priceUnitText });
// Parse numbers (remove currency symbols and commas, keep digits/dot/minus)
const parseNumber = (str) => { const parseNumber = (str) => {
if (!str) return 0;
const cleaned = str.replace(/[^0-9.-]/g, ''); const cleaned = str.replace(/[^0-9.-]/g, '');
return parseFloat(cleaned) || 0; return parseFloat(cleaned) || 0;
}; };
const qty = parseNumber(qtyText);
const lineTotal = parseNumber(priceTotalText);
// Extract unit price from "price / UoM" string (take part before " / ")
let unitPrice = 0;
if (priceUnitText) {
const slashIdx = priceUnitText.lastIndexOf('/');
const priceStr = slashIdx > 0 ? priceUnitText.substring(0, slashIdx) : priceUnitText;
unitPrice = parseNumber(priceStr);
} else if (qty > 0 && lineTotal > 0) {
unitPrice = lineTotal / qty;
} else {
unitPrice = lineTotal;
}
const parsedLine = { const parsedLine = {
productName: productName, productName: productName,
quantity: parseNumber(qtyText), quantity: qty,
price: parseNumber(priceText), price: unitPrice,
total: parseNumber(totalText) total: lineTotal
}; };
console.log(`[BluetoothPrint] Line ${index} parsed:`, parsedLine); console.log(`[BluetoothPrint] Line ${index} parsed:`, parsedLine);
@ -688,12 +736,34 @@ patch(PosPrinterService.prototype, {
console.log('[BluetoothPrint] All lines:', JSON.stringify(lines, null, 2)); console.log('[BluetoothPrint] All lines:', JSON.stringify(lines, null, 2));
// Parse totals // Parse totals
// Odoo 19 receipt structure:
// div.pos-receipt-amount.receipt-total > span.label-total + span.pos-receipt-right-align (total)
// div.pos-receipt-taxes > div > span (subtotal excl. tax) + div > span (per tax group amount)
// div.pos-receipt-amount.receipt-change > span.pos-receipt-right-align (change)
console.log('[BluetoothPrint] Parsing totals...'); console.log('[BluetoothPrint] Parsing totals...');
// Grand total
const totalEl = el.querySelector('.receipt-total .pos-receipt-right-align, .receipt-total .font-monospace');
const totalText = totalEl ? totalEl.textContent.trim() : '';
// Subtotal (excl. tax) — only present when tax groups are shown
const subtotalEl = el.querySelector('.pos-receipt-taxes .ms-auto');
const subtotalText = subtotalEl ? subtotalEl.textContent.trim() : '';
// Tax amounts (all .font-monospace inside .pos-receipt-taxes except the subtotal span)
let taxTotal = 0;
const taxGroupDivs = el.querySelectorAll('.pos-receipt-taxes > div');
taxGroupDivs.forEach((div, i) => {
if (i === 0) return; // First div is the subtotal row
const amtEl = div.querySelector('.font-monospace, .ms-auto');
if (amtEl) taxTotal += this._parseAmount(amtEl.textContent.trim());
});
const totals = { const totals = {
subtotal: this._parseAmount(getText('.pos-receipt-subtotal, .subtotal')), subtotal: subtotalText ? this._parseAmount(subtotalText) : 0,
tax: this._parseAmount(getText('.pos-receipt-tax, .tax')), tax: taxTotal,
discount: this._parseAmount(getText('.pos-receipt-discount, .discount')), discount: this._parseAmount(getText('.label-discount ~ .pos-receipt-right-align')),
total: this._parseAmount(getText('.pos-receipt-total, .total, .amount-total')) total: this._parseAmount(totalText)
}; };
console.log('[BluetoothPrint] Parsed totals:', totals); console.log('[BluetoothPrint] Parsed totals:', totals);
@ -707,15 +777,37 @@ patch(PosPrinterService.prototype, {
} }
// Parse payment info // Parse payment info
// Odoo 19: div.paymentlines contains payment method name (text node) + span amount
let paymentMethod = 'Cash';
let paymentAmount = totals.total;
const paymentLineEls = el.querySelectorAll('.paymentlines');
if (paymentLineEls.length > 0) {
const firstPayLine = paymentLineEls[0];
const amtSpan = firstPayLine.querySelector('.pos-receipt-right-align, .font-monospace');
if (amtSpan) {
paymentAmount = this._parseAmount(amtSpan.textContent.trim()) || totals.total;
// Method name is everything in the div except the amount span
const methodText = firstPayLine.textContent.replace(amtSpan.textContent, '').trim();
paymentMethod = methodText || 'Cash';
} else {
paymentMethod = firstPayLine.textContent.trim() || 'Cash';
}
}
// Change
const changeEl = el.querySelector('.receipt-change .pos-receipt-right-align, .receipt-change .font-monospace');
const changeAmount = changeEl ? this._parseAmount(changeEl.textContent.trim()) : 0;
const paymentData = { const paymentData = {
method: getText('.pos-receipt-payment-method, .payment-method') || 'Cash', method: paymentMethod,
amount: this._parseAmount(getText('.pos-receipt-payment-amount, .payment-amount')) || totals.total, amount: paymentAmount,
change: this._parseAmount(getText('.pos-receipt-change, .change')) || 0 change: changeAmount
}; };
// Footer // Footer
const footerData = { const footerData = {
message: getText('.pos-receipt-footer, .receipt-footer') || 'Thank you for your business!', message: getText('.pos-receipt-center-align, .pos-receipt-footer, .receipt-footer') || 'Thank you for your business!',
barcode: orderData.orderName || null barcode: orderData.orderName || null
}; };