first commit
This commit is contained in:
commit
94f61eccf1
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# ─── Python ───────────────────────────────────────────────────────────────────
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# ─── Odoo compiled assets ─────────────────────────────────────────────────────
|
||||||
|
# Odoo bundles JS/CSS at runtime; these are not source files
|
||||||
|
static/description/index.html
|
||||||
|
|
||||||
|
# ─── Node / npm (if ever using frontend tooling) ──────────────────────────────
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# ─── IDE / Editor ─────────────────────────────────────────────────────────────
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# ─── OS ───────────────────────────────────────────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
|
||||||
|
# ─── Logs & temp ──────────────────────────────────────────────────────────────
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
*.orig
|
||||||
|
|
||||||
|
# ─── Test artifacts ───────────────────────────────────────────────────────────
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
137
README.md
Normal file
137
README.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# pos_closing_receipt
|
||||||
|
|
||||||
|
Odoo 19 custom module — automatically prints a **session closing summary** via the browser print dialog when a POS session is closed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Intercepts the POS session closing flow.
|
||||||
|
- Reads the real session name from the server (the sequence assigned at session opening, e.g. `Mie Mapan Barata00001`).
|
||||||
|
- Collects payment totals per payment method (cash + all non-cash methods).
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Receipt Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
Mie Mapan Barata00001
|
||||||
|
SESSION CLOSING SUMMARY
|
||||||
|
--------------------------------
|
||||||
|
Cashier Elisa Ayu
|
||||||
|
Date/Time 2026-05-21 20:25:00
|
||||||
|
--------------------------------
|
||||||
|
Cash Rp 0,00
|
||||||
|
BCA Rp 500.000,00
|
||||||
|
BTN Rp 250.000,00
|
||||||
|
GOPAY Rp 100.000,00
|
||||||
|
================================
|
||||||
|
TOTAL Rp 850.000,00
|
||||||
|
--------------------------------
|
||||||
|
*** Session Closed ***
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Dependency | Notes |
|
||||||
|
|---------------|----------------------------------------|
|
||||||
|
| `point_of_sale` | Core Odoo 19 POS module |
|
||||||
|
| `pos_hr` | Required for cashier name lookup via `pos.getCashier()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Copy the `pos_closing_receipt` folder to your `customaddons/` directory.
|
||||||
|
2. Add the path to `addons_path` in `odoo.conf` if not already included.
|
||||||
|
3. Restart the Odoo server.
|
||||||
|
4. Activate **Developer Mode** in Odoo settings.
|
||||||
|
5. Go to **Apps** → search for `pos_closing_receipt` → **Install**.
|
||||||
|
|
||||||
|
Or install via CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python odoo-bin -u pos_closing_receipt --config=odoo.conf -d <your_database> --stop-after-init
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Sequence Timing
|
||||||
|
|
||||||
|
In Odoo, `pos.session.name` starts as `"/"` (the default) and gets the real sequence (e.g. `Mie Mapan Barata00001`) only when the session is **opened** via `set_opening_control()`. The frontend JS never receives this updated value, so we perform one server-side read before closing:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const result = await this.pos.data.read("pos.session", [this.pos.session.id], ["name"]);
|
||||||
|
realSessionName = result[0].name; // → "Mie Mapan Barata00001"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Print Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
- If an IoT Box printer is configured → sends to the hardware printer.
|
||||||
|
- If **no** printer is configured → falls back to `window.print()` (browser dialog).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Change the receipt layout
|
||||||
|
Edit `static/src/app/closing_receipt.xml`. The template uses standard HTML table layout with inline CSS for maximum printer compatibility.
|
||||||
|
|
||||||
|
### Add a company logo or header
|
||||||
|
Add a `<div>` above the session name section in the XML template. Use base64-encoded images if needed.
|
||||||
|
|
||||||
|
### Change paper width
|
||||||
|
The default `max-width` is `320px` (80mm thermal). For 58mm paper, reduce to `220px`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div class="pos-receipt" style="... max-width: 220px; ...">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### v1.0.0
|
||||||
|
- Initial release for Odoo 19.
|
||||||
|
- Web print via `window.print()` (no IoT Box required).
|
||||||
|
- Reads real session sequence name from server at print time.
|
||||||
|
- Fault-tolerant: print errors never block session closing.
|
||||||
1
__init__.py
Normal file
1
__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
24
__manifest__.py
Normal file
24
__manifest__.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'POS Closing Receipt Printer',
|
||||||
|
'version': '19.0.1.0.0',
|
||||||
|
'category': 'Point of Sale',
|
||||||
|
'summary': 'Print payment summary receipt when closing a POS session',
|
||||||
|
'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.)
|
||||||
|
""",
|
||||||
|
'author': 'Suherdy Yacob',
|
||||||
|
'depends': ['point_of_sale', 'pos_hr'],
|
||||||
|
'assets': {
|
||||||
|
'point_of_sale._assets_pos': [
|
||||||
|
'pos_closing_receipt/static/src/app/**/*',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
98
static/src/app/closing_receipt.xml
Normal file
98
static/src/app/closing_receipt.xml
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
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">
|
||||||
|
<div class="pos-receipt" style="font-family: 'Courier New', Courier, monospace; font-size: 13px; width: 100%; max-width: 320px; margin: 0 auto; padding: 12px;">
|
||||||
|
|
||||||
|
<!-- ===== 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="props.sessionName"/>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 4px; font-size: 11px; letter-spacing: 2px;">
|
||||||
|
SESSION CLOSING SUMMARY
|
||||||
|
</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="props.cashierName"/>
|
||||||
|
</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="props.closingTime"/>
|
||||||
|
</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="props.cashPayment">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 0;">
|
||||||
|
<t t-esc="props.cashPayment.name"/>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; padding: 3px 0;">
|
||||||
|
<t t-esc="props.cashPayment.formattedAmount"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Non-cash methods -->
|
||||||
|
<t t-foreach="props.nonCashPayments" t-as="pm" t-key="pm.id">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 0;">
|
||||||
|
<t t-esc="pm.name"/>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; padding: 3px 0;">
|
||||||
|
<t t-esc="pm.formattedAmount"/>
|
||||||
|
</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="props.grandTotal"/>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
137
static/src/app/closing_receipt_patch.js
Normal file
137
static/src/app/closing_receipt_patch.js
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { ClosePosPopup } from "@point_of_sale/app/components/popups/closing_popup/closing_popup";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// ClosingReceipt OWL Component
|
||||||
|
// Rendered by the printer service (same way OrderReceipt is rendered).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
export class ClosingReceipt extends Component {
|
||||||
|
static template = "pos_closing_receipt.ClosingReceipt";
|
||||||
|
static props = {
|
||||||
|
sessionName: String,
|
||||||
|
cashierName: String,
|
||||||
|
closingTime: String,
|
||||||
|
cashPayment: { type: Object, optional: true },
|
||||||
|
nonCashPayments: Array,
|
||||||
|
grandTotal: String,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Patch: inject printer service and print after session close
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print without awaiting so the page redirect is not delayed
|
||||||
|
this._printClosingReceipt(receiptData);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
cashierName = cashier?.name || cashier?.display_name || "";
|
||||||
|
} catch (_) {
|
||||||
|
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;
|
||||||
|
cashPayment = {
|
||||||
|
id: this.props.default_cash_details.id,
|
||||||
|
name: this.props.default_cash_details.name || "Cash",
|
||||||
|
amount,
|
||||||
|
formattedAmount: formatCurrency(amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
amount: pm.amount || 0,
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionName,
|
||||||
|
cashierName,
|
||||||
|
closingTime,
|
||||||
|
cashPayment,
|
||||||
|
nonCashPayments,
|
||||||
|
grandTotal,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user