pos_closing_receipt/static/src/app/closing_receipt_patch.js

406 lines
16 KiB
JavaScript

/** @odoo-module **/
import { ClosePosPopup } from "@point_of_sale/app/components/popups/closing_popup/closing_popup";
import { Navbar } from "@point_of_sale/app/components/navbar/navbar";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
import { parseFloat } from "@web/views/fields/parsers";
import { ConnectionLostError } from "@web/core/network/rpc";
import { _t } from "@web/core/l10n/translation";
import { PrinterService } from "@point_of_sale/app/services/printer_service";
import { waitImages } from "@point_of_sale/utils";
// Patch PrinterService.printWeb to return a promise that resolves only after print dialog is closed
patch(PrinterService.prototype, {
async printWeb(el) {
console.log("[pos_closing_receipt] Patched printWeb called");
await this.renderer.whenMounted({
el,
callback: async (elClone) => {
console.log("[pos_closing_receipt] printWeb whenMounted callback started. Awaiting images...");
await waitImages(elClone);
console.log("[pos_closing_receipt] Images loaded. Invoking window.print...");
window.print(elClone);
console.log("[pos_closing_receipt] window.print has returned.");
},
});
console.log("[pos_closing_receipt] Patched printWeb fully resolved and returning true.");
return true;
}
});
// ─────────────────────────────────────────────────────────────────────────────
// ClosingReceipt OWL Component
// Rendered by the printer service (same way OrderReceipt is rendered).
// ─────────────────────────────────────────────────────────────────────────────
export class ClosingReceipt extends Component {
static template = "pos_closing_receipt.ClosingReceipt";
static props = {
sessionName: String,
cashierName: String,
closingTime: String,
cashPayment: { type: Object, optional: true },
nonCashPayments: Array,
grandTotal: String,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Patch ClosePosPopup: print closing receipt on session close
//
// WHY we override closeSession() instead of wrapping super.closeSession():
// The original ends with `this.pos.router.close()` → window.location.href,
// a hard browser redirect. Any code after `await super.closeSession()` is
// dead code. We inline the same logic so we can print BEFORE the redirect.
// ─────────────────────────────────────────────────────────────────────────────
patch(ClosePosPopup.prototype, {
setup() {
super.setup(...arguments);
this.printer = useService("printer");
},
async closeSession() {
this.pos._resetConnectedCashier();
const syncSuccess = await this.pos.pushOrdersWithClosingPopup();
if (!syncSuccess) {
return;
}
if (this.pos.config.cash_control) {
const response = await this.pos.data.call(
"pos.session",
"post_closing_cash_details",
[this.pos.session.id],
{
counted_cash: parseFloat(
this.state.payments[this.props.default_cash_details.id].counted
),
}
);
if (!response.successful) {
return this.handleClosingError(response);
}
}
try {
await this.pos.data.call(
"pos.session",
"update_closing_control_state_session",
[this.pos.session.id, this.state.notes]
);
} catch (error) {
if (!error.data && error.data?.message !== "This session is already closed.") {
throw error;
}
}
// ── Fetch real session name before the server closes it ──────────────
let realSessionName = this.pos.config.name || "POS Session";
try {
const result = await this.pos.data.read(
"pos.session",
[this.pos.session.id],
["name"]
);
if (result && result[0] && result[0].name && result[0].name !== "/") {
realSessionName = result[0].name;
}
} catch (_) {}
// ── Capture receipt data while props/pos data are still valid ────────
const receiptData = this._buildReceiptData(realSessionName);
try {
const bankPaymentMethodDiffPairs = this.props.non_cash_payment_methods
.filter((pm) => pm.type === "bank")
.map((pm) => [pm.id, this.getDifference(pm.id)]);
// ── Print BEFORE closing session on server ───────────────────────
// IMPORTANT: Must print here while IndexedDB is still fully open.
// After close_session_from_ui responds, the Odoo bus/sync detects
// the session state change and begins IDB teardown. Any OWL render
// or Logger.log call after that point throws:
// InvalidStateError: Failed to execute 'transaction' on
// 'IDBDatabase': The database connection is closing.
await this._printClosingReceipt(receiptData);
const response = await this.pos.data.call(
"pos.session",
"close_session_from_ui",
[this.pos.session.id, bankPaymentMethodDiffPairs],
{
context: {
device_identifier: this.pos.device.identifier,
},
}
);
if (!response.successful) {
return this.handleClosingError(response);
}
// Mark session closed locally
this.pos.session.state = "closed";
// ── Navigate away ────────────────────────────────────────────────
this.pos.router.close();
} catch (error) {
if (error instanceof ConnectionLostError) {
throw error;
} else {
await this.handleClosingControlError();
}
} finally {
localStorage.removeItem(`pos.session.${odoo.pos_config_id}`);
}
},
async _printClosingReceipt(receiptData) {
console.log("[pos_closing_receipt] Initiating closing receipt print...");
if (this.pos.env.services.ui) {
console.log("[pos_closing_receipt] Blocking UI during print...");
this.pos.env.services.ui.block();
}
try {
const printResult = await this.printer.print(ClosingReceipt, receiptData, {
webPrintFallback: true,
});
console.log("[pos_closing_receipt] Print operation resolved with result:", printResult);
// Explicit safety delay to let printer buffers flush (especially for Bluetooth/JSPM)
const delayMs = 1500;
console.log(`[pos_closing_receipt] Applying safety buffer delay of ${delayMs}ms...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
console.log("[pos_closing_receipt] Safety buffer complete.");
} catch (err) {
console.error("[pos_closing_receipt] CRITICAL: Failed to print closing receipt:", err);
} finally {
if (this.pos.env.services.ui) {
console.log("[pos_closing_receipt] Unblocking UI.");
this.pos.env.services.ui.unblock();
}
}
},
_buildReceiptData(sessionName) {
const pos = this.pos;
const formatCurrency = this.env.utils.formatCurrency;
let cashierName = "";
try {
const cashier = pos.getCashier();
cashierName = cashier?.name || cashier?.display_name || "";
} catch (_) {
cashierName = pos.user?.name || "";
}
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const closingTime =
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
let cashPayment = null;
if (this.props.default_cash_details) {
const amount = this.props.default_cash_details.payment_amount || 0;
cashPayment = {
id: this.props.default_cash_details.id,
name: this.props.default_cash_details.name || "Cash",
amount,
formattedAmount: formatCurrency(amount),
};
}
const nonCashPayments = (this.props.non_cash_payment_methods || []).map((pm) => ({
id: pm.id,
name: pm.name,
amount: pm.amount || 0,
formattedAmount: formatCurrency(pm.amount || 0),
}));
const cashAmount = cashPayment ? cashPayment.amount : 0;
const nonCashTotal = nonCashPayments.reduce((sum, pm) => sum + pm.amount, 0);
const grandTotal = formatCurrency(cashAmount + nonCashTotal);
return {
sessionName,
cashierName,
closingTime,
cashPayment,
nonCashPayments,
grandTotal,
};
},
});
// ─────────────────────────────────────────────────────────────────────────────
// Patch Navbar: add "Reprint Closing Summary" to the hamburger menu
//
// Fetches the last closed session for the current POS config from the server,
// builds receipt data, and prints with webPrintFallback.
// ─────────────────────────────────────────────────────────────────────────────
patch(Navbar.prototype, {
async reprintLastClosingReceipt() {
const formatCurrency = this.env.utils.formatCurrency;
// ── 1. Fetch the last closed session for this config ─────────────────
let sessionData;
try {
const result = await this.pos.data.call(
"pos.session",
"get_last_closed_session_summary",
[this.pos.config.id]
);
if (!result) {
this.notification.add(_t("No closed session found to reprint."), {
type: "warning",
});
return;
}
sessionData = result;
} catch (err) {
console.error("[pos_closing_receipt] Could not fetch last session:", err);
this.notification.add(_t("Failed to fetch session data."), { type: "danger" });
return;
}
// ── 2. Build formatted payment rows ──────────────────────────────────
let cashRow = "";
const nonCashRows = [];
for (const pm of sessionData.payment_methods) {
const formatted = formatCurrency(pm.amount);
const row = `
<tr>
<td style="padding:3px 0;">${pm.name}</td>
<td style="text-align:right;padding:3px 0;">${formatted}</td>
</tr>`;
if (pm.is_cash) {
cashRow = row;
} else {
nonCashRows.push(row);
}
}
const cashAmount = (sessionData.payment_methods.find((p) => p.is_cash) || {}).amount || 0;
const nonCashTotal = sessionData.payment_methods
.filter((p) => !p.is_cash)
.reduce((s, p) => s + p.amount, 0);
const grandTotal = formatCurrency(cashAmount + nonCashTotal);
// ── 3. Build standalone HTML receipt and open print window ───────────
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<title>Closing Summary Reprint</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Courier New', Courier, monospace;
font-size: 28px;
background: #fff;
color: #000;
}
.receipt {
width: 100%;
max-width: 320px;
margin: 0 auto;
padding: 12px;
}
.center { text-align: center; }
.header-title {
font-size: 36px;
font-weight: bold;
letter-spacing: 1px;
text-transform: uppercase;
}
.header-sub {
margin-top: 4px;
font-size: 24px;
letter-spacing: 2px;
}
.reprint-label {
margin-top: 2px;
font-size: 22px;
color: #666;
}
.dashed { border-top: 1px dashed #000; margin: 8px 0; }
.solid { border-top: 1px solid #000; margin: 8px 0; }
table { width: 100%; border-collapse: collapse; margin-bottom: 8px; }
td { padding: 5px 0; }
.right { text-align: right; }
.bold { font-weight: bold; }
.total-row td { font-weight: bold; font-size: 32px; padding: 6px 0; }
.footer {
text-align: center;
margin-top: 12px;
font-size: 24px;
letter-spacing: 1px;
}
.feed { margin-top: 28px; }
@media print {
@page { size: 80mm auto; margin: 3mm; }
}
</style>
</head>
<body>
<div class="receipt">
<div class="center" style="margin-bottom:10px;">
<div class="header-title">${sessionData.session_name}</div>
<div class="header-sub">SESSION CLOSING SUMMARY</div>
<div class="reprint-label">(REPRINT)</div>
</div>
<div class="dashed"></div>
<table>
<tr>
<td class="bold">Cashier</td>
<td class="right">${sessionData.cashier_name}</td>
</tr>
<tr>
<td class="bold">Date/Time</td>
<td class="right" style="font-size:24px;">${sessionData.closing_time}</td>
</tr>
</table>
<div class="dashed"></div>
<table>
${cashRow}
${nonCashRows.join("")}
</table>
<div class="solid"></div>
<table>
<tr class="total-row">
<td>TOTAL</td>
<td class="right">${grandTotal}</td>
</tr>
</table>
<div class="dashed"></div>
<div class="footer">*** Session Closed ***</div>
<div class="feed">&nbsp;</div>
</div>
<script>
window.onload = function() {
window.print();
setTimeout(function() { window.close(); }, 1000);
};
</script>
</body>
</html>`;
const printWindow = window.open("", "_blank", "width=400,height=600");
if (!printWindow) {
this.notification.add(
_t("Pop-up blocked. Please allow pop-ups for this site and try again."),
{ type: "warning" }
);
return;
}
printWindow.document.open();
printWindow.document.write(html);
printWindow.document.close();
},
});