refactor: rewrite session closing logic to support backend report generation and manual closing summary reprints

This commit is contained in:
Suherdy Yacob 2026-06-01 16:08:36 +07:00
parent 94f61eccf1
commit a894acaefb
9 changed files with 564 additions and 57 deletions

View File

@ -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. - 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. - 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. - 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 User clicks "Close" in POS closing popup
├─ 1. Fetch real session name from server (pos.data.read) ├─ 1. Sync unsynced orders (pushOrdersWithClosingPopup)
├─ 2. Collect payment data from props (default_cash_details, non_cash_payment_methods) ├─ 2. Post closing cash details (if cash_control enabled)
├─ 3. Call super.closeSession() — standard Odoo closing ├─ 3. Fetch real session name from server (pos.data.read)
├─ 4. Collect payment data from props → _buildReceiptData()
└─ 4. Session state === "closed"? ├─ 5. Call close_session_from_ui on server
└─ Yes → printer.print(ClosingReceipt, data, { webPrintFallback: true }) ├─ 6. Mark session.state = "closed" locally
└─ Browser print dialog opens ├─ 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) ### 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: 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 ## File Structure
``` ```
@ -102,11 +119,16 @@ pos_closing_receipt/
├── __init__.py ├── __init__.py
├── __manifest__.py ├── __manifest__.py
├── README.md ├── README.md
└── static/ ├── models/
└── src/ │ ├── __init__.py
└── app/ │ └── pos_session.py # action_reprint_closing_summary + get_closing_summary_data
├── closing_receipt.xml # OWL template (receipt layout + inline CSS) ├── report/
└── closing_receipt_patch.js # Patch for ClosePosPopup + ClosingReceipt component │ └── 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 ## 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 ### v1.0.0
- Initial release for Odoo 19. - Initial release for Odoo 19.
- Web print via `window.print()` (no IoT Box required). - Web print via `window.print()` (no IoT Box required).

View File

@ -1 +1,2 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from . import models

View File

@ -1,18 +1,25 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
{ {
'name': 'POS Closing Receipt Printer', 'name': 'POS Closing Receipt Printer',
'version': '19.0.1.0.0', 'version': '19.0.1.3.0',
'category': 'Point of Sale', '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': """ 'description': """
Automatically prints a payment summary receipt when the POS session Automatically prints a payment summary receipt when the POS session
is successfully closed. The receipt shows: is successfully closed. The receipt shows:
- POS Session name/number - POS Session name/number
- Cashier who performed the closing - Cashier who performed the closing
- Total amount per payment method (Cash, BCA, BTN, etc.) - 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', 'author': 'Suherdy Yacob',
'depends': ['point_of_sale', 'pos_hr'], 'depends': ['point_of_sale', 'pos_hr'],
'data': [
'report/pos_closing_summary_report.xml',
'views/pos_session_views.xml',
],
'assets': { 'assets': {
'point_of_sale._assets_pos': [ 'point_of_sale._assets_pos': [
'pos_closing_receipt/static/src/app/**/*', 'pos_closing_receipt/static/src/app/**/*',

2
models/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import pos_session

140
models/pos_session.py Normal file
View File

@ -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,
}

View File

@ -0,0 +1,145 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ════════════════════════════════════════════════════════════════════
Paper format: 80mm thermal receipt
width = 80mm, no margins so content fills the full roll width.
════════════════════════════════════════════════════════════════════ -->
<record id="paperformat_pos_closing_receipt" model="report.paperformat">
<field name="name">POS Closing Receipt (80mm)</field>
<field name="default" eval="False"/>
<field name="format">custom</field>
<field name="page_width">80</field>
<field name="page_height">297</field>
<field name="orientation">Portrait</field>
<field name="margin_top">3</field>
<field name="margin_bottom">3</field>
<field name="margin_left">3</field>
<field name="margin_right">3</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">3</field>
<field name="dpi">96</field>
</record>
<!-- ════════════════════════════════════════════════════════════════════
Report Action
════════════════════════════════════════════════════════════════════ -->
<record id="action_report_pos_closing_summary" model="ir.actions.report">
<field name="name">POS Closing Summary</field>
<field name="model">pos.session</field>
<field name="report_type">qweb-html</field>
<field name="report_name">pos_closing_receipt.report_pos_closing_summary</field>
<field name="report_file">pos_closing_receipt.report_pos_closing_summary</field>
<field name="paperformat_id" ref="paperformat_pos_closing_receipt"/>
<field name="binding_model_id" ref="point_of_sale.model_pos_session"/>
<field name="binding_type">report</field>
</record>
<!-- ════════════════════════════════════════════════════════════════════
QWeb Template
Uses the same visual layout as the POS-frontend closing receipt so
the paper output looks identical to what was auto-printed on close.
════════════════════════════════════════════════════════════════════ -->
<template id="report_pos_closing_summary">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="session">
<t t-set="data" t-value="session.get_closing_summary_data()"/>
<div style="font-family: 'Courier New', Courier, monospace;
font-size: 13px;
width: 100%;
max-width: 320px;
margin: 0 auto;
padding: 12px;
page-break-after: always;">
<!-- ===== HEADER ===== -->
<div style="text-align: center; margin-bottom: 10px;">
<div style="font-size: 16px; font-weight: bold;
letter-spacing: 1px; text-transform: uppercase;">
<t t-esc="data['session_name']"/>
</div>
<div style="margin-top: 4px; font-size: 11px; letter-spacing: 2px;">
SESSION CLOSING SUMMARY
</div>
<div style="margin-top: 2px; font-size: 10px; color: #666;">
(REPRINT)
</div>
</div>
<div style="border-top: 1px dashed #000; margin: 6px 0;"/>
<!-- ===== CASHIER &amp; DATETIME ===== -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 6px;">
<tr>
<td style="font-weight: bold; padding: 2px 0;">Cashier</td>
<td style="text-align: right; padding: 2px 0;">
<t t-esc="data['cashier_name']"/>
</td>
</tr>
<tr>
<td style="font-weight: bold; padding: 2px 0;">Date/Time</td>
<td style="text-align: right; padding: 2px 0; font-size: 11px;">
<t t-esc="data['closing_time']"/>
</td>
</tr>
</table>
<div style="border-top: 1px dashed #000; margin: 6px 0;"/>
<!-- ===== PAYMENT METHODS ===== -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 6px;">
<!-- Cash -->
<t t-if="data['cash_payment']">
<tr>
<td style="padding: 3px 0;">
<t t-esc="data['cash_payment']['name']"/>
</td>
<td style="text-align: right; padding: 3px 0;">
<t t-esc="data['cash_payment']['amount']"
t-options="{'widget': 'monetary', 'display_currency': session.currency_id}"/>
</td>
</tr>
</t>
<!-- Non-cash methods -->
<t t-foreach="data['non_cash_payments']" t-as="pm">
<tr>
<td style="padding: 3px 0;">
<t t-esc="pm['name']"/>
</td>
<td style="text-align: right; padding: 3px 0;">
<t t-esc="pm['amount']"
t-options="{'widget': 'monetary', 'display_currency': session.currency_id}"/>
</td>
</tr>
</t>
</table>
<div style="border-top: 1px solid #000; margin: 6px 0;"/>
<!-- ===== GRAND TOTAL ===== -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 8px;">
<tr>
<td style="font-weight: bold; font-size: 14px; padding: 4px 0;">TOTAL</td>
<td style="text-align: right; font-weight: bold; font-size: 14px; padding: 4px 0;">
<t t-esc="data['grand_total']"
t-options="{'widget': 'monetary', 'display_currency': session.currency_id}"/>
</td>
</tr>
</table>
<div style="border-top: 1px dashed #000; margin: 6px 0;"/>
<!-- ===== FOOTER ===== -->
<div style="text-align: center; margin-top: 10px;
font-size: 11px; letter-spacing: 1px;">
*** Session Closed ***
</div>
<!-- Margin for paper feed -->
<div style="margin-top: 24px;">&#160;</div>
</div>
</t>
</t>
</template>
</odoo>

View File

@ -5,9 +5,6 @@
POS Closing Receipt Template POS Closing Receipt Template
Rendered as an OWL component and printed via the browser print dialog Rendered as an OWL component and printed via the browser print dialog
(printer service with webPrintFallback: true). (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.
--> -->
<t t-name="pos_closing_receipt.ClosingReceipt"> <t t-name="pos_closing_receipt.ClosingReceipt">
<div class="pos-receipt-print"> <div class="pos-receipt-print">
@ -25,7 +22,7 @@
<div style="border-top: 1px dashed #000; margin: 6px 0;"/> <div style="border-top: 1px dashed #000; margin: 6px 0;"/>
<!-- ===== CASHIER &amp; DATETIME ===== --> <!-- ===== CASHIER & DATETIME ===== -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 6px;"> <table style="width: 100%; border-collapse: collapse; margin-bottom: 6px;">
<tr> <tr>
<td style="font-weight: bold; padding: 2px 0;">Cashier</td> <td style="font-weight: bold; padding: 2px 0;">Cashier</td>
@ -95,4 +92,19 @@
</div> </div>
</t> </t>
<!--
Extend the POS Navbar hamburger menu.
Adds "Reprint Closing Summary" as a DropdownItem inside pos-burger-menu-items.
Calls Navbar.reprintLastClosingReceipt() which fetches the last closed
session data from the server and prints using the ClosingReceipt component.
-->
<t t-name="pos_closing_receipt.NavbarPatch" t-inherit="point_of_sale.Navbar" t-inherit-mode="extension">
<xpath expr="//div[hasclass('pos-burger-menu-items')]" position="inside">
<DropdownItem onSelected="() => this.reprintLastClosingReceipt()">
<i class="fa fa-fw fa-print me-1"/>
Reprint Closing Summary
</DropdownItem>
</xpath>
</t>
</templates> </templates>

View File

@ -1,9 +1,13 @@
/** @odoo-module **/ /** @odoo-module **/
import { ClosePosPopup } from "@point_of_sale/app/components/popups/closing_popup/closing_popup"; 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 { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl"; 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 // 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, { patch(ClosePosPopup.prototype, {
setup() { setup() {
super.setup(...arguments); super.setup(...arguments);
// The "printer" service supports webPrintFallback (browser print dialog)
this.printer = useService("printer"); this.printer = useService("printer");
}, },
async closeSession() { async closeSession() {
// Fetch the REAL session name from the server before closing. this.pos._resetConnectedCashier();
// 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
}
// Capture receipt data BEFORE closing (props/pos data still valid) const syncSuccess = await this.pos.pushOrdersWithClosingPopup();
const receiptData = this._buildReceiptData(realSessionName); if (!syncSuccess) {
// Run the standard closing logic
await super.closeSession(...arguments);
// Only print if the session was actually closed successfully
if (this.pos.session.state !== "closed") {
return; return;
} }
// Print without awaiting so the page redirect is not delayed if (this.pos.config.cash_control) {
this._printClosingReceipt(receiptData); 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) { async _printClosingReceipt(receiptData) {
try { try {
await this.printer.print(ClosingReceipt, receiptData, { await this.printer.print(ClosingReceipt, receiptData, {
webPrintFallback: true, webPrintFallback: true,
}); });
} catch (err) { } 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); 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) { _buildReceiptData(sessionName) {
const pos = this.pos; const pos = this.pos;
const formatCurrency = this.env.utils.formatCurrency; const formatCurrency = this.env.utils.formatCurrency;
// Cashier name (pos_hr returns an hr.employee from getCashier())
let cashierName = ""; let cashierName = "";
try { try {
const cashier = pos.getCashier(); const cashier = pos.getCashier();
@ -93,14 +152,12 @@ patch(ClosePosPopup.prototype, {
cashierName = pos.user?.name || ""; cashierName = pos.user?.name || "";
} }
// Closing timestamp
const now = new Date(); const now = new Date();
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
const closingTime = const closingTime =
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` + `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
// Cash payment (default_cash_details has: id, name, amount, opening, payment_amount)
let cashPayment = null; let cashPayment = null;
if (this.props.default_cash_details) { if (this.props.default_cash_details) {
const amount = this.props.default_cash_details.amount || 0; 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) => ({ const nonCashPayments = (this.props.non_cash_payment_methods || []).map((pm) => ({
id: pm.id, id: pm.id,
name: pm.name, name: pm.name,
@ -120,7 +176,6 @@ patch(ClosePosPopup.prototype, {
formattedAmount: formatCurrency(pm.amount || 0), formattedAmount: formatCurrency(pm.amount || 0),
})); }));
// Grand total
const cashAmount = cashPayment ? cashPayment.amount : 0; const cashAmount = cashPayment ? cashPayment.amount : 0;
const nonCashTotal = nonCashPayments.reduce((sum, pm) => sum + pm.amount, 0); const nonCashTotal = nonCashPayments.reduce((sum, pm) => sum + pm.amount, 0);
const grandTotal = formatCurrency(cashAmount + nonCashTotal); 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",
});
}
},
});

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Inherit the pos.session form view to add a "Reprint Closing Summary"
button in the header, visible only when the session is closed.
-->
<record id="view_pos_session_form_closing_receipt" model="ir.ui.view">
<field name="name">pos.session.form.closing.receipt</field>
<field name="model">pos.session</field>
<field name="inherit_id" ref="point_of_sale.view_pos_session_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button
name="action_reprint_closing_summary"
type="object"
string="Reprint Closing Summary"
class="btn-secondary"
invisible="state != 'closed'"
icon="fa-print"
/>
</xpath>
</field>
</record>
</odoo>