601 lines
22 KiB
JavaScript
601 lines
22 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";
|
|
import { formatCurrency as webFormatCurrency } from "@web/core/currency";
|
|
|
|
ClosePosPopup.props.push("last_cashier_name?");
|
|
|
|
const getCurrencyId = (pos) => {
|
|
if (!pos) {
|
|
return undefined;
|
|
}
|
|
if (pos.currency && typeof pos.currency === "object" && "id" in pos.currency) {
|
|
return pos.currency.id;
|
|
}
|
|
if (pos.currency && typeof pos.currency === "number") {
|
|
return pos.currency;
|
|
}
|
|
const configCurrency = pos.config?.currency_id;
|
|
if (configCurrency) {
|
|
if (typeof configCurrency === "object") {
|
|
if ("id" in configCurrency) {
|
|
return configCurrency.id;
|
|
}
|
|
if (Array.isArray(configCurrency)) {
|
|
return configCurrency[0];
|
|
}
|
|
} else if (typeof configCurrency === "number") {
|
|
return configCurrency;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const safeFormatCurrency = (value, pos, hasSymbol = true) => {
|
|
return webFormatCurrency(value, getCurrencyId(pos), {
|
|
noSymbol: !hasSymbol,
|
|
});
|
|
};
|
|
|
|
|
|
// 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 {
|
|
let cashRow = "";
|
|
if (receiptData.cashPayment) {
|
|
cashRow = `
|
|
<tr>
|
|
<td style="padding:3px 0;">${receiptData.cashPayment.name}</td>
|
|
<td style="text-align:right;padding:3px 0;">${receiptData.cashPayment.formattedAmount}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
const nonCashRows = (receiptData.nonCashPayments || []).map(
|
|
(pm) => `
|
|
<tr>
|
|
<td style="padding:3px 0;">${pm.name}</td>
|
|
<td style="text-align:right;padding:3px 0;">${pm.formattedAmount}</td>
|
|
</tr>`
|
|
);
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<title>Closing Summary</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;
|
|
}
|
|
.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">${receiptData.sessionName}</div>
|
|
<div class="header-sub">SESSION CLOSING SUMMARY</div>
|
|
</div>
|
|
<div class="dashed"></div>
|
|
<table>
|
|
<tr>
|
|
<td class="bold">Cashier</td>
|
|
<td class="right">${receiptData.cashierName}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="bold">Date/Time</td>
|
|
<td class="right" style="font-size:24px;">${receiptData.closingTime}</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">${receiptData.grandTotal}</td>
|
|
</tr>
|
|
</table>
|
|
<div class="dashed"></div>
|
|
<div class="footer">*** Session Closed ***</div>
|
|
<div class="feed"> </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) {
|
|
const notification = this.pos?.env?.services?.notification;
|
|
if (notification) {
|
|
notification.add(
|
|
_t("Pop-up blocked. Please allow pop-ups for this site and try again to print closing receipt."),
|
|
{ type: "warning" }
|
|
);
|
|
} else {
|
|
alert("Pop-up blocked. Please allow pop-ups for this site and try again to print closing receipt.");
|
|
}
|
|
return;
|
|
}
|
|
printWindow.document.open();
|
|
printWindow.document.write(html);
|
|
printWindow.document.close();
|
|
|
|
// Safety delay to allow window to open and print dialog to spawn before proceeding
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
} 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 = (val, hasSymbol = true) => safeFormatCurrency(val, pos, hasSymbol);
|
|
|
|
let cashierName = this.props.last_cashier_name || "";
|
|
if (!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);
|
|
|
|
let expectedCash = 0;
|
|
let countedCash = 0;
|
|
let cashDifference = 0;
|
|
let hasCashControl = false;
|
|
if (this.props.default_cash_details) {
|
|
hasCashControl = true;
|
|
expectedCash = this.props.default_cash_details.amount || 0;
|
|
const countedStr = this.state.payments[this.props.default_cash_details.id]?.counted || "0";
|
|
countedCash = parseFloat(countedStr) || 0;
|
|
cashDifference = countedCash - expectedCash;
|
|
}
|
|
|
|
return {
|
|
sessionName,
|
|
cashierName,
|
|
closingTime,
|
|
cashPayment,
|
|
nonCashPayments,
|
|
grandTotal,
|
|
hasCashControl,
|
|
expectedCash: formatCurrency(expectedCash),
|
|
countedCash: formatCurrency(countedCash),
|
|
cashDifference: formatCurrency(cashDifference),
|
|
rawCashDifference: cashDifference,
|
|
};
|
|
},
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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 pos = this.pos;
|
|
const formatCurrency = (val, hasSymbol = true) => safeFormatCurrency(val, pos, hasSymbol);
|
|
|
|
// ── 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);
|
|
|
|
let cashControlBlock = "";
|
|
if (sessionData.has_cash_control) {
|
|
cashControlBlock = `
|
|
<div class="dashed"></div>
|
|
<table>
|
|
<tr>
|
|
<td style="padding:5px 0;">Expected Cash</td>
|
|
<td class="right" style="padding:5px 0;">${formatCurrency(sessionData.closing_cash_expected)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:5px 0;">Counted Cash</td>
|
|
<td class="right" style="padding:5px 0;">${formatCurrency(sessionData.closing_cash_counted)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="bold" style="padding:5px 0;">Cash Difference</td>
|
|
<td class="right bold" style="padding:5px 0;">${formatCurrency(sessionData.closing_cash_difference)}</td>
|
|
</tr>
|
|
</table>`;
|
|
}
|
|
|
|
// ── 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>
|
|
${cashControlBlock}
|
|
<div class="dashed"></div>
|
|
<div class="footer">*** Session Closed ***</div>
|
|
<div class="feed"> </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();
|
|
},
|
|
});
|