[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:
+1
@@ -3,6 +3,7 @@
|
||||
<t t-name="point_of_sale.ReceiptHeader">
|
||||
<img t-attf-src="/web/image?model=res.company&id={{props.data.company.id}}&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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user