[REF] point_of_sale: unify order components

At the moment, the pos displays the selected order in multiple
screens, such as the `ProductScreen`, the `TicketScreen`, the
`SplitBillScreen`, the `self_order` app, but uses completely
different implementations for each. This means that when
a change needs to be made to the one, it generally needs to be made
to the other as well, which is error-prone.

In this commit we create generic `OrderWidget` and `Orderline` components,
that have not dependencies on POS objects and we refactor the `ProductScreen`,
the `TicketScreen` and the `SplitBillScreen` to use these components.

The `OrderSummary` component was removed and it's contents are now in the
`OrderWidget`.

We also introduce a very simple new component called `CenteredIcon` that
can be used whenever we want to show an icon that fills a whole div. In this
commit we use it for showing the cart icon when the order is empty.

In a future commit, the `pos_self_order` will be refactored in order
to use the same `OrderWidget` and `Orderline` compoenents as the POS.

Task: 3470365

In addition, this commit solves the issue from the task 3491303.

closes odoo/odoo#132248

Related: odoo/enterprise#45972
Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
This commit is contained in:
vlst
2023-09-19 08:48:25 +00:00
parent 26f447a072
commit baf85a8820
66 changed files with 913 additions and 1504 deletions
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="l10n_fr_pos_cert.Orderline" t-inherit="point_of_sale.Orderline" t-inherit-mode="extension">
<xpath expr="//ul[hasclass('info-list')]" position="inside">
<t t-if="pos.is_french_country() !== false and props.line.price_manually_set === 'manual'">
<li class="info">
Old unit price:
<span class="oldPrice">
<s>
<t t-out="env.utils.formatCurrency(props.line.get_taxed_lst_unit_price())" /> / Units
</s>
</span>
</li>
</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="l10n_fr_pos_cert.ProductScreen" t-inherit="point_of_sale.ProductScreen" t-inherit-mode="extension">
<xpath expr="//Orderline" position="inside" >
<t t-if="pos.is_french_country() !== false and line.price_manually_set === 'manual'">
<li class="info">
Old unit price:
<span class="oldPrice">
<s>
<t t-esc="env.utils.formatCurrency(line.get_taxed_lst_unit_price())" /> / Units
</s>
</span>
</li>
</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,20 @@
/** @odoo-module */
import { Component, xml } from "@odoo/owl";
export class CenteredIcon extends Component {
static props = {
icon: String,
text: { type: String, optional: true },
class: { type: String, optional: true },
};
static defaultProps = {
class: "",
};
static template = xml`
<div t-attf-class="{{props.class}} d-flex flex-column align-items-center justify-content-center h-100 w-100 text-muted">
<i t-attf-class="fa {{props.icon}}" role="img" />
<h3 t-if="props.text" t-esc="props.text" class="mt-2"/>
</div>
`;
}
@@ -5,19 +5,19 @@ import { useAutofocus } from "@web/core/utils/hooks";
import { debounce } from "@web/core/utils/timing";
/**
This component is meant to provide a "batteries included" api for working
with inputs. It is well suited to work as a search bar or as a monetary input.
Optional props allow handling debouncing, toggling between mobile and desktop views,
autofocus, validation, and more.
The only required prop is tModel, which is an array that represents the
state variable that this component should read and write to. The first
element of the array is the object returned by `useState` and second is
either a `string` or an `int` representing the key to access the value.
ex: normally you would write
```<input t-model="state.userValues[nThValue].searchString"/>```
with this component you can write
```<Input tModel="[state.userValues[nThValue], "searchString"]"/>```
*/
* This component is meant to provide a "batteries included" api for working
* with inputs. It is well suited to work as a search bar or as a monetary input.
* Optional props allow handling debouncing, toggling between mobile and desktop views,
* autofocus, validation, and more.
* The only required prop is tModel, which is an array that represents the
* state variable that this component should read and write to. The first
* element of the array is the object returned by `useState` and second is
* either a `string` or an `int` representing the key to access the value.
* ex: normally you would write
* ```<input t-model="state.userValues[nThValue].searchString"/>```
* with this component you can write
* ```<Input tModel="[state.userValues[nThValue], "searchString"]"/>```
*/
export class Input extends Component {
static template = "point_of_sale.input";
static props = {
@@ -0,0 +1,23 @@
/** @odoo-module */
import { Component, useEffect, useRef } from "@odoo/owl";
import { CenteredIcon } from "@point_of_sale/app/generic_components/centered_icon/centered_icon";
export class OrderWidget extends Component {
static template = "point_of_sale.OrderWidget";
static props = {
lines: { type: Array, element: Object },
slots: { type: Object },
total: { type: String, optional: true },
tax: { type: String, optional: true },
};
static components = { CenteredIcon };
setup() {
this.scrollableRef = useRef("scrollable");
useEffect(() => {
this.scrollableRef.el
?.querySelector(".orderline.selected")
?.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
}
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<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>
</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">
Total:
<span t-esc="props.total" class="total"/>
<div t-if="props.tax" class="fs-6 text-muted subentry">
Taxes: <span t-esc="props.tax" class="tax"/>
</div>
</div>
<t t-slot="details"/>
</t>
<t t-else="">
<CenteredIcon icon="'fa-shopping-cart fa-4x'" text="'This order is empty'"/>
<t t-slot="details"/>
</t>
</t>
</templates>
@@ -0,0 +1,32 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
/**
* How to extend this component:
*/
export class Orderline extends Component {
static template = "point_of_sale.Orderline";
static props = {
class: { type: Object, optional: true },
line: {
type: Object,
shape: {
productName: String,
price: String,
qty: String,
unit: { type: String, optional: true },
unitPrice: String,
discount: { type: String, optional: true },
comboParent: { type: String, optional: true },
oldUnitPrice: { type: String, optional: true },
customerNote: { type: String, optional: true },
internalNote: { type: String, optional: true },
},
},
slots: { type: Object, optional: true },
};
static defaultProps = {
class: {},
};
}
@@ -1,5 +1,5 @@
.orderline {
&:hover {
background-color: $o-gray-200;
}
@@ -7,9 +7,4 @@
&.selected {
background-color: $o-component-active-bg;
}
&.skip-change {
color: #7F82AC;
border-color: #7F82AC !important;
}
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.Orderline">
<t t-set="line" t-value="props.line" />
<li class="orderline w-100 p-2 lh-sm cursor-pointer" 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"/>
<t t-slot="product-name"/>
</div>
<div t-esc="line.price" class="price fw-bolder"/>
</div>
<ul class="info-list ms-2">
<li>
<em t-esc="line.qty" class="qty fst-normal fw-bolder me-1" /> <t t-if="line.unit" t-esc="line.unit" />
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'">
With a <em><t t-esc="line.discount" />% </em> discount
</li>
<li t-if="line.comboParent" class="combo-parent-name">
<i class="fa fa-th-large me-2" role="img" aria-label="Combo" title="Combo"/>
<t t-esc="line.comboParent" />
</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">
<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">
<i class="fa fa-tag me-1" role="img" aria-label="Note" title="Note"/>
<t t-esc="line.internalNote" />
</li>
</ul>
</li>
</t>
</templates>
@@ -5,7 +5,7 @@ import { Component } from "@odoo/owl";
export class ProductCard extends Component {
static template = "point_of_sale.ProductCard";
static props = {
class: String,
class: { String, optional: true },
name: String,
productId: Number,
price: String,
@@ -5,13 +5,13 @@
<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 w-100 bg-200">
<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) -->
<t isShown="!pos.tempScreen" t-component="pos.mainScreen.component"
t-props="pos.mainScreen.props"/>
<ErrorHandler onError.bind="onTempScreenError">
<t t-if="pos.tempScreen" t-component="pos.tempScreen.component"
t-props="pos.tempScreen.props" t-key="pos.tempScreen.name" />
t-props="pos.tempScreen.props" t-key="pos.tempScreen.name" />
</ErrorHandler>
</div>
</div>
@@ -1,72 +0,0 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { EditListPopup } from "@point_of_sale/app/store/select_lot_popup/select_lot_popup";
import { Orderline } from "@point_of_sale/app/screens/product_screen/orderline/orderline";
import { OrderSummary } from "@point_of_sale/app/screens/product_screen/order_summary/order_summary";
import { Component, useEffect, useRef } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/store/pos_hook";
export class OrderWidget extends Component {
static components = { Orderline, OrderSummary };
static template = "point_of_sale.OrderWidget";
setup() {
this.pos = usePos();
this.popup = useService("popup");
this.numberBuffer = useService("number_buffer");
this.scrollableRef = useRef("scrollable");
useEffect(
() => {
const selectedLineEl =
this.scrollableRef.el &&
this.scrollableRef.el.querySelector(".orderline.selected");
if (selectedLineEl) {
selectedLineEl.scrollIntoView({ behavior: "smooth", block: "start" });
}
},
() => [this.order.selected_orderline]
);
}
get order() {
return this.pos.get_order();
}
get orderlinesArray() {
return this.order ? this.order.get_orderlines() : [];
}
selectLine(orderline) {
this.numberBuffer.reset();
this.order.select_orderline(orderline);
}
// IMPROVEMENT: Might be better to lift this to ProductScreen
// because there is similar operation when clicking a product.
//
// Furthermore, what if a number different from 1 (or -1) is specified
// to an orderline that has product tracked by lot. Lot tracking (based
// on the current implementation) requires that 1 item per orderline is
// allowed.
async editPackLotLines(orderline) {
const isAllowOnlyOneLot = orderline.product.isAllowOnlyOneLot();
const packLotLinesToEdit = orderline.getPackLotLinesToEdit(isAllowOnlyOneLot);
const { confirmed, payload } = await this.popup.add(EditListPopup, {
title: _t("Lot/Serial Number(s) Required"),
name: orderline.product.display_name,
isSingleItem: isAllowOnlyOneLot,
array: packLotLinesToEdit,
});
if (confirmed) {
// Segregate the old and new packlot lines
const modifiedPackLotLines = Object.fromEntries(
payload.newArray.filter((item) => item.id).map((item) => [item.id, item.text])
);
const newPackLotLines = payload.newArray
.filter((item) => !item.id)
.map((item) => ({ lot_name: item.text }));
orderline.setPackLotLines({ modifiedPackLotLines, newPackLotLines });
}
this.order.select_orderline(orderline);
}
}
@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.OrderWidget">
<div class="order-container w-100 h-100 overflow-y-auto bg-view" t-ref="scrollable">
<div class="order d-flex flex-column h-100 text-start">
<t t-if="orderlinesArray.length === 0" >
<div class="order-empty d-flex flex-column align-items-center justify-content-center h-100 bg-100 text-muted">
<i class="fa fa-4x fa-shopping-cart" role="img" aria-label="Shopping cart"
title="Shopping cart"/>
<h3 class="mt-2">This order is empty</h3>
</div>
</t>
<t t-else="">
<ul class="orderlines flex-grow-1 overflow-auto">
<t t-foreach="orderlinesArray" t-as="orderline" t-key="orderline.id">
<Orderline
editPackLotLines.bind="editPackLotLines"
selectLine.bind="selectLine"
line="orderline" />
</t>
</ul>
<OrderSummary order="order" />
</t>
</div>
</div>
</t>
</templates>
@@ -1,25 +0,0 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { floatIsZero } from "@web/core/utils/numbers";
export class OrderSummary extends Component {
static template = "point_of_sale.OrderSummary";
setup() {
this.pos = usePos();
}
getTotal() {
return this.env.utils.formatCurrency(this.props.order.get_total_with_tax());
}
getTax() {
const total = this.props.order.get_total_with_tax();
const totalWithoutTax = this.props.order.get_total_without_tax();
const taxAmount = total - totalWithoutTax;
return {
hasTax: !floatIsZero(taxAmount, this.pos.currency.decimal_places),
displayAmount: this.env.utils.formatCurrency(taxAmount),
};
}
}
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.OrderSummary">
<div class="summary clearfix w-100 py-2 px-3 bg-200 bg-opacity-50 text-end fw-bolder">
<t t-set="_total" t-value="getTotal()" />
<t t-set="_tax" t-value="getTax()" />
<div class="line">
<div class="entry total fs-2 lh-sm">
Total:
<span class="value">
<t t-esc="_total" />
</span>
<div t-if="_tax.hasTax" class="subentry fs-6 text-muted">
Taxes:
<span class="value">
<t t-esc="_tax.displayAmount" />
</span>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -1,48 +0,0 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/store/pos_hook";
export class Orderline extends Component {
static template = "point_of_sale.Orderline";
/**
* if doubleclick, change hasChange to skipChange
*
* IMPROVEMENT: Instead of handling both double click and click in single
* method, perhaps we can separate double click from single click.
*/
setup() {
this.pos = usePos();
}
selectLine() {
const line = this.props.line; // the orderline
if (this.pos.get_order().selected_orderline.id !== line.id) {
this.mp_dbclk_time = new Date().getTime();
} else if (!this.mp_dbclk_time) {
this.mp_dbclk_time = new Date().getTime();
} else if (this.mp_dbclk_time + 500 > new Date().getTime()) {
line.toggleSkipChange();
this.mp_dbclk_time = 0;
} else {
this.mp_dbclk_time = new Date().getTime();
}
this.props.selectLine(this.props.line);
}
lotIconClicked() {
this.props.editPackLotLines(this.props.line);
}
get addedClasses() {
return {
selected: this.props.line.selected,
"has-change ps-2 text-success border-start border-success border-4":
this.props.line.hasChange && this.pos.config.module_pos_restaurant,
"skip-change ps-2 border-start border-secondary border-4":
this.props.line.skipChange && this.pos.config.module_pos_restaurant,
};
}
get customerNote() {
return this.props.line.get_customer_note();
}
}
@@ -1,87 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.Orderline">
<li t-on-click="selectLine" class="orderline w-100 m-0 py-2 px-3 lh-sm cursor-pointer transition-base" t-att-class="addedClasses">
<span class="product-name d-inline-block flex-grow-1 fw-bolder text-truncate">
<t t-esc="props.line.get_full_product_name()"/>
<t t-if="props.line.get_product().tracking!=='none' and (pos.picking_type.use_create_lots || pos.picking_type.use_existing_lots)">
<t t-if="props.line.has_valid_product_lot()">
<i t-on-click.stop="lotIconClicked"
class="oe_link_icon fa fa-list oe_icon line-lot-icon ms-1 text-success"
aria-label="Valid product lot"
role="img"
title="Valid product lot"
/>
</t>
<t t-else="">
<i t-on-click.stop="lotIconClicked"
class="oe_link_icon fa fa-list oe_icon line-lot-icon ms-1 text-danger"
aria-label="Invalid product lot"
role="img"
title="Invalid product lot"
/>
</t>
</t>
</span>
<span class="price fw-bolder float-end">
<t t-esc="env.utils.formatCurrency(props.line.get_display_price())"/>
</span>
<ul class="info-list text-dark">
<t t-if="props.line.get_quantity_str() !== '1' || props.line.selected ">
<li class="info ms-2">
<em class="fst-normal fw-bolder">
<t t-esc="props.line.get_quantity_str()" />
</em>
<span> </span><t t-esc="props.line.get_unit().name" />
at
<t t-if="props.line.display_discount_policy() == 'without_discount' and
env.utils.roundCurrency(props.line.get_unit_display_price()) lt env.utils.roundCurrency(props.line.get_taxed_lst_unit_price())">
<s>
<t t-esc="env.utils.formatCurrency(props.line.get_taxed_lst_unit_price())" />
</s>
<t t-esc="env.utils.formatCurrency(props.line.get_unit_display_price())" />
</t>
<t t-else="">
<t t-esc="env.utils.formatCurrency(props.line.get_unit_display_price())" />
</t>
/
<t t-esc="props.line.get_unit().name" />
</li>
</t>
<t t-if="props.line.get_discount_str() !== '0'">
<li class="info ms-2">
With a
<em>
<t t-esc="props.line.get_discount_str()" />%
</em>
discount
</li>
</t>
<t t-if="customerNote">
<li class="info orderline-note p-2 my-1 rounded text-bg-warning text-warning bg-opacity-25 text-break">
<i class="fa fa-sticky-note me-1" role="img" aria-label="Customer Note" title="Customer Note"/>
<t t-esc="customerNote" />
</li>
</t>
<t t-if="props.line.comboParent" t-call="point_of_sale.ComboParentName"/>
</ul>
<t t-if="props.line.pack_lot_lines">
<ul class="info-list ms-2">
<t t-foreach="props.line.get_lot_lines()" t-as="lot" t-key="lot.cid">
<li>
SN <t t-esc="lot.lot_name"/>
</li>
</t>
</ul>
</t>
</li>
</t>
<t t-name="point_of_sale.ComboParentName" owl="1">
<li class="info ms-2 combo-parent-name">
<i class="fa fa-th-large me-2" role="img" aria-label="Combo" title="Combo"/>
<t t-esc="props.line.comboParent.get_full_product_name()" />
</li>
</t>
</templates>
@@ -17,17 +17,18 @@ import { Component, onMounted, useState } from "@odoo/owl";
import { ErrorBarcodePopup } from "@point_of_sale/app/barcode/error_popup/barcode_error_popup";
import { Numpad } from "@point_of_sale/app/generic_components/numpad/numpad";
import { OrderWidget } from "@point_of_sale/app/screens/product_screen/order/order";
import { ProductsWidget } from "@point_of_sale/app/screens/product_screen/product_list/product_list";
import { ActionpadWidget } from "@point_of_sale/app/screens/product_screen/action_pad/action_pad";
import { Orderline } from "@point_of_sale/app/generic_components/orderline/orderline";
import { OrderWidget } from "@point_of_sale/app/generic_components/order_widget/order_widget";
export class ProductScreen extends ControlButtonsMixin(Component) {
static template = "point_of_sale.ProductScreen";
static components = {
ActionpadWidget,
Numpad,
OrderWidget,
ProductsWidget,
Orderline,
OrderWidget,
};
static numpadActionName = _t("Payment");
@@ -51,7 +52,7 @@ export class ProductScreen extends ControlButtonsMixin(Component) {
gs1: this._barcodeGS1Action,
});
// Call `resset` when the `onMounted` callback in `numberBuffer.use` is done.
// Call `reset` when the `onMounted` callback in `numberBuffer.use` is done.
// We don't do this in the `mounted` lifecycle method because it is called before
// the callbacks in `onMounted` hook.
onMounted(() => this.numberBuffer.reset());
@@ -97,6 +98,11 @@ export class ProductScreen extends ControlButtonsMixin(Component) {
this.numberBuffer.sendKey(buttonValue);
}
selectLine(orderline) {
this.numberBuffer.reset();
this.currentOrder.select_orderline(orderline);
}
/**
* To be overridden by modules that checks availability of
* connected scale.
@@ -1,12 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.ProductScreen">
<div class="product-screen d-flex h-100 bg-100" t-att-class="{ 'd-none': !props.isShown }">
<div class="leftpane d-flex flex-column border-end bg-200"
t-if="!ui.isSmall || pos.mobile_pane === 'left'"
t-att-class="{'flex-grow-1': ui.isSmall}">
<OrderWidget/>
<div t-if="!ui.isSmall || pos.mobile_pane === 'left'"
t-att-class="{'flex-grow-1': ui.isSmall}"
class="leftpane d-flex flex-column border-end bg-200" >
<OrderWidget lines="currentOrder.orderlines" t-slot-scope="scope"
total="env.utils.formatCurrency(currentOrder.get_total_with_tax())"
tax="!env.utils.floatIsZero(currentOrder.get_total_tax()) and env.utils.formatCurrency(currentOrder.get_total_tax()) or ''">
<t t-set="line" t-value="scope.line" />
<Orderline line="line.getDisplayData()"
t-on-click="() => this.selectLine(line)"
class="{ ...line.getDisplayClasses(), 'selected' : line.selected }">
<t t-set-slot="product-name">
<i t-if="line.get_product().isTracked()"
t-on-click.stop="() => line.editPackLotLines()" role="img"
t-attf-class="{{ line.has_valid_product_lot() ? 'text-success' : 'text-danger'}} fa fa-list line-lot-icon ms-1"
t-attf-title="{{ line.has_valid_product_lot() ? 'Valid product lot' : 'Invalid product lot'}}" />
</t>
<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>
<t t-set-slot="details" />
</OrderWidget>
<div class="pads border-top">
<div class="control-buttons d-flex flex-wrap border-bottom overflow-hidden bg-300"
t-if="!ui.isSmall">
@@ -1,25 +0,0 @@
/** @odoo-module */
import { OrderlineDetails } from "@point_of_sale/app/screens/ticket_screen/order_details/orderline_details";
import { Component } from "@odoo/owl";
/**
* @props {models.Order} order
*/
export class OrderDetails extends Component {
static components = { OrderlineDetails };
static template = "point_of_sale.OrderDetails";
get order() {
return this.props.order;
}
get orderlines() {
return this.order ? this.order.orderlines : [];
}
get total() {
return this.env.utils.formatCurrency(this.order ? this.order.get_total_with_tax() : 0);
}
get tax() {
return this.env.utils.formatCurrency(this.order ? this.order.get_total_tax() : 0);
}
}
@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.OrderDetails">
<div class="order-container h-100 overflow-y-auto bg-view">
<div t-ref="scrollable" class="order-scroller touch-scrollable h-100">
<div class="order d-flex flex-column h-100 text-start">
<t t-if="!order">
<div class="order-empty d-flex flex-column align-items-center justify-content-center h-100 text-muted">
<i class="fa fa fa-4x fa-shopping-cart" role="img" aria-label="Shopping cart"
title="Shopping cart" />
<h3 class="mt-2">Select an order</h3>
</div>
</t>
<t t-elif="orderlines.length === 0">
<div class="order-empty d-flex flex-column align-items-center justify-content-center h-100 text-muted">
<i class="fa fa fa-4x fa-shopping-cart" role="img" aria-label="Shopping cart"
title="Shopping cart" />
<h3 class="mt-2">Order is empty</h3>
</div>
</t>
<t t-else="">
<div t-if="order.locked" class="header-note text-bg-view py-2 px-3 border-bottom" t-att-class="{ 'highlight text-danger': props.highlightHeaderNote }">
Select the product(s) to refund and set the quantity
</div>
<ul class="orderlines flex-grow-1 overflow-auto">
<t t-foreach="orderlines" t-as="orderline" t-key="orderline.id">
<OrderlineDetails
onClickOrderline.bind="this.props.onClickOrderline"
onClickRefundOrderUid.bind="props.onClickRefundOrderUid"
line="orderline"
isSelected="orderline.id == props.selectedOrderlineId" />
</t>
</ul>
<!-- Not using OrderSummary because we don't need it's overrides in this view. -->
<div class="summary clearfix sticky-top w-100 py-2 px-3 bg-200 bg-opacity-50 text-end fw-bolder">
<div class="line">
<div class="entry total fs-2 lh-sm">
Total:
<span class="value">
<t t-esc="total" />
</span>
<div t-if="tax" class="subentry fs-6 text-muted">
Taxes:
<span class="value">
<t t-esc="tax" />
</span>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
</div>
</t>
</templates>
@@ -1,79 +0,0 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { Component } from "@odoo/owl";
import { formatFloat, roundPrecision as round_pr } from "@web/core/utils/numbers";
import { usePos } from "@point_of_sale/app/store/pos_hook";
/**
* @props {pos.order.line} line
*/
export class OrderlineDetails extends Component {
static template = "point_of_sale.OrderlineDetails";
setup() {
this.pos = usePos();
}
get line() {
const line = this.props.line;
const formatQty = (line) => {
const quantity = line.get_quantity();
const unit = line.get_unit();
const decimals = this.pos.dp["Product Unit of Measure"];
const rounding = Math.max(unit.rounding, Math.pow(10, -decimals));
const roundedQuantity = round_pr(quantity, rounding);
return formatFloat(roundedQuantity, { digits: [69, decimals] });
};
return {
productName: line.get_full_product_name(),
totalPrice: line.get_price_with_tax(),
quantity: formatQty(line),
unit: line.get_unit().name,
unitPrice: line.get_unit_price(),
};
}
get productName() {
return this.line.productName;
}
get totalPrice() {
return this.env.utils.formatCurrency(this.line.totalPrice);
}
get quantity() {
return this.line.quantity;
}
get unitPrice() {
return this.env.utils.formatCurrency(this.line.unitPrice);
}
get unit() {
return this.line.unit;
}
get pricePerUnit() {
return ` ${this.unit} at ${this.unitPrice} / ${this.unit}`;
}
get customerNote() {
return this.props.line.get_customer_note();
}
getToRefundDetail() {
return this.pos.toRefundLines[this.props.line.id];
}
hasRefundedQty() {
return !this.pos.isProductQtyZero(this.props.line.refunded_qty);
}
getFormattedRefundedQty() {
return this.env.utils.formatProductQty(this.props.line.refunded_qty);
}
hasToRefundQty() {
const toRefundDetail = this.getToRefundDetail();
return !this.pos.isProductQtyZero(toRefundDetail && toRefundDetail.qty);
}
getFormattedToRefundQty() {
const toRefundDetail = this.getToRefundDetail();
return this.env.utils.formatProductQty(toRefundDetail && toRefundDetail.qty);
}
getRefundingMessage() {
return _t("Refunding %s in ", this.getFormattedToRefundQty());
}
getToRefundMessage() {
return _t("To Refund: %s", this.getFormattedToRefundQty());
}
}
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.OrderlineDetails">
<li class="orderline w-100 m-0 py-2 px-3 lh-sm cursor-pointer"
t-on-click="() => this.props.onClickOrderline(props.line)"
t-att-class="{
'selected': props.isSelected,
'ps-3': this.props.line.isPartOfCombo()
}">
<span class="product-name d-inline-block flex-grow-1 fw-bolder text-truncate">
<t t-esc="productName" />
</span>
<span class="price fw-bolder float-end">
<t t-esc="totalPrice" />
</span>
<ul class="info-list">
<li class="info ms-2">
<strong>
<t t-esc="quantity" />
</strong>
<span><t t-esc="pricePerUnit" /></span>
</li>
<t t-if="customerNote">
<li class="info orderline-note p-2 my-1 rounded text-bg-warning text-warning bg-opacity-25 text-break">
<i class="fa fa-sticky-note me-1" role="img" aria-label="Customer Note" title="Customer Note"/>
<t t-esc="customerNote" />
</li>
</t>
<t t-if="props.line.comboParent" t-call="point_of_sale.ComboParentName"/>
<t t-if="hasRefundedQty()">
<li class="info refund-note mt-1">
<strong>
<t t-esc="getFormattedRefundedQty()" />
</strong>
<span> Refunded</span>
</li>
</t>
<t t-if="hasToRefundQty()">
<li class="info to-refund-highlight refund-note mt-1 fw-bold text-primary">
<t t-set="_destinationOrderUid" t-value="getToRefundDetail().destinationOrderUid"/>
<t t-if="_destinationOrderUid">
<t t-esc="getRefundingMessage()" />
<span t-esc="_destinationOrderUid" class="order-link text-decoration-underline cursor-pointer" t-on-click.stop="() => this.props.onClickRefundOrderUid(_destinationOrderUid)" />
</t>
<t t-else="">
<t t-esc="getToRefundMessage()" />
</t>
</li>
</t>
</ul>
</li>
</t>
</templates>
@@ -12,7 +12,9 @@ import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_pop
import { ActionpadWidget } from "@point_of_sale/app/screens/product_screen/action_pad/action_pad";
import { InvoiceButton } from "@point_of_sale/app/screens/ticket_screen/invoice_button/invoice_button";
import { OrderDetails } from "@point_of_sale/app/screens/ticket_screen/order_details/order_details";
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 { CenteredIcon } from "@point_of_sale/app/generic_components/centered_icon/centered_icon";
import { ReprintReceiptButton } from "@point_of_sale/app/screens/ticket_screen/reprint_receipt_button/reprint_receipt_button";
import { SearchBar } from "@point_of_sale/app/screens/ticket_screen/search_bar/search_bar";
import { usePos } from "@point_of_sale/app/store/pos_hook";
@@ -27,7 +29,9 @@ export class TicketScreen extends Component {
static components = {
ActionpadWidget,
InvoiceButton,
OrderDetails,
Orderline,
OrderWidget,
CenteredIcon,
ReprintReceiptButton,
SearchBar,
Numpad,
@@ -105,12 +105,7 @@
</div>
</t>
</t>
<t t-else="">
<div class="empty-order-list m-5 text-center text-muted">
<i role="img" aria-label="Shopping cart" title="Shopping cart" class="fa fa-4x fa-shopping-cart"></i>
<h3 class="mt-2">No orders found</h3>
</div>
</t>
<CenteredIcon t-else="" icon="'fa-shopping-cart fa-4x'" text="'No orders found'"/>
</div>
<div class="switchpane d-flex w-100" t-if="ui.isSmall">
<t t-set="_selectedSyncedOrder" t-value="getSelectedOrder()" />
@@ -125,43 +120,64 @@
<div class="leftpane d-flex flex-column flex-grow-1 w-100 h-100 h-md-100 bg-200" t-if="!ui.isSmall || pos.ticket_screen_mobile_pane === 'right'">
<t t-set="_selectedSyncedOrder" t-value="getSelectedOrder()" />
<t t-set="_selectedOrderlineId" t-value="getSelectedOrderlineId()" />
<t t-if="isOrderSynced">
<OrderDetails
onClickOrderline.bind="onClickOrderline"
onClickRefundOrderUid.bind="onClickRefundOrderUid"
order="_selectedSyncedOrder"
selectedOrderlineId="_selectedOrderlineId"
highlightHeaderNote="_state.ui.highlightHeaderNote" />
<t t-if="_selectedSyncedOrder?.get_orderlines()?.length" >
<div t-if="isOrderSynced" t-att-class="{ 'highlight text-danger': _state.ui.highlightHeaderNote }" class="text-bg-view py-2 px-3 border-bottom" >
Select the product(s) to refund and set the quantity
</div>
<OrderWidget lines="_selectedSyncedOrder.orderlines" t-slot-scope="scope"
total="env.utils.formatCurrency(_selectedSyncedOrder.get_total_with_tax())"
tax="!env.utils.floatIsZero(_selectedSyncedOrder.get_total_tax()) and env.utils.formatCurrency(_selectedSyncedOrder.get_total_tax()) or ''">
<t t-set="line" t-value="scope.line" />
<Orderline line="line.getDisplayData()"
class="{'selected': line.id === getSelectedOrderlineId()}"
t-on-click="() => this.onClickOrderline(line)">
<t t-set="toRefundDetail" t-value="line.pos.toRefundLines[line.id]" />
<li t-if="!line.pos.isProductQtyZero(line.refunded_qty)"
class="info refund-note mt-1">
<strong t-esc="env.utils.formatProductQty(line.refunded_qty)" />
<span> Refunded</span>
</li>
<li t-if="!line.pos.isProductQtyZero(toRefundDetail?.qty)" class="info to-refund-highlight refund-note mt-1 fw-bold text-primary">
<t t-set="_destinationOrderUid" t-value="toRefundDetail.destinationOrderUid"/>
<t t-set="refundQty" t-value="env.utils.formatProductQty(toRefundDetail.qty)"/>
<t t-if="_destinationOrderUid">
Refunding <t t-esc="refundQty" /> in
<span t-esc="_destinationOrderUid"
class="order-link ms-1 text-decoration-underline cursor-pointer"
t-on-click.stop="() => this.onClickRefundOrderUid(_destinationOrderUid)" />
</t>
<t t-else="">
To Refund: <t t-esc="refundQty" />
</t>
</li>
</Orderline>
</OrderWidget>
<div class="pads border-top">
<div class="control-buttons d-flex flex-wrap border-bottom overflow-hidden bg-300">
<InvoiceButton
onInvoiceOrder.bind="onInvoiceOrder"
order="_selectedSyncedOrder" />
<ReprintReceiptButton order="_selectedSyncedOrder" />
</div>
<div class="subpads d-flex">
<ActionpadWidget
partner="getSelectedPartner()"
actionName="constructor.numpadActionName"
actionType="'refund'"
actionToTrigger.bind="onDoRefund"
isActionButtonHighlighted="getHasItemsToRefund()" />
<Numpad buttons="getNumpadButtons()" class="'max-width-325px'"/>
<t t-if="isOrderSynced">
<div class="control-buttons d-flex flex-wrap border-bottom overflow-hidden bg-300">
<InvoiceButton
onInvoiceOrder.bind="onInvoiceOrder"
order="_selectedSyncedOrder" />
<ReprintReceiptButton order="_selectedSyncedOrder" />
</div>
<div class="subpads d-flex">
<ActionpadWidget
partner="getSelectedPartner()"
actionName="constructor.numpadActionName"
actionType="'refund'"
actionToTrigger.bind="onDoRefund"
isActionButtonHighlighted="getHasItemsToRefund()" />
<Numpad buttons="getNumpadButtons()" class="'max-width-325px'"/>
</div>
</t>
<div t-else="" class="pads border-top" >
<button class="button validation load-order-button w-100 btn btn-lg btn-primary rounded-0 fw-bolder py-3" t-on-click="() => this._setOrder(_selectedSyncedOrder)">
<span class="fs-1 d-block">Load Order</span>
</button>
</div>
</div>
</t>
<t t-else="">
<OrderDetails
onClickOrderline.bind="onClickOrderline"
onClickRefundOrderUid.bind="onClickRefundOrderUid"
order="_selectedSyncedOrder"
highlightHeaderNote="_state.ui.highlightHeaderNote" />
<div class="pads border-top" t-if="_selectedSyncedOrder">
<button class="button validation load-order-button w-100 btn btn-lg btn-primary rounded-0 fw-bolder py-3" t-on-click="() => this._setOrder(_selectedSyncedOrder)">
<span class="fs-1 d-block">Load Order</span>
</button>
</div>
</t>
<CenteredIcon t-else="" icon="'fa-shopping-cart fa-4x'" text="'Select an order'" class="'bg-100'"/>
</div>
</div>
</div>
@@ -19,7 +19,6 @@ import {
import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup";
import { ProductConfiguratorPopup } from "@point_of_sale/app/store/product_configurator_popup/product_configurator_popup";
import { ComboConfiguratorPopup } from "./combo_configurator_popup/combo_configurator_popup";
import { EditListPopup } from "@point_of_sale/app/store/select_lot_popup/select_lot_popup";
import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup";
import { _t } from "@web/core/l10n/translation";
import { renderToElement } from "@web/core/utils/render";
@@ -113,6 +112,12 @@ export class Product extends PosModel {
""
);
}
isTracked() {
return (
["serial", "lot"].includes(this.tracking) &&
(this.pos.picking_type.use_create_lots || this.pos.picking_type.use_existing_lots)
);
}
get_unit() {
var unit_id = this.uom_id;
if (!unit_id) {
@@ -179,24 +184,15 @@ export class Product extends PosModel {
comboLines = payload;
}
// Gather lot information if required.
if (
["serial", "lot"].includes(this.tracking) &&
(this.pos.picking_type.use_create_lots || this.pos.picking_type.use_existing_lots)
) {
const isAllowOnlyOneLot = this.isAllowOnlyOneLot();
if (isAllowOnlyOneLot) {
packLotLinesToEdit = [];
} else {
const orderline = this.pos.selectedOrder
.get_orderlines()
.filter((line) => !line.get_discount())
.find((line) => line.product.id === this.id);
if (orderline) {
packLotLinesToEdit = orderline.getPackLotLinesToEdit();
} else {
packLotLinesToEdit = [];
}
}
if (this.isTracked()) {
packLotLinesToEdit =
(!this.isAllowOnlyOneLot() &&
this.pos.selectedOrder
.get_orderlines()
.filter((line) => !line.get_discount())
.find((line) => line.product.id === this.id)
?.getPackLotLinesToEdit()) ||
[];
// if the lot information exists in the barcode, we don't need to ask it from the user.
if (code && code.type === "lot") {
// consider the old and new packlot lines
@@ -206,31 +202,14 @@ export class Product extends PosModel {
const newPackLotLines = [{ lot_name: code.code }];
draftPackLotLines = { modifiedPackLotLines, newPackLotLines };
} else {
const { confirmed, payload } = await this.pos.env.services.popup.add(
EditListPopup,
{
title: _t("Lot/Serial Number(s) Required"),
name: this.display_name,
isSingleItem: isAllowOnlyOneLot,
array: packLotLinesToEdit,
}
draftPackLotLines = await this.pos.getEditedPackLotLines(
this.isAllowOnlyOneLot(),
packLotLinesToEdit,
this.display_name
);
if (confirmed) {
// Segregate the old and new packlot lines
const modifiedPackLotLines = Object.fromEntries(
payload.newArray
.filter((item) => item.id)
.map((item) => [item.id, item.text])
);
const newPackLotLines = payload.newArray
.filter((item) => !item.id)
.map((item) => ({ lot_name: item.text }));
draftPackLotLines = { modifiedPackLotLines, newPackLotLines };
} else {
// We don't proceed on adding product.
return;
}
}
if (!draftPackLotLines) {
return;
}
}
@@ -470,6 +449,9 @@ export class Orderline extends PosModel {
orderline.customerNote = this.customerNote;
return orderline;
}
getDisplayClasses() {
return {};
}
getPackLotLinesToEdit(isAllowOnlyOneLot) {
const currentPackLotLines = this.pack_lot_lines;
let nExtraLines = Math.abs(this.quantity) - currentPackLotLines.length;
@@ -487,6 +469,23 @@ export class Orderline extends PosModel {
);
return isAllowOnlyOneLot ? [tempLines[0]] : tempLines;
}
// What if a number different from 1 (or -1) is specified
// to an orderline that has product tracked by lot? Lot tracking (based
// on the current implementation) requires that 1 item per orderline is
// allowed.
async editPackLotLines() {
const isAllowOnlyOneLot = this.product.isAllowOnlyOneLot();
const editedPackLotLines = await this.pos.getEditedPackLotLines(
isAllowOnlyOneLot,
this.getPackLotLinesToEdit(isAllowOnlyOneLot),
this.product.display_name
);
if (!editedPackLotLines) {
return;
}
this.setPackLotLines(editedPackLotLines);
this.order.select_orderline(this);
}
/**
* @param { modifiedPackLotLines, newPackLotLines }
* @param {Object} modifiedPackLotLines key-value pair of String (the cid) & String (the new lot_name)
@@ -534,11 +533,6 @@ export class Orderline extends PosModel {
setNote(note) {
this.note = note;
}
toggleSkipChange() {
if (this.hasChange || this.skipChange) {
this.skipChange = !this.skipChange;
}
}
setHasChange(isChange) {
this.hasChange = isChange;
}
@@ -879,6 +873,18 @@ export class Orderline extends PosModel {
return this.get_all_prices(1).priceWithoutTax;
}
}
/**
* This is the price that will appear as striked through.
* @returns {number | false}
*/
get_old_unit_display_price() {
return (
this.display_discount_policy() === "without_discount" &&
this.env.utils.roundCurrency(this.get_unit_display_price()) <
this.env.utils.roundCurrency(this.get_taxed_lst_unit_price()) &&
this.get_taxed_lst_unit_price()
);
}
getUnitDisplayPriceBeforeDiscount() {
if (this.pos.config.iface_tax_included === "total") {
return this.get_all_prices(1).priceWithTaxBeforeDiscount;
@@ -1120,6 +1126,20 @@ export class Orderline extends PosModel {
isPartOfCombo() {
return Boolean(this.comboParent || this.comboLines?.length);
}
getDisplayData() {
return {
productName: this.get_full_product_name(),
price: this.env.utils.formatCurrency(this.get_display_price()),
qty: this.get_quantity_str(),
unit: this.get_unit().name,
unitPrice: this.env.utils.formatCurrency(this.get_unit_display_price()),
oldUnitPrice: this.env.utils.formatCurrency(this.get_old_unit_display_price()),
discount: this.get_discount_str(),
customerNote: this.get_customer_note(),
internalNote: this.getNote(),
comboParent: this.comboParent?.get_full_product_name(),
};
}
}
export class Packlotline extends PosModel {
@@ -21,6 +21,7 @@ import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product
import { renderToString } from "@web/core/utils/render";
import { batched } from "@web/core/utils/timing";
import { TicketScreen } from "@point_of_sale/app/screens/ticket_screen/ticket_screen";
import { EditListPopup } from "./select_lot_popup/select_lot_popup";
/* Returns an array containing all elements of the given
* array corresponding to the rule function {agg} and without duplicates
@@ -1823,6 +1824,28 @@ export class PosStore extends Reactive {
await this.addProductFromUi(product, options);
this.numberBuffer.reset();
}
async getEditedPackLotLines(isAllowOnlyOneLot, packLotLinesToEdit, productName) {
const { confirmed, payload } = await this.env.services.popup.add(EditListPopup, {
title: _t("Lot/Serial Number(s) Required"),
name: productName,
isSingleItem: isAllowOnlyOneLot,
array: packLotLinesToEdit,
});
if (!confirmed) {
return;
}
// Segregate the old and new packlot lines
const modifiedPackLotLines = Object.fromEntries(
payload.newArray.filter((item) => item.id).map((item) => [item.id, item.text])
);
const newPackLotLines = payload.newArray
.filter((item) => !item.id)
.map((item) => ({ lot_name: item.text }));
return { modifiedPackLotLines, newPackLotLines };
}
// FIXME POSREF get rid of temp screens entirely?
showTempScreen(name, props = {}) {
return new Promise((resolve) => {
@@ -38,7 +38,7 @@ registry.category("web_tour.tours").add("PosComboPriceTaxIncludedTour", {
isCheck: true,
},
combo.confirm(),
...ProductScreen.do.clickOrderline("Office Combo"),
...ProductScreen.check.selectedOrderlineHas("Office Combo"),
...ProductScreen.do.clickOrderline("Combo Product 3"),
...ProductScreen.check.selectedOrderlineHas(
"Combo Product 3",
@@ -6,6 +6,7 @@ import { ReceiptScreen } from "@point_of_sale/../tests/tours/helpers/ReceiptScre
import { TextAreaPopup } from "@point_of_sale/../tests/tours/helpers/TextAreaPopupTourMethods";
import { getSteps, startSteps } 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";
registry.category("web_tour.tours").add("ProductScreenTour", {
test: true,
@@ -78,11 +79,9 @@ registry.category("web_tour.tours").add("ProductScreenTour", {
ProductScreen.do.clickDisplayedProduct("Whiteboard Pen");
ProductScreen.do.clickDisplayedProduct("Wall Shelf Unit");
ProductScreen.do.clickOrderline("Whiteboard Pen", "1.0");
ProductScreen.check.selectedOrderlineHas("Whiteboard Pen", "1.0");
ProductScreen.do.pressNumpad("2");
ProductScreen.check.selectedOrderlineHas("Whiteboard Pen", "2.0");
ProductScreen.do.clickOrderline("Wall Shelf Unit", "1.0");
ProductScreen.check.selectedOrderlineHas("Wall Shelf Unit", "1.0");
ProductScreen.do.pressNumpad("2");
ProductScreen.check.selectedOrderlineHas("Wall Shelf Unit", "2.0");
ProductScreen.do.pressNumpad("⌫");
@@ -101,25 +100,21 @@ registry.category("web_tour.tours").add("ProductScreenTour", {
ProductScreen.do.clickDisplayedProduct("Magnetic Board");
ProductScreen.do.clickDisplayedProduct("Monitor Stand");
ProductScreen.do.clickOrderline("Whiteboard Pen", "1.0");
ProductScreen.check.selectedOrderlineHas("Whiteboard Pen", "1.0");
ProductScreen.do.pressNumpad("⌫");
ProductScreen.check.selectedOrderlineHas("Whiteboard Pen", "0.0");
ProductScreen.do.pressNumpad("⌫");
ProductScreen.check.selectedOrderlineHas("Monitor Stand", "1.0");
ProductScreen.do.clickOrderline("Wall Shelf Unit", "1.0");
ProductScreen.check.selectedOrderlineHas("Wall Shelf Unit", "1.0");
ProductScreen.do.pressNumpad("⌫");
ProductScreen.check.selectedOrderlineHas("Wall Shelf Unit", "0.0");
ProductScreen.do.pressNumpad("⌫");
ProductScreen.check.selectedOrderlineHas("Monitor Stand", "1.0");
ProductScreen.do.clickOrderline("Small Shelf", "1.0");
ProductScreen.check.selectedOrderlineHas("Small Shelf", "1.0");
ProductScreen.do.pressNumpad("⌫");
ProductScreen.check.selectedOrderlineHas("Small Shelf", "0.0");
ProductScreen.do.pressNumpad("⌫");
ProductScreen.check.selectedOrderlineHas("Monitor Stand", "1.0");
ProductScreen.do.clickOrderline("Magnetic Board", "1.0");
ProductScreen.check.selectedOrderlineHas("Magnetic Board", "1.0");
ProductScreen.do.pressNumpad("⌫");
ProductScreen.check.selectedOrderlineHas("Magnetic Board", "0.0");
ProductScreen.do.pressNumpad("⌫");
@@ -136,7 +131,13 @@ registry.category("web_tour.tours").add("ProductScreenTour", {
TextAreaPopup.check.isShown();
TextAreaPopup.do.inputText("Test customer note");
TextAreaPopup.do.clickConfirm();
ProductScreen.check.orderlineHasCustomerNote("Desk Organizer", "1", "Test customer note");
Order.hasLine({
productName: "Desk Organizer",
quantity: "1.0",
customerNote: "Test customer note",
withClass: ".selected",
});
ProductScreen.check.isShown();
return getSteps();
},
});
@@ -5,6 +5,7 @@ import { ReceiptScreen } from "@point_of_sale/../tests/tours/helpers/ReceiptScre
import { PaymentScreen } from "@point_of_sale/../tests/tours/helpers/PaymentScreenTourMethods";
import { PartnerListScreen } from "@point_of_sale/../tests/tours/helpers/PartnerListScreenTourMethods";
import { TicketScreen } from "@point_of_sale/../tests/tours/helpers/TicketScreenTourMethods";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
import { ErrorPopup } from "@point_of_sale/../tests/tours/helpers/ErrorPopupTourMethods";
import { Chrome } from "@point_of_sale/../tests/tours/helpers/ChromeTourMethods";
import { getSteps, startSteps } from "@point_of_sale/../tests/tours/helpers/utils";
@@ -125,7 +126,7 @@ registry.category("web_tour.tours").add("TicketScreenTour", {
TicketScreen.check.filterIs("Paid");
TicketScreen.do.selectOrder("-0005");
TicketScreen.check.partnerIs("Colleen Diaz");
TicketScreen.do.clickOrderline("Desk Pad");
Order.hasLine({ productName: "Desk Pad", withClass: ".selected" });
ProductScreen.do.pressNumpad("3");
// Error should show because 2 is more than the number
// that can be refunded.
@@ -136,7 +137,7 @@ registry.category("web_tour.tours").add("TicketScreenTour", {
ProductScreen.check.orderIsEmpty();
ProductScreen.do.clickRefund();
TicketScreen.do.selectOrder("-0005");
TicketScreen.do.clickOrderline("Desk Pad");
Order.hasLine({ productName: "Desk Pad", withClass: ".selected" });
ProductScreen.do.pressNumpad("1");
TicketScreen.check.toRefundTextContains("To Refund: 1.00");
TicketScreen.do.confirmRefund();
@@ -3,6 +3,7 @@
import { ProductScreen } from "@point_of_sale/../tests/tours/helpers/ProductScreenTourMethods";
import { Numpad } from "@point_of_sale/../tests/tours/helpers/NumpadTourMethods";
import { registry } from "@web/core/registry";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
function add_product_to_order(product_name) {
return [
@@ -15,11 +16,7 @@ function add_product_to_order(product_name) {
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
content: "the " + product_name + " have been added to the order",
trigger: '.order .product-name:contains("' + product_name + '")',
run: function () {},
},
...Order.hasLine({ productName: product_name }),
{
content: "go back to the products",
trigger: ".floor-button",
@@ -130,20 +127,7 @@ function selected_orderline_has({ product, price = null, quantity = null }) {
mobile: true,
},
];
if (price !== null) {
result.push({
content: `Selected line has product '${product}' and price '${price}'`,
trigger: `.order-container .orderlines .orderline.selected .product-name:contains("${product}") ~ span.price:contains("${price}")`,
run: function () {},
});
}
if (quantity !== null) {
result.push({
content: `Selected line has product '${product}' and quantity '${quantity}'`,
trigger: `.order-container .orderlines .orderline.selected .product-name:contains('${product}') ~ .info-list .info em:contains('${quantity}')`,
run: function () {},
});
}
result.push(...Order.hasLine({ productName: product, quantity, price, withClass: ".selected" }));
result.push({
content: "go back to the products",
trigger: ".floor-button",
@@ -159,11 +143,7 @@ function verify_order_total(total_str) {
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
content: "order total contains " + total_str,
trigger: '.order .total .value:contains("' + total_str + '")',
run: function () {}, // it's a check
},
Order.hasTotal(total_str),
{
content: "go back to the products",
trigger: ".floor-button",
@@ -3,6 +3,35 @@
import { createTourMethods } from "@point_of_sale/../tests/tours/helpers/utils";
import { TextAreaPopup } from "@point_of_sale/../tests/tours/helpers/TextAreaPopupTourMethods";
import { Numpad } from "@point_of_sale/../tests/tours/helpers/NumpadTourMethods";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
export function adaptForMobile(steps) {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
...[steps].flat(),
{
content: "go back to the products",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
}
export function clickLine(productName, quantity = "1.0") {
return adaptForMobile([
...Order.hasLine({
withoutClass: ".selected",
run: "click",
productName,
quantity,
}),
...Order.hasLine({ withClass: ".selected", productName, quantity }),
]);
}
class Do {
clickDisplayedProduct(name) {
return [
@@ -13,27 +42,9 @@ class Do {
];
}
clickOrderline(name, quantity = "1.0") {
clickOrderline(productName, quantity = "1.0") {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
content: `selecting orderline with product '${name}' and quantity '${quantity}'`,
trigger: `.order .orderline:not(:has(.selected)) .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
},
{
content: `orderline with product '${name}' and quantity '${quantity}' has been selected`,
trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
run: () => {},
},
{
content: "go back to the products",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
...clickLine(productName, quantity),
{
content: "Check the product page",
trigger: ".product-list-container .product-list",
@@ -73,19 +84,7 @@ class Do {
* @param {...String} keys space-separated numpad keys
*/
pressNumpad(...keys) {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
...keys.map(Numpad.click),
{
content: "go back to the products",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
return adaptForMobile(keys.map(Numpad.click));
}
clickPayButton(shouldCheck = true) {
@@ -183,12 +182,7 @@ class Do {
return [{ trigger: '.opening-cash-control .button:contains("Open session")' }];
}
selectPriceList(name) {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
return adaptForMobile([
{
content: "click more button",
trigger: ".mobile-more-button",
@@ -199,12 +193,7 @@ class Do {
content: `select price list '${name}'`,
trigger: `.selection-item:contains("${name}")`,
},
{
content: "go back to the products",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
]);
}
enterOpeningAmount(amount) {
return [
@@ -309,74 +298,25 @@ class Check {
];
}
selectedOrderlineHas(name, quantity, price, comboParent) {
const res = [
return [
...adaptForMobile(
Order.hasLine({
withClass: ".selected",
productName: name,
quantity,
price,
comboParent,
})
),
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
// check first if the order widget is there and has orderlines
content: "order widget has orderlines",
trigger: ".order .orderlines",
run: () => {},
},
{
content: `'${name}' is selected`,
trigger: `.order .orderline.selected .product-name:contains("${name}")`,
run: function () {}, // it's a check
content: "Check the product page",
trigger: ".product-list-container .product-list",
isCheck: true,
},
];
if (quantity) {
res.push({
content: `selected line has ${quantity} quantity`,
trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
run: function () {}, // it's a check
});
}
if (price) {
res.push({
content: `selected line has total price of ${price}`,
trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .price:contains("${price}")`,
run: function () {}, // it's a check
});
}
if (comboParent) {
res.push({
content: `selected line has ${comboParent} as combo parent`,
trigger: `.order .orderline.selected .info-list .combo-parent-name:contains("${comboParent}")`,
isCheck: true,
});
}
res.push({
content: "go back to the products",
trigger: ".pos-rightheader .floor-button",
mobile: true,
}, {
content: "Check the product page",
trigger: ".product-list-container .product-list",
isCheck: true,
});
return res;
}
orderIsEmpty() {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
content: `order is empty`,
trigger: `.order .order-empty`,
run: () => {},
},
{
content: "go back to the products",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
return adaptForMobile(Order.doesNotHaveLine());
}
productIsDisplayed(name) {
@@ -390,75 +330,19 @@ class Check {
}
totalAmountIs(amount) {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
content: `order total amount is '${amount}'`,
trigger: `.order-container .order .summary .value:contains("${amount}")`,
run: () => {},
},
{
content: "go back to the products",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
...adaptForMobile(Order.hasTotal(amount)),
{
content: "Check the product page",
trigger: ".product-list-container .product-list",
isCheck: true,
}
},
];
}
totalTaxIs(amount) {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
content: `order total tax is '${amount}'`,
trigger: `.summary .subentry .value:contains("${amount}")`,
isCheck: true,
},
{
content: "go back to the products",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
return adaptForMobile(Order.hasTax(amount));
}
modeIsActive(mode) {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
Numpad.isActive(mode),
{
content: "go back to the products",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
}
orderlineHasCustomerNote(name, quantity, note) {
return [
{
content: `line has ${quantity} quantity`,
trigger: `.order .orderline .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
run: function () {}, // it's a check
},
{
content: `line has '${note}' as customer note`,
trigger: `.order .orderline .info-list .orderline-note:contains("${note}")`,
run: function () {}, // it's a check
},
];
return adaptForMobile(Numpad.isActive(mode));
}
checkSecondCashClosingDetailsLineAmount(amount, sign) {
return [
@@ -483,22 +367,10 @@ class Check {
];
}
noDiscountApplied(originalPrice) {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
content: "no discount is applied",
trigger: `.info:not(:contains(${originalPrice}))`,
},
{
content: "go back to the products",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
return adaptForMobile({
content: "no discount is applied",
trigger: `.orderline .info-list:not(:contains(${originalPrice}))`,
});
}
discountOriginalPriceIs(original_price) {
return [
@@ -576,11 +448,7 @@ class Execute {
if (quantity.toString() !== "1") {
res.push(...numpadWrite(quantity));
}
if (expectedTotal) {
res.push(...this._check.selectedOrderlineHas(productName, quantity, expectedTotal));
} else {
res.push(...this._check.selectedOrderlineHas(productName, quantity));
}
res.push(...this._check.selectedOrderlineHas(productName, quantity, expectedTotal));
return res;
}
addMultiOrderlines(...list) {
@@ -1,6 +1,11 @@
/** @odoo-module */
import { createTourMethods } from "@point_of_sale/../tests/tours/helpers/utils";
import {
adaptForMobile,
clickLine,
} from "@point_of_sale/../tests/tours/helpers/ProductScreenTourMethods";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
class Do {
clickNewTicket() {
@@ -130,25 +135,7 @@ class Do {
];
}
clickOrderline(name) {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
trigger: `.ticket-screen .orderline:not(:has(.selected)) .product-name:contains("${name}")`,
},
{
trigger: `.ticket-screen .orderline.selected .product-name:contains("${name}")`,
run: () => {},
},
{
content: "go back",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
return clickLine(name);
}
confirmRefund() {
return [
@@ -179,6 +166,9 @@ class Check {
},
];
}
hasLine(options) {
return adaptForMobile(Order.hasLine(options));
}
/**
* Check if the nth row contains the given string.
* Note that 1st row is the header-row.
@@ -210,22 +200,7 @@ class Check {
];
}
orderWidgetIsNotEmpty() {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
trigger: ".ticket-screen:not(:has(.order-empty))",
run: () => {},
},
{
content: "go back",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
return adaptForMobile(Order.hasLine());
}
filterIs(name) {
return [
@@ -244,58 +219,22 @@ class Check {
];
}
partnerIs(name) {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
trigger: `.ticket-screen .set-partner:contains("${name}")`,
run: () => {},
},
{
content: "Go back to main TicketScreen when in mobile",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
return adaptForMobile({
trigger: `.ticket-screen .set-partner:contains("${name}")`,
isCheck: true,
});
}
toRefundTextContains(text) {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
trigger: `.ticket-screen .to-refund-highlight:contains("${text}")`,
run: () => {},
},
{
content: "Go back to main TicketScreen when in mobile",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
return adaptForMobile({
trigger: `.ticket-screen .to-refund-highlight:contains("${text}")`,
run: () => {},
});
}
refundedNoteContains(text) {
return [
{
content: "click review button",
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
trigger: `.ticket-screen .refund-note:contains("${text}")`,
run: () => {},
},
{
content: "Go back to main TicketScreen when in mobile",
trigger: ".pos-rightheader .floor-button",
mobile: true,
},
];
return adaptForMobile({
trigger: `.ticket-screen .refund-note:contains("${text}")`,
run: () => {},
});
}
tipContains(amount) {
return [
@@ -0,0 +1,84 @@
/** @odoo-module */
import { negate } from "@point_of_sale/../tests/tours/helpers/utils";
/**
* @typedef {{
* withClass?: string, // ex: withClass: ".selected.blue"
* withoutClass?: string,
* run?: function | string,
* productName?: string,
* quantity?: string,
* price?: string,
* customerNote?: string,
* comboParent?: string,
* }} LineOptions
*/
/**
* @param {LineOptions} options
* @returns {import("@web_tour/tour_service/tour_service").TourStep[]}
*/
export function hasLine({
withClass = "",
withoutClass = "",
run = () => {},
productName,
quantity,
price,
customerNote,
comboParent,
} = {}) {
let trigger = `.order-container .orderline${withClass}`;
if (withoutClass) {
trigger += `:not(${withoutClass})`;
}
if (productName) {
trigger += `:has(.product-name:contains("${productName}"))`;
}
if (quantity) {
trigger += `:has(em.qty:contains("${quantity}"))`;
}
if (price) {
trigger += `:has(.price:contains("${price}"))`;
}
if (customerNote) {
trigger += `:has(.info-list .customer-note:contains("${customerNote}"))`;
}
if (comboParent) {
trigger += `:has(.info-list .combo-parent-name:contains("${comboParent}"))`;
}
const args = JSON.stringify(arguments[0]);
return [
{
content: `Check orderline with attributes: ${args}`,
trigger,
run: typeof run === "string" ? run : () => run(trigger),
},
];
}
/**
* @param {LineOptions} options
* @returns {import("@web_tour/tour_service/tour_service").TourStep}
*/
export function doesNotHaveLine(options = {}) {
const step = hasLine(options)[0];
return [{ ...step, trigger: negate(step.trigger) }];
}
// TODO: there are instances where we have no selected orderline. Fix those instances
export function hasTotal(amount) {
return {
content: `order total amount is '${amount}'`,
trigger: `.product-screen .order-summary .total:contains("${amount}")`,
isCheck: true,
};
}
export function hasTax(amount) {
return {
content: `order total tax is '${amount}'`,
trigger: `.order-summary .tax:contains("${amount}")`,
isCheck: true,
};
}
@@ -143,3 +143,7 @@ export function createTourMethods(name, Do, Check = class {}, Execute = class {}
},
};
}
export function negate(selector) {
return `body:not(:has(${selector}))`;
}
@@ -6,6 +6,7 @@ import { ProductScreen } from "@point_of_sale/../tests/tours/helpers/ProductScre
import { roundDecimals as round_di } from "@web/core/utils/numbers";
import { nbsp } from "@web/core/utils/strings";
import { Numpad } from "./helpers/NumpadTourMethods";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
function assert(condition, message) {
if (!condition) {
@@ -192,13 +193,7 @@ steps = steps.concat([
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
trigger:
".order-container .orderlines .orderline.selected .product-name:contains('Wall Shelf')",
extra_trigger:
".order-container .orderlines .orderline.selected .product-name:contains('Wall Shelf') ~ .info-list .info em:contains('1.0')",
run: function () {},
},
...Order.hasLine({ productName: "Wall Shelf", quantity: "1.0", withClass: ".selected" }),
{
content: "click more button",
trigger: ".mobile-more-button",
@@ -213,19 +208,9 @@ steps = steps.concat([
trigger: ".selection-item:contains('min_quantity ordering')",
},
Numpad.click("2"),
{
content: "qty of Wall Shelf line should be 2",
trigger:
".order-container .orderlines .orderline.selected .product-name:contains('Wall Shelf')",
extra_trigger:
".order-container .orderlines .orderline.selected .product-name:contains('Wall Shelf') ~ .info-list .info em:contains('2.0')",
run: function () {},
},
{
content: "verify that unit price of shelf changed to $1",
trigger: `.total > .value:contains('$${nbsp}2.00')`,
run: function () {},
},
...Order.hasLine({ productName: "Wall Shelf", quantity: "2.0", withClass: ".selected" }),
// verify that unit price of shelf changed to $1
Order.hasTotal(`$${nbsp}2.00`),
{
content: "go back to the products",
trigger: ".floor-button",
@@ -240,24 +225,11 @@ steps = steps.concat([
trigger: ".btn-switchpane:contains('Review')",
mobile: true,
},
{
content: "Small Shelf line should be selected with quantity 1",
trigger:
".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf')",
extra_trigger:
".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf') ~ .info-list .info em:contains('1.0')",
run: function () {},
},
...Order.hasLine({ productName: "Small Shelf", quantity: "1.0", withClass: ".selected" }),
Numpad.click("Price"),
Numpad.isActive("Price"),
Numpad.click("5"),
{
content: "Small Shelf line should be selected with unit price of 5",
trigger:
".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf')",
extra_trigger:
".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf') ~ .price:contains('5.0')",
},
...Order.hasLine({ productName: "Small Shelf", price: "5.0", withClass: ".selected" }),
Numpad.click("Qty"),
Numpad.isActive("Qty"),
{
@@ -273,11 +245,8 @@ steps = steps.concat([
content: "select public pricelist",
trigger: ".selection-item:contains('Public Pricelist')",
},
{
content:
"verify that the boni shelf have been recomputed and the shelf have not (their price was manually overridden)",
trigger: `.total > .value:contains('$${nbsp}8.96')`,
},
// verify that the boni shelf have been recomputed and the shelf have not (their price was manually overridden)
Order.hasTotal(`$${nbsp}8.96`),
{
content: "click more button",
trigger: ".mobile-more-button",
@@ -10,6 +10,22 @@ import { EventBus } from "@odoo/owl";
import { uiService } from "@web/core/ui/ui_service";
import { popupService } from "@point_of_sale/app/popup/popup_service";
const mockContextualUtilsService = {
dependencies: ["pos", "localization"],
start(env, { pos, localization }) {
const formatCurrency = (value, hasSymbol = true) => {
return "dummy";
};
const floatIsZero = (value) => {
return value === 0;
};
env.utils = {
formatCurrency,
floatIsZero,
};
},
};
QUnit.module("Chrome", {
beforeEach() {
registry
@@ -19,6 +35,7 @@ QUnit.module("Chrome", {
.add("barcode_reader", barcodeReaderService)
.add("ui", uiService)
.add("popup", popupService)
.add("contextual_utils_service", mockContextualUtilsService)
.add("barcode", {
start() {
return { bus: new EventBus() };
@@ -63,7 +63,7 @@
}
}
.pos .order {
.pos .orderlines {
.active-programs {
padding-top: 0.5em;
@@ -78,10 +78,6 @@
font-weight: bold;
}
}
.orderline.program-reward {
font-style: italic;
}
}
.pos-receipt .pos-coupon-rewards {
@@ -1,11 +0,0 @@
/** @odoo-module **/
import { OrderSummary } from "@point_of_sale/app/screens/product_screen/order_summary/order_summary";
import { patch } from "@web/core/utils/patch";
patch(OrderSummary.prototype, {
getLoyaltyPoints() {
const order = this.pos.get_order();
return order.getLoyaltyPoints();
},
});
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_loyalty.OrderSummary" t-inherit="point_of_sale.OrderSummary" t-inherit-mode="extension">
<xpath expr="//div[hasclass('summary')]" position="after">
<t t-set="_loyaltyStats" t-value="getLoyaltyPoints()"/>
<t t-foreach="_loyaltyStats" t-as="_loyaltyStat" t-key="_loyaltyStat.couponId">
<t t-if="_loyaltyStat.points.won || _loyaltyStat.points.spent">
<div class="summary clearfix w-100 border-top text-end fw-bolder">
<div class='d-flex flex-column px-3 py-2 bg-200 text-start'>
<div class="loyalty-points-title text-center mb-1">
<t t-esc="_loyaltyStat.points.name"/>
</div>
<div class="d-flex justify-content-around gap-2 mt-1">
<div
t-if='_loyaltyStat.points.won'
class="loyalty-points-won d-flex flex-column align-items-center justify-content-center flex-grow-1 rounded bg-300 px-3 py-2" >
<span class="text-muted">Points Won</span>
<span class='value text-success '>+<t t-esc='_loyaltyStat.points.won'/></span>
</div>
<div
t-if='_loyaltyStat.points.spent'
class="loyalty-points-won d-flex flex-column align-items-center justify-content-center flex-grow-1 rounded bg-300 px-3 py-2" >
<span class="text-muted">Points Spent</span>
<span class='value text-danger'>-<t t-esc='_loyaltyStat.points.spent'/></span>
</div>
<div class="loyalty-points-total d-flex flex-column align-items-center justify-content-center flex-grow-1 rounded bg-300 px-3 py-2">
<span class="text-muted">New Total</span>
<span class='value text-primary'><t t-esc='_loyaltyStat.points.total'/></span>
</div>
</div>
</div>
</div>
</t>
</t>
</xpath>
</t>
</templates>
@@ -1,31 +0,0 @@
/** @odoo-module **/
import { Orderline } from "@point_of_sale/app/screens/product_screen/orderline/orderline";
import { patch } from "@web/core/utils/patch";
patch(Orderline.prototype, {
get addedClasses() {
return Object.assign(
{ "program-reward": this.props.line.is_reward_line },
super.addedClasses
);
},
_isGiftCardOrEWalletReward() {
const coupon = this.pos.couponCache[this.props.line.coupon_id];
if (coupon) {
const program = this.pos.program_by_id[coupon.program_id];
return (
["ewallet", "gift_card"].includes(program.program_type) &&
this.props.line.is_reward_line
);
}
return false;
},
_getGiftCardOrEWalletBalance() {
const coupon = this.pos.couponCache[this.props.line.coupon_id];
if (coupon) {
return this.env.utils.formatCurrency(coupon.balance);
}
return this.env.utils.formatCurrency(0);
},
});
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_loyalty.Orderline" t-inherit="point_of_sale.Orderline" t-inherit-mode="extension">
<xpath expr="//ul[hasclass('info-list')]" position="attributes">
<attribute name="t-if">!_isGiftCardOrEWalletReward()</attribute>
</xpath>
<xpath expr="//ul[hasclass('info-list')]" position="after">
<ul t-else="" class="info-list">
Current Balance: <span t-esc="_getGiftCardOrEWalletBalance()"/>
</ul>
</xpath>
</t>
</templates>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_loyalty.ProductScreen" t-inherit="point_of_sale.ProductScreen" t-inherit-mode="extension">
<xpath expr="//Orderline" position="inside" >
<li t-if="line.isGiftCardOrEWalletReward()">
Current Balance: <t t-esc="line.getGiftCardOrEWalletBalance()"/>
</li>
</xpath>
<xpath expr="//OrderWidget/t[@t-set-slot='details']" position="inside">
<t t-foreach="pos.get_order()?.getLoyaltyPoints() or []" t-as="_loyaltyStat" t-key="_loyaltyStat.couponId">
<div t-if="_loyaltyStat.points.won || _loyaltyStat.points.spent" class="w-100 border-top mt-auto fw-bolder d-flex flex-column px-3 py-2 bg-200">
<div t-esc="_loyaltyStat.points.name" class="loyalty-points-title text-center mb-1" />
<div class="d-flex justify-content-around gap-2 mt-1">
<div t-if='_loyaltyStat.points.won' class="loyalty-points-won d-flex flex-column align-items-center justify-content-center flex-grow-1 rounded bg-300 px-3 py-2" >
<span class="text-muted">Points Won</span>
<span class='value text-success '>+<t t-esc='_loyaltyStat.points.won'/></span>
</div>
<div t-if='_loyaltyStat.points.spent' class="loyalty-points-won d-flex flex-column align-items-center justify-content-center flex-grow-1 rounded bg-300 px-3 py-2" >
<span class="text-muted">Points Spent</span>
<span class='value text-danger'>-<t t-esc='_loyaltyStat.points.spent'/></span>
</div>
<div class="loyalty-points-total d-flex flex-column align-items-center justify-content-center flex-grow-1 rounded bg-300 px-3 py-2">
<span class="text-muted">New Total</span>
<span class='value text-primary'><t t-esc='_loyaltyStat.points.total'/></span>
</div>
</div>
</div>
</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,35 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { Orderline } from "@point_of_sale/app/store/models";
patch(Orderline.prototype, {
/**
* @returns {boolean}
*/
isGiftCardOrEWalletReward() {
const coupon = this.pos.couponCache[this.coupon_id];
if (!coupon || !this.is_reward_line) {
return false;
}
const program = this.pos.program_by_id[coupon.program_id];
return ["ewallet", "gift_card"].includes(program.program_type);
},
/**
* @returns {string}
*/
getGiftCardOrEWalletBalance() {
const coupon = this.pos.couponCache[this.coupon_id];
return this.env.utils.formatCurrency(coupon?.balance || 0);
},
/**
* override
*/
getDisplayClasses() {
return {
...super.getDisplayClasses(),
"fst-italic":
this.pos.mainScreen.component.name === "ProductScreen" && this.is_reward_line,
};
},
});
@@ -8,96 +8,90 @@ import { registry } from "@web/core/registry";
import { TicketScreen } from "@point_of_sale/../tests/tours/helpers/TicketScreenTourMethods";
//#region GiftCardProgramCreateSetTour1
registry
.category("web_tour.tours")
.add("GiftCardProgramCreateSetTour1", {
test: true,
url: "/pos/web",
steps: () => {
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
ProductScreen.do.clickDisplayedProduct("Gift Card");
PosLoyalty.check.orderTotalIs("50.00");
PosLoyalty.exec.finalizeOrder("Cash", "50");
return getSteps();
}
});
registry.category("web_tour.tours").add("GiftCardProgramCreateSetTour1", {
test: true,
url: "/pos/web",
steps: () => {
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
ProductScreen.do.clickDisplayedProduct("Gift Card");
PosLoyalty.check.orderTotalIs("50.00");
PosLoyalty.exec.finalizeOrder("Cash", "50");
return getSteps();
},
});
//#endregion
//#region GiftCardProgramCreateSetTour2
registry
.category("web_tour.tours")
.add("GiftCardProgramCreateSetTour2", {
test: true,
url: "/pos/web",
steps: () => {
startSteps();
ProductScreen.do.clickHomeCategory();
ProductScreen.do.clickDisplayedProduct("Whiteboard Pen");
PosLoyalty.do.enterCode("044123456");
PosLoyalty.check.orderTotalIs("0.00");
PosLoyalty.exec.finalizeOrder("Cash", "0");
return getSteps();
}
});
registry.category("web_tour.tours").add("GiftCardProgramCreateSetTour2", {
test: true,
url: "/pos/web",
steps: () => {
startSteps();
ProductScreen.do.clickHomeCategory();
ProductScreen.do.clickDisplayedProduct("Whiteboard Pen");
PosLoyalty.do.enterCode("044123456");
PosLoyalty.check.orderTotalIs("0.00");
PosLoyalty.exec.finalizeOrder("Cash", "0");
return getSteps();
},
});
//#endregion
//#region GiftCardProgramScanUseTour
registry
.category("web_tour.tours")
.add("GiftCardProgramScanUseTour", {
test: true,
url: "/pos/web",
steps: () => {
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
// Pay the 5$ gift card.
ProductScreen.do.clickDisplayedProduct("Gift Card");
TextInputPopup.check.isShown();
TextInputPopup.do.inputText("044123456");
TextInputPopup.do.clickConfirm();
PosLoyalty.check.orderTotalIs("5.00");
PosLoyalty.exec.finalizeOrder("Cash", "5");
// Partially use the gift card. (4$)
ProductScreen.exec.addOrderline("Desk Pad", "2", "2", "4.0");
PosLoyalty.do.enterCode("044123456");
PosLoyalty.check.orderTotalIs("0.00");
PosLoyalty.exec.finalizeOrder("Cash", "0");
// Use the remaining of the gift card. (5$ - 4$ = 1$)
ProductScreen.exec.addOrderline("Whiteboard Pen", "6", "6", "36.0");
PosLoyalty.do.enterCode("044123456");
PosLoyalty.check.orderTotalIs("35.00");
PosLoyalty.exec.finalizeOrder("Cash", "35");
return getSteps();
}
});
registry.category("web_tour.tours").add("GiftCardProgramScanUseTour", {
test: true,
url: "/pos/web",
steps: () => {
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
// Pay the 5$ gift card.
ProductScreen.do.clickDisplayedProduct("Gift Card");
TextInputPopup.check.isShown();
TextInputPopup.do.inputText("044123456");
TextInputPopup.do.clickConfirm();
PosLoyalty.check.orderTotalIs("5.00");
PosLoyalty.exec.finalizeOrder("Cash", "5");
// Partially use the gift card. (4$)
ProductScreen.exec.addOrderline("Desk Pad", "2", "2", "4.0");
PosLoyalty.do.enterCode("044123456");
PosLoyalty.check.orderTotalIs("0.00");
PosLoyalty.exec.finalizeOrder("Cash", "0");
// Use the remaining of the gift card. (5$ - 4$ = 1$)
ProductScreen.exec.addOrderline("Whiteboard Pen", "6", "6", "36.0");
PosLoyalty.do.enterCode("044123456");
PosLoyalty.check.orderTotalIs("35.00");
PosLoyalty.exec.finalizeOrder("Cash", "35");
return getSteps();
},
});
//#endregion
registry
.category("web_tour.tours")
.add("GiftCardWithRefundtTour", {
test: true,
url: "/pos/web",
steps: () => {
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
ProductScreen.exec.addOrderline("Magnetic Board", "1"); // 1.98
PosLoyalty.check.orderTotalIs("1.98");
PosLoyalty.exec.finalizeOrder("Cash", "20");
ProductScreen.do.clickRefund();
TicketScreen.do.selectOrder("-0001");
TicketScreen.do.clickOrderline("Magnetic Board");
ProductScreen.do.pressNumpad("1");
TicketScreen.do.confirmRefund();
ProductScreen.check.isShown();
ProductScreen.check.selectedOrderlineHas("Magnetic Board", "-1.00");
ProductScreen.exec.addOrderline("Gift Card", "1");
ProductScreen.check.selectedOrderlineHas("Gift Card", "1");
PosLoyalty.check.orderTotalIs("0.0");
return getSteps();
}
});
registry.category("web_tour.tours").add("GiftCardWithRefundtTour", {
test: true,
url: "/pos/web",
steps: () => {
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
ProductScreen.exec.addOrderline("Magnetic Board", "1"); // 1.98
PosLoyalty.check.orderTotalIs("1.98");
PosLoyalty.exec.finalizeOrder("Cash", "20");
ProductScreen.do.clickRefund();
TicketScreen.do.selectOrder("-0001");
TicketScreen.check.hasLine({
withClass: ".selected",
productName: "Magnetic Board",
});
ProductScreen.do.pressNumpad("1");
TicketScreen.do.confirmRefund();
ProductScreen.check.isShown();
ProductScreen.check.selectedOrderlineHas("Magnetic Board", "-1.00");
ProductScreen.exec.addOrderline("Gift Card", "1");
ProductScreen.check.selectedOrderlineHas("Gift Card", "1");
PosLoyalty.check.orderTotalIs("0.0");
return getSteps();
},
});
@@ -5,6 +5,7 @@ import { Do as ProductScreenDo } from "@point_of_sale/../tests/tours/helpers/Pro
import { Do as PaymentScreenDo } from "@point_of_sale/../tests/tours/helpers/PaymentScreenTourMethods";
import { Do as ReceiptScreenDo } from "@point_of_sale/../tests/tours/helpers/ReceiptScreenTourMethods";
import { Do as ChromeDo } from "@point_of_sale/../tests/tours/helpers/ChromeTourMethods";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
const ProductScreen = { do: new ProductScreenDo() };
const PaymentScreen = { do: new PaymentScreenDo() };
@@ -14,15 +15,16 @@ const Chrome = { do: new ChromeDo() };
class Do {
selectRewardLine(rewardName) {
return [
{
content: "select reward line",
trigger: `.orderline.program-reward .product-name:contains("${rewardName}")`,
},
{
content: "check reward line if selected",
trigger: `.orderline.selected.program-reward .product-name:contains("${rewardName}")`,
run: function () {}, // it's a check
},
...Order.hasLine({
withClass: ".fst-italic",
withoutClass: ".selected",
run: "click",
productName: rewardName,
}),
...Order.hasLine({
withClass: ".selected.fst-italic",
productName: rewardName,
}),
];
}
enterCode(code) {
@@ -99,35 +101,15 @@ class Do {
class Check {
hasRewardLine(rewardName, amount, qty) {
const steps = [
{
content: "check if reward line is there",
trigger: `.orderline.program-reward span.product-name:contains("${rewardName}")`,
run: function () {},
},
{
content: "check if the reward price is correct",
trigger: `.orderline.program-reward span.price:contains("${amount}")`,
run: function () {},
},
];
if (qty) {
steps.push({
content: "check if the reward qty is correct",
trigger: `.order .orderline.program-reward .product-name:contains("${rewardName}") ~ .info-list em:contains("${qty}")`,
run: function () {},
});
}
return steps;
return Order.hasLine({
withClass: ".fst-italic",
productName: rewardName,
price: amount,
quantity: qty,
});
}
orderTotalIs(total_str) {
return [
{
content: "order total contains " + total_str,
trigger: '.order .total .value:contains("' + total_str + '")',
run: function () {}, // it's a check
},
];
return [Order.hasTotal(total_str)];
}
checkNoClaimableRewards() {
return [
@@ -1,44 +0,0 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { OrderWidget } from "@point_of_sale/app/screens/product_screen/order/order";
import { AlertPopup } from "@point_of_sale/app/utils/alert_popup/alert_popup";
import { _t } from "@web/core/l10n/translation";
patch(OrderWidget.prototype, {
releaseTable() {
const currentTable = this.pos.table;
const orderOnTable = this.pos.orders.filter(
(o) => o.tableId === currentTable.id && o.finalized === false
);
const orderOnTableWithOrderline = orderOnTable.find((o) => o.orderlines.length > 0);
if (orderOnTableWithOrderline) {
this.popup.add(AlertPopup, {
title: _t("Open orders"),
body: _t(
"There are open orders on this table. Please close them before releasing the table."
),
});
return;
}
for (const order of orderOnTable) {
this.pos.removeOrder(order);
}
this.pos.showScreen("FloorScreen");
},
get showReleaseBtn() {
if (!this.pos.config.module_pos_restaurant) {
return false;
}
const currentTable = this.pos.table;
const noEmptyOrderOnTable = this.pos.orders.filter(
(o) => o.tableId === currentTable.id && o.finalized === false && o.orderlines.length > 0
);
return noEmptyOrderOnTable.length ? false : true;
},
});
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_restaurant.OrderWidget" t-inherit="point_of_sale.OrderWidget" t-inherit-mode="extension">
<xpath expr="//div[hasclass('order-empty')]" position="after">
<button t-if="showReleaseBtn" class="btn btn-primary py-2 rounded-0" style="border:none; font-size: 20px;" t-on-click="releaseTable">Release table</button>
</xpath>
</t>
</templates>
@@ -0,0 +1,25 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
patch(ProductScreen.prototype, {
releaseTable() {
const orderOnTable = this.pos.orders.filter(
(o) => o.tableId === this.pos.table.id && o.finalized === false
);
for (const order of orderOnTable) {
this.pos.removeOrder(order);
}
this.pos.showScreen("FloorScreen");
},
showReleaseBtn() {
return (
this.pos.config.module_pos_restaurant &&
!this.pos.orders.some(
(o) =>
o.tableId === this.pos.table.id && o.finalized === false && o.orderlines.length
)
);
},
});
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_restaurant.ProductScreen" t-inherit="point_of_sale.ProductScreen" t-inherit-mode="extension">
<xpath expr="//OrderWidget/t[@t-set-slot='details']" position="inside">
<button t-if="showReleaseBtn()" class="btn btn-primary py-2 rounded-0" style="border:none; font-size: 20px;" t-on-click="releaseTable">Release table</button>
</xpath>
</t>
</templates>
@@ -2,15 +2,16 @@
import { Order } from "@point_of_sale/app/store/models";
import { SplitOrderline } from "@pos_restaurant/app/split_bill_screen/split_orderline/split_orderline";
import { registry } from "@web/core/registry";
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { Component, useState } 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 { groupBy } from "@web/core/utils/arrays";
export class SplitBillScreen extends Component {
static template = "pos_restaurant.SplitBillScreen";
static components = { SplitOrderline };
static components = { Orderline, OrderWidget };
setup() {
this.pos = usePos();
@@ -38,6 +39,13 @@ export class SplitBillScreen extends Component {
this._updateNewOrder(l);
}
}
getLineData(line) {
const splitQty = this.splitlines[line.id].quantity;
if (!splitQty) {
return line.getDisplayData();
}
return { ...line.getDisplayData(), qty: `${splitQty} / ${line.get_quantity_str()}` };
}
back() {
this.pos.showScreen("ProductScreen");
}
@@ -13,20 +13,17 @@
<h2 class="mb-0">Bill Splitting</h2>
</div>
</div>
<div t-if="newOrder" class="main d-flex flex-nowrap flex-grow-1 overflow-hidden">
<div class="lines d-flex flex-shrink-1 flex-grow-1 flex-basis-0 justify-content-center border-end overflow-y-auto ">
<div class="order w-100 h-auto py-2 m-4 border rounded bg-view text-start">
<ul class="orderlines flex-grow-1 h-100 overflow-auto">
<t t-foreach="orderlines" t-as="line" t-key="line.cid">
<SplitOrderline
onClickLine.bind="onClickLine"
line="line"
split="splitlines[line.id]" />
</t>
</ul>
</div>
<div class="flex-grow-1 mw-50 m-3 bg-view border rounded w-50">
<OrderWidget lines="orderlines" t-slot-scope="scope">
<t t-set="line" t-value="scope.line" />
<Orderline line="getLineData(line)"
t-on-click="() => this.onClickLine(line)"
class="{'selected text-bg-primary': splitlines[line.id].quantity !== 0}" />
</OrderWidget>
</div>
<div class="controls d-flex flex-column flex-nowrap flex-grow-1 flex-shrink-1 flex-basis-0">
<div class="controls border-start flex-column flex-nowrap flex-grow-1 flex-shrink-1 flex-basis-0">
<div class="order-info py-4 border-bottom text-center text-success">
<span class="subtotal">
<t t-esc="env.utils.formatCurrency(newOrder.get_subtotal())" />
@@ -1,15 +0,0 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
export class SplitOrderline extends Component {
static template = "pos_restaurant.SplitOrderline";
setup() {}
get isSelected() {
return this.props.split.quantity !== 0;
}
click() {
this.props.onClickLine(this.props.line);
}
}
@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_restaurant.SplitOrderline">
<li class="orderline w-100 m-0 py-2 px-3 lh-sm cursor-pointer" t-att-class="{ 'selected text-bg-primary' : isSelected, partially: props.split.quantity !== props.line.get_quantity() }" t-on-click="() => this.click()">
<span class="product-name d-inline-block flex-grow-1 fw-bolder text-truncate">
<t t-esc="props.line.get_product().display_name" />
</span>
<span class="price fw-bolder float-end">
<t t-esc="env.utils.formatCurrency(props.line.get_display_price())" />
</span>
<ul class="info-list ms-2">
<t t-if="props.line.get_quantity_str() !== '1'">
<li class="info">
<t t-if="isSelected and props.line.is_pos_groupable()">
<em class="big fs-3 fst-normal fw-bolder me-1">
<t t-esc="props.split.quantity" />
</em>
/
<t t-esc="props.line.get_quantity_str()" />
</t>
<t t-else="">
<em class="fst-normal fw-bolder me-1">
<t t-esc="props.line.get_quantity_str()" />
</em>
</t>
<t t-esc="props.line.get_unit().name" />
at
<t t-esc="env.utils.formatCurrency(props.line.get_unit_price())" />
/
<t t-esc="props.line.get_unit().name" />
</li>
</t>
<t t-if="props.line.get_discount_str() !== '0'">
<li class="info">
<span>With a </span>
<em>
<t t-esc="props.line.get_discount_str()" />
<span>%</span>
</em>
<span> discount</span>
</li>
</t>
</ul>
</li>
</t>
</templates>
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_restaurant.Orderline" t-inherit="point_of_sale.Orderline" t-inherit-mode="extension">
<xpath expr="//ul[hasclass('info-list')]" position="inside">
<t t-if="props.line.getNote()">
<li class="info orderline-note 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="props.line.getNote()" />
</li>
</t>
</xpath>
</t>
</templates>
@@ -22,5 +22,10 @@
</button>
</t>
</xpath>
<xpath expr="//Orderline" position="attributes">
<attribute name="t-on-dblclick">
() => line.toggleSkipChange()
</attribute>
</xpath>
</t>
</templates>
@@ -92,6 +92,18 @@ patch(Orderline.prototype, {
return "" + this.id;
}
},
toggleSkipChange() {
if (this.hasChange || this.skipChange) {
this.skipChange = !this.skipChange;
}
},
getDisplayClasses() {
return {
...super.getDisplayClasses(),
"has-change text-success border-start border-success border-4": this.hasChange,
"skip-change text-primary border-start border-primary border-4": this.skipChange,
};
},
});
patch(Payment.prototype, {
@@ -7,62 +7,65 @@ import { FloorScreen } from "@pos_restaurant/../tests/tours/helpers/FloorScreenT
import { ProductScreen } from "@pos_restaurant/../tests/tours/helpers/ProductScreenTourMethods";
import { SplitBillScreen } from "@pos_restaurant/../tests/tours/helpers/SplitBillScreenTourMethods";
import { BillScreen } from "@pos_restaurant/../tests/tours/helpers/BillScreenTourMethods";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
import { getSteps, startSteps } from "@point_of_sale/../tests/tours/helpers/utils";
import { registry } from "@web/core/registry";
registry
.category("web_tour.tours")
.add("ControlButtonsTour", {
test: true,
url: "/pos/ui",
steps: () => {
// signal to start generating steps
// when finished, steps can be taken from getSteps
startSteps();
// Test TransferOrderButton
FloorScreen.do.clickTable("2");
ProductScreen.exec.addOrderline("Water", "5", "2", "10.0");
ProductScreen.do.clickTransferButton();
FloorScreen.do.clickTable("4");
ProductScreen.do.clickOrderline("Water", "5", "2");
Chrome.do.backToFloor();
FloorScreen.do.clickTable("2");
ProductScreen.check.orderIsEmpty();
Chrome.do.backToFloor();
FloorScreen.do.clickTable("4");
ProductScreen.do.clickOrderline("Water", "5", "2");
// Test SplitBillButton
ProductScreen.do.clickSplitBillButton();
SplitBillScreen.do.clickBack();
// Test OrderlineNoteButton
ProductScreen.do.clickNoteButton();
TextAreaPopup.check.isShown();
TextAreaPopup.do.inputText("test note");
TextAreaPopup.do.clickConfirm();
ProductScreen.check.orderlineHasNote("Water", "5", "test note");
ProductScreen.exec.addOrderline("Water", "8", "1", "8.0");
// Test PrintBillButton
ProductScreen.do.clickPrintBillButton();
BillScreen.check.isShown();
BillScreen.do.clickOk();
// Test GuestButton
ProductScreen.do.clickGuestButton();
NumberPopup.do.enterValue("15");
NumberPopup.check.inputShownIs("15");
NumberPopup.do.clickConfirm();
ProductScreen.check.guestNumberIs("15");
ProductScreen.do.clickGuestButton();
NumberPopup.do.enterValue("5");
NumberPopup.check.inputShownIs("5");
NumberPopup.do.clickConfirm();
ProductScreen.check.guestNumberIs("5");
registry.category("web_tour.tours").add("ControlButtonsTour", {
test: true,
url: "/pos/ui",
steps: () => {
// signal to start generating steps
// when finished, steps can be taken from getSteps
startSteps();
return getSteps();
}
});
// Test TransferOrderButton
FloorScreen.do.clickTable("2");
ProductScreen.exec.addOrderline("Water", "5", "2", "10.0");
ProductScreen.do.clickTransferButton();
FloorScreen.do.clickTable("4");
Chrome.do.backToFloor();
FloorScreen.do.clickTable("2");
ProductScreen.check.orderIsEmpty();
Chrome.do.backToFloor();
FloorScreen.do.clickTable("4");
// Test SplitBillButton
ProductScreen.do.clickSplitBillButton();
SplitBillScreen.do.clickBack();
// Test OrderlineNoteButton
ProductScreen.do.clickNoteButton();
TextAreaPopup.check.isShown();
TextAreaPopup.do.inputText("test note");
TextAreaPopup.do.clickConfirm();
Order.hasLine({
productName: "Water",
quantity: "5",
price: "10.0",
customerNote: "test note",
withClass: ".selected",
});
ProductScreen.exec.addOrderline("Water", "8", "1", "8.0");
// Test PrintBillButton
ProductScreen.do.clickPrintBillButton();
BillScreen.check.isShown();
BillScreen.do.clickOk();
// Test GuestButton
ProductScreen.do.clickGuestButton();
NumberPopup.do.enterValue("15");
NumberPopup.check.inputShownIs("15");
NumberPopup.do.clickConfirm();
ProductScreen.check.guestNumberIs("15");
ProductScreen.do.clickGuestButton();
NumberPopup.do.enterValue("5");
NumberPopup.check.inputShownIs("5");
NumberPopup.do.clickConfirm();
ProductScreen.check.guestNumberIs("5");
return getSteps();
},
});
@@ -34,7 +34,9 @@ registry.category("web_tour.tours").add("RefundStayCurrentTableTour", {
ProductScreen.check.orderIsEmpty();
ProductScreen.do.clickRefund();
TicketScreen.do.selectOrder("-0001");
TicketScreen.do.clickOrderline("Coca-Cola");
TicketScreen.check.hasLine({
productName: "Coca-Cola",
});
ProductScreen.do.pressNumpad("2");
TicketScreen.check.toRefundTextContains("To Refund: 2.00");
TicketScreen.do.confirmRefund();
@@ -4,6 +4,7 @@ import { PaymentScreen } from "@point_of_sale/../tests/tours/helpers/PaymentScre
import { ReceiptScreen } from "@point_of_sale/../tests/tours/helpers/ReceiptScreenTourMethods";
import { Chrome } from "@pos_restaurant/../tests/tours/helpers/ChromeTourMethods";
import { FloorScreen } from "@pos_restaurant/../tests/tours/helpers/FloorScreenTourMethods";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
import { ProductScreen } from "@pos_restaurant/../tests/tours/helpers/ProductScreenTourMethods";
import { SplitBillScreen } from "@pos_restaurant/../tests/tours/helpers/SplitBillScreenTourMethods";
import { TicketScreen } from "@point_of_sale/../tests/tours/helpers/TicketScreenTourMethods";
@@ -92,7 +93,7 @@ registry.category("web_tour.tours").add("SplitBillScreenTour2", {
Chrome.do.clickTicketButton();
TicketScreen.do.selectOrder("-0001");
TicketScreen.do.loadSelectedOrder();
ProductScreen.do.clickOrderline("Minute Maid", "1.0");
Order.hasLine({ productName: "Minute Maid", quantity: "1.0", withClass: ".selected" });
ProductScreen.check.totalAmountIs("2.00");
return getSteps();
},
@@ -125,7 +126,7 @@ registry.category("web_tour.tours").add("SplitBillScreenTour3", {
// Check if there is still water in the order
ProductScreen.check.isShown();
ProductScreen.do.clickOrderline("Water", "1.0");
ProductScreen.check.selectedOrderlineHas("Water", "1.0");
ProductScreen.do.clickPayButton(true);
PaymentScreen.do.clickPaymentMethod("Bank");
PaymentScreen.do.clickValidate();
@@ -184,7 +185,7 @@ registry.category("web_tour.tours").add("SplitBillScreenTour4PosCombo", {
// Check if there is still water in the order
...ProductScreen.check.isShown(),
// now we check that all the lines that remained in the order are correct
...ProductScreen.do.clickOrderline("Minute Maid", "1.0"),
...ProductScreen.check.selectedOrderlineHas("Minute Maid", "1.0"),
...ProductScreen.do.clickOrderline("Office Combo"),
...ProductScreen.do.clickOrderline("Combo Product 2"),
...ProductScreen.check.selectedOrderlineHas(
@@ -2,6 +2,7 @@
import { createTourMethods } from "@point_of_sale/../tests/tours/helpers/utils";
import { Do, Check, Execute } from "@point_of_sale/../tests/tours/helpers/ProductScreenTourMethods";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
class DoExt extends Do {
clickSplitBillButton() {
@@ -13,13 +14,7 @@ class DoExt extends Do {
];
}
doubleClickOrderline(name) {
return [
{
content: "first click orderline",
trigger: `.order .orderline .product-name:contains("${name}")`,
run: "dblclick"
},
];
return Order.hasLine({ productName: name, run: "dblclick" });
}
clickTransferButton() {
return [
@@ -77,13 +72,7 @@ class DoExt extends Do {
class CheckExt extends Check {
orderlinesHaveNoChange() {
return [
{
content: "Orderlines have no change",
trigger: ".orderlines .orderline:not(.has-change)",
run: function () {},
},
];
return Order.doesNotHaveLine({ withClass: ".has-change" });
}
isPrintingError() {
// because we don't have printer in the test.
@@ -95,36 +84,16 @@ class CheckExt extends Check {
];
}
orderlineIsToOrder(name) {
return [
{
content: `Line is to order`,
trigger: `.order .orderline.has-change .product-name:contains("${name}")`,
run: function () {}, // it's a check
},
];
return Order.hasLine({
productName: name,
withClass: ".has-change.text-success.border-start.border-success.border-4",
});
}
orderlineIsToSkip(name) {
return [
{
content: `Line is to order`,
trigger: `.order .orderline.skip-change .product-name:contains("${name}")`,
run: function () {}, // it's a check
},
];
}
orderlineHasNote(name, quantity, note) {
return [
{
content: `line has ${quantity} quantity`,
trigger: `.order .orderline .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
run: function () {}, // it's a check
},
{
content: `line has '${note}' note`,
trigger: `.order .orderline .info-list .orderline-note:contains("${note}")`,
run: function () {}, // it's a check
},
];
return Order.hasLine({
withClass: ".skip-change.text-primary.border-start.border-primary.border-4",
productName: name,
});
}
guestNumberIs(numberInString) {
return [
@@ -1,19 +1,11 @@
/** @odoo-module */
import { createTourMethods } from "@point_of_sale/../tests/tours/helpers/utils";
import * as Order from "@point_of_sale/../tests/tours/helpers/generic_components/OrderWidgetMethods";
class Do {
clickOrderline(name, totalQuantity) {
let trigger = `.splitbill-screen li.orderline .product-name:contains("${name}")`;
if (totalQuantity) {
trigger += ` ~ .info-list .info:contains("${totalQuantity}")`;
}
return [
{
content: `click '${name}' orderline with total quantity of '${totalQuantity}'`,
trigger,
},
];
clickOrderline(productName) {
return Order.hasLine({ productName, run: "click" });
}
clickBack() {
return [
@@ -35,18 +27,10 @@ class Do {
class Check {
orderlineHas(name, totalQuantity, splitQuantity) {
return [
{
content: `'${name}' orderline has total quantity of '${totalQuantity}'`,
trigger: `.splitbill-screen li.orderline .product-name:contains("${name}") ~ .info-list .info:contains("${totalQuantity}")`,
run: () => {},
},
{
content: `'${name}' orderline has '${splitQuantity}' quantity to split`,
trigger: `.splitbill-screen li.orderline .product-name:contains("${name}") ~ .info-list .info em:contains("${splitQuantity}")`,
run: () => {},
},
];
return Order.hasLine({
productName: name,
quantity: splitQuantity != 0 ? `${splitQuantity} / ${totalQuantity}` : totalQuantity,
});
}
subtotalIs(amount) {
return [
@@ -28,8 +28,8 @@ registry.category("web_tour.tours").add("pos_restaurant_sync", {
ProductScreen.check.orderlineIsToOrder("Water");
ProductScreen.check.orderlineIsToSkip("Coca-Cola");
ProductScreen.do.clickOrderButton();
ProductScreen.check.orderlinesHaveNoChange();
ProductScreen.check.isPrintingError();
ProductScreen.check.orderlinesHaveNoChange();
ProductScreen.check.totalAmountIs("4.40");
// Create 2nd order (paid)
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_sale.Orderline" t-inherit="point_of_sale.Orderline" t-inherit-mode="extension">
<xpath expr="//ul[hasclass('info-list')]" position="inside">
<t t-if="props.line.get_sale_order()">
<li class="info orderline-sale-order ms-2">
<i class="fa fa-basket" role="img" aria-label="SO" title="SO"/>
<t t-esc="props.line.get_sale_order().name" />
</li>
<table t-if="props.line.get_sale_order().details" class="sale-order-info ms-2">
<t t-foreach='props.line.get_sale_order().details' t-as='line' t-key='line_index'>
<tr>
<td class="text-truncate"><t t-esc="line['product_uom_qty']"/>x</td>
<td class="text-truncate" style="max-width: 275px;">
<t t-esc="line['product_name']" />
</td>
<td class="text-truncate">:</td>
<td class="text-truncate"><t t-esc="env.utils.formatCurrency(line['total'])" /> (tax incl.)</td>
</tr>
</t>
</table>
</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_sale.ProductScreen" t-inherit="point_of_sale.ProductScreen" t-inherit-mode="extension">
<xpath expr="//Orderline" position="inside" >
<t t-if="line.get_sale_order()">
<li class="info orderline-sale-order ms-2">
<i class="fa fa-shopping-basket me-1" role="img" aria-label="SO" title="SO"/>
<t t-esc="line.get_sale_order().name" />
</li>
<table t-if="line.get_sale_order().details" class="sale-order-info ms-2">
<tr t-foreach="line.get_sale_order()?.details" t-as="soLine" t-key="soLine_index">
<td class="text-truncate"><t t-esc="soLine.product_uom_qty"/>x</td>
<td class="text-truncate" style="max-width: 275px;"
t-esc="soLine.product_name" />
<td class="text-truncate">: </td>
<td class="text-truncate"><t t-esc="env.utils.formatCurrency(soLine.total)" /> (tax incl.)</td>
</tr>
</table>
</t>
</xpath>
</t>
</templates>
@@ -105,7 +105,7 @@ registry
// Filter should be automatically 'Paid'.
TicketScreen.check.filterIs('Paid');
TicketScreen.do.selectOrder('-0001');
TicketScreen.do.clickOrderline('Down Payment');
TicketScreen.check.hasLine({productName: 'Down Payment', withClass: '.selected', quantity: '1.0'});
ProductScreen.do.pressNumpad('1');
TicketScreen.do.confirmRefund();
ProductScreen.do.clickPayButton();
@@ -150,7 +150,6 @@ registry
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickQuotationButton();
ProductScreen.do.selectFirstOrder();
ProductScreen.do.clickOrderline("Product A", "1");
ProductScreen.check.selectedOrderlineHas('Product A', '1.00');
ProductScreen.do.clickPayButton();
PaymentScreen.do.clickPaymentMethod('Bank');