[IMP] pos_self_order*: enable kiosk receipt

*point_of_sale, pos_self_order, pos_self_order_epson_printer

In this commit we introduce the functionality that allows
the pos_self_order module to generate receipts.

At the moment, this is possible by using Epson Printers via HTTP.
This is done through the use of the newly added bridge module
`pos_self_order_epson_printer`.

In order to generate the data required for the receipt we add more data
on the first load of the kiosk ( such as company information ) and
also in the `_export_for_self_order` method.

Printing is done through the `printer` service from POS.

closes odoo/odoo#137397

Task: 3487638
Related: odoo/enterprise#48304
Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
This commit is contained in:
vlst
2023-10-18 15:26:31 +00:00
parent f740d30adb
commit d149b8add6
21 changed files with 208 additions and 33 deletions
@@ -3,6 +3,7 @@
<t t-name="point_of_sale.ReceiptHeader">
<img t-attf-src="/web/image?model=res.company&amp;id={{props.data.company.id}}&amp;field=logo" alt="Logo" class="pos-receipt-logo"/>
<br/>
<h1 class="text-center" style="font-size: 100px" t-if="props.data.trackingNumber" t-esc="props.data.trackingNumber" />
<div class="pos-receipt-contact">
<!-- contact address -->
<div t-if="props.data.company.partner_id?.[1]" t-esc="props.data.company.partner_id[1]" />
@@ -1,6 +1,6 @@
/** @odoo-module */
import { qrCodeSrc, uuidv4, constructFullProductName } from "@point_of_sale/utils";
import { random5Chars, uuidv4, qrCodeSrc, constructFullProductName } from "@point_of_sale/utils";
// FIXME POSREF - unify use of native parseFloat and web's parseFloat. We probably don't need the native version.
import { parseFloat as oParseFloat } from "@web/views/fields/parsers";
import {
@@ -2683,15 +2683,7 @@ export class Order extends PosModel {
return true;
}
}
/**
* Returns a random 5 digits alphanumeric code
* @returns {string}
*/
_generateTicketCode() {
let code = "";
while (code.length != 5) {
code = Math.random().toString(36).slice(2, 7);
}
return code;
return random5Chars();
}
}
+11
View File
@@ -53,6 +53,17 @@ export function constructFullProductName(line, attribute_value_by_id, display_na
return attributeString !== "" ? `${display_name} ${attributeString}` : display_name;
}
/**
* Returns a random 5 digits alphanumeric code
* @returns {string}
*/
export function random5Chars() {
let code = "";
while (code.length != 5) {
code = Math.random().toString(36).slice(2, 7);
}
return code;
}
export function qrCodeSrc(url, { size = 200 } = {}) {
return `/report/barcode/QR/${encodeURIComponent(url)}?width=${size}&height=${size}`;
+8 -1
View File
@@ -61,11 +61,18 @@
'web/static/lib/bootstrap/js/dist/base-component.js',
"web/static/lib/bootstrap/js/dist/carousel.js",
'web/static/lib/bootstrap/js/dist/scrollspy.js',
"pos_self_order/static/src/app/**/*",
"point_of_sale/static/src/app/store/models/product_custom_attribute.js",
"point_of_sale/static/src/utils.js",
'web_editor/static/src/js/editor/odoo-editor/src/base_style.scss',
'web_editor/static/src/scss/web_editor.common.scss',
"point_of_sale/static/src/app/generic_components/**/*",
"point_of_sale/static/src/css/pos_receipts.css",
"point_of_sale/static/src/app/screens/receipt_screen/receipt/**/*",
"point_of_sale/static/src/app/printer/base_printer.js",
"point_of_sale/static/src/app/printer/printer_service.js",
'web_editor/static/lib/html2canvas.js',
"point_of_sale/static/src/app/printer/render_service.js",
"pos_self_order/static/src/app/**/*",
],
# Assets tests
"pos_self_order.assets_tests": [
@@ -46,6 +46,7 @@ class PosSelfOrderController(http.Controller):
'amount_paid': 0,
'amount_return': 0,
'table_stand_number': order.get('table_stand_number'),
'ticket_code': order.get('ticket_code'),
},
'to_invoice': False,
'session_id': pos_session.id,
+8 -2
View File
@@ -339,8 +339,12 @@ class PosConfig(models.Model):
return {
"pos_config_id": self.id,
"pos_session": self.current_session_id.read(["id", "access_token"])[0] if self.current_session_id and self.current_session_id.state == 'opened' else False,
"company_name": self.company_id.name,
"company_color": self.company_id.color,
"company": {
**self.company_id.read(["name", "color", "email", "website", "vat", "name", "phone", "point_of_sale_use_ticket_qr_code", "point_of_sale_ticket_unique_code"])[0],
"partner_id": [None, self.company_id.partner_id.contact_address],
"country": self.company_id.country_id.read(["vat_label"])[0],
},
"base_url": self.get_base_url(),
"custom_links": self._get_self_order_custom_links(),
"currency_id": self.currency_id.id,
"pos_payment_methods": payment_methods if self.self_ordering_mode == "kiosk" else [],
@@ -358,6 +362,8 @@ class PosConfig(models.Model):
"self_ordering_image_home_ids": self._get_self_ordering_attachment(self.self_ordering_image_home_ids),
"self_ordering_image_brand": self._get_self_ordering_image(self.self_ordering_image_brand),
"self_ordering_pay_after": self.self_ordering_pay_after,
"receipt_header": self.receipt_header,
"receipt_footer": self.receipt_footer,
},
}
+23
View File
@@ -45,6 +45,28 @@ class PosOrder(models.Model):
table_stand_number = fields.Char(string="Table Stand Number")
take_away = fields.Boolean(string="Take Away", default=False)
def _compute_tax_details(self):
self.ensure_one()
taxes = sum([line.tax_ids.compute_all(line.price_unit, quantity=line.qty, product=line.product_id)['taxes']
for line in self.lines], [])
tax_percetanges = {tax['id']: tax['amount'] for tax in self.env['account.tax'].search([]).read(['amount'])}
merged_tax_details = {}
for tax_obj in taxes:
tax_id = tax_obj['id']
if tax_id not in merged_tax_details:
merged_tax_details[tax_id] = {
'tax': {
'id': tax_id,
'amount': tax_percetanges[tax_id]
},
'name': tax_obj['name'],
'amount': 0.0,
'base': 0.0,
}
merged_tax_details[tax_id]['amount'] += tax_obj['amount']
merged_tax_details[tax_id]['base'] += tax_obj['base']
return list(merged_tax_details.values())
@api.model
def create_from_ui(self, orders, draft=False):
orders = super().create_from_ui(orders, draft)
@@ -112,6 +134,7 @@ class PosOrder(models.Model):
}
for line in self.lines
],
"tax_details": self._compute_tax_details(),
}
def _send_order(self):
@@ -43,7 +43,6 @@ export class Line extends Reactive {
this.initCustomAttribute();
}
initCustomAttribute() {
this.custom_attribute_value_ids = this.custom_attribute_value_ids.map(
(customAttribute) => new ProductCustomAttribute(customAttribute)
@@ -1,6 +1,7 @@
/** @odoo-module **/
import { Reactive } from "@web/core/utils/reactive";
import { Line } from "./line";
import { random5Chars } from "@point_of_sale/utils";
export class Order extends Reactive {
constructor({
@@ -15,6 +16,7 @@ export class Order extends Reactive {
amount_tax,
lastChangesSent,
take_away,
tax_details,
}) {
super();
this.setup(...arguments);
@@ -32,6 +34,8 @@ export class Order extends Reactive {
this.amount_tax = order.amount_tax || 0;
this.lines = order.lines || [];
this.take_away = typeof order.take_away === "boolean" ? order.take_away : null;
this.ticket_code = random5Chars(); // 5-digits alphanum code shown on the receipt
this.tax_details = order.tax_details || [];
// data
this.lastChangesSent = order.lastChangesSent || {};
@@ -6,7 +6,6 @@ import { useSelfOrder } from "@pos_self_order/app/self_order_service";
import { PopupTable } from "@pos_self_order/app/components/popup_table/popup_table";
import { _t } from "@web/core/l10n/translation";
import { OrderWidget } from "@pos_self_order/app/components/order_widget/order_widget";
import { attributeFormatter } from "@pos_self_order/app/utils";
export class CartPage extends Component {
static template = "pos_self_order.CartPage";
@@ -190,10 +189,4 @@ export class CartPage extends Component {
});
}
}
getSelectedAttributes(line) {
const attributeValues = line.attribute_value_ids;
const customAttr = line.custom_attribute_value_ids;
return attributeFormatter(this.selfOrder.attributeById, attributeValues, customAttr);
}
}
@@ -28,14 +28,14 @@
<div class="small">
<div t-if="childLines.length === 0" class="product-info d-flex flex-column flex-grow-1 text-muted">
<div t-foreach="getSelectedAttributes(line)" t-as="attribute" t-key="attribute.name">
<div t-foreach="selfOrder.getSelectedAttributes(line)" t-as="attribute" t-key="attribute.name">
- <span t-esc="attribute.name" /> : <span t-esc="attribute.value" />
</div>
</div>
<div t-foreach="childLines" t-as="cline" t-key="cline.uuid" class="text-muted">
- <span t-esc="selfOrder.productByIds[cline.product_id].name" /> <span t-if="cline.price_subtotal_incl">(<t t-esc="selfOrder.formatMonetary(cline.price_subtotal_incl)"/>)</span>
<div class="product-info d-flex flex-column flex-grow-1 ms-3">
<div t-foreach="getSelectedAttributes(cline)" t-as="attribute" t-key="attribute.name">
<div t-foreach="selfOrder.getSelectedAttributes(cline)" t-as="attribute" t-key="attribute.name">
- <span t-esc="attribute.name" /> : <span t-esc="attribute.value" />
</div>
</div>
@@ -4,6 +4,7 @@ import { Component, onMounted, useState } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/app/self_order_service";
import { cookie } from "@web/core/browser/cookie";
import { useService } from "@web/core/utils/hooks";
import { OrderReceipt } from "@point_of_sale/app/screens/receipt_screen/receipt/order_receipt";
export class ConfirmationPage extends Component {
static template = "pos_self_order.ConfirmationPage";
@@ -12,6 +13,7 @@ export class ConfirmationPage extends Component {
setup() {
this.selfOrder = useSelfOrder();
this.router = useService("router");
this.printer = useService("printer");
this.confirmedOrder = {};
this.changeToDisplay = [];
this.state = useState({
@@ -25,6 +27,12 @@ export class ConfirmationPage extends Component {
this.setDefautLanguage();
}, 5000);
}
setTimeout(() => {
this.printer.print(OrderReceipt, {
data: this.selfOrder.export_for_printing(this.selfOrder.currentOrder),
formatCurrency: this.selfOrder.formatMonetary,
});
}, 500);
});
this.initOrder();
@@ -4,7 +4,6 @@ import { Component, useState } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/app/self_order_service";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
export class OrdersHistoryPage extends Component {
static template = "pos_self_order.OrdersHistoryPage";
@@ -32,7 +31,7 @@ export class OrdersHistoryPage extends Component {
}
getPrice(line) {
return this.selfOrder.show_prices_with_tax_included
return this.selfOrder.config.iface_tax_included
? line.price_subtotal_incl
: line.price_subtotal;
}
@@ -99,6 +99,7 @@
</button>
</div>
</div>
</div>
</t>
</templates>
@@ -67,4 +67,11 @@ small {
@include row-cols(4);
}
}
}
}
ul, ol {
padding: 0;
margin: 0;
}
li {
list-style-type: none;
}
@@ -7,13 +7,16 @@ import { Product } from "@pos_self_order/app/models/product";
import { Combo } from "@pos_self_order/app/models/combo";
import { session } from "@web/session";
import { getColor } from "@web/core/colors/colors";
import { categorySorter } from "@pos_self_order/app/utils";
import { categorySorter, attributeFormatter } from "@pos_self_order/app/utils";
import { Order } from "@pos_self_order/app/models/order";
import { batched } from "@web/core/utils/timing";
import { useState, markup } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { cookie } from "@web/core/browser/cookie";
import { formatDateTime } from "@web/core/l10n/dates";
import { printerService } from "@point_of_sale/app/printer/printer_service";
import { qrCodeSrc } from "@point_of_sale/utils";
export class SelfOrder extends Reactive {
constructor(...args) {
@@ -21,12 +24,13 @@ export class SelfOrder extends Reactive {
this.ready = this.setup(...args).then(() => this);
}
async setup(env, { rpc, notification, router }) {
async setup(env, { rpc, notification, router, printer }) {
// services
this.notification = notification;
this.router = router;
this.env = env;
this.rpc = rpc;
this.printer = printer;
// data
Object.assign(this, {
@@ -48,7 +52,7 @@ export class SelfOrder extends Reactive {
this.comboByIds = {};
this.ordering = false;
this.orders = [];
this.color = getColor(this.company_color);
this.color = getColor(this.company.color);
this.initData();
if (this.config.self_ordering_mode === "kiosk") {
@@ -390,7 +394,7 @@ export class SelfOrder extends Reactive {
}
handleProductChanges(payload) {
const product = new Product(payload.product, this.show_prices_with_tax_included);
const product = new Product(payload.product, this.config.iface_tax_included);
this.productByIds[payload.product.id] = product;
for (const categ of payload.product.pos_categ_ids) {
if (!this.pos_category.map((c) => c.id).includes(categ.id)) {
@@ -448,15 +452,70 @@ export class SelfOrder extends Reactive {
return product.price_info.display_price_default;
}
}
getLinePrice(line) {
return this.config.iface_tax_included ? line.price_subtotal_incl : line.price_subtotal;
}
getSelectedAttributes(line) {
const attributeValues = line.attribute_value_ids;
const customAttr = line.custom_attribute_value_ids;
return attributeFormatter(this.attributeById, attributeValues, customAttr);
}
getFullProductName(line) {
const attrs = this.getSelectedAttributes(line);
const attrsStr = " (" + attrs.map((a) => a.value).join(", ") + ")";
return line.full_product_name + (attrs.length ? attrsStr : "");
}
getLineDisplayData(line) {
return {
productName: this.getFullProductName(line),
price: this.formatMonetary(this.getLinePrice(line)),
qty: line.qty.toString(),
unitPrice: this.formatMonetary(this.getLinePrice(line) / line.qty),
comboParent: line.combo_parent_uuid
? this.currentOrder.lines.find((l) => l.uuid === line.combo_parent_uuid)
.full_product_name
: "",
customerNote: line.customer_note,
};
}
export_for_printing(order) {
order.receiptDate ||= formatDateTime(luxon.DateTime.now());
return {
orderlines: order.lines.map(this.getLineDisplayData.bind(this)),
amount_total: order.amount_total,
amount_tax: order.amount_tax,
total_without_tax: order.amount_total - order.amount_tax,
total_paid: order.amount_paid,
paymentlines: [{ name: "Bank", amount: order.amount_total }],
change: 0,
tax_details: order.tax_details,
name: order.pos_reference,
date: order.receiptDate,
ticket_code: this.company.point_of_sale_ticket_unique_code && order.ticketCode,
base_url: this.base_url,
footer: this.config.receipt_footer,
headerData: {
header: this.config.receipt_header,
company: this.company,
trackingNumber: order.trackingNumber,
},
pos_qr_code:
this.company.point_of_sale_use_ticket_qr_code &&
qrCodeSrc(
`${this.base_url}/pos/ticket/validate?access_token=${order.access_token}`
),
};
}
}
export const selfOrderService = {
dependencies: ["rpc", "notification", "router"],
async start(env, { rpc, notification, router }) {
return new SelfOrder(env, { rpc, notification, router }).ready;
dependencies: ["rpc", "notification", "router", "printer"],
async start(env, { rpc, notification, router, printer }) {
return new SelfOrder(env, { rpc, notification, router, printer }).ready;
},
};
registry.category("services").add("printer", printerService);
registry.category("services").add("self_order", selfOrderService);
export function useSelfOrder() {
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'POS Self Order Epson Printer',
'version': '1.0',
'category': 'Sales/Point of Sale',
'sequence': 6,
'summary': 'Epson ePOS Printers in PoS Kiosk',
'description': "Use Epson ePOS Printers without the IoT Box in the PoS Kiosk",
'depends': ['pos_epson_printer', 'pos_self_order'],
'installable': True,
'auto_install': True,
'assets': {
'pos_self_order.assets': [
'pos_epson_printer/static/src/app/epson_printer.js',
'pos_epson_printer/static/src/app/components/epos_templates.xml',
'pos_self_order_epson_printer/static/src/**/*',
],
},
'license': 'LGPL-3',
}
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import pos_config
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class PosConfig(models.Model):
_inherit = 'pos.config'
def _get_self_ordering_data(self):
data = super()._get_self_ordering_data()
data["config"]["epson_printer_ip"] = self.epson_printer_ip
data["config"]["other_devices"] = self.other_devices
return data
@@ -0,0 +1,19 @@
/** @odoo-module */
import { EpsonPrinter } from "@pos_epson_printer/app/epson_printer";
import { SelfOrder } from "@pos_self_order/app/self_order_service";
import { patch } from "@web/core/utils/patch";
patch(SelfOrder.prototype, {
async setup() {
await super.setup(...arguments);
if (!this.config.epson_printer_ip || !this.config.other_devices) {
return;
}
this.printer.setPrinter(
new EpsonPrinter({
ip: this.config.epson_printer_ip,
})
);
},
});