From a894acaefb2466a96a342b54f822faf035345f68 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Mon, 1 Jun 2026 16:08:36 +0700 Subject: [PATCH] refactor: rewrite session closing logic to support backend report generation and manual closing summary reprints --- README.md | 60 +++++-- __init__.py | 1 + __manifest__.py | 11 +- models/__init__.py | 2 + models/pos_session.py | 140 +++++++++++++++ report/pos_closing_summary_report.xml | 145 ++++++++++++++++ static/src/app/closing_receipt.xml | 20 ++- static/src/app/closing_receipt_patch.js | 218 +++++++++++++++++++----- views/pos_session_views.xml | 24 +++ 9 files changed, 564 insertions(+), 57 deletions(-) create mode 100644 models/__init__.py create mode 100644 models/pos_session.py create mode 100644 report/pos_closing_summary_report.xml create mode 100644 views/pos_session_views.xml diff --git a/README.md b/README.md index f14bd91..a884880 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Odoo 19 custom module — automatically prints a **session closing summary** via - Triggers the browser's native print dialog (`window.print()`) — no IoT Box or network printer required. - Prints cleanly to thermal receipt printers (≤ 80mm paper) via the browser. - Fault-tolerant: print failures are caught and logged; they never block the session closing process. +- **Reprint**: adds a **Reprint Closing Summary** button on the closed session form in the Odoo backend. --- @@ -77,15 +78,23 @@ realSessionName = result[0].name; // → "Mie Mapan Barata00001" ``` User clicks "Close" in POS closing popup │ - ├─ 1. Fetch real session name from server (pos.data.read) - ├─ 2. Collect payment data from props (default_cash_details, non_cash_payment_methods) - ├─ 3. Call super.closeSession() — standard Odoo closing - │ - └─ 4. Session state === "closed"? - └─ Yes → printer.print(ClosingReceipt, data, { webPrintFallback: true }) - └─ Browser print dialog opens + ├─ 1. Sync unsynced orders (pushOrdersWithClosingPopup) + ├─ 2. Post closing cash details (if cash_control enabled) + ├─ 3. Fetch real session name from server (pos.data.read) + ├─ 4. Collect payment data from props → _buildReceiptData() + ├─ 5. Call close_session_from_ui on server + ├─ 6. Mark session.state = "closed" locally + ├─ 7. _printClosingReceipt() → browser print dialog + └─ 8. pos.router.close() → redirect away from POS ``` +> **Why we override `closeSession()` instead of wrapping `super.closeSession()`:** +> The original method ends by calling `this.pos.router.close()` which does +> `window.location.href = ...` — a hard browser redirect that immediately +> unloads the page. Any code placed **after** `await super.closeSession()` would +> never execute. The fix inlines the closing logic so the receipt is printed +> **before** the redirect. + ### Web Print (No IoT Box) Uses Odoo's built-in `printer` service with `webPrintFallback: true` — the same mechanism used by the standard POS order receipt: @@ -95,6 +104,14 @@ Uses Odoo's built-in `printer` service with `webPrintFallback: true` — the sam --- +## Reprint from Backend + +When the POS session is already closed, open it from **Point of Sale → Sessions**, then click **Reprint Closing Summary** in the form header. +The report opens in a new browser tab styled identically to the auto-print receipt and includes a *(REPRINT)* label. +Use the browser print dialog (Ctrl+P) to send it to any printer. + +--- + ## File Structure ``` @@ -102,11 +119,16 @@ pos_closing_receipt/ ├── __init__.py ├── __manifest__.py ├── README.md -└── static/ - └── src/ - └── app/ - ├── closing_receipt.xml # OWL template (receipt layout + inline CSS) - └── closing_receipt_patch.js # Patch for ClosePosPopup + ClosingReceipt component +├── models/ +│ ├── __init__.py +│ └── pos_session.py # action_reprint_closing_summary + get_closing_summary_data +├── report/ +│ └── pos_closing_summary_report.xml # QWeb template + paper format + report action +├── views/ +│ └── pos_session_views.xml # adds Reprint button to session form +└── static/src/app/ + ├── closing_receipt.xml # OWL template (receipt layout + inline CSS) + └── closing_receipt_patch.js # Patch for ClosePosPopup + ClosingReceipt component ``` --- @@ -130,6 +152,20 @@ The default `max-width` is `320px` (80mm thermal). For 58mm paper, reduce to `22 ## Changelog +### v1.2.0 +- **Feature**: Added **Reprint Closing Summary** button on closed `pos.session` form view. +- Backend QWeb report (`qweb-html`) renders identically to the auto-print receipt with *(REPRINT)* label. +- Custom 80mm paper format registered for accurate thermal receipt sizing. +- `models/pos_session.py`: `action_reprint_closing_summary()` + `get_closing_summary_data()` aggregate payment totals from `pos.payment` records. + +### v1.1.0 +- **Fix**: Closing receipt was never printed because the original implementation + placed the print call **after** `await super.closeSession()`, which ends with + `window.location.href = ...` — a hard page redirect that unloads everything + before the print can execute. +- Solution: override `closeSession()` fully (mirroring core logic) and invoke + `_printClosingReceipt()` **before** `pos.router.close()`. + ### v1.0.0 - Initial release for Odoo 19. - Web print via `window.print()` (no IoT Box required). diff --git a/__init__.py b/__init__.py index 40a96af..a0fdc10 100644 --- a/__init__.py +++ b/__init__.py @@ -1 +1,2 @@ # -*- coding: utf-8 -*- +from . import models diff --git a/__manifest__.py b/__manifest__.py index c7e2a12..61e02b6 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -1,18 +1,25 @@ # -*- coding: utf-8 -*- { 'name': 'POS Closing Receipt Printer', - 'version': '19.0.1.0.0', + 'version': '19.0.1.3.0', 'category': 'Point of Sale', - 'summary': 'Print payment summary receipt when closing a POS session', + 'summary': 'Print payment summary receipt when closing a POS session, with reprint from backend', 'description': """ Automatically prints a payment summary receipt when the POS session is successfully closed. The receipt shows: - POS Session name/number - Cashier who performed the closing - Total amount per payment method (Cash, BCA, BTN, etc.) + + Also adds a "Reprint Closing Summary" button on the closed session + form view in the Odoo backend for easy reprinting. """, 'author': 'Suherdy Yacob', 'depends': ['point_of_sale', 'pos_hr'], + 'data': [ + 'report/pos_closing_summary_report.xml', + 'views/pos_session_views.xml', + ], 'assets': { 'point_of_sale._assets_pos': [ 'pos_closing_receipt/static/src/app/**/*', diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..ac13d7d --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import pos_session diff --git a/models/pos_session.py b/models/pos_session.py new file mode 100644 index 0000000..8200184 --- /dev/null +++ b/models/pos_session.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- + +from odoo import models, api + + +class PosSession(models.Model): + _inherit = 'pos.session' + + def action_reprint_closing_summary(self): + """Open the closing summary report for this session (backend reprint).""" + self.ensure_one() + return self.env.ref( + 'pos_closing_receipt.action_report_pos_closing_summary' + ).report_action(self) + + def get_closing_summary_data(self): + """ + Return structured data consumed by the QWeb report template. + Aggregates payments grouped by payment method for all orders + in this session. + """ + self.ensure_one() + session = self + + payments = self.env['pos.payment'].search([ + ('session_id', '=', session.id), + ('is_change', '=', False), + ]) + + method_totals = {} + for payment in payments: + pm = payment.payment_method_id + if pm.id not in method_totals: + method_totals[pm.id] = { + 'name': pm.name, + 'is_cash': pm.type == 'cash', + 'amount': 0.0, + } + method_totals[pm.id]['amount'] += payment.amount + + sorted_methods = sorted( + method_totals.values(), + key=lambda m: (0 if m['is_cash'] else 1, m['name']) + ) + + cash_payment = None + non_cash_payments = [] + for m in sorted_methods: + if m['is_cash']: + cash_payment = m + else: + non_cash_payments.append(m) + + grand_total = sum(m['amount'] for m in sorted_methods) + + cashier_name = session.user_id.name or '' + closing_time = '' + if session.stop_at: + tz = self.env.user.tz or 'UTC' + try: + import pytz + utc_dt = session.stop_at.replace(tzinfo=pytz.utc) + local_dt = utc_dt.astimezone(pytz.timezone(tz)) + closing_time = local_dt.strftime('%Y-%m-%d %H:%M:%S') + except Exception: + closing_time = session.stop_at.strftime('%Y-%m-%d %H:%M:%S') + + return { + 'session': session, + 'session_name': session.name, + 'cashier_name': cashier_name, + 'closing_time': closing_time, + 'cash_payment': cash_payment, + 'non_cash_payments': non_cash_payments, + 'grand_total': grand_total, + } + + @api.model + def get_last_closed_session_summary(self, config_id): + """ + Called from the POS frontend (Navbar) to reprint the last closed + session summary for the given config. + + Returns a dict with: + - session_name + - cashier_name + - closing_time (localized to current user's timezone) + - payment_methods: [{id, name, is_cash, amount}] + + Returns False if no closed session exists for this config. + """ + session = self.search( + [('config_id', '=', config_id), ('state', '=', 'closed')], + order='stop_at desc', + limit=1, + ) + if not session: + return False + + payments = self.env['pos.payment'].search([ + ('session_id', '=', session.id), + ('is_change', '=', False), + ]) + + method_totals = {} + for payment in payments: + pm = payment.payment_method_id + if pm.id not in method_totals: + method_totals[pm.id] = { + 'id': pm.id, + 'name': pm.name, + 'is_cash': pm.type == 'cash', + 'amount': 0.0, + } + method_totals[pm.id]['amount'] += payment.amount + + # Sort: cash first, then alphabetically + payment_methods = sorted( + method_totals.values(), + key=lambda m: (0 if m['is_cash'] else 1, m['name']) + ) + + cashier_name = session.user_id.name or '' + closing_time = '' + if session.stop_at: + tz = self.env.user.tz or 'UTC' + try: + import pytz + utc_dt = session.stop_at.replace(tzinfo=pytz.utc) + local_dt = utc_dt.astimezone(pytz.timezone(tz)) + closing_time = local_dt.strftime('%Y-%m-%d %H:%M:%S') + except Exception: + closing_time = session.stop_at.strftime('%Y-%m-%d %H:%M:%S') + + return { + 'session_name': session.name, + 'cashier_name': cashier_name, + 'closing_time': closing_time, + 'payment_methods': payment_methods, + } diff --git a/report/pos_closing_summary_report.xml b/report/pos_closing_summary_report.xml new file mode 100644 index 0000000..27c2c0c --- /dev/null +++ b/report/pos_closing_summary_report.xml @@ -0,0 +1,145 @@ + + + + + + POS Closing Receipt (80mm) + + custom + 80 + 297 + Portrait + 3 + 3 + 3 + 3 + + 3 + 96 + + + + + POS Closing Summary + pos.session + qweb-html + pos_closing_receipt.report_pos_closing_summary + pos_closing_receipt.report_pos_closing_summary + + + report + + + + + + diff --git a/static/src/app/closing_receipt.xml b/static/src/app/closing_receipt.xml index e2c7248..3f4aa6d 100644 --- a/static/src/app/closing_receipt.xml +++ b/static/src/app/closing_receipt.xml @@ -5,9 +5,6 @@ POS Closing Receipt Template Rendered as an OWL component and printed via the browser print dialog (printer service with webPrintFallback: true). - - The outer wrapper uses the standard POS receipt print class so that - Odoo's existing @media print CSS hides the rest of the UI automatically. -->
@@ -25,7 +22,7 @@
- + @@ -95,4 +92,19 @@ + + + + + + Reprint Closing Summary + + + + diff --git a/static/src/app/closing_receipt_patch.js b/static/src/app/closing_receipt_patch.js index 613e91e..5b0bb71 100644 --- a/static/src/app/closing_receipt_patch.js +++ b/static/src/app/closing_receipt_patch.js @@ -1,9 +1,13 @@ /** @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"; // ───────────────────────────────────────────────────────────────────────────── // ClosingReceipt OWL Component @@ -22,69 +26,124 @@ export class ClosingReceipt extends Component { } // ───────────────────────────────────────────────────────────────────────────── -// Patch: inject printer service and print after session close +// 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); - // The "printer" service supports webPrintFallback (browser print dialog) this.printer = useService("printer"); }, async closeSession() { - // Fetch the REAL session name from the server before closing. - // pos.session.name on the frontend is always "/" because the sequence - // is assigned server-side in set_opening_control() and never synced back. - 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 (_) { - // Keep the fallback name if the read fails - } + this.pos._resetConnectedCashier(); - // Capture receipt data BEFORE closing (props/pos data still valid) - const receiptData = this._buildReceiptData(realSessionName); - - // Run the standard closing logic - await super.closeSession(...arguments); - - // Only print if the session was actually closed successfully - if (this.pos.session.state !== "closed") { + const syncSuccess = await this.pos.pushOrdersWithClosingPopup(); + if (!syncSuccess) { return; } - // Print without awaiting so the page redirect is not delayed - this._printClosingReceipt(receiptData); + 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)]); + + 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"; + + // ── Print BEFORE redirect ──────────────────────────────────────── + await this._printClosingReceipt(receiptData); + + // ── 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}`); + } }, - /** - * Render the ClosingReceipt component and trigger browser print dialog. - * Uses printer.print() with webPrintFallback: true — same as printReceipt(). - */ async _printClosingReceipt(receiptData) { try { await this.printer.print(ClosingReceipt, receiptData, { webPrintFallback: true, }); } catch (err) { - // Print errors must never surface to the user after session is closed console.warn("[pos_closing_receipt] Failed to print closing receipt:", err); } }, - /** - * Build the data object for the receipt template. - * @param {string} sessionName - The real session name fetched from the server. - * Must be called BEFORE session close while props are still available. - */ _buildReceiptData(sessionName) { const pos = this.pos; const formatCurrency = this.env.utils.formatCurrency; - // Cashier name (pos_hr returns an hr.employee from getCashier()) let cashierName = ""; try { const cashier = pos.getCashier(); @@ -93,14 +152,12 @@ patch(ClosePosPopup.prototype, { cashierName = pos.user?.name || ""; } - // Closing timestamp 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())}`; - // Cash payment (default_cash_details has: id, name, amount, opening, payment_amount) let cashPayment = null; if (this.props.default_cash_details) { const amount = this.props.default_cash_details.amount || 0; @@ -112,7 +169,6 @@ patch(ClosePosPopup.prototype, { }; } - // Non-cash payment methods: [{ id, name, amount, type, number }] const nonCashPayments = (this.props.non_cash_payment_methods || []).map((pm) => ({ id: pm.id, name: pm.name, @@ -120,7 +176,6 @@ patch(ClosePosPopup.prototype, { formattedAmount: formatCurrency(pm.amount || 0), })); - // Grand total const cashAmount = cashPayment ? cashPayment.amount : 0; const nonCashTotal = nonCashPayments.reduce((sum, pm) => sum + pm.amount, 0); const grandTotal = formatCurrency(cashAmount + nonCashTotal); @@ -135,3 +190,88 @@ patch(ClosePosPopup.prototype, { }; }, }); + +// ───────────────────────────────────────────────────────────────────────────── +// 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, { + setup() { + super.setup(...arguments); + this.printer = useService("printer"); + }, + + async reprintLastClosingReceipt() { + const formatCurrency = this.env.utils.formatCurrency; + + // ── 1. Fetch the last closed session for this config ───────────────── + let sessionData; + try { + const sessions = await this.pos.data.call( + "pos.session", + "get_last_closed_session_summary", + [this.pos.config.id] + ); + if (!sessions) { + this.notification.add(_t("No closed session found to reprint."), { + type: "warning", + }); + return; + } + sessionData = sessions; + } 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 receipt props from server data ────────────────────────── + let cashPayment = null; + const nonCashPayments = []; + + for (const pm of sessionData.payment_methods) { + if (pm.is_cash) { + cashPayment = { + id: pm.id, + name: pm.name, + amount: pm.amount, + formattedAmount: formatCurrency(pm.amount), + }; + } else { + nonCashPayments.push({ + id: pm.id, + name: pm.name, + amount: pm.amount, + formattedAmount: formatCurrency(pm.amount), + }); + } + } + + const cashAmount = cashPayment ? cashPayment.amount : 0; + const nonCashTotal = nonCashPayments.reduce((s, pm) => s + pm.amount, 0); + const grandTotal = formatCurrency(cashAmount + nonCashTotal); + + const receiptData = { + sessionName: sessionData.session_name, + cashierName: sessionData.cashier_name, + closingTime: sessionData.closing_time + " (REPRINT)", + cashPayment, + nonCashPayments, + grandTotal, + }; + + // ── 3. Print ───────────────────────────────────────────────────────── + try { + await this.printer.print(ClosingReceipt, receiptData, { + webPrintFallback: true, + }); + } catch (err) { + console.warn("[pos_closing_receipt] Reprint failed:", err); + this.notification.add(_t("Print failed. Check browser print settings."), { + type: "warning", + }); + } + }, +}); diff --git a/views/pos_session_views.xml b/views/pos_session_views.xml new file mode 100644 index 0000000..4b24be3 --- /dev/null +++ b/views/pos_session_views.xml @@ -0,0 +1,24 @@ + + + + + pos.session.form.closing.receipt + pos.session + + + +
Cashier