[REF] pos*: simplify receipt printing

pos*: l10n_co_pos, l10n_fr_pos_cert, l10n_gcc_pos,
          l10n_in_pos, l10n_sa_pos, point_of_sale,
          pos_epson_printer, pos_loyalty, pos_mercury,
          pos_online_payment, pos_restaurant, pos_sale

There is a need to print pos receipts in new environments,
such as the kiosk. In this commit we simplify the steps to
generate the receipt data and introduce a portable mechanism
for printing.

Changes:

- removed the `getOrderReceiptEnv` method; this method
  was returning a lot of data that the receipt was not
  actually using; we now simply use the data from
  `Order.export_for_printing`. This means that it will be much
  easier to recreate this data in other environments, such
  as in the kiosk.

- removed the error prone `generate_wrapped_product_name` method.
  It's aim was to split the orderline name, but this task is much
  better accomplished by declarative css.

- removed the `Orderline.export_for_printing` method; instead,
  we simply use the existing `Orderline.getDisplayData`;

- removed the `AbstractReceiptScreen` component and replaced
  it's functionality with the new `printer` service;
    - using this service means that we were free to remove
      the `pos-receipt-print` div from the root of the pos app;

- made the receipt be a standalone component. It thus benefits
  from all the advantages of components, such as prop validation
  and usage of slots;
    - replaced the `OrderLinesReceipt` template with the generic
      `OrderWidget` component'; this greatly simplifies the code;

- created the `renderer` service. It's goal is to do for components
  what `renderToElement` does for templates; we use this service to
  render the receipt component for printing;

- removed jquery from the EpsonPrinter class, such that it can be
  imported into projects that do not rely on jquery;

- created the `ReceiptHeader` component. This unifies the header
  between all the different receipts; ( before, each receipt was
  implementing it's own header )

Task: 3547597
Part-of: odoo/odoo#137397
This commit is contained in:
vlst
2023-10-18 15:26:31 +00:00
parent 5236435bb2
commit f740d30adb
59 changed files with 719 additions and 930 deletions
@@ -3,8 +3,8 @@
<templates id="template" xml:space="preserve">
<t t-name="l10n_co_pos.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<xpath expr="//div[hasclass('pos-receipt-order-data')]" position="inside">
<t t-if="receiptData.receipt.l10n_co_dian !== false">
<div style="word-wrap:break-word;"><t t-esc="receiptData.receipt.l10n_co_dian"/></div>
<t t-if="props.data.l10n_co_dian !== false">
<div style="word-wrap:break-word;"><t t-esc="props.data.l10n_co_dian"/></div>
</t>
</xpath>
</t>
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<xpath expr="//div[hasclass('pos-receipt-contact')]//div" position="before">
<t t-set="partner" t-value="receiptData.order.get_partner()"/>
<t t-set="isSimplifiedInvoice" t-value="receiptData.order.is_l10n_es_simplified_invoice"/>
<t t-set="receipt" t-value="receiptData.receipt"/>
<t t-if="pos.config.is_spanish">
<t t-if="isSimplifiedInvoice">
<div>Simplified invoice</div>
<div class="simplified-invoice-number" t-esc="receiptData.order.invoice_name" />
</t>
<div t-if="receipt.company.street" t-esc="receipt.company.street" />
<div t-if="receipt.company.zip" t-esc="receipt.company.zip" />
<div t-if="receipt.company.city" t-esc="receipt.company.city" />
<div t-if="receipt.company.state_id">(<t t-esc="receipt.company.state_id[1]"/>)</div>
</t>
</xpath>
<xpath expr="//div[hasclass('pos-receipt-contact')]" position="inside">
<t t-if="pos.config.is_spanish and partner?.id !== pos.config.simplified_partner_id[0]">
<div>Customer: <t t-esc="partner.name" /></div>
<div t-if="partner.vat">
<t t-esc="receipt.company.vat_label" />:
<t t-esc="partner.vat" />
</div>
<div t-if="partner.address" t-esc="partner.address"/>
</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="ReceiptHeader" t-inherit="point_of_sale.ReceiptHeader" t-inherit-mode="extension">
<xpath expr="//div[hasclass('pos-receipt-contact')]//div" position="before">
<t t-set="isSimplifiedInvoice" t-value="props.data.is_l10n_es_simplified_invoice"/>
<t t-if="props.data.is_spanish">
<t t-if="isSimplifiedInvoice">
<div>Simplified invoice</div>
<div class="simplified-invoice-number" t-esc="props.data.invoice_name" />
</t>
<div t-if="props.data.company.street" t-esc="props.data.company.street" />
<div t-if="props.data.company.zip" t-esc="props.data.company.zip" />
<div t-if="props.data.company.city" t-esc="props.data.company.city" />
<div t-if="props.data.company.state_id">(<t t-esc="props.data.company.state_id[1]"/>)</div>
</t>
</xpath>
<xpath expr="//div[hasclass('pos-receipt-contact')]" position="inside">
<t t-set="partner" t-value="props.data.partner"/>
<t t-if="props.data.is_spanish and partner?.id !== props.data.simplified_partner_id">
<div>Customer: <t t-esc="partner.name" /></div>
<div t-if="partner.vat"><t t-esc="props.data.company.country?.vat_label || 'Tax ID'"/>: <t t-esc="partner.vat" /></div>
<div t-if="partner.address" t-esc="partner.address"/>
</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,16 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { PosStore } from "@point_of_sale/app/store/pos_store";
patch(PosStore.prototype, {
getReceiptHeaderData() {
return {
...super.getReceiptHeaderData(...arguments),
is_spanish: this.config.is_spanish,
simplified_partner_id: this.config.simplified_partner_id[0],
is_l10n_es_simplified_invoice: this.get_order().is_l10n_es_simplified_invoice,
partner: this.get_order().get_partner(),
invoice_name: this.get_order().invoice_name,
};
},
});
@@ -2,21 +2,21 @@
<templates id="template" xml:space="preserve">
<t t-name="l10n_fr_pos_cert.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<xpath expr="//div[hasclass('pos-receipt-order-data')]" position="inside">
<t t-if="receiptData.receipt.l10n_fr_hash !== false">
<t t-if="props.data.l10n_fr_hash !== false">
<br/>
<div style="word-wrap:break-word;"><t t-out="receiptData.receipt.l10n_fr_hash"/></div>
<div style="word-wrap:break-word;"><t t-out="props.data.l10n_fr_hash"/></div>
</t>
</xpath>
</t>
<t t-name="l10n_fr_pos_cert.OrderLinesReceipt" t-inherit="point_of_sale.OrderLinesReceipt" t-inherit-mode="extension">
<xpath expr="//div[@id='receipt-orderline']" position="inside">
<t t-if="receiptData.receipt.l10n_fr_hash !== false and line.price_type === 'manual'">
<t t-name="l10n_fr_pos_cert.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<xpath expr="//Orderline" position="inside">
<t t-if="props.data.l10n_fr_hash !== false and line.price_type === 'manual'">
<div class="pos-receipt-right-padding">
Old unit price:
<span class="oldPrice">
<s>
<t t-out="env.utils.formatCurrency(line.taxed_lst_unit_price)" /> / Units
<t t-out="props.formatCurrency(line.taxed_lst_unit_price)" /> / Units
</s>
</span>
</div>
@@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<t t-inherit="point_of_sale.ReceiptHeader" t-inherit-mode="extension">
<xpath expr="//div[hasclass('pos-receipt-contact')]" position="after">
<t t-if="receiptData.receipt.is_gcc_country">
<t t-if="props.data.is_gcc_country">
<br/>
<br/>
<div class="pos-receipt-header">
@@ -15,99 +14,64 @@
</t>
</xpath>
<xpath expr="//t[@t-esc='receiptData.receipt.cashier']/.." position="attributes">
<attribute name="t-if">!receiptData.receipt.is_gcc_country</attribute>
<xpath expr="//t[@t-esc='props.data.cashier']/.." position="attributes">
<attribute name="t-if">!props.data.is_gcc_country</attribute>
</xpath>
<xpath expr="//t[@t-esc='receiptData.receipt.cashier']/.." position="after">
<div t-if="receiptData.receipt.is_gcc_country" t-translation="off">
<div>Served by / خدم بواسطة <t t-esc="receiptData.receipt.cashier"/></div>
<xpath expr="//t[@t-esc='props.data.cashier']/.." position="after">
<div t-if="props.data.is_gcc_country" t-translation="off">
<div>Served by / خدم بواسطة <t t-esc="props.data.cashier"/></div>
</div>
</xpath>
</t>
<xpath expr="//span[@t-esc='env.utils.formatCurrency(receiptData.receipt.total_with_tax)']/.." position="attributes">
<attribute name="t-if">!receiptData.receipt.is_gcc_country</attribute>
<t t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<xpath expr="//span[@t-esc='props.formatCurrency(props.data.amount_total)']/.." position="attributes">
<attribute name="t-if">!props.data.is_gcc_country</attribute>
</xpath>
<xpath expr="//span[@t-esc='env.utils.formatCurrency(receiptData.receipt.total_with_tax)']/.." position="after">
<div t-if="receiptData.receipt.is_gcc_country" class="pos-receipt-amount pos-receipt-amount-arabic" t-translation="off">
<xpath expr="//span[@t-esc='props.formatCurrency(props.data.amount_total)']/.." position="after">
<div t-if="props.data.is_gcc_country" class="pos-receipt-amount pos-receipt-amount-arabic" t-translation="off">
TOTAL / الإجمالي
<span t-esc="env.utils.formatCurrency(receiptData.receipt.total_with_tax)" class="pos-receipt-right-align"/>
<span t-esc="props.formatCurrency(props.data.amount_total)" class="pos-receipt-right-align"/>
</div>
</xpath>
<xpath expr="//span[@t-esc='env.utils.formatCurrency(receiptData.receipt.rounding_applied)']/.." position="attributes">
<attribute name="t-if">!receiptData.receipt.is_gcc_country</attribute>
<xpath expr="//t[@t-if='props.data.rounding_applied']/.." position="attributes">
<attribute name="t-if">!props.data.is_gcc_country</attribute>
</xpath>
<xpath expr="//span[@t-esc='env.utils.formatCurrency(receiptData.receipt.rounding_applied)']/.." position="after">
<div t-if="receiptData.receipt.is_gcc_country" class="pos-receipt-amount pos-receipt-amount-arabic" t-translation="off">
<xpath expr="//t[@t-if='props.data.rounding_applied']/.." position="after">
<div t-if="props.data.is_gcc_country" class="pos-receipt-amount pos-receipt-amount-arabic" t-translation="off">
Rounding / التقريب
<span t-esc="env.utils.formatCurrency(receiptData.receipt.rounding_applied)" class="pos-receipt-right-align"/>
<span t-esc="props.formatCurrency(props.data.rounding_applied)" class="pos-receipt-right-align"/>
</div>
<div class="pos-receipt-amount">
To Pay
<span t-esc="props.formatCurrency(props.data.amount_total + props.data.rounding_applied)" class="pos-receipt-right-align"/>
</div>
</xpath>
<xpath expr="//span[@t-esc='env.utils.formatCurrency(receiptData.receipt.total_rounded)']/.." position="attributes">
<attribute name="t-if">!receiptData.receipt.is_gcc_country</attribute>
<xpath expr="//span[@t-esc='props.formatCurrency(props.data.change)']/.." position="attributes">
<attribute name="t-if">!props.data.is_gcc_country</attribute>
</xpath>
<xpath expr="//span[@t-esc='env.utils.formatCurrency(receiptData.receipt.total_rounded)']/.." position="after">
<div t-if="receiptData.receipt.is_gcc_country" class="pos-receipt-amount pos-receipt-amount-arabic" t-translation="off">
To Pay / للسداد
<span t-esc="env.utils.formatCurrency(receiptData.receipt.total_rounded)" class="pos-receipt-right-align"/>
</div>
</xpath>
<xpath expr="//span[@t-esc='env.utils.formatCurrency(receiptData.receipt.change)']/.." position="attributes">
<attribute name="t-if">!receiptData.receipt.is_gcc_country</attribute>
</xpath>
<xpath expr="//span[@t-esc='env.utils.formatCurrency(receiptData.receipt.change)']/.." position="after">
<div t-if="receiptData.receipt.is_gcc_country" class="pos-receipt-amount receipt-change pos-receipt-amount-arabic" t-translation="off">
<xpath expr="//span[@t-esc='props.formatCurrency(props.data.change)']/.." position="after">
<div t-if="props.data.is_gcc_country" class="pos-receipt-amount receipt-change pos-receipt-amount-arabic" t-translation="off">
CHANGE / الباقي
<span t-esc="env.utils.formatCurrency(receiptData.receipt.change)" class="pos-receipt-right-align"/>
<span t-esc="props.formatCurrency(props.data.change)" class="pos-receipt-right-align"/>
</div>
</xpath>
<xpath expr="//span[@t-esc='env.utils.formatCurrency(receiptData.receipt.total_discount)']/.." position="attributes">
<attribute name="t-if">!receiptData.receipt.is_gcc_country</attribute>
<xpath expr="//span[@t-esc='props.formatCurrency(props.data.total_discount)']/.." position="attributes">
<attribute name="t-if">!props.data.is_gcc_country</attribute>
</xpath>
<xpath expr="//span[@t-esc='env.utils.formatCurrency(receiptData.receipt.total_discount)']/.." position="after">
<div t-if="receiptData.receipt.is_gcc_country" t-translation="off">
<xpath expr="//span[@t-esc='props.formatCurrency(props.data.total_discount)']/.." position="after">
<div t-if="props.data.is_gcc_country" t-translation="off">
Discounts / الخصومات
<span t-esc="env.utils.formatCurrency(receiptData.receipt.total_discount)" class="pos-receipt-right-align"/>
<span t-esc="props.formatCurrency(props.data.total_discount)" class="pos-receipt-right-align"/>
</div>
</xpath>
<xpath expr="//div[hasclass('pos-receipt-taxes')]/span[text()='Total']" position="replace">
<span t-if="receiptData.receipt.is_gcc_country" t-translation="off">Total Taxes / إجمالي الضرائب</span>
<span t-if="props.data.is_gcc_country" t-translation="off">Total Taxes / إجمالي الضرائب</span>
<span t-else="">Total</span>
</xpath>
</t>
<t t-inherit="point_of_sale.OrderLinesReceipt" t-inherit-mode="extension">
<xpath expr="//t[@t-esc='line.unit_name']/.." position="attributes">
<attribute name="t-if">!receiptData.receipt.is_gcc_country</attribute>
</xpath>
<xpath expr="//t[@t-esc='line.unit_name']/.." position="after">
<t t-if="receiptData.receipt.is_gcc_country">
<div class="pos-receipt-left-padding">
<t t-esc="Math.round(line.quantity * Math.pow(10, pos.dp['Product Unit of Measure'])) / Math.pow(10, pos.dp['Product Unit of Measure'])"/>
<t t-if="!line.is_in_unit" t-esc="line.unit_name" />
x
<t t-esc="env.utils.formatCurrency(line.price_display_one, false)" />
</div>
<div class="responsive-price">
<div class="pos-receipt-left-padding" style="display: inline-flex;">
<div t-translation="off">Taxes / الضرائب</div>:<span t-esc="env.utils.formatCurrency(line.tax, false)" style="margin-left: 5px"/>
</div>
<span t-esc="env.utils.formatCurrency(line.price_display, false)" class="price_display pos-receipt-right-align"/>
</div>
</t>
</xpath>
<xpath expr="//t[@t-esc='line.discount']/.." position="attributes">
<attribute name="t-if">!receiptData.receipt.is_gcc_country</attribute>
</xpath>
<xpath expr="//t[@t-esc='line.discount']/.." position="after">
<div class="pos-receipt-left-padding" style="display: inline-flex;" t-if="receiptData.receipt.is_gcc_country" t-translation="off">
<div>Discount / الخصم</div>: <t t-esc="line.discount" />%
</div>
</xpath>
</t>
</templates>
@@ -4,13 +4,12 @@ import { Order } from "@point_of_sale/app/store/models";
import { patch } from "@web/core/utils/patch";
patch(Order.prototype, {
getOrderReceiptEnv() {
const receiptData = super.getOrderReceiptEnv();
const receipt = receiptData.receipt;
const country = receiptData.order.pos.company.country;
receipt.is_gcc_country = country
? ["SA", "AE", "BH", "OM", "QA", "KW"].includes(country && country.code)
: false;
return receiptData;
export_for_printing() {
return {
...super.export_for_printing(),
is_gcc_country: ["SA", "AE", "BH", "OM", "QA", "KW"].includes(
this.pos.company.country?.code
),
};
},
});
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="l10n_in_pos.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<xpath expr="//div[hasclass('orderlines')]" position="before">
<t t-if="receiptData.receipt.partner and pos.company.country and pos.company.country.code == 'IN'">
<t t-name="l10n_in_pos.ReceiptHeader" t-inherit="point_of_sale.ReceiptHeader" t-inherit-mode="extension">
<xpath expr="//div[hasclass('pos-receipt-contact')]" position="after">
<t t-if="props.data.partner and props.data.company?.country?.code == 'IN'">
<div class="pos-receipt-center-align">
<div><t t-esc="receiptData.receipt.partner.name" /></div>
<t t-if="receiptData.receipt.partner.phone">
<div><t t-esc="props.data.partner.name" /></div>
<t t-if="props.data.partner.phone">
<div>
<span>Phone: </span>
<t t-esc="receiptData.receipt.partner.phone" />
<t t-esc="props.data.partner.phone" />
</div>
</t>
<br />
@@ -17,8 +17,8 @@
</xpath>
</t>
<t t-name="l10n_in_pos.OrderLinesReceipt" t-inherit="point_of_sale.OrderLinesReceipt" t-inherit-mode="extension">
<xpath expr="//div[@id='receipt-orderline']" position="inside">
<t t-name="l10n_in_pos.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<xpath expr="//Orderline" position="inside">
<t t-if="line.l10n_in_hsn_code and pos.company.country and pos.company.country.code == 'IN'">
<div class="pos-receipt-left-padding">
<span>HSN Code: </span>
@@ -4,9 +4,10 @@ import { Orderline } from "@point_of_sale/app/store/models";
import { patch } from "@web/core/utils/patch";
patch(Orderline.prototype, {
export_for_printing() {
const line = super.export_for_printing(...arguments);
line.l10n_in_hsn_code = this.get_product().l10n_in_hsn_code;
return line;
getDisplayData() {
return {
...super.getDisplayData(),
l10n_in_hsn_code: this.get_product().l10n_in_hsn_code,
};
},
});
@@ -1,21 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<t t-inherit="point_of_sale.ReceiptHeader" t-inherit-mode="extension">
<xpath expr="//img[hasclass('pos-receipt-logo')]" position="after">
<t t-if="receiptData.receipt.is_gcc_country and !receiptData.receipt.is_settlement">
<img t-if="receiptData.receipt.qr_code" id="qrcode" t-att-src="receiptData.receipt.qr_code" class="pos-receipt-logo"/>
<t t-if="props.data.is_gcc_country and !props.data.is_settlement">
<img t-if="props.data.qr_code" id="qrcode" t-att-src="props.data.qr_code" class="pos-receipt-logo"/>
<br/>
</t>
</xpath>
<xpath expr="//span[@id='title_english']" position="replace">
<t t-if="!receiptData.receipt.is_settlement">
<t t-if="!props.data.is_settlement">
<span id="title_english">Simplified Tax Invoice</span>
</t>
</xpath>
<xpath expr="//span[@id='title_arabic']" position="replace">
<t t-if="!receiptData.receipt.is_settlement">
<t t-if="!props.data.is_settlement">
<span id="title_arabic">فاتورة ضريبية مبسطة</span>
</t>
</xpath>
@@ -7,13 +7,6 @@ import { onMounted } from "@odoo/owl";
export class ErrorPopup extends AbstractAwaitablePopup {
static template = "point_of_sale.ErrorPopup";
static props = {
confirmText: String,
title: String,
body: String,
cancelKey: Boolean,
sound: Boolean,
};
static defaultProps = {
confirmText: _t("Ok"),
title: _t("Error"),
@@ -3,11 +3,8 @@
<t t-name="point_of_sale.OrderWidget">
<t t-if="props.lines?.length">
<div t-ref="scrollable" class="order-container bg-view overflow-y-auto flex-grow-1 d-flex flex-column text-start">
<!-- this is similar to doing `for i in range(mylist)` -->
<!-- we do this instead of looping directly on the lines because the lines might not have -->
<!-- an element that we could use as a `t-key` -->
<t t-foreach="props.lines.map((el, idx) => idx)" t-as="i" t-key="i">
<t t-slot="default" line="props.lines[i]"/>
<t t-foreach="props.lines" t-as="line" t-key="line_index">
<t t-slot="default" line="line"/>
</t>
</div>
<div t-if="props.total or props.tax" class="order-summary w-100 py-2 px-3 bg-100 text-end fw-bolder fs-2 lh-sm">
@@ -19,6 +19,7 @@ export class Orderline extends Component {
oldUnitPrice: { type: String, optional: true },
customerNote: { type: String, optional: true },
internalNote: { type: String, optional: true },
"*": true,
},
},
slots: { type: Object, optional: true },
@@ -5,7 +5,7 @@
<li class="orderline p-2 lh-sm cursor-pointer" t-attf-class="{{ line.comboParent ? 'border-start border-3 ms-4' : '' }}" t-att-class="props.class">
<div class="d-flex justify-content-between">
<div class="product-name d-inline-block flex-grow-1 fw-bolder pe-1 text-truncate">
<span t-esc="line.productName"/>
<span class="text-wrap" t-esc="line.productName"/>
<t t-slot="product-name"/>
</div>
<div t-esc="line.price" class="price fw-bolder"/>
@@ -16,15 +16,15 @@
x <s t-esc="line.oldUnitPrice" t-if="line.oldUnitPrice" />
<t t-esc="line.unitPrice" /> <span t-if="line.unit"> / <t t-esc="line.unit" /></span>
</li>
<li t-if="line.discount !== '0'">
<li t-if="line.discount and line.discount !== '0'">
With a <em><t t-esc="line.discount" />% </em> discount
</li>
<t t-slot="default" />
<li t-if="line.customerNote" class="customer-note w-50 p-2 my-1 rounded text-bg-warning text-warning bg-opacity-25 text-break">
<li t-if="line.customerNote" class="customer-note w-100 p-2 my-1 rounded text-break text-bg-warning text-warning bg-opacity-25">
<i class="fa fa-sticky-note me-1" role="img" aria-label="Customer Note" title="Customer Note"/>
<t t-esc="line.customerNote" />
</li>
<li t-if="line.internalNote" class="internal-note w-50 p-2 my-1 rounded text-bg-info text-info bg-opacity-25 text-break">
<li t-if="line.internalNote" class="internal-note w-100 p-2 my-1 rounded text-bg-info text-info bg-opacity-25 text-break">
<i class="fa fa-tag me-1" role="img" aria-label="Note" title="Note"/>
<t t-esc="line.internalNote" />
</li>
@@ -1,14 +1,13 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { renderToElement } from "@web/core/utils/render";
import { useService } from "@web/core/utils/hooks";
import { parseFloat } from "@web/views/fields/parsers";
import { useState } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { AbstractAwaitablePopup } from "@point_of_sale/app/popup/abstract_awaitable_popup";
import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup";
import { CashMoveReceipt } from "@point_of_sale/app/navbar/cash_move_popup/cash_move_receipt/cash_move_receipt";
import { useAsyncLockedMethod } from "@point_of_sale/app/utils/hooks";
import { Input } from "@point_of_sale/app/generic_components/inputs/input/input";
@@ -22,6 +21,7 @@ export class CashMovePopup extends AbstractAwaitablePopup {
this.orm = useService("orm");
this.pos = usePos();
this.hardwareProxy = useService("hardware_proxy");
this.printer = useService("printer");
this.state = useState({
/** @type {'in'|'out'} */
type: "out",
@@ -54,28 +54,13 @@ export class CashMovePopup extends AbstractAwaitablePopup {
`${_t("Cash")} ${translatedType} - ${_t("Amount")}: ${formattedAmount}`,
"CASH_DRAWER_ACTION"
);
if (this.hardwareProxy.printer) {
const renderedReceipt = renderToElement("point_of_sale.CashMoveReceipt", {
_receipt: {
type,
reason,
amount,
translatedType,
formattedAmount,
cashier: this.pos.get_cashier(),
company: this.pos.company,
date: new Date().toLocaleString(),
},
});
const printResult = await this.hardwareProxy.printer.printReceipt(renderedReceipt);
if (!printResult.successful) {
this.popup.add(ErrorPopup, {
title: printResult.message.title,
body: printResult.message.body,
});
}
}
await this.printer.print(CashMoveReceipt, {
reason,
translatedType,
formattedAmount,
headerData: this.pos.getReceiptHeaderData(),
date: new Date().toLocaleString(),
});
this.props.close();
this.notification.add(
_t("Successfully made a cash %s of %s.", type, formattedAmount),
@@ -0,0 +1,16 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { ReceiptHeader } from "@point_of_sale/app/screens/receipt_screen/receipt/receipt_header/receipt_header";
export class CashMoveReceipt extends Component {
static template = "point_of_sale.CashMoveReceipt";
static components = { ReceiptHeader };
static props = {
reason: String,
translatedType: String,
formattedAmount: String,
headerData: Object,
date: String,
};
}
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.CashMoveReceipt">
<div class="pos-receipt">
<ReceiptHeader data="props.headerData" />
<div class="pos-receipt-center-align">
CASH
<t t-esc="props.translatedType.toUpperCase()" />
</div>
<br />
<div>
AMOUNT
<span t-esc="props.formattedAmount" class="pos-receipt-right-align" />
</div>
<div>
REASON
<span t-esc="props.reason" class="pos-receipt-right-align" />
</div>
<br />
<div class="pos-receipt-order-data">
<div><t t-esc="props.date" /></div>
</div>
</div>
</t>
</templates>
@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.CashMoveReceipt">
<div class="pos-receipt">
<t t-if="_receipt.company.logo">
<img class="pos-receipt-logo" t-att-src="_receipt.company.logo" alt="Logo" />
<br />
</t>
<t t-if="!_receipt.company.logo">
<h2 class="pos-receipt-center-align">
<t t-esc="_receipt.company.name" />
</h2>
<br />
</t>
<div class="pos-receipt-contact">
<div t-if="_receipt.company.contact_address">
<t t-esc="_receipt.company.contact_address" />
</div>
<t t-if="_receipt.company.phone">
<div>Tel:
<t t-esc="_receipt.company.phone" />
</div>
</t>
<t t-if="_receipt.company.vat">
<div>VAT:
<t t-esc="_receipt.company.vat" />
</div>
</t>
<t t-if="_receipt.company.email">
<div>
<t t-esc="_receipt.company.email" />
</div>
</t>
<t t-if="_receipt.company.website">
<div>
<t t-esc="_receipt.company.website" />
</div>
</t>
<t t-if="_receipt.cashier">
<div class="cashier">
<div>--------------------------------</div>
<div>Done by
<t t-esc="_receipt.cashier.name" />
</div>
</div>
</t>
</div>
<br />
<div class="pos-receipt-center-align">
CASH
<t t-esc="_receipt.translatedType.toUpperCase()" />
</div>
<br />
<div>
AMOUNT
<span t-esc="_receipt.formattedAmount" class="pos-receipt-right-align" />
</div>
<div>
REASON
<span t-esc="_receipt.reason" class="pos-receipt-right-align" />
</div>
<br />
<div class="pos-receipt-order-data">
<div><t t-esc="_receipt.date" /></div>
</div>
</div>
</t>
</templates>
@@ -3,7 +3,6 @@
<t t-name="point_of_sale.Chrome">
<div class="pos dvh-100 d-flex flex-column">
<div class="pos-receipt-print"></div>
<Navbar />
<div class="pos-content flex-grow-1 overflow-auto bg-200">
<!-- FIXME POSREF: better error handling in main screens (currently, a crash in owl lifecycle of a main screen blows up the application and the error can't be displayed) -->
@@ -1,8 +1,7 @@
/* @odoo-module */
/* global html2canvas */
import { _t } from "@web/core/l10n/translation";
import { htmlToCanvas } from "@point_of_sale/app/printer/render_service";
/**
* Implements basic printer functions.
*/
@@ -28,7 +27,9 @@ export class BasePrinter {
let image, printResult;
while (this.receiptQueue.length > 0) {
receipt = this.receiptQueue.shift();
image = await this.htmlToImg(receipt);
image = this.processCanvas(
await htmlToCanvas(receipt, { addClass: "pos-receipt-print" })
);
try {
printResult = await this.sendPrintingJob(image);
} catch {
@@ -62,33 +63,6 @@ export class BasePrinter {
return canvas.toDataURL("image/jpeg").replace("data:image/jpeg;base64,", "");
}
/**
* This function renders `el` as an image for printing assuming the following
* - a `div.pos-receipt-print` is present in the dom and will serve as the container
* - a `.pos-receipt` is the root element of `el`.
* @param {HTMLElement} el The element to be converted as image
* @returns {HTMLCanvasElement}
*/
async htmlToImg(el) {
const elClone = el.cloneNode(true);
const receiptContainer = document.querySelector(".pos-receipt-print");
receiptContainer.appendChild(elClone);
const receipt = receiptContainer.querySelector(".pos-receipt");
// Odoo RTL support automatically flip left into right but html2canvas
// won't work as expected if the receipt is aligned to the right of the
// screen so we need to flip it back.
const parent = receipt.parentElement;
parent.style.left = 0;
parent.style.right = "auto";
const canvas = await html2canvas(receipt, {
height: Math.ceil(receipt.clientHeight),
width: Math.ceil(receipt.clientWidth),
scale: 1,
});
receipt.remove();
return this.processCanvas(canvas);
}
/**
* Return value of this method will be the result of calling `printReceipt`
* if it failed to connect to the IoT box.
@@ -116,7 +90,7 @@ export class BasePrinter {
"Please check if the printer is still connected. \n" +
"Some browsers don't allow HTTP calls from websites to devices in the network (for security reasons). " +
"If it is the case, you will need to follow Odoo's documentation for " +
"'Self-signed certificate for ePOS printers' and 'Secure connection (HTTPS)' to solve the issue"
"'Self-signed certificate for ePOS printers' and 'Secure connection (HTTPS)' to solve the issue. "
),
},
};
@@ -0,0 +1,57 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup";
import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup";
import { PrinterService } from "@point_of_sale/app/printer/printer_service";
const posPrinterService = {
dependencies: ["hardware_proxy", "popup", "renderer"],
start(env, { hardware_proxy, popup, renderer }) {
return new PosPrinterService(env, { hardware_proxy, popup, renderer });
},
};
class PosPrinterService extends PrinterService {
constructor(...args) {
super(...args);
this.setup(...args);
}
setup(env, { hardware_proxy, popup, renderer }) {
this.renderer = renderer;
this.hardware_proxy = hardware_proxy;
this.popup = popup;
this.device = hardware_proxy.printer;
}
printWeb() {
try {
return super.printWeb(...arguments);
} catch {
this.popup.add(ErrorPopup, {
title: _t("Printing is not supported on some browsers"),
body: _t("It is possible to print your tickets by making use of an IoT Box."),
});
return false;
}
}
async printHtml() {
this.setPrinter(this.hardware_proxy.printer);
try {
return await super.printHtml(...arguments);
} catch (error) {
const { confirmed } = await this.popup.add(ConfirmPopup, {
title: error.title || _t("Printing error"),
body: error.body + _t("Do you want to print using the web printer? "),
});
if (!confirmed) {
return false;
}
// We want to call the _printWeb when the popup is fully gone
// from the screen which happens after the next animation frame.
await new Promise(requestAnimationFrame);
return await this.printWeb(...arguments);
}
}
}
registry.category("services").add("printer", posPrinterService);
@@ -0,0 +1,42 @@
/** @odoo-module **/
export const printerService = {
dependencies: ["renderer"],
start(env, { renderer }) {
return new PrinterService(env, { renderer });
},
};
export class PrinterService {
constructor(...args) {
this.setup(...args);
}
setup(env, { renderer }) {
this.renderer = renderer;
this.device = null;
}
setPrinter(newDevice) {
this.device = newDevice;
}
printWeb(el) {
this.renderer.whenMounted({ el, callback: window.print });
return true;
}
async printHtml(el, { webPrintFallback = false } = {}) {
if (!this.device) {
return webPrintFallback && this.printWeb(el);
}
const printResult = await this.device.printReceipt(el);
if (printResult.successful) {
return true;
}
throw {
title: printResult.message.title || "Error",
body: printResult.message.body,
};
}
async print(component, props, options) {
const el = await this.renderer.toHtml(component, props);
return await this.printHtml(el, options);
}
is = () => Boolean(this.device?.printReceipt);
}
@@ -0,0 +1,105 @@
/** @odoo-module **/
/* global html2canvas */
import { registry } from "@web/core/registry";
import { Component, onRendered, reactive, useRef, xml } from "@odoo/owl";
export class RenderContainer extends Component {
static props = ["comp", "onRendered"];
// the `.render-container` is used by other functions that need a
// place where to momentarily render some html code
// we should only intact with that div through the `whenMounted` function
static template = xml`
<div style="left: -1000px; position: absolute;">
<div t-ref="ref">
<t t-if="props.comp.component" t-component="props.comp.component" t-props="props.comp.props"/>
</div>
<div class="render-container" />
</div>`;
setup() {
this.ref = useRef("ref");
onRendered(async () => {
// this timeout is needed in order to wait for the
// component to arrive in it's final state
await new Promise((r) => setTimeout(r, 100));
this.props.onRendered(this.ref?.el?.firstChild);
});
}
}
/**
* This service does for components what renderToElement does for templates.
* In order to obtain the html code that represents a component, we need to
* actually mount the respective component in the dom.
*/
const renderService = {
dependencies: [],
start() {
const toBeRenderedComponentData = reactive({});
let elem, resolver;
registry.category("main_components").add("RenderContainer", {
Component: RenderContainer,
props: {
comp: toBeRenderedComponentData,
onRendered: (el) => {
elem = el;
resolver?.();
// after obtaining the html code, we need to flush the
// contents of the RenderContainer component
toBeRenderedComponentData.component = null;
},
},
});
const toHtml = async (component, props) => {
Object.assign(toBeRenderedComponentData, { component, props });
// we wait for the RenderContainer component to actually
// render our component
await new Promise((r) => (resolver = r));
return elem;
};
const toCanvas = async (component, props, options) => {
return htmlToCanvas(await toHtml(component, props), options);
};
const toJpeg = async (component, props, options) => {
const canvas = await toCanvas(component, props, options);
return canvas.toDataURL("image/jpeg").replace("data:image/jpeg;base64,", "");
};
const whenMounted = async ({ el, container, callback }) => {
container ||= document.querySelector(".render-container");
return await applyWhenMounted({ el, container, callback });
};
return { toHtml, toCanvas, toJpeg, whenMounted };
},
};
registry.category("services").add("renderer", renderService);
/**
* This function is meant to be used for the cases where an
* action needs to be performed based on some html code, but
* that html code has to be in the dom for the action to be
* performed. ( for example calling html2canvas )
*/
const applyWhenMounted = async ({ el, container, callback }) => {
const elClone = el.cloneNode(true);
container.appendChild(elClone);
const res = await callback(elClone);
elClone.remove();
return res;
};
/**
* This function assumes that the `renderer` service is available.
*/
export const htmlToCanvas = async (el, options) => {
el.classList.add(options.addClass || "");
// html2canvas expects the given element to be in the DOM
return await applyWhenMounted({
el,
container: document.querySelector(".render-container"),
callback: async (el) =>
await html2canvas(el, {
height: Math.ceil(el.clientHeight),
width: Math.ceil(el.clientWidth),
scale: 1,
}),
});
};
@@ -15,10 +15,10 @@ import { ConnectionLostError } from "@web/core/network/rpc_service";
import { PaymentScreenPaymentLines } from "@point_of_sale/app/screens/payment_screen/payment_lines/payment_lines";
import { PaymentScreenStatus } from "@point_of_sale/app/screens/payment_screen/payment_status/payment_status";
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { Component, useState, useRef, onMounted } from "@odoo/owl";
import { renderToElement } from "@web/core/utils/render";
import { Component, useState, onMounted } from "@odoo/owl";
import { Numpad } from "@point_of_sale/app/generic_components/numpad/numpad";
import { floatIsZero } from "@web/core/utils/numbers";
import { OrderReceipt } from "@point_of_sale/app/screens/receipt_screen/receipt/order_receipt";
export class PaymentScreen extends Component {
static template = "point_of_sale.PaymentScreen";
@@ -36,7 +36,7 @@ export class PaymentScreen extends Component {
this.report = useService("report");
this.notification = useService("pos_notification");
this.hardwareProxy = useService("hardware_proxy");
this.orderReceipt = useRef("order-receipt");
this.printer = useService("printer");
this.payment_methods_from_config = this.pos.payment_methods.filter((method) =>
this.pos.config.payment_method_ids.includes(method.id)
);
@@ -357,16 +357,12 @@ export class PaymentScreen extends Component {
: true;
if (this.hardwareProxy.printer && invoiced_finalized) {
const orderReceipt = renderToElement("point_of_sale.OrderReceipt", {
receiptData: this.pos.get_order().getOrderReceiptEnv(),
pos: this.pos,
env: this.env,
const printResult = await this.printer.print(OrderReceipt, {
data: this.pos.get_order().export_for_printing(),
formatCurrency: this.env.utils.formatCurrency,
});
// Need to await to have the result in case of automatic skip screen.
const printResult = await this.hardwareProxy.printer.printReceipt(orderReceipt);
if (printResult.successful && this.pos.config.iface_print_skip_screen) {
if (printResult && this.pos.config.iface_print_skip_screen) {
this.pos.removeOrder(this.currentOrder);
this.pos.add_new_order();
nextScreen = "ProductScreen";
@@ -1,64 +0,0 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup";
import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup";
import { Component, useRef } from "@odoo/owl";
/**
* This relies on the assumption that there is a reference to
* `order-receipt` so it is important to declare a `t-ref` to
* `order-receipt` in the template of the Component that extends
* this abstract component.
*/
export class AbstractReceiptScreen extends Component {
setup() {
this.orderReceipt = useRef("order-receipt");
this.popup = useService("popup");
this.hardwareProxy = useService("hardware_proxy");
}
async _printReceipt() {
if (this.hardwareProxy.printer) {
const printResult = await this.hardwareProxy.printer.printReceipt(
this.orderReceipt.el.firstChild.firstChild
);
if (printResult.successful) {
return true;
} else {
await this.popup.add(ErrorPopup, {
title: printResult.message.title,
body: printResult.message.body,
});
const { confirmed } = await this.popup.add(ConfirmPopup, {
title: printResult.message.title,
body: _t("Do you want to print using the web printer?"),
});
if (confirmed) {
// We want to call the _printWeb when the popup is fully gone
// from the screen which happens after the next animation frame.
await new Promise(requestAnimationFrame);
return await this._printWeb();
}
return false;
}
} else {
return await this._printWeb();
}
}
async _printWeb() {
try {
window.print();
return true;
} catch {
await this.popup.add(ErrorPopup, {
title: _t("Printing is not supported on some browsers"),
body: _t(
"Printing is not supported on some browsers due to no default printing protocol " +
"is available. It is possible to print your tickets by making use of an IoT Box."
),
});
return false;
}
}
}
@@ -0,0 +1,23 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { Orderline } from "@point_of_sale/app/generic_components/orderline/orderline";
import { OrderWidget } from "@point_of_sale/app/generic_components/order_widget/order_widget";
import { ReceiptHeader } from "@point_of_sale/app/screens/receipt_screen/receipt/receipt_header/receipt_header";
import { omit } from "@web/core/utils/objects";
export class OrderReceipt extends Component {
static template = "point_of_sale.OrderReceipt";
static components = {
Orderline,
OrderWidget,
ReceiptHeader,
};
static props = {
data: Object,
formatCurrency: Function,
};
omit(...args) {
return omit(...args);
}
}
@@ -1,146 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.OrderReceipt">
<div class="pos-receipt">
<t t-if="receiptData.receipt.company.logo">
<img class="pos-receipt-logo" t-att-src="receiptData.receipt.company.logo" alt="Logo"/>
<br/>
</t>
<t t-if="!receiptData.receipt.company.logo">
<h2 class="pos-receipt-center-align">
<t t-esc="receiptData.receipt.company.name" />
</h2>
<br/>
</t>
<div class="pos-receipt-contact">
<t t-if="receiptData.receipt.company.contact_address">
<div><t t-esc="receiptData.receipt.company.contact_address" /></div>
</t>
<t t-if="receiptData.receipt.company.phone">
<div>Tel:<t t-esc="receiptData.receipt.company.phone" /></div>
</t>
<t t-if="receiptData.receipt.company.vat">
<div><t t-esc="receiptData.receipt.company.vat_label"/>:<t t-esc="receiptData.receipt.company.vat" /></div>
</t>
<t t-if="receiptData.receipt.company.email">
<div><t t-esc="receiptData.receipt.company.email" /></div>
</t>
<t t-if="receiptData.receipt.company.website">
<div><t t-esc="receiptData.receipt.company.website" /></div>
</t>
<t t-if="receiptData.receipt.header">
<div style="white-space:pre-line"><t t-esc="receiptData.receipt.header" /></div>
</t>
<t t-if="receiptData.receipt.cashier">
<div class="cashier">
<div>--------------------------------</div>
<div>Served by <t t-esc="receiptData.receipt.cashier" /></div>
</div>
</t>
</div>
<br /><br />
<!-- Orderlines -->
<div class="orderlines">
<t t-call="point_of_sale.OrderLinesReceipt"/>
</div>
<ReceiptHeader data="props.data.headerData" />
<OrderWidget lines="props.data.orderlines" t-slot-scope="scope">
<t t-set="line" t-value="scope.line"/>
<Orderline line="omit(scope.line, 'customerNote')">
<li t-if="line.customerNote" class="customer-note w-100 p-2 my-1 rounded text-break">
<i class="fa fa-sticky-note me-1" role="img" aria-label="Customer Note" title="Customer Note"/>
<t t-esc="line.customerNote" />
</li>
<ul t-if="line.pack_lot_lines">
<li t-foreach="line.get_lot_lines()" t-as="lot" t-key="lot.cid">
SN <t t-esc="lot.lot_name"/>
</li>
</ul>
</Orderline>
</OrderWidget>
<!-- Total -->
<div class="pos-receipt-right-align">--------</div>
<br/>
<div class="pos-receipt-amount">
TOTAL
<span t-esc="env.utils.formatCurrency(receiptData.receipt.total_with_tax)" class="pos-receipt-right-align"/>
<span t-esc="props.formatCurrency(props.data.amount_total)" class="pos-receipt-right-align"/>
</div>
<t t-if="receiptData.receipt.total_rounded != receiptData.receipt.total_with_tax">
<t t-if="props.data.rounding_applied">
<div class="pos-receipt-amount">
Rounding
<span t-esc='env.utils.formatCurrency(receiptData.receipt.rounding_applied)' class="pos-receipt-right-align"/>
<span t-esc='props.formatCurrency(props.data.rounding_applied)' class="pos-receipt-right-align"/>
</div>
<div class="pos-receipt-amount">
To Pay
<span t-esc='env.utils.formatCurrency(receiptData.receipt.total_rounded)' class="pos-receipt-right-align"/>
<span t-esc='props.formatCurrency(props.data.amount_total + props.data.rounding_applied)' class="pos-receipt-right-align"/>
</div>
</t>
<br/><br/>
<!-- Payment Lines -->
<t t-foreach="receiptData.receipt.paymentlines" t-as="line" t-key="line.cid">
<div>
<t t-esc="line.name" />
<span t-esc="env.utils.formatCurrency(line.amount, false)" class="pos-receipt-right-align"/>
</div>
</t>
<br/>
<div class="pos-receipt-amount receipt-change">
CHANGE
<span t-esc="env.utils.formatCurrency(receiptData.receipt.change)" class="pos-receipt-right-align"/>
<div class="paymentlines" t-foreach="props.data.paymentlines" t-as="line" t-key="line_index">
<t t-esc="line.name" />
<span t-esc="props.formatCurrency(line.amount, false)" class="pos-receipt-right-align"/>
</div>
<div class="pos-receipt-amount receipt-change mt-2">
CHANGE
<span t-esc="props.formatCurrency(props.data.change)" class="pos-receipt-right-align"/>
</div>
<br/>
<!-- Extra Payment Info -->
<t t-if="receiptData.receipt.total_discount">
<t t-if="props.data.total_discount">
<div>
Discounts
<span t-esc="env.utils.formatCurrency(receiptData.receipt.total_discount)" class="pos-receipt-right-align"/>
<span t-esc="props.formatCurrency(props.data.total_discount)" class="pos-receipt-right-align"/>
</div>
</t>
<div t-if="receiptData.receipt.tax_details.length > 0" class="pos-receipt-taxes">
<div t-if="props.data.tax_details.length > 0" class="pos-receipt-taxes">
<span />
<span>VAT%</span>
<span>VAT</span>
<span>ExVAT</span>
<span>Total</span>
<t t-foreach="receiptData.receipt.tax_details" t-as="tax" t-key="tax.tax.id">
<t t-foreach="props.data.tax_details" t-as="tax" t-key="tax.tax.id">
<span />
<span><t t-esc="tax.tax.amount"/> %</span>
<span t-esc="env.utils.formatCurrency(tax.amount, false)" />
<span t-esc="env.utils.formatCurrency(tax.base, false)" />
<span t-esc="env.utils.formatCurrency(tax.amount + tax.base, false)" />
<span t-esc="props.formatCurrency(tax.amount, false)" />
<span t-esc="props.formatCurrency(tax.base, false)" />
<span t-esc="props.formatCurrency(tax.amount + tax.base, false)" />
</t>
<t t-if="receiptData.receipt.tax_details.length > 1">
<t t-if="props.data.tax_details.length > 1">
<span />
<span />
<span t-esc="env.utils.formatCurrency(receiptData.receipt.total_tax, false)" />
<span t-esc="env.utils.formatCurrency(receiptData.receipt.total_without_tax, false)" />
<span t-esc="env.utils.formatCurrency(receiptData.receipt.total_with_tax, false)" />
<span t-esc="props.formatCurrency(props.data.amount_tax, false)" />
<span t-esc="props.formatCurrency(props.data.total_without_tax, false)" />
<span t-esc="props.formatCurrency(props.data.amount_total, false)" />
</t>
</div>
<div class="before-footer" />
<div t-if="receiptData.receipt.pos_qr_code">
<div t-if="props.data.pos_qr_code">
<br /><br />
<div class="pos-receipt-order-data">
<div class="pos-receipt-order-data mb-2">
Scan me to request an invoice for your purchase.
</div>
<img id="posqrcode" t-att-src="receiptData.receipt.pos_qr_code" class="pos-receipt-logo"/>
<img id="posqrcode" t-att-src="props.data.pos_qr_code" class="pos-receipt-logo"/>
</div>
<div t-if="receiptData.receipt.ticket_code">
<div t-if="props.data.ticket_code">
<br /><br />
<div class="pos-receipt-order-data">
You can go to <t t-out="receiptData.receipt.base_url"/>/pos/ticket and use the code below to request an invoice online
You can go to <t t-out="props.data.base_url"/>/pos/ticket and use the code below to request an invoice online
</div>
<div class="pos-receipt-order-data">
Unique Code: <t t-out="receiptData.receipt.ticket_code"/>
Unique Code: <t t-out="props.data.ticket_code"/>
</div>
</div>
<!-- Footer -->
<div t-if="receiptData.receipt.footer" class="pos-receipt-center-align" style="white-space:pre-line">
<br/>
<t t-esc="receiptData.receipt.footer" />
<div t-if="props.data.footer" class="pos-receipt-center-align" style="white-space:pre-line">
<br/>
<t t-esc="props.data.footer" />
<br/>
<br/>
</div>
<div class="after-footer">
<t t-foreach="receiptData.receipt.paymentlines" t-as="line" t-key="line_index">
<t t-foreach="props.data.paymentlines" t-as="line" t-key="line_index">
<t t-if="line.ticket">
<br />
<div class="pos-payment-terminal-receipt">
@@ -151,81 +119,18 @@
</div>
<br/>
<t t-if="receiptData.shippingDate">
<t t-if="props.shippingDate">
<div class="pos-receipt-order-data">
Expected delivery:
<div><t t-esc="receiptData.shippingDate" /></div>
<div><t t-esc="props.shippingDate" /></div>
</div>
</t>
<br/>
<div class="pos-receipt-order-data">
<div><t t-esc="receiptData.receipt.name" /></div>
<t t-if="receiptData.receipt.date.localestring">
<div><t t-esc="receiptData.receipt.date.localestring" /></div>
</t>
<t t-else="">
<div><t t-esc="receiptData.receipt.date.validation_date" /></div>
</t>
<div t-esc="props.data.name" />
<div t-esc="props.data.date" />
</div>
</div>
</t>
<t t-name="point_of_sale.OrderLinesReceipt">
<div id="receipt-orderline" t-foreach="receiptData.receipt.orderlines" t-as="line" t-key="line.id" t-att-class="{'border-start border-3 ps-1': line.isPartOfCombo }">
<div t-esc="line.product_name_wrapped[0]" />
<span>
<t t-foreach="line.product_name_wrapped.slice(1)" t-as="wrapped_line" t-key="wrapped_line_index">
<t t-esc="wrapped_line"/>
</t>
</span>
<t t-if="line.display_discount_policy == 'without_discount' and line.price != line.price_lst">
<div class="pos-receipt-left-padding">
<t t-esc="env.utils.formatCurrency(line.price_lst, false)" />
->
<t t-esc="env.utils.formatCurrency(line.price, false)" />
</div>
</t>
<t t-elif="line.discount !== 0">
<div class="pos-receipt-left-padding">
<t t-if="pos.config.iface_tax_included === 'total'">
<t t-esc="env.utils.formatCurrency(line.price_with_tax_before_discount, false)"/>
</t>
<t t-else="">
<t t-esc="env.utils.formatCurrency(line.unitDisplayPriceBeforeDiscount, false)"/>
</t>
</div>
</t>
<t t-if="line.discount !== 0">
<div class="pos-receipt-left-padding">
Discount: <t t-esc="line.discount" />%
</div>
</t>
<div class="pos-receipt-left-padding">
<t t-esc="Math.round(line.quantity * Math.pow(10, pos.dp['Product Unit of Measure'])) / Math.pow(10, pos.dp['Product Unit of Measure'])"/>
<t t-if="!line.is_in_unit" t-esc="line.unit_name" />
x
<t t-esc="env.utils.formatCurrency(line.price_display_one)" />
<div class="price_display pos-receipt-right-align">
<span t-esc="env.utils.formatCurrency(line.price_display, false)" />
<span class="tax-letter" />
</div>
</div>
<t t-if="line.customer_note">
<div class="pos-receipt-left-padding pos-receipt-customer-note">
<t t-esc="line.customer_note"/>
</div>
</t>
<t t-if="line.pack_lot_lines">
<div class="pos-receipt-left-padding">
<ul>
<t t-foreach="line.pack_lot_lines" t-as="lot" t-key="lot.cid">
<li>
SN <t t-esc="lot.lot_name"/>
</li>
</t>
</ul>
</div>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,19 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class ReceiptHeader extends Component {
static template = "point_of_sale.ReceiptHeader";
static props = {
data: {
type: Object,
shape: {
company: Object,
header: { type: [String, { value: false }], optional: true },
cashier: { type: String, optional: true },
trackingNumber: { type: String, optional: true },
"*": true,
},
},
};
}
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<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/>
<div class="pos-receipt-contact">
<!-- contact address -->
<div t-if="props.data.company.partner_id?.[1]" t-esc="props.data.company.partner_id[1]" />
<t t-if="props.data.company.phone">
<div>Tel:<t t-esc="props.data.company.phone" /></div>
</t>
<t t-if="props.data.company.vat">
<div><t t-esc="props.data.company.country?.vat_label || 'Tax ID'"/>: <t t-esc="props.data.company.vat" /></div>
</t>
<div t-if="props.data.company.email" t-esc="props.data.company.email" />
<div t-if="props.data.company.website" t-esc="props.data.company.website" />
<div t-if="props.data.header" style="white-space:pre-line" t-esc="props.data.header" />
<div t-if="props.data.cashier" class="cashier">
<div>--------------------------------</div>
<div>Served by <t t-esc="props.data.cashier" /></div>
</div>
</div>
<br /><br />
</t>
</templates>
@@ -2,24 +2,25 @@
import { _t } from "@web/core/l10n/translation";
import { useErrorHandlers } from "@point_of_sale/app/utils/hooks";
import { AbstractReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/abstract_receipt_screen";
import { OfflineErrorPopup } from "@point_of_sale/app/errors/popups/offline_error_popup";
import { registry } from "@web/core/registry";
import { useRef, useState, onWillStart } from "@odoo/owl";
import { OrderReceipt } from "@point_of_sale/app/screens/receipt_screen/receipt/order_receipt";
import { useRef, useState, onWillStart, Component } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { useService } from "@web/core/utils/hooks";
import { BasePrinter } from "@point_of_sale/app/printer/base_printer";
export class ReceiptScreen extends AbstractReceiptScreen {
export class ReceiptScreen extends Component {
static template = "point_of_sale.ReceiptScreen";
static components = { OrderReceipt };
setup() {
super.setup();
this.pos = usePos();
this.printer = useService("printer");
useErrorHandlers();
this.ui = useState(useService("ui"));
this.orm = useService("orm");
this.orderReceipt = useRef("order-receipt");
this.renderer = useService("renderer");
this.buttonMailReceipt = useRef("order-mail-receipt-button");
this.buttonPrintReceipt = useRef("order-print-receipt-button");
this.currentOrder = this.pos.get_order();
@@ -36,9 +37,6 @@ export class ReceiptScreen extends AbstractReceiptScreen {
}
});
}
get receiptData() {
return this.pos.get_order().getOrderReceiptEnv();
}
_addNewOrder() {
this.pos.add_new_order();
}
@@ -118,11 +116,17 @@ export class ReceiptScreen extends AbstractReceiptScreen {
}
async printReceipt() {
this.buttonPrintReceipt.el.className = "fa fa-fw fa-spin fa-circle-o-notch";
const currentOrder = this.currentOrder;
const isPrinted = await this._printReceipt();
const isPrinted = await this.printer.print(
OrderReceipt,
{
data: this.pos.get_order().export_for_printing(),
formatCurrency: this.env.utils.formatCurrency,
},
{ webPrintFallback: true }
);
if (isPrinted) {
currentOrder._printed = true;
this.currentOrder._printed = true;
}
if (this.buttonPrintReceipt.el) {
@@ -130,16 +134,24 @@ export class ReceiptScreen extends AbstractReceiptScreen {
}
}
async _sendReceiptToCustomer() {
const printer = new BasePrinter();
const receiptString = this.orderReceipt.el.firstChild;
const ticketImage = await printer.htmlToImg(receiptString);
const order = this.currentOrder;
const partner = order.get_partner();
const orderName = order.get_name();
const partner = this.currentOrder.get_partner();
const orderPartner = {
email: this.orderUiState.inputEmail,
name: partner ? partner.name : this.orderUiState.inputEmail,
};
await this.sendToCustomer(orderPartner, "action_receipt_to_customer");
}
async sendToCustomer(orderPartner, methodName) {
const ticketImage = await this.renderer.toJpeg(
OrderReceipt,
{
data: this.pos.get_order().export_for_printing(),
formatCurrency: this.env.utils.formatCurrency,
},
{ addClass: "pos-receipt-print" }
);
const order = this.currentOrder;
const orderName = order.get_name();
const order_server_id = this.pos.validated_orders_name_server_id_map[orderName];
if (!order_server_id) {
this.popup.add(OfflineErrorPopup, {
@@ -150,7 +162,7 @@ export class ReceiptScreen extends AbstractReceiptScreen {
});
return Promise.reject();
}
await this.orm.call("pos.order", "action_receipt_to_customer", [
await this.orm.call("pos.order", methodName, [
[order_server_id],
orderName,
orderPartner,
@@ -32,7 +32,7 @@
top: 0;
left: 0;
}
.pos .receipt-screen .pos-receipt-container, .pos .receipt-screen .pos-receipt-container * {
.pos .receipt-screen .pos-receipt-container, .pos .receipt-screen .pos-receipt-container *, .render-container * {
visibility: visible;
background: white !important;
color: black !important;
@@ -45,4 +45,9 @@
font-size: 14px !important;
width: 266px !important;
}
.render-container {
position: absolute !important;
top: 10px;
left: 10px;
}
}
@@ -33,9 +33,9 @@
</div>
</div>
</div>
<div class="pos-receipt-container bg-200 text-center overflow-auto" t-ref="order-receipt">
<div class="pos-receipt-container bg-200 text-center overflow-auto">
<div class="d-inline-block m-3 p-3 border rounded bg-view text-start overflow-hidden">
<t t-call="point_of_sale.OrderReceipt" />
<OrderReceipt data="pos.get_order().export_for_printing()" formatCurrency="env.utils.formatCurrency" />
</div>
</div>
</div>
@@ -1,27 +1,35 @@
/** @odoo-module */
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { AbstractReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/abstract_receipt_screen";
import { OrderReceipt } from "@point_of_sale/app/screens/receipt_screen/receipt/order_receipt";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class ReprintReceiptScreen extends AbstractReceiptScreen {
export class ReprintReceiptScreen extends Component {
static template = "point_of_sale.ReprintReceiptScreen";
static components = { OrderReceipt };
static storeOnOrder = false;
static props = ["order"];
setup() {
super.setup();
this.pos = usePos();
this.printer = useService("printer");
}
confirm() {
this.pos.showScreen("TicketScreen", { reuseSavedUIState: true });
}
async tryReprint() {
await this._printReceipt();
}
get receiptData() {
return this.props.order.getOrderReceiptEnv();
tryReprint() {
this.printer.print(
OrderReceipt,
{
data: this.props.order.export_for_printing(),
formatCurrency: this.env.utils.formatCurrency,
},
{ webPrintFallback: true }
);
}
}
@@ -15,9 +15,9 @@
<button class="button print btn btn-lg btn-primary" t-on-click="tryReprint">
<i class="fa fa-print ms-2"></i> Print Receipt
</button>
<div class="pos-receipt-container mt-2 bg-200 text-center overflow-y-auto" t-ref="order-receipt">
<div class="pos-receipt-container mt-2 bg-200 text-center overflow-y-auto">
<div class="d-inline-block m-3 p-3 border rounded bg-view text-start overflow-hidden">
<t t-call="point_of_sale.OrderReceipt" />
<OrderReceipt data="props.order.export_for_printing()" formatCurrency="env.utils.formatCurrency" />
</div>
</div>
</div>
@@ -2,8 +2,8 @@
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { Component } from "@odoo/owl";
import { renderToElement } from "@web/core/utils/render";
import { useService } from "@web/core/utils/hooks";
import { OrderReceipt } from "@point_of_sale/app/screens/receipt_screen/receipt/order_receipt";
import { useAsyncLockedMethod } from "@point_of_sale/app/utils/hooks";
export class ReprintReceiptButton extends Component {
@@ -11,7 +11,7 @@ export class ReprintReceiptButton extends Component {
setup() {
this.pos = usePos();
this.hardwareProxy = useService("hardware_proxy");
this.printer = useService("printer");
this.click = useAsyncLockedMethod(this.click);
}
@@ -19,19 +19,10 @@ export class ReprintReceiptButton extends Component {
if (!this.props.order) {
return;
}
if (this.hardwareProxy.printer) {
const orderReceipt = renderToElement("point_of_sale.OrderReceipt", {
receiptData: {
...this.props.order.getOrderReceiptEnv(),
},
pos: this.pos,
env: this.env,
});
// Need to await to have the result in case of automatic skip screen.
await this.hardwareProxy.printer.printReceipt(orderReceipt);
} else {
this.pos.showScreen("ReprintReceiptScreen", { order: this.props.order });
}
// Need to await to have the result in case of automatic skip screen.
(await this.printer.print(OrderReceipt, {
data: this.props.order.export_for_printing(),
formatCurrency: this.env.utils.formatCurrency,
})) || this.pos.showScreen("ReprintReceiptScreen", { order: this.props.order });
}
}
@@ -23,6 +23,7 @@ import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_pop
import { _t } from "@web/core/l10n/translation";
import { renderToElement } from "@web/core/utils/render";
import { ProductCustomAttribute } from "./models/product_custom_attribute";
import { omit } from "@web/core/utils/objects";
const { DateTime } = luxon;
@@ -792,68 +793,7 @@ export class Orderline extends PosModel {
combo_parent_id: this.comboParent?.id || this.comboParent?.cid,
};
}
//used to create a json of the ticket, to be sent to the printer
export_for_printing() {
return {
id: this.id,
quantity: this.get_quantity(),
unit_name: this.get_unit().name,
is_in_unit: this.get_unit().id == this.pos.uom_unit_id,
price: this.get_unit_display_price(),
discount: this.get_discount(),
product_name: this.get_product().display_name,
product_name_wrapped: this.generate_wrapped_product_name(),
price_lst: this.get_taxed_lst_unit_price(),
fixed_lst_price: this.get_fixed_lst_price(),
price_type: this.price_type,
display_discount_policy: this.display_discount_policy(),
price_display_one: this.get_display_price_one(),
price_display: this.get_display_price(),
price_with_tax: this.get_price_with_tax(),
price_without_tax: this.get_price_without_tax(),
price_with_tax_before_discount: this.get_price_with_tax_before_discount(),
tax: this.get_tax(),
tax_details: this.get_tax_details(),
product_description: this.get_product().description,
product_description_sale: this.get_product().description_sale,
pack_lot_lines: this.get_lot_lines(),
customer_note: this.get_customer_note(),
taxed_lst_unit_price: this.get_taxed_lst_unit_price(),
isPartOfCombo: this.isPartOfCombo(),
custom_attribute_value_ids: this.custom_attribute_value_ids,
unitDisplayPriceBeforeDiscount: this.getUnitDisplayPriceBeforeDiscount(),
};
}
generate_wrapped_product_name() {
var MAX_LENGTH = 24; // 40 * line ratio of .6
var wrapped = [];
var name = this.get_full_product_name();
var current_line = "";
while (name.length > 0) {
var space_index = name.indexOf(" ");
if (space_index === -1) {
space_index = name.length;
}
if (current_line.length + space_index > MAX_LENGTH) {
if (current_line.length) {
wrapped.push(current_line);
}
current_line = "";
}
current_line += name.slice(0, space_index + 1);
name = name.slice(space_index + 1);
}
if (current_line.length) {
wrapped.push(current_line);
}
return wrapped;
}
// changes the base price of the product for this orderline
set_unit_price(price) {
this.order.assert_editable();
@@ -1296,7 +1236,6 @@ export class Payment extends PosModel {
//exports as JSON for receipt printing
export_for_printing() {
return {
cid: this.cid,
amount: this.get_amount(),
name: this.name,
ticket: this.ticket,
@@ -1556,97 +1495,42 @@ export class Order extends PosModel {
}
return json;
}
_exportShippingDateForPrinting() {
const shippingDate = DateTime.fromJSDate(new Date(this.shippingDate));
return formatDate(shippingDate);
}
export_for_printing() {
var orderlines = [];
this.orderlines.forEach(function (orderline) {
orderlines.push(orderline.export_for_printing());
});
// If order is locked (paid), the 'change' is saved as negative payment,
// and is flagged with is_change = true. A receipt that is printed first
// time doesn't show this negative payment so we filter it out.
var paymentlines = this.paymentlines
.filter(function (paymentline) {
return !paymentline.is_change;
})
.map(function (paymentline) {
return paymentline.export_for_printing();
});
const partner = this.partner;
const cashier = this.cashier;
const company = this.pos.company;
const date = new Date();
var receipt = {
orderlines: orderlines,
paymentlines: paymentlines,
subtotal: this.get_subtotal(),
total_with_tax: this.get_total_with_tax(),
total_rounded: this.get_total_with_tax() + this.get_rounding_applied(),
const paymentlines = this.paymentlines
.filter((p) => !p.is_change)
.map((p) => p.export_for_printing());
this.receiptDate ||= formatDateTime(luxon.DateTime.now());
return {
orderlines: this.orderlines.map((l) => omit(l.getDisplayData(), "internalNote")),
paymentlines,
amount_total: this.get_total_with_tax(),
total_without_tax: this.get_total_without_tax(),
total_tax: this.get_total_tax(),
amount_tax: this.get_total_tax(),
total_paid: this.get_total_paid(),
total_discount: this.get_total_discount(),
rounding_applied: this.get_rounding_applied(),
tax_details: this.get_tax_details(),
change: this.locked ? this.amount_return : this.get_change(),
name: this.get_name(),
partner: partner ? partner : null,
invoice_id: null, //TODO
cashier: cashier ? cashier.name : null,
precision: {
price: 2,
money: 2,
quantity: 3,
},
date: {
year: date.getFullYear(),
month: date.getMonth(),
date: date.getDate(), // day of the month
day: date.getDay(), // day of the week
hour: date.getHours(),
minute: date.getMinutes(),
isostring: date.toISOString(),
localestring: formatDateTime(luxon.DateTime.now()),
date_order: this.date_order,
},
company: {
email: company.email,
website: company.website,
company_registry: company.company_registry,
contact_address: company.partner_id[1],
vat: company.vat,
vat_label: (company.country && company.country.vat_label) || _t("Tax ID"),
name: company.name,
phone: company.phone,
logo: this.pos.company_logo_base64,
},
currency: this.pos.currency,
cashier: this.cashier?.name,
date: this.receiptDate,
pos_qr_code:
this.pos.company.point_of_sale_use_ticket_qr_code &&
qrCodeSrc(
`${this.pos.base_url}/pos/ticket/validate?access_token=${this.access_token}`
),
ticket_code: this.pos.company.point_of_sale_ticket_unique_code
? this.ticketCode
: false,
ticket_code: this.pos.company.point_of_sale_ticket_unique_code && this.ticketCode,
base_url: this.pos.base_url,
footer: this.pos.config.receipt_footer,
// FIXME: isn't there a better way to handle this date?
shippingDate:
this.shippingDate && formatDate(DateTime.fromJSDate(new Date(this.shippingDate))),
headerData: this.pos.getReceiptHeaderData(),
};
const isHeaderOrFooter = this.pos.config.is_header_or_footer;
receipt.header = (isHeaderOrFooter && this.pos.config.receipt_header) || "";
receipt.footer = (isHeaderOrFooter && this.pos.config.receipt_footer) || "";
if (!receipt.date.localestring && (!this.state || this.state == "draft")) {
receipt.date.localestring = formatDateTime(DateTime.local());
}
return receipt;
}
// This function send order change to printer
// For sending changes to preparation display see sendChanges function.
@@ -2751,23 +2635,6 @@ export class Order extends PosModel {
wait_for_push_order() {
return false;
}
/**
* @returns {Object} object to use as props for instantiating OrderReceipt.
*/
getOrderReceiptEnv() {
// Formerly get_receipt_render_env defined in ScreenWidget.
const receipt = this.export_for_printing();
const getOrderlineTaxes = (line) =>
Object.keys(line.tax_details).map((taxId) => this.pos.taxes_by_id[taxId]);
return {
getOrderlineTaxes: getOrderlineTaxes,
order: this,
receipt: receipt,
orderlines: this.get_orderlines(),
paymentlines: this.get_paymentlines(),
shippingDate: this.shippingDate ? this._exportShippingDateForPrinting() : false,
};
}
updatePricelistAndFiscalPosition(newPartner) {
let newPartnerPricelist, newPartnerFiscalPosition;
const defaultFiscalPosition = this.pos.fiscal_positions.find(
@@ -276,7 +276,6 @@ export class PosStore extends Reactive {
this.fiscal_positions = loadedData["account.fiscal.position"];
this.base_url = loadedData["base_url"];
this.pos_has_valid_product = loadedData["pos_has_valid_product"];
await this._loadPictures();
await this._loadPosPrinters(loadedData["pos.printer"]);
this.open_orders_json = loadedData["open_orders"];
}
@@ -1869,8 +1868,6 @@ export class PosStore extends Reactive {
const image = new Image();
image.src = `/web/image?model=pos.category&field=image_128&id=${category.id}&unique=${category.write_date}`;
}
const image = new Image();
image.src = "/point_of_sale/static/src/img/backspace.png";
}
/**
@@ -1910,6 +1907,14 @@ export class PosStore extends Reactive {
doNotAllowRefundAndSales() {
return false;
}
getReceiptHeaderData() {
return {
company: this.company,
cashier: this.get_cashier()?.name,
header: this.config.receipt_header,
};
}
}
PosStore.prototype.electronic_payment_interfaces = {};
@@ -1,12 +1,5 @@
.pos-receipt-print {
width: 512px;
height: 0;
overflow: hidden;
position: absolute;
left: 0;
top: 0;
text-align: left;
direction: ltr;
font-size: 27px;
color: #000000;
}
@@ -4,7 +4,7 @@ import { PaymentScreen } from "@point_of_sale/../tests/tours/helpers/PaymentScre
import { ProductScreen } from "@point_of_sale/../tests/tours/helpers/ProductScreenTourMethods";
import { ReceiptScreen } from "@point_of_sale/../tests/tours/helpers/ReceiptScreenTourMethods";
import { TextAreaPopup } from "@point_of_sale/../tests/tours/helpers/TextAreaPopupTourMethods";
import { getSteps, startSteps } from "@point_of_sale/../tests/tours/helpers/utils";
import { getSteps, startSteps, insertSteps } from "@point_of_sale/../tests/tours/helpers/utils";
import { registry } from "@web/core/registry";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
@@ -159,7 +159,7 @@ registry.category("web_tour.tours").add("FiscalPositionNoTax", {
PaymentScreen.check.remainingIs("0.00");
PaymentScreen.do.clickValidate();
ReceiptScreen.check.isShown();
ReceiptScreen.check.noOrderlineContainsDiscount();
insertSteps(Order.doesNotHaveLine({ discount: "" }));
return getSteps();
},
});
@@ -5,7 +5,8 @@ import { ReceiptScreen } from "@point_of_sale/../tests/tours/helpers/ReceiptScre
import { PaymentScreen } from "@point_of_sale/../tests/tours/helpers/PaymentScreenTourMethods";
import { Chrome } from "@point_of_sale/../tests/tours/helpers/ChromeTourMethods";
import { NumberPopup } from "@point_of_sale/../tests/tours/helpers/NumberPopupTourMethods";
import { getSteps, startSteps } from "@point_of_sale/../tests/tours/helpers/utils";
import { getSteps, startSteps, insertSteps } from "@point_of_sale/../tests/tours/helpers/utils";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
import { registry } from "@web/core/registry";
import { nbsp } from "@web/core/utils/strings";
@@ -71,7 +72,7 @@ registry
ProductScreen.do.clickPayButton();
PaymentScreen.do.clickPaymentMethod("Bank");
PaymentScreen.do.clickValidate();
ReceiptScreen.check.customerNoteIsThere("Test customer note");
insertSteps(Order.hasLine({ customerNote: "Test customer note" }));
return getSteps();
}
});
@@ -92,7 +93,7 @@ registry
ProductScreen.do.clickPayButton();
PaymentScreen.do.clickPaymentMethod("Cash");
PaymentScreen.do.clickValidate();
ReceiptScreen.check.discountAmountIs("0.7");
insertSteps(Order.hasLine({ discount: "0.7" }));
return getSteps();
},
});
@@ -93,32 +93,6 @@ class Check {
},
];
}
customerNoteIsThere(note) {
return [
{
trigger: `.receipt-screen .orderlines .pos-receipt-left-padding:contains("${note}")`,
},
];
}
discountAmountIs(value) {
return [
{
trigger: `.pos-receipt>div:contains("Discounts")>span:contains("${value}")`,
run: () => {},
},
];
}
noOrderlineContainsDiscount() {
return [
{
trigger: `.orderlines:not(:contains('->'))`,
run: () => {},
},
];
}
}
class Execute {
@@ -27,6 +27,8 @@ export function hasLine({
quantity,
price,
customerNote,
comboParent,
discount,
} = {}) {
let trigger = `.order-container .orderline${withClass}`;
if (withoutClass) {
@@ -44,6 +46,12 @@ export function hasLine({
if (customerNote) {
trigger += `:has(.info-list .customer-note:contains("${customerNote}"))`;
}
if (comboParent) {
trigger += `:has(.info-list .combo-parent-name:contains("${comboParent}"))`;
}
if (discount || discount === "") {
trigger += `:has(.info-list .discount.em:contains("${discount}"))`;
}
const args = JSON.stringify(arguments[0]);
return [
{
@@ -60,7 +60,9 @@ export function startSteps() {
},
];
}
export function insertSteps(...steps) {
steps.push(...steps);
}
export function getSteps() {
return steps;
}
@@ -61,15 +61,17 @@ export class EpsonPrinter extends BasePrinter {
* @override
*/
async sendPrintingJob(img) {
const res = await $.ajax({
url: this.address,
const res = await fetch(this.address, {
method: "POST",
data: img,
body: img,
});
const response = $(res).find("response");
const body = await res.text();
const parser = new DOMParser();
const parsedBody = parser.parseFromString(body, "application/xml");
const response = parsedBody.querySelector("response");
return {
result: response.attr("success") === "true",
printerErrorCode: response.attr("code"),
result: response.getAttribute("success") === "true",
printerErrorCode: response.getAttribute("code"),
};
}
@@ -152,21 +154,10 @@ export class EpsonPrinter extends BasePrinter {
* @override
*/
getActionError() {
const printRes = {
successful: false,
message: {
title: _t("Connection to the printer failed"),
body: _t(
"Please check if the printer is still connected. \n" +
"Some browsers don't allow HTTP calls from websites to devices in the network (for security reasons). " +
"If it is the case, you will need to follow Odoo's documentation for " +
"'Self-signed certificate for ePOS printers' and 'Secure connection (HTTPS)' to solve the issue"
),
},
};
const printRes = super.getResultsError();
if (window.location.protocol === "https:") {
printRes.message.body += _t(
"If you are on a secure server (HTTPS) please make sure you manually accepted the certificate by accessing %s",
"If you are on a secure server (HTTPS) please make sure you manually accepted the certificate by accessing %s. ",
this.url
);
}
@@ -3,40 +3,38 @@
<t t-name="pos_coupon.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<xpath expr="//div[hasclass('pos-receipt')]//div[hasclass('before-footer')]" position="inside">
<t t-if='receiptData.receipt.loyaltyStats'>
<t t-foreach="receiptData.receipt.loyaltyStats" t-as="_loyaltyStat" t-key="_loyaltyStat.couponId">
<!-- Show only if portal_visible. -->
<div t-if="_loyaltyStat.program.portal_visible" class='loyalty'>
<span class="pos-receipt-center-align">
<div>--------------------------------</div>
<br/>
<div t-esc='_loyaltyStat.program.name' class="pos-receipt-title" />
<div t-esc='receiptData.receipt.partner.name' />
<br />
</span>
<t t-if='_loyaltyStat.points.won'>
<div><t t-esc="_loyaltyStat.points.name"/> Won: <span t-esc='_loyaltyStat.points.won' class="pos-receipt-right-align"/></div>
</t>
<t t-if='_loyaltyStat.points.spent'>
<div><t t-esc="_loyaltyStat.points.name"/> Spent: <span t-esc='_loyaltyStat.points.spent' class="pos-receipt-right-align"/></div>
</t>
<!-- Don't use points.total, it's wrong in this context (after the order synced). -->
<!-- Show balance as it's updated during _postPushOrderResolve. -->
<t t-if='_loyaltyStat.points.balance'>
<div>Balance <t t-esc="_loyaltyStat.points.name"/>: <span t-esc='_loyaltyStat.points.balance' class="pos-receipt-right-align"/></div>
</t>
<t t-foreach="props.data.loyaltyStats or []" t-as="_loyaltyStat" t-key="_loyaltyStat.couponId">
<!-- Show only if portal_visible. -->
<div t-if="_loyaltyStat.program.portal_visible" class='loyalty'>
<span class="pos-receipt-center-align">
<div>--------------------------------</div>
<br/>
<div t-esc='_loyaltyStat.program.name' class="pos-receipt-title" />
<div t-esc='props.data.partner.name' />
<br />
</div>
</t>
</span>
<t t-if='_loyaltyStat.points.won'>
<div><t t-esc="_loyaltyStat.points.name"/> Won: <span t-esc='_loyaltyStat.points.won' class="pos-receipt-right-align"/></div>
</t>
<t t-if='_loyaltyStat.points.spent'>
<div><t t-esc="_loyaltyStat.points.name"/> Spent: <span t-esc='_loyaltyStat.points.spent' class="pos-receipt-right-align"/></div>
</t>
<!-- Don't use points.total, it's wrong in this context (after the order synced). -->
<!-- Show balance as it's updated during _postPushOrderResolve. -->
<t t-if='_loyaltyStat.points.balance'>
<div>Balance <t t-esc="_loyaltyStat.points.name"/>: <span t-esc='_loyaltyStat.points.balance' class="pos-receipt-right-align"/></div>
</t>
<br />
</div>
</t>
<t t-if="receiptData.receipt.new_coupon_info and receiptData.receipt.new_coupon_info.length !== 0">
<t t-if="props.data.new_coupon_info and props.data.new_coupon_info.length !== 0">
<div class="pos-coupon-rewards">
<div>------------------------</div>
<br/>
<div>
Coupon Codes
</div>
<t t-foreach="receiptData.receipt.new_coupon_info" t-as="coupon_info" t-key="coupon_info.code">
<t t-foreach="props.data.new_coupon_info" t-as="coupon_info" t-key="coupon_info.code">
<div class="coupon-container">
<div style="font-size: 110%;">
<t t-esc="coupon_info['program_name']"/>
@@ -2,7 +2,7 @@
<templates id="template" xml:space="preserve">
<t t-name="pos_mercury.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<xpath expr="//t[@t-foreach='receiptData.receipt.paymentlines']" position="inside">
<xpath expr="//div[hasclass('paymentlines')]" position="inside">
<t t-if="line.mercury_data">
<div class="pos-receipt-left-padding">
<span>APPROVAL CODE: </span>
@@ -13,7 +13,7 @@
</t>
</xpath>
<xpath expr="//div[hasclass('pos-receipt-order-data')]" position="after">
<div t-if="receiptData.hasPosMercurySignature">
<div t-if="props.data.hasPosMercurySignature">
<br />
<div>CARDHOLDER WILL PAY CARD ISSUER</div>
<div>ABOVE AMOUNT PURSUANT</div>
@@ -4,15 +4,12 @@ import { Order } from "@point_of_sale/app/store/models";
import { patch } from "@web/core/utils/patch";
patch(Order.prototype, {
getOrderReceiptEnv() {
const receiptData = super.getOrderReceiptEnv();
receiptData.hasPosMercurySignature = receiptData.paymentlines.some((line) => {
if (line.mercury_data) {
return true;
}
});
return receiptData;
export_for_printing() {
return {
...super.export_for_printing(),
hasPosMercurySignature: this.get_paymentlines().some((line) => {
line.mercury_data;
}),
};
},
});
@@ -1,10 +1,12 @@
/** @odoo-module */
import { ReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/receipt_screen";
import { OrderReceipt } from "@point_of_sale/app/screens/receipt_screen/receipt/order_receipt";
import { registry } from "@web/core/registry";
export class BillScreen extends ReceiptScreen {
static template = "pos_restaurant.BillScreen";
static components = { OrderReceipt };
confirm() {
if (!this.env.isMobile) {
this.props.resolve({ confirmed: true, payload: null });
@@ -22,10 +24,6 @@ export class BillScreen extends ReceiptScreen {
get isBill() {
return true;
}
get receiptData() {
return this.pos.get_order().getOrderReceiptEnv();
}
}
registry.category("pos_screens").add("BillScreen", BillScreen);
@@ -19,9 +19,9 @@
<span> </span>
<span>Print</span>
</div>
<div class="pos-receipt-container text-center" t-ref="order-receipt">
<div class="pos-receipt-container text-center">
<div class="d-inline-block m-3 p-3 border rounded bg-view text-start overflow-hidden">
<t t-call="point_of_sale.OrderReceipt" />
<OrderReceipt data="pos.get_order().export_for_printing()" formatCurrency="env.utils.formatCurrency" />
</div>
</div>
</div>
@@ -3,7 +3,7 @@
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
import { Component } from "@odoo/owl";
import { renderToElement } from "@web/core/utils/render";
import { OrderReceipt } from "@point_of_sale/app/screens/receipt_screen/receipt/order_receipt";
import { useService } from "@web/core/utils/hooks";
import { useAsyncLockedMethod } from "@point_of_sale/app/utils/hooks";
@@ -12,7 +12,7 @@ export class PrintBillButton extends Component {
setup() {
this.pos = usePos();
this.hardwareProxy = useService("hardware_proxy");
this.printer = useService("printer");
this.click = useAsyncLockedMethod(this.click);
}
@@ -25,20 +25,11 @@ export class PrintBillButton extends Component {
}
async click() {
if (this.hardwareProxy.printer) {
const orderReceipt = renderToElement("point_of_sale.OrderReceipt", {
receiptData: {
...this.pos.get_order().getOrderReceiptEnv(),
},
pos: this.pos,
env: this.env,
});
// Need to await to have the result in case of automatic skip screen.
await this.hardwareProxy.printer.printReceipt(orderReceipt);
} else {
this.pos.showTempScreen("BillScreen");
}
// Need to await to have the result in case of automatic skip screen.
(await this.printer.print(OrderReceipt, {
data: this.pos.get_order().export_for_printing(),
formatCurrency: this.env.utils.formatCurrency,
})) || this.pos.showTempScreen("BillScreen");
}
}
@@ -0,0 +1,10 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { ReceiptHeader } from "@point_of_sale/app/screens/receipt_screen/receipt/receipt_header/receipt_header";
export class TipReceipt extends Component {
static template = "pos_restaurant.TipReceipt";
static components = { ReceiptHeader };
static props = ["headerData", "data", "total"];
}
@@ -3,44 +3,7 @@
<t t-name="pos_restaurant.TipReceipt">
<div class="pos-receipt">
<t t-if="receipt.company.logo">
<img class="pos-receipt-logo" t-att-src="receipt.company.logo" alt="Logo"/>
<br/>
</t>
<t t-if="!receipt.company.logo">
<h2 class="pos-receipt-center-align">
<t t-esc="receipt.company.name" />
</h2>
<br/>
</t>
<div class="pos-receipt-contact">
<t t-if="receipt.company.contact_address">
<div><t t-esc="receipt.company.contact_address" /></div>
</t>
<t t-if="receipt.company.phone">
<div>Tel:<t t-esc="receipt.company.phone" /></div>
</t>
<t t-if="receipt.company.vat">
<div>VAT:<t t-esc="receipt.company.vat" /></div>
</t>
<t t-if="receipt.company.email">
<div><t t-esc="receipt.company.email" /></div>
</t>
<t t-if="receipt.company.website">
<div><t t-esc="receipt.company.website" /></div>
</t>
<t t-if="receipt.header">
<div><t t-esc="receipt.header" /></div>
</t>
<t t-if="receipt.cashier">
<div class="cashier">
<div>--------------------------------</div>
<div>Served by <t t-esc="receipt.cashier" /></div>
</div>
</t>
</div>
<br/>
<ReceiptHeader data="props.headerData" />
<div class="pos-payment-terminal-receipt">
<pre t-esc="data" />
</div>
@@ -2,13 +2,13 @@
import { _t } from "@web/core/l10n/translation";
import { parseFloat } from "@web/views/fields/parsers";
import { renderToElement } from "@web/core/utils/render";
import { registry } from "@web/core/registry";
import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup";
import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup";
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { useService } from "@web/core/utils/hooks";
import { Component, useRef, onMounted } from "@odoo/owl";
import { TipReceipt } from "@pos_restaurant/app/tip_receipt/tip_receipt";
export class TipScreen extends Component {
static template = "pos_restaurant.TipScreen";
@@ -17,12 +17,12 @@ export class TipScreen extends Component {
this.posReceiptContainer = useRef("pos-receipt-container");
this.popup = useService("popup");
this.orm = useService("orm");
this.hardwareProxy = useService("hardware_proxy");
this.printer = useService("printer");
this.state = this.currentOrder.uiState.TipScreen;
this._totalAmount = this.currentOrder.get_total_with_tax();
onMounted(() => {
this.printTipReceipt();
onMounted(async () => {
await this.printTipReceipt();
});
}
get overallAmountStr() {
@@ -110,46 +110,16 @@ export class TipScreen extends Component {
this.currentOrder.selected_paymentline.ticket,
this.currentOrder.selected_paymentline.cashier_receipt,
];
for (let i = 0; i < receipts.length; i++) {
const data = receipts[i];
var receipt = renderToElement("pos_restaurant.TipReceipt", {
receipt: this.currentOrder.getOrderReceiptEnv().receipt,
data: data,
total: this.env.utils.formatCurrency(this.totalAmount),
});
if (this.hardwareProxy.printer) {
await this._printIoT(receipt);
} else {
await this._printWeb(receipt);
}
}
}
async _printIoT(receipt) {
const printResult = await this.hardwareProxy.printer.printReceipt(receipt);
if (!printResult.successful) {
await this.popup.add(ErrorPopup, {
title: printResult.message.title,
body: printResult.message.body,
});
}
}
async _printWeb(receipt) {
try {
this.posReceiptContainer.el.textContent = "";
this.posReceiptContainer.el.appendChild(receipt);
window.print();
} catch {
await this.popup.add(ErrorPopup, {
title: _t("Printing is not supported on some browsers"),
body: _t(
"Printing is not supported on some browsers due to no default printing protocol " +
"is available. It is possible to print your tickets by making use of an IoT Box."
),
});
await this.printer.print(
TipReceipt,
{
headerData: this.pos.getReceiptHeaderData(),
data: receipts[i],
total: this.env.utils.formatCurrency(this.totalAmount),
},
{ webPrintFallback: true }
);
}
}
}
@@ -10,7 +10,7 @@
<i class="fa fa-angle-double-left me-2"/>
<span>Back</span>
</button>
<button class="button btn btn-lg btn-outline-primary" t-if="hardwareProxy.printer" t-on-click="printTipReceipt">
<button class="button btn btn-lg btn-outline-primary" t-if="printer.is()" t-on-click="printTipReceipt">
<i class="fa fa-print"></i>
<span> </span>
<span>Reprint receipts</span>
@@ -10,14 +10,6 @@
<xpath expr="//div[hasclass('receipt-change')]" position="attributes">
<attribute name="t-if">!isBill</attribute>
</xpath>
<xpath expr="//div[hasclass('cashier')]" position="after">
<t t-if="receiptData.receipt.table">
at table <t t-esc="receiptData.receipt.table" />
</t>
<t t-if="receiptData.receipt.table and receiptData.receipt.customer_count">
<div>Guests: <t t-esc="receiptData.receipt.customer_count" /></div>
</t>
</xpath>
<xpath expr="//div[hasclass('before-footer')]" position="after">
<t t-if="isBill and pos.config.set_tip_after_payment">
<div class="tip-form py-3">
@@ -26,19 +18,19 @@
<div class="option d-flex flex-column flex-nowrap align-items-center justify-content-center flex-grow-1">
<div>15%</div>
<div class="amount">
<t t-esc="env.utils.formatCurrency(receiptData.receipt.total_with_tax * 0.15)"></t>
<t t-esc="props.formatCurrency(props.data.amount_total * 0.15)"></t>
</div>
</div>
<div class="option d-flex flex-column flex-nowrap align-items-center justify-content-center flex-grow-1">
<div>20%</div>
<div class="amount">
<t t-esc="env.utils.formatCurrency(receiptData.receipt.total_with_tax * 0.20)"></t>
<t t-esc="props.formatCurrency(props.data.amount_total * 0.20)"></t>
</div>
</div>
<div class="option d-flex flex-column flex-nowrap align-items-center justify-content-center flex-grow-1">
<div>25%</div>
<div class="amount">
<t t-esc="env.utils.formatCurrency(receiptData.receipt.total_with_tax * 0.25)"></t>
<t t-esc="props.formatCurrency(props.data.amount_total * 0.25)"></t>
</div>
</div>
</div>
@@ -46,5 +38,15 @@
</t>
</xpath>
</t>
<t t-name="pos_restaurant.ReceiptHeader" t-inherit="point_of_sale.ReceiptHeader" t-inherit-mode="extension">
<xpath expr="//div[hasclass('cashier')]" position="after">
<t t-if="props.data.table">
at table <t t-esc="props.data.table" />
</t>
<t t-if="props.data.table and props.data.customer_count">
<div>Guests: <t t-esc="props.data.customer_count" /></div>
</t>
</xpath>
</t>
</templates>
@@ -32,17 +32,6 @@ patch(Order.prototype, {
this.customerCount = json.customer_count;
}
},
//@override
export_for_printing() {
const json = super.export_for_printing(...arguments);
if (this.pos.config.module_pos_restaurant) {
if (this.getTable()) {
json.table = this.getTable().name;
}
json.customer_count = this.getCustomerCount();
}
return json;
},
getCustomerCount() {
return this.customerCount;
},
@@ -62,6 +62,16 @@ patch(PosStore.prototype, {
this.showScreen("FloorScreen", { floor: table?.floor });
}
},
getReceiptHeaderData() {
const json = super.getReceiptHeaderData(...arguments);
if (this.config.module_pos_restaurant) {
if (this.get_order().getTable()) {
json.table = this.get_order().getTable().name;
}
json.customer_count = this.get_order().getCustomerCount();
}
return json;
},
shouldResetIdleTimer() {
const stayPaymentScreen =
this.mainScreen.component === PaymentScreen && this.get_order().paymentlines.length > 0;
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_sale.OrderReceipt" t-inherit="point_of_sale.OrderLinesReceipt" t-inherit-mode="extension">
<xpath expr="//t[@t-foreach]" position="inside">
<t t-name="pos_sale.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<xpath expr="//Orderline" position="inside">
<div class="pos-receipt-left-padding" t-if="line.so_reference">From <t t-esc="line.so_reference"/></div>
<div class="pos-receipt-left-padding" t-if="line.down_payment_details">
<table class="sale-order-info ms-2 text-truncate">
@@ -60,13 +60,12 @@ patch(Orderline.prototype, {
}
return false;
},
export_for_printing() {
var json = super.export_for_printing(...arguments);
json.down_payment_details = this.down_payment_details;
if (this.sale_order_origin_id) {
json.so_reference = this.sale_order_origin_id.name;
}
return json;
getDisplayData() {
return {
...super.getDisplayData(),
down_payment_details: this.down_payment_details,
so_reference: this.sale_order_origin_id?.name,
};
},
/**
* Set quantity based on the give sale order line.