commit 94f61eccf17c9c54d551a3c5929d0b34377a4886 Author: Suherdy Yacob Date: Thu May 21 20:30:54 2026 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c46a4e --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f14bd91 --- /dev/null +++ b/README.md @@ -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 --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 `
` 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 +
+``` + +--- + +## 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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..c7e2a12 --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/static/src/app/closing_receipt.xml b/static/src/app/closing_receipt.xml new file mode 100644 index 0000000..e2c7248 --- /dev/null +++ b/static/src/app/closing_receipt.xml @@ -0,0 +1,98 @@ + + + + + +
+
+ + +
+
+ +
+
+ SESSION CLOSING SUMMARY +
+
+ +
+ + + + + + + + + + + +
Cashier + +
Date/Time + +
+ +
+ + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ +
+ + + + + + + +
TOTAL + +
+ +
+ + +
+ *** Session Closed *** +
+ + +
 
+
+
+ + + diff --git a/static/src/app/closing_receipt_patch.js b/static/src/app/closing_receipt_patch.js new file mode 100644 index 0000000..613e91e --- /dev/null +++ b/static/src/app/closing_receipt_patch.js @@ -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, + }; + }, +});