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.
- 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,9 +119,14 @@ pos_closing_receipt/
├── __init__.py
├── __manifest__.py
├── README.md
└── static/
└── src/
└── app/
├── 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).

View File

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

View File

@ -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/**/*',

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
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.
-->
<t t-name="pos_closing_receipt.ClosingReceipt">
<div class="pos-receipt-print">
@ -25,7 +22,7 @@
<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;">
<tr>
<td style="font-weight: bold; padding: 2px 0;">Cashier</td>
@ -95,4 +92,19 @@
</div>
</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>

View File

@ -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",
});
}
},
});

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>