refactor: rewrite session closing logic to support backend report generation and manual closing summary reprints
This commit is contained in:
parent
94f61eccf1
commit
a894acaefb
56
README.md
56
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,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).
|
||||
|
||||
@ -1 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
|
||||
@ -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
2
models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import pos_session
|
||||
140
models/pos_session.py
Normal file
140
models/pos_session.py
Normal 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,
|
||||
}
|
||||
145
report/pos_closing_summary_report.xml
Normal file
145
report/pos_closing_summary_report.xml
Normal 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 & 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;"> </div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@ -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 & 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>
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
24
views/pos_session_views.xml
Normal file
24
views/pos_session_views.xml
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user