[IMP] pos_self_order: refacto, models and serverside price calculation

General refactoring of the codebase. Inline of certain components
that had no logic in the JS side, the XML of these components was
directly added to the place where they were called.

Simplification of the python controller, inline of many functions and
separation of the logic. There is now one function that manages new
orders and another that manages and updates existing orders if we are
in "pay after meal" mode. In "pay after each" mode, orders cannot be
edited.

Addition of different models, product, order and orderlines in order to
facilitate the use of these objects between the backend and the
frontend.

Added price calculation on the serverside because we can't trust the
data coming from the customer in the self since no identification
is required.

Management of last change sent added. Thanks to this, users will no
longer be able to click on the order button if no changes have been
made to the order.

closes odoo/odoo#122901

Signed-off-by: Heinz Robin (rhe) <rhe@odoo.com>
This commit is contained in:
David Monnom (moda)
2023-06-16 14:59:31 +02:00
parent d01a1bd382
commit a0aff5bda2
86 changed files with 2125 additions and 2236 deletions
@@ -196,7 +196,6 @@
</record>
<!-- Floors: Main Floor -->
<record id="floor_main" model="restaurant.floor">
<field name="name">Main Floor</field>
<field name="background_color">rgb(136,137,242)</field>
+8 -1
View File
@@ -9,13 +9,13 @@
"auto_install": ["pos_restaurant"],
"demo": [
"demo/pos_restaurant_demo.xml",
"demo/custom_link_data.xml",
],
"data": [
"security/ir.model.access.csv",
"views/index.xml",
"views/qr_code.xml",
"views/custom_link_views.xml",
"data/custom_link_data.xml",
"data/restaurant_table_data.xml",
"views/res_config_settings_views.xml",
"views/point_of_sale_dashboard.xml",
@@ -35,6 +35,8 @@
"web/static/src/session.js",
"web/static/src/core/utils/transitions.scss",
"web/static/src/core/**/*",
("remove", "web/static/src/core/browser/router_service.js"),
("remove", "web/static/src/core/debug/**/*"),
"web/static/lib/owl/owl.js",
"web/static/lib/owl/odoo_module.js",
"web/static/lib/luxon/luxon.js",
@@ -43,6 +45,11 @@
"web/static/lib/odoo_ui_icons/*",
"pos_self_order/static/src/**/*",
"point_of_sale/static/src/utils.js",
# bus service
'bus/static/src/services/bus_service.js',
'bus/static/src/bus_parameters_service.js',
'bus/static/src/multi_tab_service.js',
'bus/static/src/workers/*',
],
"pos_self_order.assets_tests": [
"web/static/lib/jquery/jquery.js",
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
from . import qr_menu
from . import ordering
from . import entry
from . import orders
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
from typing import Dict, Optional
from typing import Optional
import werkzeug
from odoo import http
@@ -11,8 +11,6 @@ from odoo.addons.pos_self_order.controllers.utils import (
get_any_pos_config_sudo,
get_table_sudo,
)
from odoo.addons.pos_self_order.models.pos_config import PosConfig
from odoo.addons.pos_self_order.models.pos_restaurant import RestaurantTable
class PosQRMenuController(http.Controller):
@@ -30,41 +28,46 @@ class PosQRMenuController(http.Controller):
return request.redirect(f"/menu/{get_any_pos_config_sudo().id}")
@http.route(
[
"/menu/<pos_name>",
"/menu/<pos_name>/products",
"/menu/<pos_name>/products/<int:product_id>",
"/menu/<pos_name>/cart",
"/menu/<pos_name>/orders",
],
[
"/menu/<config_id>",
"/menu/<config_id>/<path:subpath>"
],
auth="public",
website=True,
sitemap=True,
)
def pos_self_order_start(
self, pos_name: str, at: Optional[str] = None, product_id: Optional[int] = None
):
def pos_self_order_start(self, config_id: str, at=None):
"""
The user gets this route from the QR code that they scan at the table
:param pos_name: the name of the pos config: can be the id or the slugified name of the pos config. (e.g. "3" or "bar-3")
:param config_id: the name of the pos config: can be the id or the slugified name of the pos config. (e.g. "3" or "bar-3")
:param at: the access token of the table; we call this argument "at" because
it will be displayed in the url ( as a query param ), and "at" is more user friendly than "table_access_token"
it will be displayed in the url ( as a query param ), and "at" is more user friendly than "access_token"
the user is allowed to order only if this "at" matches the access token of a table
:param product_id: the id of the product that the user wants to see the details of;
we never actually use this argument in this function ( it will be read by the client side router ),
but we still have it here, because otherwise we get a Warning in the logs
:return: the rendered template
"""
config_sudo = get_pos_config_sudo(config_id)
table_sudo = get_table_sudo(access_token=at)
self_order_mode = 'qr_code'
table_infos = False
if config_sudo.has_active_session and config_sudo.self_order_table_mode and table_sudo:
table_infos = table_sudo._get_self_order_data()
self_order_mode = config_sudo.self_order_pay_after
return request.render(
"pos_self_order.index",
'pos_self_order.index',
{
"session_info": {
'session_info': {
**request.env["ir.http"].get_frontend_session_info(),
"currencies": request.env["ir.http"].get_currencies(),
"pos_self_order_data": self._get_self_order_config(
get_pos_config_sudo(pos_name),
get_table_sudo(table_access_token=at),
),
'currencies': request.env["ir.http"].get_currencies(),
'pos_self_order_data': {
'self_order_mode': self_order_mode,
'table': table_infos,
**config_sudo._get_self_order_data(),
},
}
},
)
@@ -116,21 +119,3 @@ class PosQRMenuController(http.Controller):
._get_image_stream_from(pos_config_sudo, field_name="self_order_image")
.get_response()
)
def _get_self_order_config(
self, pos_config_sudo: PosConfig, table_sudo: RestaurantTable
) -> Dict:
"""
Returns the necessary information for the POS Self Order App
"""
return {
**pos_config_sudo._get_self_order_data(),
**(
table_sudo
and pos_config_sudo.has_active_session
and pos_config_sudo.self_order_table_mode
# having the table_id in the frontend means that the client will be able to order
and {"table": table_sudo._get_self_order_data()}
or {}
),
}
@@ -1,464 +0,0 @@
# -*- coding: utf-8 -*-
from typing import List, Dict, Optional, Union
import werkzeug
import uuid
from odoo import http, fields
from odoo.http import request
from odoo.tools import partition
from odoo.addons.pos_self_order.controllers.utils import (
get_pos_config_sudo,
get_table_sudo,
)
from odoo.addons.pos_self_order.models.product_product import ProductProduct
from odoo.addons.pos_self_order.models.pos_order import PosOrder, PosOrderLine
from odoo.addons.pos_self_order.models.pos_config import PosConfig
class PosSelfOrderController(http.Controller):
@http.route(
"/pos-self-order/send-order",
auth="public",
type="json",
website=True,
)
def pos_self_order_send_order(
self,
cart: List[Dict],
pos_config_id: int,
table_access_token: Optional[str] = None,
order_pos_reference: Optional[str] = None,
order_access_token: Optional[str] = None,
) -> Dict[str, str]:
"""
There are 2 types of self order configurations:
1. Order at table and pay after each order
==> pos doesn't allow ongoing orders
2. Order at table and pay at the end of the meal
==> pos allows ongoing orders
(order at table means order from personal device (smartphone))
In case 1. we will create a new order each time the user sends an order
In case 2. we will create a new order only if the user does not have an ongoing order
(we will look whether there is an order with state = 'draft'
with the same pos_reference and order_access_token as the ones that the user has provided)
:param cart: order lines
:param pos_config_id: the id of the POS where the order is being sent
:param table_access_token: the access token of the table where the order is being sent (UUID v4)
:param pos_reference: the id of the order that is being edited; ex: "Order 00001-001-0001"
:param order_access_token: the access token of the order that is being edited (UUID v4)
:return: dictionary with keys: pos_reference, order_access_token
"""
# we will only respond with the order "access_token" and "id". After clicking on the `Order` button,
# the customer will be redirected to the landing page.
# From the `landing page`, the customer can go to the `My Orders` page.
# on this page, the app will automatically make a request to the `view-order` route,
# so the customer will get the order details that way
pos_config_sudo = get_pos_config_sudo(pos_config_id)
if not pos_config_sudo.self_order_table_mode or not pos_config_sudo.has_active_session:
raise werkzeug.exceptions.BadRequest()
order = self._form_order(
self._create_order_data(
cart,
pos_config_sudo,
table_access_token,
order_pos_reference,
order_access_token,
)
)
posted_order_id = (
request.env["pos.order"]
.sudo()
.create_from_ui(
[order],
draft=True,
)[0]
.get("id")
)
return (
request.env["pos.order"]
.sudo()
.browse(posted_order_id)
.read(["pos_reference", "access_token"])[0]
)
# # is_trusted is set to True by default.
# # We need to set it to False, because we are creating an order from a public route
# # FIXME: make it so we only set it to false if it is a new order
# # if the server already acknowledged the order, we should not set it to false again
# # every time the user adds a new item to the order
# request.env["pos.order"].sudo().browse(order_resp.get("id")).is_trusted = False
@http.route("/pos-self-order/view-order", auth="public", type="json", website=True)
def pos_self_order_view_order(self, pos_reference: str, access_token: str) -> Dict:
"""
Return some information about a given order.
This is used by the frontend to find the latest state of an order.
(e.g. if a customer orders something from the self order app and then the waiter adds an item to the order,
the customer will be able to see the new item in the order)
:param pos_reference: the id of the order that we want to view
:param access_token: the access token of the order that we want to view -- this is needed
for security reasons, so that only the customer who created the order
can view it
"""
order_sudo = self._find_order(pos_reference, access_token)
if not order_sudo:
raise werkzeug.exceptions.Unauthorized()
return order_sudo._export_for_self_order()
def _create_order_data(
self,
cart: List[Dict],
pos_config_sudo: PosConfig,
table_access_token: Optional[str],
order_pos_reference: Optional[str],
order_access_token: Optional[str],
) -> Dict[str, Union[int, str, List[Dict]]]:
"""
Given the data, we decide whether we need to create a new order or update an existing one
:return: dictionary with the data needed to create a new order
"""
# We only care about the previous order if the customer is paying after the meal
# ( paying after the meal means that the customer can order multiple times on the same order )
order_sudo = pos_config_sudo.self_order_pay_after == "meal" and self._find_order(
order_pos_reference, order_access_token, state="draft"
)
return {
**(
order_sudo._get_self_order_data()
if order_sudo
else self._create_new_order_data(pos_config_sudo, table_access_token)
),
"lines": self._create_orderlines(
self._merge_orderlines(
cart, order_sudo.lines.export_for_ui(), order_sudo.config_id
)
if order_sudo
else cart,
pos_config_sudo,
),
}
def _create_new_order_data(
self, pos_config_sudo: PosConfig, table_access_token: str
) -> Dict[str, Union[str, int]]:
"""
:param pos_config_id: the id of the pos config for which we want to create the order
:param table_access_token: the access token of the table for which we want to create the order
:return: a dictionary containing the data that we need to create a new order
"""
table_sudo = get_table_sudo(table_access_token)
pos_session_sudo = pos_config_sudo.current_session_id
if not table_sudo or not pos_session_sudo:
raise werkzeug.exceptions.Unauthorized()
sequence_number = self._get_sequence_number(table_sudo.id, pos_session_sudo.id)
return {
"id": self._generate_unique_id(pos_session_sudo.id, table_sudo.id, sequence_number),
"sequence_number": sequence_number,
"access_token": uuid.uuid4().hex,
"session_id": pos_session_sudo.id,
"table_id": table_sudo.id,
}
def _merge_orderlines(
self,
incoming_orderlines: List[Dict],
existing_orderlines: List[Dict],
pos_config_sudo: PosConfig,
) -> List[Dict]:
"""
If the customer has an existing order, we will add the items from the existing order to the current cart.
(This is because the create_from_ui method will overwrite the old items from the order)
:param cart: The cart from the frontend.
:return: list of dictionaries with the items from the cart and the items from the existing order
"""
has_merge_candidate = lambda item: self._has_merge_candidate(item, existing_orderlines)
orderlines_to_be_merged, orderlines_to_be_appended = partition(
has_merge_candidate, incoming_orderlines
)
return (
self._get_updated_orderlines(
existing_orderlines, orderlines_to_be_merged, pos_config_sudo
)
+ orderlines_to_be_appended
)
def _has_merge_candidate(self, item: Dict, existing_orderlines: List[Dict]) -> bool:
return any(self._can_be_merged(item, line) for line in existing_orderlines)
def _can_be_merged(self, orderline1, orderline2):
return self._is_pos_groupable(orderline1["product_id"]) and all(
orderline1.get(key, "") == orderline2.get(key, "")
for key in PosOrderLine._get_unique_keys()
)
def _get_updated_orderlines(
self, orderlines: List[Dict], cart: List[Dict], pos_config_sudo: PosConfig
) -> List[Dict]:
"""
:returns updated version of orderlines that takes into account the new items from the cart
"""
return [
self._get_updated_orderline(line, updated_item, pos_config_sudo)
if (
updated_item := next(
(item for item in cart if self._can_be_merged(item, line)), None
)
)
else line
for line in orderlines
]
def _get_updated_orderline(
self, line: Dict, updated_item: Dict, pos_config_sudo: PosConfig
) -> Dict:
"""
This function assumes that the price of the product was not changed between the time the
first items were added to the order and the time the new items were added to the order.
"""
new_qty = updated_item["qty"] + line["qty"]
new_price = (
request.env["product.product"]
.sudo()
.browse(int(line["product_id"]))
._get_price_info(pos_config_sudo, qty=new_qty)
)
return {
**line,
**{
"price_subtotal": new_price["price_without_tax"],
"price_subtotal_incl": new_price["price_with_tax"],
"qty": new_qty,
"customer_note": updated_item.get("customer_note") or line.get("customer_note"),
},
}
def _is_pos_groupable(self, product_id: int) -> bool:
return (
request.env["product.product"].sudo().browse(int(product_id)).uom_id.is_pos_groupable
)
def _get_full_product_name(self, name: str, description: str) -> str:
"""
:param name: ex: "[E-COM12] Desk Organizer"
:param description: ex: "M, Leather"
:return: ex: "Desk Organizer (M, Leather)"
"""
return f"{name} ({description})" if description else name
def _create_orderlines(self, cart: List[Dict], pos_config_sudo: PosConfig) -> List[Dict]:
"""
Function that constructs the order lines (as the create_from_ui method expects them) from the cart.
From the frontend we only get basic data of the cart, such as the id of the product and the quantity.
We need to get the other details of the product from the database.
This is done for security reasons.
"""
return [
[
0,
0,
self._create_orderline(
item,
pos_config_sudo,
),
]
# having the "full product name" means that the orderline is already in the db
# so we don't need to create a new object
if not item.get("full_product_name") else [0, 0, item]
for item in cart
]
def _create_orderline(self, item: Dict, pos_config_sudo: PosConfig) -> Dict:
"""
Function that constructs an order line (as the create_from_ui method expects it) from an item from the cart.
"""
product_sudo = request.env["product.product"].sudo().browse(int(item.get("product_id")))
return {
"product_id": item.get("product_id"),
"qty": item.get("qty"),
**self._get_orderline_price_info(product_sudo, item, pos_config_sudo),
"discount": 0,
"tax_ids": product_sudo.taxes_id,
"pack_lot_ids": [],
"description": item.get("description"), # ex "M, Leather"
"full_product_name": self._get_full_product_name( # ex: "Desk Organizer (M, Leather)"
product_sudo._get_name(),
item.get("description"),
),
"customer_note": item.get("customer_note"),
"pricec_type": "original",
"note": "",
"uuid": str(uuid.uuid4()),
}
def _get_orderline_price_info(
self, product_sudo: ProductProduct, item: Dict, pos_config_sudo: PosConfig
) -> Dict:
price_extra = (
self._compute_price_extra(product_sudo, item.get("description"), pos_config_sudo)
if item.get("description")
else 0
)
price_unit = product_sudo.lst_price + price_extra
price_subtotal_info = product_sudo._get_price_info(
pos_config_sudo, price_unit, item.get("qty")
)
return {
"price_unit": price_unit,
"price_subtotal": price_subtotal_info["price_without_tax"],
"price_subtotal_incl": price_subtotal_info["price_with_tax"],
"price_extra": price_extra,
}
def _compute_price_extra(
self, product_sudo: ProductProduct, description: str, pos_config_sudo: PosConfig
) -> float:
"""
Function that computes the price_extra of a product based on the description.
:param description: The description of the product. ex: "M, Leather"
represents the selected values of the product's attributes.
"""
return sum(
v["price_extra"]["price_without_tax"]
for attr, selected_value_name in zip(
product_sudo._get_attributes(pos_config_sudo), description.split(", ")
)
for v in attr["values"]
if v["name"] == selected_value_name
)
def _form_order(self, order: Dict) -> Dict:
"""
Function that constructs an order object (as the create_from_ui method expects it)
It takes as input the order object that was created by self._create_order_data()
:param order: Dictionary representing an order.
:return: Dictionary representing an order (as the create_from_ui method expects it).
"""
# The self order can only update an order that was created on the self order itself.
# This is because the self order does not have the ability to update orders that were created on the POS,
# because the app wouldn't know the access token of the order.
return {
"id": order.get("id"),
"data": {
"name": order.get("id"),
"amount_paid": 0,
"amount_total": self._compute_amount_total(order.get("lines")),
"amount_tax": self._compute_amount_tax(order.get("lines")),
"amount_return": 0,
"lines": order.get("lines"),
"statement_ids": [],
"pos_session_id": order.get("session_id"),
"partner_id": False,
"user_id": request.session.uid,
# we only need the digits of the pos_reference, not the whole id, which starts with word "Order"
"uid": order.get("id")[6:],
"sequence_number": order.get("sequence_number"),
"creation_date": str(fields.Datetime.now()),
"fiscal_position_id": False,
"to_invoice": False,
"to_ship": False,
"is_tipped": False,
"tip_amount": 0,
"access_token": order.get("access_token"),
"server_id": False,
"table_id": order.get("table_id"),
},
"to_invoice": False,
"session_id": order.get("session_id"),
}
def _compute_amount_total(self, lines: List[Dict]) -> float:
return sum(orderline[2].get("price_subtotal_incl") for orderline in lines)
def _compute_amount_tax(self, lines: List[Dict]) -> float:
return sum(
orderline[2].get("price_subtotal_incl") - orderline[2].get("price_subtotal")
for orderline in lines
)
# the 2nd argument of pos_reference is the login number;
# the regular pos receives the login number from the backend on the first load
# the problem is that if the pos has login number 1, for example
# and we create a new order in the database, with sequence number 1, for example,
# when the regular pos will try to create a new order, it will also use sequence number 1
# and login number 1 and the new order will have the same id as the order created by the self order app
# so it will ultimately not be recorded in the database.
# ( the regular pos does not check what was the last order id from the database )
# it keeps track itself of the last order id -- this is why this problem occurs
# I thought that it is best to make the pos_self_order app work around
# the problem, instead of modifying the regular pos.
# so I decided to use the numbers from 900 to 999 for the login number
# this way, the regular pos will have a login number of 1, 2, 3, etc
# and the pos_self_order app will have a login number of 901, 902, 903, etc,
# where 901 is the login number of the table with id = 1,
# 902 is the login number of the table with id = 2, etc
# TODO: make it so the regular pos can't have a login number of 900 or more
# FIXME: what happens if `table_id` is larger that 100?
# [possible fix: using a different pos session for the self order an synchronizing the orders]
def _generate_unique_id(self, pos_session_id: int, table_id: int, sequence_number: int) -> str:
"""
Generates a public identification number for the order.
:return: The order name. Example: Order 00001-001-0001
"""
return f"Order {pos_session_id:0>5}-{900 + table_id :0>3}-{sequence_number:0>4}"
def _find_order(
self,
pos_reference: Optional[str],
order_access_token: Optional[str],
state: Optional[str] = None,
) -> PosOrder:
"""
:return: pos order with the given pos_reference, access token and state
"""
return (
pos_reference
and order_access_token
and request.env["pos.order"]
.sudo()
.search(
[
("pos_reference", "=", pos_reference),
("access_token", "=", order_access_token),
*(state and [("state", "=", state)] or []),
],
limit=1,
)
)
def _get_sequence_number(self, table_id: int, session_id: int) -> int:
"""
:return: the new sequence number for the order.
"""
return (
request.env["pos.order"]
.sudo()
.search(
[
(
"pos_reference",
"=like",
f"Order {session_id:0>5}-{900 + table_id}-____",
),
],
limit=1,
)
.sequence_number
+ 1
) or 1
+211
View File
@@ -0,0 +1,211 @@
# -*- coding: utf-8 -*-
import uuid
from odoo import http, fields, Command
from odoo.http import request
from odoo.addons.pos_self_order.controllers.utils import (
get_pos_config_sudo,
get_table_sudo,
)
from werkzeug.exceptions import NotFound, BadRequest, Unauthorized
class PosSelfOrderController(http.Controller):
@http.route("/pos-self-order/process-new-order", auth="public", type="json", website=True)
def process_new_order(self, order, table_access_token):
pos_config_id = order.get('pos_config_id')
lines = order.get('lines')
pos_config_sudo = get_pos_config_sudo(pos_config_id)
pos_session_sudo = pos_config_sudo.current_session_id
table_sudo = get_table_sudo(table_access_token)
if not pos_config_sudo.self_order_table_mode or not pos_config_sudo.has_active_session:
raise Unauthorized
if not table_sudo or not pos_session_sudo:
raise Unauthorized
sequence_number = self._get_sequence_number(table_sudo.id, pos_session_sudo.id)
unique_id = self._generate_unique_id(pos_session_sudo.id, table_sudo.id, sequence_number)
# Create the order without lines and prices computed
# We need to remap the order because some required fields are not used in the frontend.
order = {
'id': unique_id,
'data': {
'uuid': order.get('uuid'),
'name': unique_id,
'user_id': request.session.uid,
'sequence_number': sequence_number,
'access_token': uuid.uuid4().hex,
'pos_session_id': pos_session_sudo.id,
'table_id': table_sudo.id,
"partner_id": False,
"creation_date": str(fields.Datetime.now()),
"fiscal_position_id": pos_config_sudo.default_fiscal_position_id,
"statement_ids": [],
"lines": [],
'amount_tax': 0,
'amount_total': 0,
'amount_paid': 0,
'amount_return': 0,
},
"to_invoice": False,
'session_id': pos_session_sudo.id,
}
# Save the order in the database to get the id
posted_order_id = request.env['pos.order'].sudo().create_from_ui([order], draft=True)[0].get('id')
# Process the lines and get their prices computed
lines = self._process_lines(lines, pos_config_sudo, posted_order_id)
# Compute the order prices
amount_total, amount_untaxed = self._get_order_prices(lines)
# Update the order with the computed prices and lines
order_sudo = request.env["pos.order"].sudo().browse(posted_order_id)
order_sudo.write({
'lines': [Command.create(line) for line in lines],
'amount_tax': amount_total - amount_untaxed,
'amount_total': amount_total,
})
return order_sudo._export_for_self_order()
@http.route('/pos-self-order/get-orders-taxes', auth='public', type='json', website=True)
def get_order_taxes(self, order, pos_config_id):
pos_config_sudo = get_pos_config_sudo(pos_config_id)
if not pos_config_sudo or not pos_config_sudo.self_order_table_mode:
raise Unauthorized
lines = self._process_lines(order.get('lines'), pos_config_sudo, 0)
amount_total, amount_untaxed = self._get_order_prices(lines)
return {
'lines': [{
'uuid': line.get('uuid'),
'price_subtotal': line.get('price_subtotal'),
'price_subtotal_incl': line.get('price_subtotal_incl'),
} for line in lines],
'amount_total': amount_total,
'amount_tax': amount_total - amount_untaxed,
}
@http.route('/pos-self-order/update-existing-order', auth="public", type="json", website=True)
def update_existing_order(self, order):
order_pos_reference = order.get('pos_reference')
order_access_token = order.get('access_token')
pos_config_id = order.get('pos_config_id')
pos_config_sudo = get_pos_config_sudo(pos_config_id)
order_sudo = request.env['pos.order'].sudo().search([
('pos_reference', '=', order_pos_reference),
('access_token', '=', order_access_token),
])
if not order_sudo:
raise Unauthorized("Order not found in the server !")
elif order_sudo.state != 'draft':
raise BadRequest("Order is not in draft state")
lines = self._process_lines(order.get('lines'), pos_config_sudo, order_sudo.id)
for line in lines:
if line.get('id'):
line_sudo = order_sudo.lines.filtered(lambda l: l.id == line.get('id'))
if line.get('qty') < line_sudo.qty:
line.set('qty', line_sudo.qty)
line_sudo.write({
**line,
})
else:
order_sudo.lines.create(line)
amount_total, amount_untaxed = self._get_order_prices(lines)
order_sudo.write({
'amount_tax': amount_total - amount_untaxed,
'amount_total': amount_total,
})
return order_sudo._export_for_self_order()
@http.route('/pos-self-order/get-orders', auth='public', type='json', website=True)
def get_orders_by_access_token(self, access_tokens):
orders_sudo = request.env["pos.order"].sudo().search([
("access_token", "in", access_tokens),
])
if not orders_sudo:
raise NotFound()
orders = []
for order in orders_sudo:
orders.append(order._export_for_self_order())
return orders
def _process_lines(self, lines, pos_config_sudo, pos_order_id):
newLines = []
pricelist = request.env['product.pricelist'].sudo().browse(pos_config_sudo.pricelist_id.id)
for line in lines:
product_sudo = request.env["product.product"].sudo().browse(int(line.get("product_id")))
# todo take into account the price extra
price_unit = pricelist._get_product_price(product_sudo, quantity=1) if pricelist else product_sudo.lst_price
config_fiscal_pos = pos_config_sudo.default_fiscal_position_id
selected_account_tax = config_fiscal_pos.map_tax(product_sudo.taxes_id) if config_fiscal_pos else product_sudo.taxes_id
tax_results = selected_account_tax.compute_all(
price_unit,
pos_config_sudo.currency_id,
line.get('qty'),
product_sudo,
)
newLines.append({
'price_unit': price_unit,
'price_subtotal': tax_results.get('total_excluded'),
'price_subtotal_incl': tax_results.get('total_included'),
'price_extra': 0,
'id': line.get('id'),
'order_id': pos_order_id,
'tax_ids': product_sudo.taxes_id,
'uuid': line.get('uuid'),
'product_id': line.get('product_id'),
'qty': line.get('qty'),
'customer_note': line.get('customer_note'),
'selected_attributes': line.get('selected_attributes'),
'full_product_name': line.get('full_product_name'),
})
return newLines
def _get_order_prices(self, lines):
amount_untaxed = sum([line.get('price_subtotal') for line in lines])
amount_total = sum([line.get('price_subtotal_incl') for line in lines])
return amount_total, amount_untaxed
# The first part will be the session_id of the order.
# The second part will be the table_id of the order.
# Last part the sequence number of the order.
# INFO: This is allow a maximum of 999 tables and 9999 orders per table, so about ~1M orders per session.
# Example: 'Self-Order 00001-001-0001'
def _generate_unique_id(self, pos_session_id: int, table_id: int, sequence_number: int) -> str:
first_part = "{:05d}".format(int(pos_session_id))
second_part = "{:03d}".format(int(table_id))
third_part = "{:04d}".format(int(sequence_number))
return f"Self-Order {first_part}-{second_part}-{third_part}"
def _get_sequence_number(self, table_id: int, session_id: int) -> int:
order_sudo = request.env["pos.order"].sudo().search([(
'pos_reference',
'like',
f"Self-Order {session_id:0>5}-{table_id:0>3}")], order='id desc', limit=1)
return (order_sudo.sequence_number + 1) or 1
+3 -3
View File
@@ -39,11 +39,11 @@ def get_any_pos_config_sudo() -> PosConfig:
) or _raise(werkzeug.exceptions.NotFound())
def get_table_sudo(table_access_token: Optional[str]) -> Optional[RestaurantTable]:
return table_access_token and (
def get_table_sudo(access_token: Optional[str]) -> Optional[RestaurantTable]:
return access_token and (
request.env["restaurant.table"]
.sudo()
.search([("access_token", "=", table_access_token), ("active", "=", True)], limit=1)
.search([("access_token", "=", access_token), ("active", "=", True)], limit=1)
)
@@ -1,9 +0,0 @@
<?xml version="1.0"?>
<odoo>
<data noupdate="1">
<record id="default_custom_link" model="pos_self_order.custom_link">
<field name="name">View Menu</field>
<field name="url">/products</field>
</record>
</data>
</odoo>
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<odoo>
<data noupdate="1">
<record id="default_custom_link" model="pos_self_order.custom_link">
<field name="name">View Menu</field>
<field name="pos_config_ids" eval="[(4, ref('pos_restaurant.pos_config_restaurant'))]"/>
<field name="url" model="pos.config" eval="'/menu/' + str(obj().env.ref('pos_restaurant.pos_config_restaurant').id) + '/products'" />
</record>
</data>
</odoo>
@@ -4,12 +4,12 @@
<record id="pos_restaurant.pos_config_restaurant" model="pos.config">
<field name="self_order_view_mode">True</field>
<field name="self_order_table_mode">True</field>
<field name="self_order_image"
model="pos.config"
<field name="self_order_image"
model="pos.config"
eval="obj().env.ref('pos_restaurant.pos_config_restaurant')._self_order_default_image()"
/>
<field name="self_order_image_name"
model="pos.config"
<field name="self_order_image_name"
model="pos.config"
eval="obj().env.ref('pos_restaurant.pos_config_restaurant')._self_order_default_image_name()"
/>
</record>
+17 -12
View File
@@ -61,15 +61,21 @@ class PosConfig(models.Model):
directly on the fields, but `module_pos_restaurant` would not be
known at the time that the function for this default value would run)
"""
for vals in vals_list:
if vals.get("module_pos_restaurant"):
vals.update(
{
"self_order_view_mode": True,
"self_order_table_mode": True,
}
)
return super().create(vals_list)
pos_config_ids = super().create(vals_list)
for pos_config_id in pos_config_ids:
if pos_config_id.module_pos_restaurant:
pos_config_id.self_order_view_mode = True
pos_config_id.self_order_table_mode = True
self.env['pos_self_order.custom_link'].create({
'url': '/menu/%s/products' % pos_config_id.id,
'name': 'View Menu',
'pos_config_ids': pos_config_id,
'style': 'primary',
})
return pos_config_ids
@api.depends("module_pos_restaurant")
def _compute_self_order(self):
@@ -86,14 +92,14 @@ class PosConfig(models.Model):
base_route = f"/menu/{self.id}"
if not self.self_order_table_mode:
return base_route
table_access_token = (
access_token = (
self.env["restaurant.table"]
.search(
[("active", "=", True), *(table_id and [("id", "=", table_id)] or [])], limit=1
)
.access_token
)
return f"{base_route}?at={table_access_token}"
return f"{base_route}?at={access_token}"
def _get_self_order_url(self, table_id: Optional[int] = None) -> str:
self.ensure_one()
@@ -165,7 +171,6 @@ class PosConfig(models.Model):
"custom_links": self._get_self_order_custom_links(),
"products": self._get_available_products()._get_self_order_data(self),
"has_active_session": self.has_active_session,
"orderline_unique_keys": PosOrderLine._get_unique_keys(),
}
def _generate_data_for_qr_codes_page(self, cols: int = 4) -> Dict[str, List[Dict]]:
+17 -46
View File
@@ -4,80 +4,51 @@
import re
from typing import Dict, Union
from odoo import models
from odoo import models, fields
class PosOrderLine(models.Model):
_inherit = "pos.order.line"
# For the moment we need this to keep attributes consistency between the server and client_side.
selected_attributes = fields.Json(string="Selected Attributes")
# FIXME: uuid already pass in pos and move note in pos_restaurant.
def _export_for_ui(self, orderline):
return {
"uuid": orderline.uuid,
"note": orderline.note,
'uuid': orderline.uuid,
'note': orderline.note,
**super()._export_for_ui(orderline),
}
def _get_description(self):
"""
The pos sends a "description" key to the backend, which is a string containing the selected value for
each attribute of the product, separated by a comma. In the db we end up storing the
"full_product_name", which is composed of the product name and the description.
:line.full_product_name: ex: "Desk Organizer (M, Leather)"
:return: ex: "M, Leather"
"""
self.ensure_one()
description = re.findall(r"\(([^)]+)\)", self.full_product_name)
if description:
# It might happen that the product has a name with parenthesis. ex: "Salad (Vegie)"
# In that case the full_product_name will be "Salad (Vegie) (Small)", but are interested in returning the variant "Small"
# That's why we return the last element of the list
return description[-1]
return ""
@staticmethod
def _get_unique_keys():
"""
These are the keys that define the uniqueness of an orderline.
We use them to check if 2 orderlines can be merged or not.
"""
return ["product_id", "description", "customer_note"]
class PosOrder(models.Model):
_inherit = "pos.order"
def _export_for_self_order(self) -> Dict:
"""
Given an order, it returns a dictionary with the keys that we need in the frontend
"""
self.ensure_one()
return {
"id": self.id,
"pos_config_id": self.config_id.id,
"pos_reference": self.pos_reference,
"access_token": self.access_token,
"state": self.state,
"date": str(self.date_order),
"amount_total": self.amount_total,
"amount_tax": self.amount_tax,
"items": [
"lines": [
{
"id": line.id,
"price_subtotal": line.price_subtotal,
"price_subtotal_incl": line.price_subtotal_incl,
"product_id": line.product_id.id,
"selected_attributes": line.selected_attributes,
"uuid": line.uuid,
"qty": line.qty,
"customer_note": line.customer_note,
"price_extra": line.product_id._get_price_info(
self.config_id,
line.price_extra,
),
"description": line._get_description(),
"full_product_name": line.full_product_name,
}
for line in self.lines
],
}
def _get_self_order_data(self) -> Dict[str, Union[str, int]]:
return {
"id": self.pos_reference,
"sequence_number": self.sequence_number,
"access_token": self.access_token,
"session_id": self.session_id.id,
"table_id": self.table_id.id,
}
@@ -42,6 +42,7 @@ class RestaurantTable(models.Model):
def _get_data_for_qr_codes_page(self, url: Callable[[Optional[int]], str]) -> List[Dict]:
return [
{
"access_token": table.access_token,
"id": table.id,
"name": table.name,
"url": url(table.id),
@@ -62,6 +62,7 @@ class ProductProduct(models.Model):
)
return attributes
# FIXME: this method should be verified about price computation (pricelist taxes....)
def _get_price_info(
self, pos_config: PosConfig, price: Optional[float] = None, qty: int = 1
) -> Dict[str, float]:
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class ResConfigSettings(models.TransientModel):
@@ -70,6 +71,9 @@ class ResConfigSettings(models.TransientModel):
"""
Generate the data needed to print the QR codes page
"""
if len(self.pos_config_id.floor_ids.table_ids) == 0:
raise UserError(_("Before you can create QR codes, you need to create tables for the restaurant."))
return self.env.ref("pos_self_order.report_self_order_qr_codes_page").report_action(
[], data=self.pos_config_id._generate_data_for_qr_codes_page(cols=3)
)
@@ -1,8 +0,0 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
export class IncrementCounter extends Component {
static template = "pos_self_order.IncrementCounter";
static props = ["value", "onClick"];
}
@@ -1,4 +0,0 @@
.o_self_order_incr_button button {
width: 40px;
height: 40px;
}
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.IncrementCounter" owl="1">
<div class="o_self_order_incr_button btn-group text-center border-light mb-1" role="group" aria-label="Quantity select">
<button type="button"
t-on-click = "() => props.onClick(false)"
class="btn btn-light rounded-circle"> - </button>
<div class="d-flex align-items-center px-4 fw-bold" t-esc="props.value"/>
<button type="button"
t-on-click = "() => props.onClick(true)"
class="btn btn-light rounded-circle"> + </button>
</div>
</t>
</templates>
@@ -1,8 +0,0 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
export class Loading extends Component {
static template = "pos_self_order.Loading";
static props = [];
}
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.Loading" owl="1">
<div class="d-flex align-items-center h-100 justify-content-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</t>
</templates>
@@ -1,8 +0,0 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
export class MainButton extends Component {
static template = "pos_self_order.MainButton";
static props = { slots: Object, onClick: Function };
}
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.MainButton" owl="1">
<div class="d-flex flex-column align-items-center w-100 p-3 bg-white shadow-lg">
<t t-slot="above"/>
<button t-on-click="props.onClick" class="o_self_order_main_button btn btn-primary w-100 px-2 py-3 mt-2 rounded">
<t t-slot="default"/>
</button>
</div>
</t>
</templates>
@@ -1,15 +0,0 @@
/** @odoo-module */
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
import { Component } from "@odoo/owl";
import { ProductCard } from "@pos_self_order/Components/ProductCard/ProductCard";
import { OrderLines } from "@pos_self_order/Components/OrderLines/OrderLines";
import { PriceDetails } from "@pos_self_order/Components/PriceDetails/PriceDetails";
export class Order extends Component {
static template = "pos_self_order.Order";
static components = { ProductCard, OrderLines, PriceDetails };
static props = ["order"];
setup() {
this.selfOrder = useSelfOrder();
}
}
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.Order" owl="1">
<div class="o_so_order d-flex flex-column">
<div class="o_so_order_header p-3 bg-white">
<div class="d-flex align-items-center justify-content-between">
<h6 class="m-0"
t-esc="props.order.pos_reference"/>
<span class="badge p-2 text-bg-primary"
t-esc="props.order.state"/>
</div>
<p class="small m-0 fst-italic text-muted"
t-esc="props.order.date"/>
</div>
<div class="o_so_order_body p-3 bg-300">
<OrderLines t-if="props.order.items" lines="props.order.items"/>
<div class="d-flex flex-column align-items-center px-3 pt-3 bg-white">
<PriceDetails tax="props.order.amount_tax" total="props.order.amount_total"/>
</div>
</div>
</div>
</t>
</templates>
@@ -1,19 +0,0 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
import { ProductCard } from "../ProductCard/ProductCard";
export class OrderLines extends Component {
static template = "pos_self_order.OrderLines";
static components = { ProductCard };
static props = ["lines"];
setup() {
this.selfOrder = useSelfOrder();
}
formatProduct(product) {
return (({ description_sale, has_image, ...rest }) => ({ has_image: false, ...rest }))(
product
);
}
}
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.OrderLines" owl="1">
<t t-foreach="props.lines" t-as="orderLine" t-key="selfOrder.orderline_unique_keys.map((key) => orderLine[key])">
<t t-set="product" t-value="selfOrder.getProduct({ id: orderLine.product_id })"/>
<ProductCard product="formatProduct(product)"
orderLine="orderLine"
/>
</t>
</t>
</templates>
@@ -1,55 +0,0 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
import { useForwardRefToParent } from "@web/core/utils/hooks";
export class ProductCard extends Component {
static template = "pos_self_order.ProductCard";
static props = ["product", "orderLine?", "currentProductCard?", "class?"];
static defaultProps = { class: "" };
setup() {
this.selfOrder = useSelfOrder();
useForwardRefToParent("currentProductCard");
this.qtyInCart = !this.props.orderLine && this.getQtyInCart();
}
clickOnProduct() {
const product = this.props.product;
if (!this.canOpenProductMainView(product)) {
return;
}
if (this.env.getCurrentRoute()[0] === "cart") {
this.selfOrder.setCurrentlyEditedOrderLine(this.props?.orderLine);
}
this.env.navigate("/products/" + product.product_id);
}
canOpenProductMainView(product) {
return (
this.selfOrder.table ||
product.has_image ||
product.description_sale ||
product.attributes.length
);
}
getQtyInCart() {
const cart = this.selfOrder.cart;
const product = this.props.product;
return cart
.filter((x) => x.product_id === product.product_id)
.reduce((sum, x) => sum + x.qty, 0);
}
getQtyInCartString() {
return `${this.props.orderLine.qty} x ${this.selfOrder.formatMonetary(
this.props.product.price_info.list_price +
(this.props.orderLine?.price_extra.list_price || 0)
)}`;
}
getTotalPriceString() {
return `${this.selfOrder.formatMonetary(
(this.props.orderLine?.qty || 1) *
(this.props.product.price_info.list_price +
(this.props.orderLine?.price_extra.list_price || 0))
)}`;
}
}
@@ -1,59 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.ProductCard" owl="1">
<div
t-attf-class="{{props.class}} o_self_order_item_card position-relative d-flex align-items-start w-100 bg-white px-3 py-2 overflow-hidden"
t-on-click="clickOnProduct"
t-ref="currentProductCard"
>
<div t-attf-class="d-flex {{ props.product.has_image ? 'flex-column' : 'flex-row justify-content-between' }} w-100 py-1">
<div t-attf-class="d-flex {{ props.orderLine?.qty ? 'flex-column align-items-start' : 'flex-row align-items-center' }} text-900 fw-bold fs-6">
<span
class="d-flex align-items-center me-1 text-primary fw-bolder"
t-if="qtyInCart"
t-esc="`${qtyInCart}x`"
/>
<h3 class="mb-0"
t-esc="props.product.name"
/>
<span
t-if="props.orderLine?.qty"
class="flex-grow-1 me-3 small text-muted"
t-esc="getQtyInCartString()"
/>
<!-- this description represents the list of selected variants of the product -->
<span
t-if="props.orderLine?.description"
class="m-0 text-muted small break-line"
t-esc="props.orderLine.description"
/>
<div t-if="props.orderLine?.customer_note" class="d-inline-block m-0 text-muted small break-line">
<i class="fa fa-pencil-square-o" aria-hidden="true" />
<span t-esc="props.orderLine.customer_note" class="ms-1" />
</div>
</div>
<span
t-attf-class="card-text small"
t-esc="getTotalPriceString()"
/>
<p
t-if="props.product.description_sale"
class="o_self_order_item_card_description m-0 text-muted small break-line"
t-esc="props.product.description_sale"
/>
</div>
<img
t-if="props.product.has_image"
class="o_self_order_item_card_image h-100 w-100 ms-2 rounded-3 border border-light bg-light"
t-attf-src="/menu/get-image/{{ props.product.product_id }}"
alt="Product image"
loading="lazy"
onerror="this.remove()"
/>
<span
t-if="qtyInCart"
class="position-absolute top-0 start-0 h-100 border-start border-5 border-primary"
/>
</div>
</t>
</templates>
@@ -1,32 +0,0 @@
/** @odoo-module */
import { Component, useEffect, useRef } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
export class TagSelector extends Component {
static template = "pos_self_order.TagSelector";
static props = ["activeTag", "tagList", "onClick"];
setup() {
this.selfOrder = useSelfOrder();
this.tagButtons = Object.fromEntries(
Array.from(this.selfOrder.tagList).map((tag) => {
return [tag, useRef(`tag_${tag}`)];
})
);
this.tagList = useRef("tagList");
// we scroll the tag list horizontally so that the selected tag is in the middle of the screen
useEffect(
(activeTag) => {
if (!activeTag) {
return;
}
const tag = this.tagButtons[activeTag].el;
this.tagList.el.scroll({
left: tag.offsetLeft + tag.offsetWidth / 2 - window.innerWidth / 2,
behavior: "smooth",
});
},
() => [this.props.activeTag]
);
}
}
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.TagSelector" owl="1">
<ul t-ref="tagList" class="nav nav-pills flex-nowrap flex-grow-1 px-2 border-end overflow-auto">
<t t-foreach="props.tagList" t-as="tag" t-key="tag">
<li class="nav-item mx-1 py-2"
t-on-click="() => props.onClick(tag)">
<span
t-attf-class="user-select-none nav-link py-2 rounded {{props.activeTag === tag ? 'active' : 'text-dark'}}"
t-ref="tag_{{tag}}"
t-esc="tag"/>
</li>
</t>
</ul>
</t>
</templates>
@@ -1,93 +0,0 @@
/** @odoo-module */
import { useEffect, useState } from "@odoo/owl";
/**
* This hook returns the target detected by the intersection observer.
* @param {ref} root : reference to the element that is the root of the intersection observer
* @param {Object} targets : object with references to each target
* @param {()=>{[boolean]}} getDeps : function that returns a list with one item; this item should be true when the effect should be run and false when it should be stopped
* @param {*} callback : function that is called when the tag is detected
* @returns {{name:string}} : object with the name of the tag that is currently selected
*/
export function useDetection(root, targets, getDeps, callback = () => {}) {
const detected = useState({ name: "" });
useEffect((stop) => {
if (stop) {
return;
}
/** @type{IntersectionObserverEntry[]} */
let entriesOnScreen = [];
const observer = new IntersectionObserver(
(entries) => {
entriesOnScreen = updateEntries(entriesOnScreen, entries);
detected.name = getName(getFirst(entriesOnScreen));
callback(detected.name);
},
{
root: root.el,
rootMargin: "-100px",
}
);
Object.keys(targets).forEach((tag) => {
observer.observe(targets[tag]?.el);
});
return () => {
observer.disconnect();
};
}, getDeps);
return detected;
}
/**
* @param {IntersectionObserverEntry[]} entries
* @returns {IntersectionObserverEntry} : the entry that is the highest on the page
*/
function getFirst(entries) {
return getMax(entries, (entry) => entry.target.offsetTop);
}
/**
* @template T
* @param {T[]} entries - The array of objects to search through.
* @param {Function} [criterion=(x) => x] - A function that returns a number for each entry. The entry with the highest value of this function will be returned. If not provided, defaults to an identity function that returns the entry itself.
* @param {boolean} [inverted=false] - If true, the entry with the lowest value of the criterion function will be returned instead.
* @returns {T} The entry with the highest or lowest value of the criterion function, depending on the value of `inverted`.
*/
function getMax(entries, criterion = (x) => x, inverted = false) {
return entries.reduce((prev, current) => {
const res = criterion(prev) < criterion(current);
return (inverted ? !res : res) ? prev : current;
});
}
/**
* @param {IntersectionObserverEntry[]} entriesOnScreen
* @param {IntersectionObserverEntry[]} entries
* @returns {IntersectionObserverEntry[]}
*/
function updateEntries(entriesOnScreen, entries) {
const entriesNoLongerIntersecting = entries.filter((entry) => !entry.isIntersecting);
const newEntriesIntersecting = entries.filter((entry) => entry.isIntersecting);
return [
...entriesOnScreen.filter((entry) => notIncludes(entry, entriesNoLongerIntersecting)),
...newEntriesIntersecting.filter((entry) => notIncludes(entry, entriesOnScreen)),
];
}
/**
* @param {IntersectionObserverEntry} entry
* @param {IntersectionObserverEntry[]} entries
* @returns {boolean}
*/
function notIncludes(entry, entries) {
return !entries.some((e) => getName(e) === getName(entry));
}
/**
* @param {IntersectionObserverEntry} item
* @returns {string}: the title of the group
*/
function getName(item) {
return item.target.querySelector("h3").textContent;
}
@@ -1,26 +0,0 @@
/** @odoo-module */
import { Component, useEffect } from "@odoo/owl";
import { NavBar } from "@pos_self_order/Components/NavBar/NavBar";
import { ProductCard } from "@pos_self_order/Components/ProductCard/ProductCard";
import { OrderLines } from "@pos_self_order/Components/OrderLines/OrderLines";
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
import { PriceDetails } from "@pos_self_order/Components/PriceDetails/PriceDetails";
import { MainButton } from "@pos_self_order/Components/MainButton/MainButton";
export class CartView extends Component {
static components = { NavBar, ProductCard, OrderLines, PriceDetails, MainButton };
static props = [];
static template = "pos_self_order.CartView";
setup() {
this.selfOrder = useSelfOrder();
useEffect(
(cart) => {
if (!cart.length) {
this.env.navigate("/products");
}
},
() => [this.selfOrder.cart]
);
}
}
@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.CartView" owl="1">
<div class="d-flex flex-column dvh-100">
<NavBar customText="'Cart'" goBack="'/products'"/>
<main class="h-100 bg-100 overflow-auto">
<OrderLines lines="selfOrder.cart"/>
</main>
<t t-call="pos_self_order.OrderButton"/>
</div>
</t>
<t t-name="pos_self_order.OrderButton" owl="1">
<MainButton onClick="() => selfOrder.sendOrder()">
<t t-set-slot="above">
<PriceDetails tax="selfOrder.getTotalCartTax()" total="selfOrder.getTotalCartCost()"/>
</t>
Order
</MainButton>
</t>
</templates>
@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.LandingPage" owl="1">
<div class="d-flex flex-column h-100 align-items-center p-4 overflow-auto"
t-attf-style="
background-image: linear-gradient(to bottom, transparent 33%, #000000 99%), url(/menu/get-bg-image/{{selfOrder.pos_config_id}});
background-size: cover;
background-position: center;
">
<div class="d-flex flex-column flex-grow-1 w-100 justify-content-end mb-4">
<t t-foreach="selfOrder.custom_links" t-as="link" t-key="link.id">
<a type="button"
t-attf-href="{{!link.url.startsWith('/') and link.url}}"
t-on-click="(event)=> link.url.startsWith('/') and env.navigate(link.url)"
t-attf-class="btn btn-{{link.style}} py-3 my-1 rounded">
<t t-esc="link.name"/>
</a>
</t>
<t t-if="selfOrder.orders.length">
<hr class="bg-500"/>
<a
type="button"
t-on-click="()=> env.navigate('/orders')"
class="btn btn-outline-light py-3 my-2">
My Orders
</a>
</t>
</div>
<t t-call="pos_self_order.LandingPageFooter"/>
</div>
</t>
<t t-name="pos_self_order.LandingPageFooter" owl="1">
<div class="d-flex align-items-center py-2 px-3 text-white-50 small">Powered By
<span class="o_pos_self_order_odoo_logo ms-2 mt-n1">
<svg height="16" viewBox="0 0 621 196" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Odoo Logo</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="odoo_logo" fill-rule="nonzero">
<path d="M546,196 C504.579,196 471,162.421 471,121 C471,79.579 504.579,46 546,46 C587.421,46 621,79.579 621,121 C621,162.421 587.421,196 546,196 Z M546,165 C570.3,165 590,145.3 590,121 C590,96.7 570.3,77 546,77 C521.7,77 502,96.7 502,121 C502,145.3 521.7,165 546,165 Z M389,196 C347.579,196 314,162.421 314,121 C314,79.579 347.579,46 389,46 C430.421,46 464,79.579 464,121 C464,162.421 430.421,196 389,196 Z M389,165 C413.3,165 433,145.3 433,121 C433,96.7 413.3,77 389,77 C364.7,77 345,96.7 345,121 C345,145.3 364.7,165 389,165 Z M307,120 C307,161.935 273.408,196 231.991,196 C190.575,196 157,162.005 157,120.07 C157,78.134 187.5,46 231.991,46 C248.433,46 263.638,49.496 275.998,58.58 L276,15.09 C276,6.756 283.27,0 291.5,0 C299.728,0 307,6.762 307,15.09 L307,120 Z M232,165 C256.3,165 276,145.3 276,121 C276,96.7 256.3,77 232,77 C207.7,77 188,96.7 188,121 C188,145.3 207.7,165 232,165 Z" fill="#FFF"></path>
<path d="M75,196 C33.579,196 0,162.421 0,121 C0,79.579 33.579,46 75,46 C116.421,46 150,79.579 150,121 C150,162.421 116.421,196 75,196 Z M75,165 C99.3,165 119,145.3 119,121 C119,96.7 99.3,77 75,77 C50.7,77 31,96.7 31,121 C31,145.3 50.7,165 75,165 Z" fill="#FFF"></path>
</g>
</g>
</svg>
</span>
</div>
</t>
</templates>
@@ -1,16 +0,0 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
import { Order } from "@pos_self_order/Components/Order/Order";
import { NavBar } from "@pos_self_order/Components/NavBar/NavBar";
import { Loading } from "@pos_self_order/Components/Loading/Loading";
export class OrdersView extends Component {
static template = "pos_self_order.OrdersView";
static components = { Order, NavBar, Loading };
static props = [];
setup() {
this.selfOrder = useSelfOrder();
this.selfOrder.updateOrders();
}
}
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.OrdersView" owl="1">
<NavBar customText="'Orders'" goBack="'/'"/>
<div class="overflow-auto dvh-100 pb-5">
<t t-if="selfOrder.orders == null">
<Loading/>
</t>
<div t-else="">
<t t-foreach="selfOrder.orders" t-as="order" t-key="order.pos_reference">
<Order order="order"/>
</t>
</div>
</div>
</t>
</templates>
@@ -1,105 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.ProductList" owl="1">
<div class="d-flex flex-column dvh-100">
<t t-call="pos_self_order.Header"/>
<!-- the body is set to overflow: hidden and this div is set to overflow-auto
this way we only scroll on the product list, and not on the whole page, which includes the navbar -->
<main t-ref="productsList" class="bg-white overflow-auto d-flex flex-row flex-wrap">
<t t-if="search.isFocused" t-call="pos_self_order.SearchResults"/>
<t t-else="" t-call="pos_self_order.productsGroupedByTag"/>
</main>
<!-- we use flex-grow-1 to push the button to the bottom of the page
( normally there would be many products, so the page would be long enough to push the button to the bottom,
but if there are no products (for example if you search for something that does not exist), the button
would be at the top of the page )
-->
<div class="flex-grow-1"></div>
<t t-if="selfOrder.table and selfOrder.cart.length" t-call="pos_self_order.GoToCartButton"/>
</div>
</t>
<t t-name="pos_self_order.Header" owl="1">
<header class="bg-white shadow-sm">
<NavBar class="shouldNavbarBeShown() ? '' : 'o_self_order_navbar_hide' " goBack="'/'"/>
<div class="o_self_order_searchbar_filter d-flex justify-content-start align-items-center p-0 m-0 border-bottom">
<t t-if="search.isFocused" t-call="pos_self_order.SearchBox"/>
<TagSelector
t-if="!search.isFocused"
activeTag="currentProductGroup.name"
tagList="Array.from(selfOrder.tagList)"
onClick.bind="tagOnClick"
/>
<t t-if="!search.isFocused" t-call="pos_self_order.SearchIcon"/>
</div>
</header>
</t>
<t t-name="pos_self_order.NoProductsMessage" owl="1">
<p class="mx-auto mt-3 text-center">
No products found
</p>
</t>
<t t-name="pos_self_order.productsGroupedByTag" owl="1">
<t t-set="tags" t-value="Object.keys(selfOrder.productsGroupedByTag)" />
<t t-if="!tags?.length" t-call="pos_self_order.NoProductsMessage" />
<t t-foreach="tags" t-as="tag" t-key="tag">
<section t-if="selfOrder.productsGroupedByTag[tag].length" t-ref="productsWithTag_{{tag}}" class="w-100 d-flex flex-wrap">
<h3 class="w-100 m-0 mt-n1 pb-3 pt-4 px-3 bg-light" t-esc="tag" />
<t t-foreach="selfOrder.productsGroupedByTag[tag]" t-as="product" t-key="product.product_id">
<!-- If this is the product that we last visited, we use currentProductCard to
get a reference to it -->
<ProductCard product="product"
currentProductCard="product.product_id === selfOrder.currentProduct and currentProductCard"
class="'w-md-50 w-xl-25'"/>
</t>
</section>
</t>
</t>
<t t-name="pos_self_order.SearchResults" owl="1">
<t t-set="filteredProducts" t-value="filteredProducts()"/>
<t t-if="!filteredProducts?.length" t-call="pos_self_order.NoProductsMessage"/>
<t t-foreach="filteredProducts" t-as="product" t-key="product.product_id">
<ProductCard product="product"
class="'w-md-50 w-xl-25'"/>
</t>
</t>
<t t-name="pos_self_order.SearchBox" owl="1">
<div class="input-group d-flex align-items-center">
<input type="text"
class="form-control p-3 border-0 shadow-none"
placeholder="Pizza"
t-model="search.input"/>
<div class="px-2">
<button class="btn bg-200 rounded" t-on-click="closeSearch">
<i class="fa fa-times"/>
</button>
</div>
</div>
</t>
<t t-name="pos_self_order.SearchIcon" owl="1">
<button class="btn btn-link text-dark px-3" t-on-click="focusSearch">
<i class="oi oi-search px-1"/>
</button>
</t>
<t t-name="pos_self_order.GoToCartButton" owl="1">
<MainButton onClick="() => env.navigate('/cart')">
<div class="d-flex align-items-center justify-content-between mx-2">
<i class="fa fa-shopping-cart pe-2" aria-hidden="true"/>
<p class="m-0">
Review (
<t t-esc="selfOrder.getTotalCartQty()" />
)
</p>
<span class="ms-auto"
t-esc="selfOrder.formatMonetary(selfOrder.getTotalCartCost())"
/>
</div>
</MainButton>
</t>
</templates>
@@ -1,97 +0,0 @@
/** @odoo-module */
import { Component, onWillUnmount, useState } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
import { NavBar } from "@pos_self_order/Components/NavBar/NavBar";
import { IncrementCounter } from "@pos_self_order/Components/IncrementCounter/IncrementCounter";
import { MainButton } from "@pos_self_order/Components/MainButton/MainButton";
export class ProductMainView extends Component {
static template = "pos_self_order.ProductMainView";
static props = { product: Object };
static components = {
NavBar,
IncrementCounter,
MainButton,
};
setup() {
this.selfOrder = useSelfOrder();
onWillUnmount(() => {
this.selfOrder.currentlyEditedOrderLine = null;
});
// we want to keep track of the last product that was viewed
this.selfOrder.currentProduct = this.props.product.product_id;
this.orderLine = useState({
qty: this.selfOrder?.currentlyEditedOrderLine?.qty || 1,
customer_note: this.selfOrder?.currentlyEditedOrderLine?.customer_note || "",
selectedVariants: Object.fromEntries(
this.props.product.attributes.map((attribute, key) => [
attribute.name,
this.selfOrder?.currentlyEditedOrderLine?.description?.split(", ")?.[key] ||
attribute.values[0].name,
])
),
});
}
incrementQty(up) {
this.orderLine.qty = this.computeNewQty(this.orderLine.qty, up);
}
computeNewQty(qty, up) {
if (up) {
return qty + 1;
}
if (qty > 1) {
return qty - 1;
}
if (this.selfOrder.currentlyEditedOrderLine) {
return 0;
}
return 1;
}
/**
* @param {Object} selectedVariants
* @param {import ("@pos_self_order/SelfOrderService").Attribute[]} attributes
* @param {"list_price" | "price_with_tax" | "price_without_tax"} type
* @returns {Number}
*/
getPriceExtra(selectedVariants, attributes, type = "list_price") {
return (
Object.entries(selectedVariants).reduce((sum, selected) => {
return (
sum +
attributes
.find((attribute) => attribute.name == selected[0])
.values.find((value) => value.name == selected[1]).price_extra[type]
);
}, 0) || 0
);
}
getAllPricesExtra(selectedVariants, attributes) {
const getPriceExtra = (type) => this.getPriceExtra(selectedVariants, attributes, type);
const priceTypes = ["list_price", "price_without_tax", "price_with_tax"];
return Object.fromEntries(priceTypes.map((type) => [type, getPriceExtra(type)]));
}
preFormOrderline() {
const orderLine = this.orderLine;
const product = this.props.product;
return {
product_id: product.product_id,
customer_note: orderLine.customer_note,
description: Object.values(orderLine.selectedVariants).join(", "),
nonFinalQty: orderLine.qty,
price_extra: this.getAllPricesExtra(orderLine.selectedVariants, product.attributes),
};
}
addToCartButtonClicked() {
const preFormedOrderline = this.preFormOrderline();
this.selfOrder.updateCart(preFormedOrderline);
this.env.navigate(this.returnRoute());
}
returnRoute() {
return this.selfOrder.currentlyEditedOrderLine ? "/cart" : "/products";
}
}
@@ -1,120 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.ProductMainView" owl="1">
<div class="d-flex flex-column dvh-100">
<NavBar goBack="returnRoute()"/>
<div class="bg-white d-flex flex-column pt-3 overflow-auto h-100">
<div class="px-3">
<img t-if="props.product.has_image"
class="w-100 rounded-3"
style="max-height: 400px; object-fit: cover;"
t-attf-src="/menu/get-image/{{ props.product.product_id }}/1920"
alt="Product image"/>
<div class="pt-3">
<h3 t-esc="props.product.name"
class="o_self_order_product_main_view_name"
/>
<h4 class="text-primary"
t-esc="selfOrder.formatMonetary(props.product.price_info.list_price)"/>
</div>
</div>
<div t-if="props.product.description_sale"
class="o_self_order_main_desc px-3 pb-3 text-muted"
t-esc="props.product.description_sale"
/>
<div class="o_self_order_main_options px-3 py-4 border-bottom bg-100">
<t t-if="props.product.attributes"
t-foreach="props.product.attributes"
t-as="attribute"
t-key="attribute.id">
<div class="mb-3">
<div class="attribute_name py-1 fs-5 fw-bold" t-esc="attribute.name"/>
<t t-call="pos_self_order.RadioProductAttribute" t-if="attribute.display_type === 'radio' or attribute.display_type === 'pills'" />
<t t-call="pos_self_order.SelectProductAttribute" t-elif="attribute.display_type === 'select'" />
<t t-call="pos_self_order.ColorProductAttribute" t-elif="attribute.display_type === 'color'" />
</div>
</t>
<div t-if="selfOrder.table">
<label class="form-label fw-bold" for="note">Add a note:</label>
<textarea class="form-control rounded bg-white" id="note" type="textarea" rows="1" placeholder="No onions please" t-model="orderLine.customer_note"/>
</div>
</div>
</div>
<t t-if="selfOrder.table" t-call="pos_self_order.AddToCartButton"/>
</div>
</t>
<t t-name="pos_self_order.RadioProductAttribute" owl="1">
<t t-foreach="attribute.values" t-as="value" t-key="value.id">
<div class="d-flex align-items-center justify-content-between py-2">
<input
type="radio"
class="form-check-input my-1 me-2"
t-att-name="attribute.id"
t-att-value="value.name"
t-attf-id="{{ attribute.id }}_{{ value.id }}"
t-model="orderLine.selectedVariants[attribute.name]"
/>
<span class="flex-grow-1">
<t t-esc="value.name"/>
<t t-if="value.price_extra.list_price">
+ <t t-esc="selfOrder.formatMonetary(value.price_extra.list_price)" />
</t>
</span>
</div>
</t>
</t>
<t t-name="pos_self_order.SelectProductAttribute" owl="1">
<select class="configurator_select form-select"
t-model="orderLine.selectedVariants[attribute.name]">
<option t-foreach="attribute.values" t-as="value" t-key="value.id" t-att-value="value.name">
<t t-esc="value.name"/>
<t t-if="value.price_extra.list_price">
+ <t t-esc="selfOrder.formatMonetary(value.price_extra.list_price)" />
</t>
</option>
</select>
</t>
<t t-name="pos_self_order.ColorProductAttribute" owl="1">
<div>
<ul>
<li t-foreach="attribute.values" t-as="value" t-key="value.id">
<label
t-attf-style="background-color: {{ value.html_color }};"
t-att-data-color="value.name">
<input
type="radio"
class="form-check-input my-1 me-2"
t-att-name="attribute.id"
t-att-value="value.name"
t-attf-id="{{ attribute.id }}_{{ value.id }}"
t-model="orderLine.selectedVariants[attribute.name]"
/>
</label>
<span class="flex-grow-1">
<t t-esc="value.name"/>
<t t-if="value.price_extra.list_price">
+ <t t-esc="selfOrder.formatMonetary(value.price_extra.list_price)" />
</t>
</span>
</li>
</ul>
</div>
</t>
<t t-name="pos_self_order.AddToCartButton" owl="1">
<MainButton onClick.bind="addToCartButtonClicked">
<t t-set-slot="above">
<IncrementCounter onClick.bind="incrementQty" value="this.orderLine.qty"/>
</t>
<t t-if="selfOrder.currentlyEditedOrderLine">
Update Cart
</t>
<t t-else="">
Add to Cart
</t>
</MainButton>
</t>
</templates>
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.SelfOrderRoot" owl="1">
<Router pos_config_id="selfOrder.pos_config_id">
<t t-set-slot="default">
<LandingPage/>
</t>
<t t-set-slot="/menu/{string:pos_name}/products">
<ProductList/>
</t>
<t t-set-slot="/menu/{string:pos_name}/products/{int:productId}" t-slot-scope="url">
<ProductMainView
product="selfOrder.getProduct({ id: url.productId })"/>
</t>
<t t-set-slot="/menu/{string:pos_name}/cart">
<CartView/>
</t>
<t t-set-slot="/menu/{string:pos_name}/orders">
<OrdersView/>
</t>
</Router>
<MainComponentsContainer/>
</t>
</templates>
@@ -1,402 +0,0 @@
/** @odoo-module */
import { useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { session } from "@web/session";
import { formatMonetary } from "@web/views/fields/formatters";
import { _t } from "@web/core/l10n/translation";
import { groupBy } from "@web/core/utils/arrays";
import { effect } from "@web/core/utils/reactive";
/**
* @template T
* @typedef {{ [k in keyof T]: T[k] } & {}} Prettify
*/
/**
* @typedef {Object} ReducedOrder
* @property {string} pos_reference
* @property {string} access_token
* @property {string} [state]
*
/**
* @typedef {Prettify<ReducedOrder & {state: string, lines: OrderLine[], amount_total: number, amount_tax: number}>} Order
*/
/**
* The type of orderline that we get from the Product Main View
* It contains the keys necessary to uniqely identify an orderline
* @typedef {Object} PreFormedOrderLine
* @property {number} product_id
* @property {string} customer_note
* @property {string} description
* @property {number} nonFinalQty
* @property {price_extra} PriceInfo
*/
/**
* The type of orderline that we send to the server
* @typedef {Object} ReducedOrderLine
* @property {number} product_id
* @property {number} qty
* @property {string} customer_note
* @property {string} description
*/
/**
* @typedef {Prettify<ReducedOrderLine & {price_extra: PriceInfo}>} OrderLine
*/
/**
* @typedef {Object} PriceInfo
* @property {number} list_price
* @property {number} price_with_tax
* @property {number} price_without_tax
*/
/**
* @typedef {Object} Product
* @property {number} product_id
* @property {PriceInfo} price_info
* @property {string} tag
* @property {string} name
* @property {string} description_sale
* @property {boolean} has_image
* @property {Attribute[]} attributes
*/
/**
* @typedef {Object} Attribute
* @property {string} display_type - The type of display for the attribute.
* @property {number} id - The unique identifier of the attribute.
* @property {string} name - The name of the attribute.
* @property {Value[]} values
*/
/**
* @typedef {Object} Value - An array of objects representing the attribute values.
* @property {bool | string} value.html_color - False if the value has no color, otherwise the color in hex format. ( ex: #FF0000 )
* @property {number} value.id - The unique identifier of the value.
* @property {boolean} value.is_custom
* @property {string} value.name
* @property {PriceInfo} value.price_extra
*/
export class SelfOrder {
constructor(...args) {
this.setup(...args);
}
setup(env, rpc, notification) {
Object.assign(this, {
env,
rpc,
notification,
...session.pos_self_order_data,
// Global state
currentProduct: 0,
/** @type {OrderLine[]} */
cart: JSON.parse(localStorage.getItem("cart")) ?? [],
/** @type {ReducedOrder[] | Order[]} */
orders: JSON.parse(localStorage.getItem("orders")) ?? [],
currentlyEditedOrderLine: null,
});
// we create a set with all the tags that are present in the menu
this.tagList = new Set(this.products.map((product) => product.tag));
this.productsGroupedByTag = groupBy(this.products, "tag");
// We want to keep a backup of some of the state in local storage
effect(
(state) => {
Array.isArray(state.cart) &&
localStorage.setItem("cart", JSON.stringify(state.cart));
},
[this]
);
effect(
(state) => {
Array.isArray(state.orders) &&
localStorage.setItem("orders", JSON.stringify(state.orders));
},
[this]
);
if (!this.has_active_session) {
this.closeNotification = this.notification.add(
_t("The restaurant is closed. You can browse the menu, but ordering is disabled."),
{ type: "warning" }
);
}
}
/**
* @param {OrderLine} orderLine
*/
setCurrentlyEditedOrderLine(orderLine) {
this.currentlyEditedOrderLine = orderLine;
}
/**
* @param {number} price
* @returns {number}
*/
formatMonetary(price) {
return formatMonetary(price, { currencyId: this.currency_id });
}
/**
* @returns {number}
*/
getTotalCartQty() {
const cart = this.cart;
return cart.reduce((sum, orderLine) => {
return sum + orderLine.qty;
}, 0);
}
/**
* @returns {number}
*/
getTotalCartCost() {
const cart = this.cart;
return cart.reduce((sum, orderLine) => {
return (
sum +
(this.getProduct({ id: orderLine.product_id }).price_info.price_with_tax +
orderLine.price_extra.price_with_tax) *
orderLine.qty
);
}, 0);
}
/**
* @returns {number}
*/
getTotalCartTax() {
return this._getTotalCartTax(this.cart);
}
/**
* From the server, for each product we get both the price with and without tax.
* We never actually compute taxes on the frontend.
* Here we add up the tax for each product in the cart
* @param {OrderLine[]} cart
* @returns {number}
*/
_getTotalCartTax(cart) {
return cart.reduce((sum, orderLine) => {
const product = this.getProduct({ id: orderLine.product_id });
const getTax = (x) => x.price_with_tax - x.price_without_tax;
return (
sum + (getTax(product.price_info) + getTax(orderLine.price_extra)) * orderLine.qty
);
}, 0);
}
/**
* @param {PreFormedOrderLine} preFormedOrderline
*/
updateCart(preFormedOrderline) {
const orderLineToReplace = this.currentlyEditedOrderLine;
this.deleteOrderLine(orderLineToReplace);
const orderLine = this.constructOrderline(preFormedOrderline);
this.cart = this.getUpdatedCart(this.cart, orderLine);
}
deleteOrderLine(orderLine) {
if (!orderLine) {
return;
}
this.cart = this.cart.filter((x) => JSON.stringify(x) !== JSON.stringify(orderLine));
}
/**
* Returns the cart with the updated item
* if we are editing an orderline, instead of trying to change the existing orderline
* we delete it and add a new one
* @param {OrderLine[]} cart
* @param {OrderLine} orderLine
* @returns {OrderLine[]}
*/
getUpdatedCart(cart, orderLine) {
return cart
.filter((item) => !this.canBeMerged(item, orderLine))
.concat(orderLine.qty ? [orderLine] : []);
}
/**
* @param {OrderLine} orderline1
* @param {OrderLine} orderline2
* @returns {boolean}
*/
canBeMerged(orderline1, orderline2) {
return (
this.getProduct({ id: orderline1.product_id }).is_pos_groupable &&
this.orderline_unique_keys.every((key) => orderline1[key] === orderline2[key])
);
}
/**
* @param {PreFormedOrderLine} preFormedOrderline
* @returns {OrderLine?}
*/
findMergeableOrderLine(preFormedOrderline) {
return this.cart.find((item) => this.canBeMerged(item, preFormedOrderline));
}
/**
* The selfOrder.updateCart method expects us to give it the
* total qty the orderline should have.
* If we are currently editing an existing orderline ( that means that we came to this
* page from the cart page), it means that we are editing the total qty itself,
* so we just return orderLine.qty.
* If we came to this page from the products page, it means that we are adding items,
* so we need to add the qty of the current product to the qty that is
* already in the cart.
* @param {PreFormedOrderLine} preFormedOrderline
* @returns {number}
*/
findOrderlineQty(preFormedOrderline) {
// ordeline.qty can only be 0 if we are editing an existing orderline
// If we are editing an existing orderline, we don't care about the
// qty that might have already been in the cart.
// This is why we only look at what quantity might already be in the cart
// if orderline.qty != 0
const qtyAlreadyInCart =
preFormedOrderline.nonFinalQty &&
(this.findMergeableOrderLine(preFormedOrderline)?.qty || 0);
return qtyAlreadyInCart + preFormedOrderline.nonFinalQty;
}
/**
* @param {PreFormedOrderLine}
* @returns {OrderLine}
*/
constructOrderline(preFormedOrderline) {
const qty = this.findOrderlineQty(preFormedOrderline);
return (({ nonFinalQty, ...rest }) => ({
qty,
...rest,
}))(preFormedOrderline);
}
/**
* @param {Object} options
* @param {number} options.id
* @returns {Product}
*/
getProduct({ id }) {
return this.products.find((product) => product.product_id === id);
}
////// Orders Logic /////////
/**
* @param {ReducedOrder[] |Order[]} orders
* @param {ReducedOrder | Order} new_order
* @returns {ReducedOrder[] | Order[]}
*/
combineOrders(orders, new_order) {
// We only want to keep the last 10 orders
return [
new_order,
...orders
.filter((order) => order.pos_reference !== new_order.pos_reference)
.slice(0, 9),
];
}
async sendOrder() {
try {
/*
If this is the first time the user is sending an order
we just send the order items to the server
If this is not the first time the user is sending an order
( the user is adding more items to an existing order )
we send the order items along with the order id and access_token to the server
*/
/**@type {ReducedOrder} */
const postedOrder = await this.rpc(`/pos-self-order/send-order`, this.getOrderData());
this.orders = this.combineOrders(this.orders, postedOrder);
this.notification.add(_t("Your order has been placed!"), { type: "success" });
// we only want to clear the cart if the order was sent successfully;
// in the case of an unsuccessful order the user might want to try again
this.cart = [];
} catch (error) {
this.notification.add(_t("Error sending order"), { type: "danger" });
console.error(error);
} finally {
this.navigate("/");
}
}
/**
* @returns {{pos_config_id: number, cart: ReducedOrderLine[], table_access_token: string, order_pos_reference: string, order_access_token: string}}}
*/
getOrderData() {
return {
pos_config_id: this.pos_config_id,
cart: this.extractCartData(this.cart),
table_access_token: this?.table?.access_token,
// The last order is always the first one in the array
// The orders are kept in reverse chronological order
order_pos_reference: this.orders?.[0]?.pos_reference,
order_access_token: this.orders?.[0]?.access_token,
};
}
/**
* @param {OrderLine[]} cart
* @returns {ReducedOrderLine[]}
*/
extractCartData(cart) {
return cart.map((orderLine) => ({
product_id: orderLine.product_id,
qty: orderLine.qty,
description: orderLine.description,
customer_note: orderLine.customer_note,
}));
}
async updateOrders() {
const old_orders = this.orders;
// we set this to null so we have a way to see in the template
// that we are still loading the orders
this.orders = null;
// This await is needed;
this.orders = await this.getUpdatedOrdersFromServer(old_orders);
}
/**
* @param {ReducedOrder[] | Order[]} old_orders_list
* @returns {ReducedOrder[] | Order[]}
*/
async getUpdatedOrdersFromServer(old_orders_list) {
return await Promise.all(
old_orders_list.map(async (order) =>
order.state === "paid" || order.state === "done"
? order
: this.getUpdatedOrderFromServer(order)
)
);
}
/**
* @param {ReducedOrder | Order} old_orders_list
* @returns {ReducedOrder | Order}
*/
async getUpdatedOrderFromServer(order) {
try {
return await this.rpc(`/pos-self-order/view-order/`, {
pos_reference: order.pos_reference,
access_token: order.access_token,
});
} catch {
return { ...order, state: "not found" };
}
}
}
export const selfOrderService = {
dependencies: ["rpc", "notification"],
async start(env, { rpc, notification }) {
return new SelfOrder(env, rpc, notification);
},
};
registry.category("services").add("self_order", selfOrderService);
export function useSelfOrder() {
return useState(useService("self_order"));
}
@@ -0,0 +1,40 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/self_order_service";
import { _t } from "@web/core/l10n/translation";
import { extractProductNameAndAttributes } from "../../utils";
import { useService } from "@web/core/utils/hooks";
export class Lines extends Component {
static template = "pos_self_order.Lines";
setup() {
this.selfOrder = useSelfOrder();
this.router = useService("router");
}
get lines() {
return this.props.order.lines;
}
clickOnLine(line) {
const order = this.props.order;
this.selfOrder.editedLine = line;
if (
!order.access_token ||
(order.state === "draft" && this.selfOrder.self_order_mode === "meal")
) {
this.selfOrder.editedOrder = order;
this.router.navigate("product", { product_id: line.product_id });
} else {
this.selfOrder.notification.add(_t("You cannot edit an posted order!"), {
type: "danger",
});
}
}
getNameAndDescription(line) {
const productInfos = extractProductNameAndAttributes(line.full_product_name);
return productInfos;
}
}
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.Lines" owl="1">
<t t-foreach="lines" t-as="line" t-key="line.uuid">
<div
t-attf-class="o_self_order_item_card position-relative d-flex align-items-start w-100 bg-white px-3 py-2 overflow-hidden"
t-on-click="() => this.clickOnLine(line)"
>
<div class="d-flex w-100 py-1 justify-content-between">
<div t-attf-class="d-flex {{ line.qty ? 'flex-column align-items-start' : 'flex-row align-items-center' }} text-900 fw-bold fs-6">
<t t-set="lineName" t-value="getNameAndDescription(line)" />
<h3 class="mb-0" t-esc="lineName.productName" />
<span
t-if="line.qty"
class="flex-grow-1 me-3 small text-muted"
t-esc="`${line.qty} x ${selfOrder.formatMonetary(selfOrder.productByIds[line.product_id].prices)}`"
/>
<span
t-if="lineName.attributes"
class="m-0 text-muted small break-line"
t-esc="lineName.attributes"
/>
<div t-if="line.customer_note" class="d-inline-block m-0 text-muted small break-line">
<i class="fa fa-pencil-square-o" aria-hidden="true" />
<span t-esc="line.customer_note" class="ms-1" />
</div>
</div>
<span
t-attf-class="card-text small"
t-esc="this.selfOrder.formatMonetary(line.qty * selfOrder.productByIds[line.product_id].prices)"
/>
</div>
</div>
</t>
</t>
</templates>
@@ -1,16 +1,18 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
import { useSelfOrder } from "@pos_self_order/self_order_service";
import { useService } from "@web/core/utils/hooks";
export class NavBar extends Component {
static template = "pos_self_order.NavBar";
static props = {
customText: { type: String, optional: true },
goBack: { type: String },
class: { type: String, optional: true },
};
static defaultProps = { class: "" };
setup() {
this.router = useService("router");
this.selfOrder = useSelfOrder();
}
}
@@ -3,13 +3,13 @@
<t t-name="pos_self_order.NavBar" owl="1">
<nav t-attf-class="{{props.class}} o_self_order_navbar d-flex align-items-center bg-white border-bottom">
<button class="btn btn-link text-dark"
t-on-click="() => env.navigate(props.goBack)">
t-on-click="() => this.router.back()">
<i class="fa fa-fw fa-chevron-left"/>
</button>
<div class="mx-auto overflow-hidden">
<h3 class="my-3 text-primary text-truncate"
<h3 class="my-3 text-primary text-truncate"
t-esc="props.customText or selfOrder.company_name"
t-on-click="() => env.navigate('/')"
t-on-click="() => this.router.back('default')"
/>
</div>
<span class="px-3 text-center" t-esc="selfOrder?.table?.name"/>
@@ -1,7 +1,7 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
import { useSelfOrder } from "@pos_self_order/self_order_service";
export class PriceDetails extends Component {
static template = "pos_self_order.PriceDetails";
@@ -3,12 +3,13 @@
<t t-name="pos_self_order.PriceDetails" owl="1">
<div class="d-grid gap-2 w-100 w-100 pb-3">
<div class="d-flex justify-content-between text-muted">
Tax:
<span t-esc="selfOrder.formatMonetary(props.tax)"/>
Tax:
<span t-if="!selfOrder.priceLoading" t-esc="selfOrder.formatMonetary(props.tax)"/>
<span t-else="" class="spinner-border o-self-order-spinner-custom-height"></span>
</div>
<div class="d-flex justify-content-between fw-bold">
Total:
<span t-esc="selfOrder.formatMonetary(props.total)"/>
Total:
<span t-if="!selfOrder.priceLoading" t-esc="selfOrder.formatMonetary(props.total)"/>
</div>
</div>
</t>
@@ -0,0 +1,56 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/self_order_service";
import { useForwardRefToParent, useService } from "@web/core/utils/hooks";
export class ProductCard extends Component {
static template = "pos_self_order.ProductCard";
static props = ["product", "line?", "currentProductCard?", "class?"];
static defaultProps = { class: "" };
setup() {
this.selfOrder = useSelfOrder();
this.router = useService("router");
useForwardRefToParent("currentProductCard");
}
get quantityInCart() {
return this.selfOrder.currentOrder.lines?.reduce((sum, line) => {
return line.product_id === this.props.product.id ? sum + line.qty : sum;
}, 0);
}
// FIXME: we need to verify the product name for future attribute variants
// in case of variants, we need to show the main product screen with "add" btn
// if the user select the same variant as an existing orderline, we merge it.
clickOnProduct() {
const product = this.props.product;
if (!this.canOpenProductMainView(product)) {
return;
}
this.router.navigate("product", { product_id: product.id });
}
canOpenProductMainView(product) {
return (
this.selfOrder.table ||
product.has_image ||
product.description_sale ||
product.attributes.length
);
}
getQtyInCartString() {
// FIXME need to implement price_extra
const productId = this.props.line.product_id;
return `${this.props.line.qty} x ${this.selfOrder.productByIds[productId].prices}`;
}
getTotalPriceString() {
const productPriceWithTax = this.props.product.prices;
const quantity = this.props.line?.qty ? this.props.line.qty : 1;
return this.selfOrder.formatMonetary(productPriceWithTax * quantity);
}
}
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.ProductCard" owl="1">
<div
t-attf-class="{{props.class}} o_self_order_item_card position-relative d-flex align-items-start w-100 bg-white px-3 py-2 overflow-hidden"
t-on-click="clickOnProduct"
t-ref="currentProductCard"
>
<div class="d-flex flex-column w-100 py-1">
<div class="d-flex flex-row align-items-center text-900 fw-bold fs-6">
<span
class="d-flex align-items-center me-1 text-primary fw-bolder"
t-if="quantityInCart and selfOrder.table"
t-esc="`${quantityInCart}x`"
/>
<h3 class="mb-0" t-esc="props.product.name" />
</div>
<span class="card-text small" t-esc="getTotalPriceString()" />
<p
t-if="props.product.description_sale"
class="o_self_order_item_card_description m-0 text-muted small break-line"
t-esc="props.product.description_sale"
/>
</div>
<img
class="o_self_order_item_card_image h-100 w-100 ms-2 rounded-3 border border-light bg-light"
t-attf-src="/menu/get-image/{{ props.product.id }}"
alt="Product image"
loading="lazy"
onerror="this.remove()"
/>
<span t-if="quantityInCart and selfOrder.table" class="position-absolute top-0 start-0 h-100 border-start border-5 border-primary" />
</div>
</t>
</templates>
@@ -0,0 +1,60 @@
/** @odoo-module */
import { useEffect, useState } from "@odoo/owl";
/**
* This hook returns the target detected by the intersection observer.
* @param {ref} root : reference to the element that is the root of the intersection observer
* @param {Object} targets : object with references to each target
* @param {()=>{[boolean]}} getDeps : function that returns a list with one item; this item should be true when the effect should be run and false when it should be stopped
* @param {*} callback : function that is called when the tag is detected
* @returns {{name:string}} : object with the name of the tag that is currently selected
*/
export function useDetection(root, targets, getDeps, callback = () => {}) {
const detected = useState({ name: "" });
useEffect((stop) => {
if (stop) {
return;
}
/** @type{IntersectionObserverEntry[]} */
const allVisible = {};
const observer = new IntersectionObserver(
(entries) => {
let selectedName = "";
entries.forEach((entry) => {
const name = entry.target.firstChild.innerHTML;
if (entry.isIntersecting || entry.intersectionRatio > 0) {
allVisible[name] = entry.intersectionRatio;
} else {
delete allVisible[name];
}
if (Object.keys(allVisible).length > 0) {
selectedName = Object.entries(allVisible).sort((a, b) => {
return b[1] - a[1];
})[0][0];
}
});
if (selectedName) {
detected.name = selectedName;
callback(detected);
}
},
{
root: root.el,
rootMargin: "-100px",
}
);
Object.keys(targets).forEach((tag) => {
observer.observe(targets[tag]?.el);
});
return () => {
observer.disconnect();
};
}, getDeps);
return detected;
}
@@ -0,0 +1,33 @@
/** @odoo-module **/
import { Reactive } from "@web/core/utils/reactive";
import { uuidv4 } from "@point_of_sale/utils";
export class Line extends Reactive {
constructor({
id,
uuid,
product_id,
qty,
customer_note,
full_product_name,
price_subtotal_incl,
price_subtotal,
selected_attributes,
}) {
super();
this.setup(...arguments);
}
setup(line) {
// server data
this.id = line.id || null;
this.uuid = line.uuid || uuidv4();
this.full_product_name = line.full_product_name;
this.product_id = line.product_id;
this.qty = line.qty ? line.qty : 0;
this.customer_note = line.customer_note;
this.price_subtotal_incl = line.price_subtotal_incl || 0;
this.price_subtotal = line.price_subtotal || 0;
this.selected_attributes = line.selected_attributes || [];
}
}
@@ -0,0 +1,79 @@
/** @odoo-module **/
import { Reactive } from "@web/core/utils/reactive";
import { Line } from "./line";
export class Order extends Reactive {
constructor({
id,
access_token,
pos_config_id,
pos_reference,
state,
lines,
date,
amount_total,
amount_tax,
lastChangesSent,
}) {
super();
this.setup(...arguments);
}
setup(order) {
// server only data (recovered after first send to server)
this.id = order.id || null;
this.pos_config_id = order.pos_config_id;
this.access_token = order.access_token || null;
this.pos_reference = order.pos_reference || null;
this.state = order.state || "draft";
this.date = order.date || null;
this.amount_total = order.amount_total || 0;
this.amount_tax = order.amount_tax || 0;
this.lines = order.lines || [];
// data
this.lastChangesSent = order.lastChangesSent || {};
this.initLines();
}
get isAlreadySent() {
return this.id && this.access_token && this.pos_reference ? true : false;
}
get totalQuantity() {
return this.lines.reduce((acc, line) => (acc += line.qty), 0);
}
initLines() {
this.lines = this.lines.map((line) => new Line(line));
}
computelastChangesSent() {
for (const changeIdx in this.lastChangesSent) {
const changeFound = this.lines.find((line) => line.uuid === changeIdx);
if (!changeFound) {
delete this.lastChangesSent[changeIdx];
}
}
this.lastChangesSent = this.lines.reduce((acc, line) => {
acc[line.uuid] = {
qty: line.qty,
};
return acc;
}, {});
}
isChange() {
return this.lines.find((line) => {
const lastSend = this.lastChangesSent[line.uuid];
if (!lastSend) {
return true;
}
return lastSend.qty !== line.qty;
});
}
}
@@ -0,0 +1,42 @@
/** @odoo-module **/
import { Reactive } from "@web/core/utils/reactive";
export class Product extends Reactive {
constructor(
{
price_info,
has_image,
attributes,
name,
product_id,
description_sale,
tag,
is_pos_groupable,
},
selfOrder
) {
super();
this.setup(...arguments);
}
setup(product, selfOrder) {
// server data
this.id = product.product_id;
this.price_info = product.price_info;
this.has_image = product.has_image;
this.attributes = product.attributes;
this.name = product.name;
this.description_sale = product.description_sale;
this.tag = product.tag;
this.is_pos_groupable = product.is_pos_groupable;
// data
this.selfOrder = selfOrder;
}
get prices() {
return this.selfOrder.show_prices_with_tax_included
? this.price_info.price_with_tax
: this.price_info.price_without_tax;
}
}
@@ -1,13 +1,15 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
import { useSelfOrder } from "@pos_self_order/self_order_service";
import { useService } from "@web/core/utils/hooks";
import { formatMonetary } from "@web/views/fields/formatters";
export class LandingPage extends Component {
static template = "pos_self_order.LandingPage";
static props = [];
setup() {
this.selfOrder = useSelfOrder();
this.router = useService("router");
this.formatMonetary = formatMonetary;
this.selfOrder.currentProduct = 0;
}
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.LandingPage" owl="1">
<div class="d-flex flex-column h-100 align-items-center p-4 overflow-auto"
t-attf-style="
background-image: linear-gradient(to bottom, transparent 33%, #000000 99%), url(/menu/get-bg-image/{{selfOrder.pos_config_id}});
background-size: cover;
background-position: center;
">
<div class="d-flex flex-column flex-grow-1 w-100 justify-content-end mb-4">
<t t-foreach="selfOrder.custom_links" t-as="link" t-key="link.id">
<a type="button"
t-on-click="(event) => this.router.customLink(link)"
t-attf-class="btn btn-{{link.style}} py-3 my-1 rounded">
<t t-esc="link.name"/>
</a>
</t>
<t t-if="selfOrder.table and selfOrder.self_order_mode === 'each'">
<hr class="bg-500"/>
<a
type="button"
t-on-click="() => this.router.navigate('orderHistory')"
class="btn btn-outline-light py-3 my-2">
My Orders
</a>
</t>
</div>
<div class="d-flex align-items-center py-2 px-3 text-white-50 small">Powered by
<span class="o_pos_self_order_odoo_logo ms-2 mt-n1">
<svg height="16" viewBox="0 0 621 196" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Odoo Logo</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="odoo_logo" fill-rule="nonzero">
<path d="M546,196 C504.579,196 471,162.421 471,121 C471,79.579 504.579,46 546,46 C587.421,46 621,79.579 621,121 C621,162.421 587.421,196 546,196 Z M546,165 C570.3,165 590,145.3 590,121 C590,96.7 570.3,77 546,77 C521.7,77 502,96.7 502,121 C502,145.3 521.7,165 546,165 Z M389,196 C347.579,196 314,162.421 314,121 C314,79.579 347.579,46 389,46 C430.421,46 464,79.579 464,121 C464,162.421 430.421,196 389,196 Z M389,165 C413.3,165 433,145.3 433,121 C433,96.7 413.3,77 389,77 C364.7,77 345,96.7 345,121 C345,145.3 364.7,165 389,165 Z M307,120 C307,161.935 273.408,196 231.991,196 C190.575,196 157,162.005 157,120.07 C157,78.134 187.5,46 231.991,46 C248.433,46 263.638,49.496 275.998,58.58 L276,15.09 C276,6.756 283.27,0 291.5,0 C299.728,0 307,6.762 307,15.09 L307,120 Z M232,165 C256.3,165 276,145.3 276,121 C276,96.7 256.3,77 232,77 C207.7,77 188,96.7 188,121 C188,145.3 207.7,165 232,165 Z" fill="#FFF"></path>
<path d="M75,196 C33.579,196 0,162.421 0,121 C0,79.579 33.579,46 75,46 C116.421,46 150,79.579 150,121 C150,162.421 116.421,196 75,196 Z M75,165 C99.3,165 119,145.3 119,121 C119,96.7 99.3,77 75,77 C50.7,77 31,96.7 31,121 C31,145.3 50.7,165 75,165 Z" fill="#FFF"></path>
</g>
</g>
</svg>
</span>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,34 @@
/** @odoo-module */
import { Component, onWillStart } from "@odoo/owl";
import { NavBar } from "@pos_self_order/components/navbar/navbar";
import { ProductCard } from "@pos_self_order/components/product_card/product_card";
import { Lines } from "@pos_self_order/components/lines/lines";
import { useSelfOrder } from "@pos_self_order/self_order_service";
import { PriceDetails } from "@pos_self_order/components/price_details/price_details";
import { _t } from "@web/core/l10n/translation";
export class OrderCart extends Component {
static components = { NavBar, ProductCard, Lines, PriceDetails };
static props = [];
static template = "pos_self_order.OrderCart";
setup() {
this.selfOrder = useSelfOrder();
onWillStart(() => {
this.selfOrder.getPricesFromServer();
});
}
get buttonToShow() {
return this.selfOrder.self_order_mode === "each" ? "Pay" : "Order";
}
processOrder() {
if (this.selfOrder.self_order_mode === "meal") {
this.selfOrder.sendDraftOrderToServer();
} else {
this.selfOrder.notification.add(_t("Not yet implemented!"), { type: "danger" });
}
}
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.OrderCart" owl="1">
<div class="d-flex flex-column dvh-100">
<NavBar customText="'Cart'" />
<main class="h-100 bg-100 overflow-auto">
<Lines order="this.selfOrder.currentOrder" />
</main>
<t t-call="pos_self_order.OrderButton"/>
</div>
</t>
<t t-name="pos_self_order.OrderButton" owl="1">
<div class="d-flex flex-column align-items-center w-100 p-3 bg-white shadow-lg">
<t t-set="order" t-value="selfOrder.currentOrder"/>
<PriceDetails tax="order.amount_tax" total="order.amount_total"/>
<button t-on-click="processOrder" t-attf-class="{{ !order.isChange() ? 'disabled' : '' }} o_self_order_main_button btn btn-primary w-100 px-2 py-3 mt-2 rounded">
<t t-esc="buttonToShow" />
<t t-if="!order.isChange()">
already sent
</t>
</div>
</t>
</templates>
@@ -0,0 +1,44 @@
/** @odoo-module */
import { Component, useState } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/self_order_service";
import { ProductCard } from "@pos_self_order/components/product_card/product_card";
import { Lines } from "@pos_self_order/components/lines/lines";
import { PriceDetails } from "@pos_self_order/components/price_details/price_details";
import { NavBar } from "@pos_self_order/components/navbar/navbar";
import { _t } from "@web/core/l10n/translation";
export class OrdersHistory extends Component {
static template = "pos_self_order.OrdersHistory";
static components = { NavBar, ProductCard, Lines, PriceDetails };
static props = [];
setup() {
this.selfOrder = useSelfOrder();
this.state = useState({
loadingProgress: true,
});
this.loadOrder();
}
async loadOrder() {
await this.selfOrder.getOrdersFromServer();
this.state.loadingProgress = false;
}
get orders() {
return this.selfOrder.orders.filter((o) => o.access_token);
}
editOrder(order) {
if (this.selfOrder.self_order_mode === "meal" && order.state === "draft") {
this.selfOrder.editedOrder = order;
this.router.navigate("productList");
} else {
this.selfOrder.notification.add(_t("You cannot edit an posted order!"), {
type: "danger",
});
}
}
}
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.OrdersHistory" owl="1">
<NavBar customText="'Orders'" />
<div class="overflow-auto dvh-100 pb-5">
<t t-if="state.loadingProgress">
<div class="d-flex align-items-center h-100 justify-content-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</t>
<div t-else="">
<t t-foreach="orders" t-as="order" t-key="order.pos_reference">
<div class="o_so_order d-flex flex-column">
<div class="o_so_order_header p-3 bg-white" t-on-click="() => this.editOrder(order)">
<div class="d-flex align-items-center justify-content-between">
<h6 class="m-0"
t-esc="order.pos_reference"/>
<span class="badge p-2 text-bg-primary"
t-esc="order.state"/>
</div>
<p class="small m-0 fst-italic text-muted"
t-esc="order.date"/>
</div>
<div class="o_so_order_body p-3 bg-300">
<Lines t-if="order.lines" order="order" />
<div class="d-flex flex-column align-items-center px-3 pt-3 bg-white">
<PriceDetails tax="order.amount_tax" total="order.amount_total"/>
</div>
</div>
</div>
</t>
<div t-if="orders.length === 0" class="d-flex justify-content-center mt-3">
<div>No order found</div>
</div>
</div>
</div>
</t>
</templates>
@@ -1,16 +1,14 @@
/** @odoo-module */
import { Component, onMounted, useRef, useState } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
import { useAutofocus, useChildRef } from "@web/core/utils/hooks";
import { NavBar } from "@pos_self_order/Components/NavBar/NavBar";
import { ProductCard } from "@pos_self_order/Components/ProductCard/ProductCard";
import { useSelfOrder } from "@pos_self_order/self_order_service";
import { useAutofocus, useChildRef, useService } from "@web/core/utils/hooks";
import { NavBar } from "@pos_self_order/components/navbar/navbar";
import { ProductCard } from "@pos_self_order/components/product_card/product_card";
import { fuzzyLookup } from "@web/core/utils/search";
import { useScrollDirection } from "@pos_self_order/Hooks/useScrollDirection";
import { useDetection } from "@pos_self_order/Hooks/useDetection";
import { useScrollDirection } from "@pos_self_order/hooks/use_scroll_direction";
import { useDetection } from "@pos_self_order/hooks/use_detection";
import { Transition } from "@web/core/transition";
import { TagSelector } from "@pos_self_order/Components/TagSelector/TagSelector";
import { MainButton } from "@pos_self_order/Components/MainButton/MainButton";
export class ProductList extends Component {
static template = "pos_self_order.ProductList";
@@ -19,23 +17,21 @@ export class ProductList extends Component {
NavBar,
ProductCard,
Transition,
TagSelector,
MainButton,
};
setup() {
// reference to the last visited product
// (used to scroll back to it when the user comes back from the product page)
this.selfOrder = useSelfOrder();
this.router = useService("router");
this.productsList = useRef("productsList");
this.currentProductCard = useChildRef();
this.search = useState({
isFocused: false,
input: "",
});
this.selfOrder = useSelfOrder();
useAutofocus({ refName: "input", mobile: true });
this.productsList = useRef("productsList");
// reference to the last visited product
// (used to scroll back to it when the user comes back from the product page)
this.currentProductCard = useChildRef();
// object with references to each product group
this.productGroups = Object.fromEntries(
Array.from(this.selfOrder.tagList).map((tag) => {
return [tag, useRef(`productsWithTag_${tag}`)];
@@ -43,12 +39,11 @@ export class ProductList extends Component {
);
onMounted(() => {
// if the user is coming from the product page
// we scroll back to the product card that he was looking at before
if (this.selfOrder.currentProduct) {
if (this.selfOrder.lastEditedProductId) {
this.scrollTo(this.currentProductCard, { behavior: "instant" });
}
});
// this is used to hide the navbar when the user is scrolling down
this.scroll = useScrollDirection(this.productsList);
@@ -76,6 +71,7 @@ export class ProductList extends Component {
behavior,
});
}
/**
* This function returns the list of products that should be displayed;
* it filters the products based on the search input
@@ -91,6 +87,7 @@ export class ProductList extends Component {
(product) => product.name + product.description_sale
);
}
/**
* This function is called when a tag is clicked;
* @param {string} tag_name
@@ -104,16 +101,12 @@ export class ProductList extends Component {
this.currentProductGroup.name != tag_name ? this.productGroups[tag_name] : null
);
}
/**
* This function is called when the search button is clicked.
*/
focusSearch() {
this.search.isFocused = true;
this.scrollTo();
}
/**
* This function is called when the 'Close Search' button is clicked ( the 'x' button )
*/
closeSearch() {
this.search.isFocused = false;
this.search.input = "";
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.ProductList" owl="1">
<div class="d-flex flex-column dvh-100">
<header class="bg-white shadow-sm">
<NavBar class="shouldNavbarBeShown() ? '' : 'o_self_order_navbar_hide' " />
<div class="o_self_order_searchbar_filter d-flex justify-content-start align-items-center p-0 m-0 border-bottom">
<div t-if="search.isFocused" class="input-group d-flex align-items-center" >
<input type="text"
class="form-control p-3 border-0 shadow-none"
placeholder="Pizza"
t-model="search.input"/>
<div class="px-2">
<button class="btn bg-200 rounded" t-on-click="closeSearch">
<i class="fa fa-times"/>
</button>
</div>
</div>
<t t-else="">
<ul t-ref="tagList" class="nav nav-pills flex-nowrap flex-grow-1 px-2 border-end overflow-auto">
<t t-foreach="Array.from(selfOrder.tagList)" t-as="tag" t-key="tag">
<li class="nav-item mx-1 py-2"
t-on-click="() => this.tagOnClick(tag)">
<span
t-attf-class="user-select-none nav-link py-2 rounded {{currentProductGroup.name === tag ? 'active' : 'text-dark'}}"
t-ref="tag_{{tag}}"
t-esc="tag"/>
</li>
</t>
</ul>
<button class="btn btn-link text-dark px-3" t-on-click="focusSearch">
<i class="oi oi-search px-1"/>
</button>
</t>
</div>
</header>
<!-- the body is set to overflow: hidden and this div is set to overflow-auto
this way we only scroll on the product list, and not on the whole page, which includes the navbar -->
<main t-ref="productsList" class="bg-white overflow-auto d-flex flex-row flex-wrap">
<t t-if="search.isFocused">
<t t-set="filteredProducts" t-value="filteredProducts()"/>
<p t-if="!filteredProducts?.length" class="mx-auto mt-3 text-center">No products found</p>
<t t-foreach="filteredProducts" t-as="product" t-key="product.id">
<ProductCard product="product"
class="'w-md-50 w-xl-25'"/>
</t>
</t>
<t t-else="">
<t t-set="tags" t-value="Object.keys(selfOrder.productsGroupedByTag)" />
<p t-if="!tags?.length" class="mx-auto mt-3 text-center">No products found</p>
<t t-foreach="tags" t-as="tag" t-key="tag">
<section t-if="selfOrder.productsGroupedByTag[tag].length" t-ref="productsWithTag_{{tag}}" class="w-100 d-flex flex-wrap">
<h3 class="w-100 m-0 mt-n1 pb-3 pt-4 px-3 bg-light" t-esc="tag" />
<t t-foreach="selfOrder.productsGroupedByTag[tag]" t-as="product" t-key="product.id">
<!-- If this is the product that we last visited, we use currentProductCard to
get a reference to it -->
<ProductCard product="product"
currentProductCard="product.id === selfOrder.lastEditedProductId and currentProductCard"
class="'w-md-50 w-xl-25'"/>
</t>
</section>
</t>
</t>
</main>
<!-- we use flex-grow-1 to push the button to the bottom of the page
( normally there would be many products, so the page would be long enough to push the button to the bottom,
but if there are no products (for example if you search for something that does not exist), the button
would be at the top of the page )
-->
<div class="flex-grow-1"></div>
<div t-if="selfOrder.table and selfOrder.currentOrder.totalQuantity" class="d-flex flex-column align-items-center w-100 p-3 bg-white shadow-lg">
<t t-set="order" t-value="selfOrder.currentOrder"/>
<button t-on-click="() => this.router.navigate('cart')" class="o_self_order_main_button btn btn-primary w-100 px-2 py-3 mt-2 rounded">
<div class="d-flex align-items-center justify-content-between mx-2">
<i class="fa fa-shopping-cart pe-2" aria-hidden="true"/>
<p class="m-0">Review (<t t-esc="order.totalQuantity" />)</p>
<span t-if="!selfOrder.priceLoading" class="ms-auto" t-esc="selfOrder.formatMonetary(order.amount_total)"/>
<span t-else="" class="spinner-border o-self-order-spinner-custom-height ms-auto"></span>
</div>
</button>
</div>
</div>
</t>
<t t-name="pos_self_order.SearchBox" owl="1">
<div class="input-group d-flex align-items-center">
<input type="text"
class="form-control p-3 border-0 shadow-none"
placeholder="Pizza"
t-model="search.input"/>
<div class="px-2">
<button class="btn bg-200 rounded" t-on-click="closeSearch">
<i class="fa fa-times"/>
</button>
</div>
</div>
</t>
<t t-name="pos_self_order.SearchIcon" owl="1">
<button class="btn btn-link text-dark px-3" t-on-click="focusSearch">
<i class="oi oi-search px-1"/>
</button>
</t>
</templates>
@@ -0,0 +1,143 @@
/** @odoo-module */
import { Component, onWillUnmount, useState } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/self_order_service";
import { NavBar } from "@pos_self_order/components/navbar/navbar";
import { Product } from "../../models/product";
import { Line } from "../../models/line";
import { useService } from "@web/core/utils/hooks";
export class ProductMainView extends Component {
static template = "pos_self_order.ProductMainView";
static props = { product: { type: Product, optional: true } };
static components = {
NavBar,
};
setup() {
this.selfOrder = useSelfOrder();
this.router = useService("router");
if (!this.props.product) {
this.router.navigate("productList");
return;
}
this.product = this.props.product;
this.selfOrder.lastEditedProductId = this.product.id;
this.state = useState({
qty: 1,
customer_note: "",
selectedVariants: [],
cartQty: 0,
});
onWillUnmount(() => {
this.selfOrder.editedLine = null;
});
this.initState();
}
initState() {
const editedLine = this.selfOrder.editedLine;
if (editedLine) {
this.state.customer_note = editedLine.customer_note;
this.state.cartQty = editedLine.qty - 1;
if (editedLine.selected_attributes) {
this.state.selectedVariants = editedLine.selected_attributes;
}
} else {
this.state.selectedVariants = this.product.attributes.reduce((acc, curr) => {
acc[curr.name] = curr.values[0].name;
return acc;
}, {});
}
return 0;
}
get fullProductName() {
const productAttributeString = Object.values(this.state.selectedVariants).join(", ");
let name = `${this.product.name}`;
if (productAttributeString) {
name += ` (${productAttributeString})`;
}
return name;
}
changeQuantity(increase) {
const currentQty = this.state.qty + this.state.cartQty;
const lastOrderSent = this.selfOrder.currentOrder.lastChangesSent;
const sentQty = lastOrderSent[this.selfOrder.editedLine?.uuid]?.qty;
if (!increase && currentQty === 0) {
return;
}
if (!increase && sentQty === currentQty) {
this.selfOrder.notification.add(
this.env._t(
"You cannot reduce the quantity of an order that has already been sent!"
),
{ type: "danger" }
);
return;
}
return increase ? this.state.qty++ : this.state.qty--;
}
orderlineCanBeMerged(newLine) {
const editedLine = this.selfOrder.editedLine;
if (editedLine) {
return editedLine;
}
const line = this.selfOrder.currentOrder.lines.find(
(l) =>
l.full_product_name === this.fullProductName &&
l.customer_note === this.state.customer_note &&
l.product_id === this.product.id
);
return line ? line : false;
}
async addToCart() {
const lines = this.selfOrder.currentOrder.lines;
const lineToMerge = this.orderlineCanBeMerged();
if (lineToMerge) {
const editedLine = this.selfOrder.editedLine;
const gap = editedLine ? -1 : 0;
lineToMerge.selected_attributes = this.state.selectedVariants;
lineToMerge.customer_note = this.state.customer_note;
lineToMerge.full_product_name = this.fullProductName;
lineToMerge.qty += this.state.qty + gap;
} else {
lines.push(
new Line({
id: lineToMerge ? lineToMerge.id : null,
uuid: lineToMerge ? lineToMerge.uuid : null,
qty: this.state.qty,
product_id: this.product.id,
full_product_name: this.fullProductName,
customer_note: this.state.customer_note,
selected_attributes: this.state.selectedVariants,
})
);
}
// If a command line does not have a quantity greater than 0, we consider it deleted
await this.selfOrder.getPricesFromServer();
this.selfOrder.currentOrder.lines = lines.filter((o) => o.qty > 0);
this.router.back();
}
}
@@ -0,0 +1,4 @@
.o_self_order_incr_button button {
width: 40px;
height: 40px;
}
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.ProductMainView" owl="1">
<div class="d-flex flex-column dvh-100">
<NavBar />
<div class="bg-white d-flex flex-column pt-3 overflow-auto h-100">
<div class="px-3">
<img t-if="props.product.has_image"
class="w-100 rounded-3"
style="max-height: 400px; object-fit: cover;"
t-attf-src="/menu/get-image/{{ props.product.id }}/1920"
alt="Product image"/>
<div class="pt-3">
<h3 t-esc="props.product.name"
class="o_self_order_product_main_view_name"
/>
<h4 class="text-primary"
t-esc="selfOrder.formatMonetary(props.product.price_info.list_price)"/>
</div>
</div>
<div t-if="props.product.description_sale"
class="o_self_order_main_desc px-3 pb-3 text-muted"
t-esc="props.product.description_sale"
/>
<div class="o_self_order_main_options px-3 py-4 border-bottom bg-100">
<t t-if="props.product.attributes"
t-foreach="props.product.attributes"
t-as="attribute"
t-key="attribute.id">
<div class="mb-3">
<div class="attribute_name py-1 fs-5 fw-bold" t-esc="attribute.name"/>
<t t-if="attribute.display_type === 'radio' or attribute.display_type === 'pills'">
<t t-foreach="attribute.values" t-as="value" t-key="value.id">
<div class="d-flex align-items-center justify-content-between py-2">
<input
type="radio"
class="form-check-input my-1 me-2"
t-att-name="attribute.id"
t-att-value="value.name"
t-attf-id="{{ attribute.id }}_{{ value.id }}"
t-model="state.selectedVariants[attribute.name]"
/>
<span class="flex-grow-1">
<t t-esc="value.name"/>
<t t-if="value.price_extra.list_price">
+ <t t-esc="selfOrder.formatMonetary(value.price_extra.list_price)" />
</t>
</span>
</div>
</t>
</t>
<select t-elif="attribute.display_type === 'select'" class="configurator_select form-select"
t-model="state.selectedVariants[attribute.name]">
<option t-foreach="attribute.values" t-as="value" t-key="value.id" t-att-value="value.name">
<t t-esc="value.name"/>
<t t-if="value.price_extra.list_price">
+ <t t-esc="selfOrder.formatMonetary(value.price_extra.list_price)" />
</t>
</option>
</select>
<div t-elif="attribute.display_type === 'color'">
<ul>
<li t-foreach="attribute.values" t-as="value" t-key="value.id">
<label
t-attf-style="background-color: {{ value.html_color }};"
t-att-data-color="value.name">
<input
type="radio"
class="form-check-input my-1 me-2"
t-att-name="attribute.id"
t-att-value="value.name"
t-attf-id="{{ attribute.id }}_{{ value.id }}"
t-model="state.selectedVariants[attribute.name]"
/>
</label>
<span class="flex-grow-1">
<t t-esc="value.name"/>
<t t-if="value.price_extra.list_price">
+ <t t-esc="selfOrder.formatMonetary(value.price_extra.list_price)" />
</t>
</span>
</li>
</ul>
</div>
</div>
</t>
<div t-if="selfOrder.table">
<label class="form-label fw-bold" for="note">Add a note:</label>
<textarea class="form-control rounded bg-white" id="note" type="textarea" rows="1" placeholder="No onions please" t-model="state.customer_note"/>
</div>
</div>
</div>
<div t-if="selfOrder.table" class="d-flex flex-column align-items-center w-100 p-3 bg-white shadow-lg">
<div class="o_self_order_incr_button btn-group text-center border-light mb-1" role="group" aria-label="Quantity select">
<button type="button"
t-on-click = "() => this.changeQuantity(false)"
class="btn btn-light rounded-circle"> - </button>
<div class="d-flex align-items-center px-4 fw-bold" t-esc="state.qty + state.cartQty"/>
<button type="button"
t-on-click = "() => this.changeQuantity(true)"
class="btn btn-light rounded-circle"> + </button>
</div>
<button t-on-click="addToCart" class="o_self_order_main_button btn btn-primary w-100 px-2 py-3 mt-2 rounded">
<t t-if="this.selfOrder.editedLine">
Update Cart
</t>
<t t-else="">
Add to Cart
</t>
</button>
</div>
</div>
</t>
</templates>
@@ -10,9 +10,13 @@ body {
}
.dvh-100{
// we specify the 100vh as a fallback in case the browser does not
// we specify the 100vh as a fallback in case the browser does not
// know dvh
height: 100vh !important;
height: 100dvh !important;
}
.o-self-order-spinner-custom-height {
height: 1rem;
width: 1rem;
}
+33 -49
View File
@@ -1,10 +1,10 @@
/** @odoo-module */
import { Component, useState, useSubEnv, xml } from "@odoo/owl";
import { Component, onWillRender, useState, xml } from "@odoo/owl";
import { escapeRegExp } from "@web/core/utils/strings";
import { browser } from "@web/core/browser/browser";
import { zip } from "@web/core/utils/arrays";
import { useSelfOrder } from "./SelfOrderService";
import { useSelfOrder } from "./self_order_service";
import { useService } from "@web/core/utils/hooks";
function parseParams(matches, paramSpecs) {
return Object.fromEntries(
@@ -21,78 +21,62 @@ function parseParams(matches, paramSpecs) {
})
);
}
export class Router extends Component {
static props = { slots: Object, pos_config_id: Number };
static template = xml`<t t-slot="{{state.activeSlot}}" t-props="state.slotProps"/>`;
static template = xml`<t t-slot="{{activeSlot}}" t-props="slotProps"/>`;
setup() {
this.selfOrder = useSelfOrder();
this.selfOrder.navigate = this.navigate.bind(this);
useSubEnv({
navigate: this.navigate.bind(this),
getCurrentRoute: this.getCurrentRoute.bind(this),
});
this.state = useState({
activeSlot: "default",
slotProps: {},
});
// this is needed for the back button to work
window.addEventListener("popstate", (event) => {
this.matchURL();
});
this.routes = Object.keys(this.props.slots).map((route) => {
this.router = useState(useService("router"));
this.activeSlot = "default";
this.slotProps = {};
this.routes = {};
for (const [routeName, slot] of Object.entries(this.props.slots)) {
const route = slot.route;
const paramStrings = route.match(/\{\w+:\w+\}/g);
if (!paramStrings) {
return { route, paramSpecs: [], regex: new RegExp(`^${route}$`) };
this.routes[routeName] = { route, paramSpecs: [], regex: new RegExp(`^${route}$`) };
continue;
}
const paramSpecs = paramStrings.map((paramString) => {
const [, type, name] = paramString.match(/(\w+):(\w+)/);
return { type, name };
});
const regex = new RegExp(
`^${route
.split(/\{\w+:\w+\}/)
.map((part) => escapeRegExp(part))
.join("([^/]+)")}$`
);
return { route, paramSpecs, regex };
this.routes[routeName] = { route, paramSpecs, regex };
}
this.router.registerRoutes(this.routes);
onWillRender(() => {
this.matchURL();
});
this.matchURL();
}
matchURL() {
const path = browser.location.pathname;
for (const { route, paramSpecs, regex } of this.routes) {
const path = this.router.path;
for (const [routeName, { paramSpecs, regex }] of Object.entries(this.routes)) {
const match = path.match(regex);
if (match) {
const parsedParams = parseParams(match.slice(1), paramSpecs);
this.state.activeSlot = route;
this.state.slotProps = parsedParams;
this.activeSlot = routeName;
this.slotProps = parsedParams;
return;
}
}
this.state.activeSlot = "default";
this.state.slotProps = {};
}
/**
* Navigate to the given relative route.
* We use the history API to navigate to it.
* (this means that we don't make additional requests to the server)
* @param {string} route
* @param {number} pos_config_id
*/
navigate(route, pos_config_id = this.props.pos_config_id) {
const url = new URL(browser.location.href);
url.pathname = `menu/${pos_config_id}${route}`;
history.pushState({}, "", url);
this.matchURL();
}
/**
* @returns {string[]}
*/
getCurrentRoute() {
const baseLength = "/menu/{string:pos_name}".split("/").length;
// The base part of the route is there on all routes, so it
// makes no sense to have it in the return
return this.state.activeSlot.split("/").splice(baseLength);
this.router.navigate("default");
}
}
@@ -0,0 +1,50 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { effect } from "@web/core/utils/reactive";
export class SelfOrderBus {
static serviceDependencies = ["self_order", "orm", "bus_service"];
constructor(...args) {
this.setup(...args);
}
setup(env, { self_order, orm, bus_service }) {
this.selfOrder = self_order;
this.orm = orm;
this.bus = bus_service;
effect(
(orders) => {
for (const order of orders) {
if (order.access_token) {
this.bus.addChannel(`self_order-${order.access_token}`);
}
}
},
[this.selfOrder.orders]
);
bus_service.addEventListener("notification", ({ detail }) => {
for (const message of detail) {
this.dispatch(message);
}
});
}
dispatch(message) {
if (message.type === "ORDER_PAID") {
console.log("ORDER_PAID", message.payload);
}
}
}
export const SelfOrderBusService = {
dependencies: SelfOrderBus.serviceDependencies,
async start(env, deps) {
return new SelfOrderBus(env, deps);
},
};
registry.category("services").add("self_order_bus_service", SelfOrderBusService);
@@ -3,15 +3,15 @@ import { Component, whenReady, App } from "@odoo/owl";
import { makeEnv, startServices } from "@web/env";
import { setLoadXmlDefaultApp, templates } from "@web/core/assets";
import { _t } from "@web/core/l10n/translation";
import { LandingPage } from "@pos_self_order/Pages/LandingPage/LandingPage";
import { NavBar } from "@pos_self_order/Components/NavBar/NavBar";
import { ProductMainView } from "@pos_self_order/Pages/ProductMainView/ProductMainView";
import { CartView } from "@pos_self_order/Pages/CartView/CartView";
import { ProductList } from "@pos_self_order/Pages/ProductList/ProductList";
import { OrdersView } from "@pos_self_order/Pages/OrdersView/OrdersView";
import { LandingPage } from "@pos_self_order/pages/landing_page/landing_page";
import { NavBar } from "@pos_self_order/components/navbar/navbar";
import { ProductMainView } from "@pos_self_order/pages/product_main_view/product_main_view";
import { OrderCart } from "@pos_self_order/pages/order_cart/order_cart";
import { ProductList } from "@pos_self_order/pages/product_list/product_list";
import { OrdersHistory } from "@pos_self_order/pages/orders_history/orders_history";
import { Router } from "@pos_self_order/router";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { useSelfOrder } from "./SelfOrderService";
import { useSelfOrder } from "./self_order_service";
class SelfOrderRoot extends Component {
static template = "pos_self_order.SelfOrderRoot";
@@ -22,8 +22,8 @@ class SelfOrderRoot extends Component {
NavBar,
ProductList,
Router,
CartView,
OrdersView,
OrderCart,
OrdersHistory,
MainComponentsContainer,
};
/*
@@ -35,8 +35,8 @@ class SelfOrderRoot extends Component {
1. ProductList -- the screen that shows the list of products ( the menu )
2. ProductMainView -- the screen that shows the details of a product ( the product page )
3. CartView -- the screen that shows the cart
4. OrdersView -- the screen that shows the list of orders
3. OrderCart -- the screen that shows the cart
4. OrdersHistory -- the screen that shows the list of orders
*/
setup() {
this.selfOrder = useSelfOrder();
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.SelfOrderRoot" owl="1">
<Router pos_config_id="selfOrder.pos_config_id">
<t t-set-slot="default" route="`/menu/${selfOrder.pos_config_id}`">
<LandingPage />
</t>
<t t-set-slot="productList" route="`/menu/${selfOrder.pos_config_id}/products`">
<ProductList />
</t>
<t t-set-slot="product" route="`/menu/${selfOrder.pos_config_id}/products/{int:product_id}`" t-slot-scope="url">
<ProductMainView product="selfOrder.productByIds[url.product_id]"/>
</t>
<t t-set-slot="cart" route="`/menu/${selfOrder.pos_config_id}/cart`">
<OrderCart />
</t>
<t t-set-slot="orderHistory" route="`/menu/${selfOrder.pos_config_id}/orders`">
<OrdersHistory />
</t>
</Router>
<MainComponentsContainer />
</t>
</templates>
@@ -0,0 +1,67 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { Reactive } from "@web/core/utils/reactive";
import { browser } from "@web/core/browser/browser";
export class SelfOrderRouter extends Reactive {
static serviceDependencies = [];
constructor(...args) {
super(...args);
this.setup(...args);
}
setup(env) {
this.path = window.location.pathname;
this.registeredRoutes = {};
window.addEventListener("popstate", (event) => {
this.path = window.location.pathname;
});
}
back() {
history.back();
this.path = window.location.pathname;
}
/**
* Navigate to the given relative route.
* We use the history API to navigate to it.
* (this means that we don't make additional requests to the server)
* @param {string} route
*/
navigate(routeName, routeParams = {}) {
const { route } = this.registeredRoutes[routeName];
const url = new URL(browser.location.href);
url.pathname = route.replace(/\{\w+:(\w+)\}/g, (match, paramName) => {
return routeParams[paramName];
});
history.pushState({}, "", url);
this.path = window.location.pathname;
}
registerRoutes(routes) {
Object.assign(this.registeredRoutes, routes);
}
customLink(link) {
const url = new URL(browser.location.href);
url.pathname = link.url;
history.pushState({}, "", url);
this.path = window.location.pathname;
}
}
export const SelfOrderRouterService = {
dependencies: SelfOrderRouter.serviceDependencies,
async start(env, deps) {
return new SelfOrderRouter(env, deps);
},
};
registry.category("services").add("router", SelfOrderRouterService);
@@ -0,0 +1,228 @@
/** @odoo-module */
import { useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { session } from "@web/session";
import { formatMonetary } from "@web/views/fields/formatters";
import { _t } from "@web/core/l10n/translation";
import { groupBy } from "@web/core/utils/arrays";
import { effect } from "@web/core/utils/reactive";
import { Order } from "./models/order";
import { Product } from "./models/product";
import { ConnectionLostError, RPCError } from "@web/core/network/rpc_service";
export class SelfOrder {
constructor(...args) {
this.setup(...args);
}
setup(env, rpc, notification, router) {
Object.assign(this, {
...session.pos_self_order_data,
});
this.env = env;
this.router = router;
this.rpc = rpc;
this.orders = [];
this.tagList = new Set();
this.editedOrder = null;
this.productByIds = {};
this.priceLoading = false;
this.currentProduct = 0;
this.lastEditedProductId = null;
this.productsGroupedByTag = {};
this.notification = notification;
this.initData();
if (this.self_order_mode !== "qr_code") {
effect((state) => this.saveOrderToLocalStorage(state.orders), [this]);
}
if (!this.has_active_session && this.self_order_mode !== "qr_code") {
this.closeNotification = this.notification.add(
_t("The restaurant is closed. You can browse the menu, but ordering is disabled."),
{ type: "warning" }
);
}
}
initData() {
this.products = this.products.map((p) => {
const product = new Product(p, this);
this.tagList.add(product.tag);
this.productByIds[product.id] = product;
return product;
});
if (this.self_order_mode !== "qr_code") {
const orders = JSON.parse(localStorage.getItem("orders")) ?? [];
this.orders.push(
...orders.map((o) => {
o.lines = o.lines.filter((l) => this.productByIds[l.product_id]);
return new Order(o);
})
);
}
this.productsGroupedByTag = groupBy(this.products, "tag");
}
saveOrderToLocalStorage(orders) {
Array.isArray(orders) && localStorage.setItem("orders", JSON.stringify(orders));
}
// In case of self_order_mode === "meal", we keep the same order until the user pays
// In case of self_order_mode === "each", we create a new order each time the user orders somethings
get currentOrder() {
if (this.editedOrder) {
return this.editedOrder;
}
const existingOrder = this.orders.find(
(o) => (this.self_order_mode === "each" && !o.access_token) || o.state === "draft"
);
if (!existingOrder) {
const newOrder = new Order({
pos_config_id: this.pos_config_id,
});
this.orders.push(newOrder);
this.editedOrder = newOrder;
} else {
this.editedOrder = existingOrder;
}
return this.editedOrder;
}
formatMonetary(price) {
return formatMonetary(price, { currencyId: this.currency_id });
}
async sendDraftOrderToServer() {
try {
let order = {};
if (this.currentOrder.isAlreadySent) {
order = await this.rpc(`/pos-self-order/update-existing-order`, {
order: this.currentOrder,
});
} else {
order = await this.rpc(`/pos-self-order/process-new-order`, {
order: this.currentOrder,
table_access_token: this?.table?.access_token,
});
}
this.editedOrder = Object.assign(this.editedOrder, order);
this.editedOrder.computelastChangesSent();
if (this.self_order_mode === "each") {
this.editedOrder = null;
}
this.notification.add(_t("Your order has been placed!"), { type: "success" });
} catch (error) {
this.handleErrorNotification(error, [this.editedOrder.access_token]);
} finally {
this.router.navigate("default");
}
}
async getOrdersFromServer() {
const accessTokens = this.orders.map((order) => order.access_token);
try {
const orderChanges = {};
const orders = await this.rpc(`/pos-self-order/get-orders/`, {
access_tokens: accessTokens,
});
this.orders = this.orders.filter((order) => {
orderChanges[order.access_token] = order.lastChangesSent;
return !order.access_token;
});
this.orders.push(
...orders.map((o) => {
const newOrder = new Order({
...o,
lastChangesSent: orderChanges[o.access_token],
});
return newOrder;
})
);
this.editedOrder = null;
} catch (error) {
this.handleErrorNotification(
error,
this.orders.map((order) => order.access_token)
);
}
}
async getPricesFromServer() {
this.priceLoading = true;
try {
const taxes = await this.rpc(`/pos-self-order/get-orders-taxes/`, {
order: this.currentOrder,
pos_config_id: this.pos_config_id,
});
for (const line of this.editedOrder.lines) {
const lineTaxes = taxes.lines.find((ol) => ol.uuid === line.uuid);
line.price_subtotal = lineTaxes.price_subtotal;
line.price_subtotal_incl = lineTaxes.price_subtotal_incl;
}
this.editedOrder.amount_total = taxes.amount_total;
this.editedOrder.amount_tax = taxes.amount_tax;
} catch (error) {
this.handleErrorNotification(error);
}
this.priceLoading = false;
}
handleErrorNotification(error, accessToken = []) {
let message = _t("An error has occurred");
if (error instanceof RPCError) {
if (error.data.name === "werkzeug.exceptions.Unauthorized") {
message = _t("You're not authorized to perform this action");
} else if (error.data.name === "werkzeug.exceptions.NotFound") {
message = _t("Orders wasn't found on the server");
}
} else if (error instanceof ConnectionLostError) {
message = _t("Connection lost, please try again later");
}
this.notification.add(message, {
type: "danger",
});
if (accessToken) {
this.editedOrder = null;
this.orders = this.orders.filter((o) => !accessToken.includes(o.access_token));
}
}
}
export const selfOrderService = {
dependencies: ["rpc", "notification", "router"],
async start(env, { rpc, notification, router }) {
return new SelfOrder(env, rpc, notification, router);
},
};
registry.category("services").add("self_order", selfOrderService);
export function useSelfOrder() {
return useState(useService("self_order"));
}
+14
View File
@@ -0,0 +1,14 @@
/** @odoo-module */
export const extractProductNameAndAttributes = (str) => {
const regex = /\(([^()]+)\)[^(]*$/;
const matches = str.match(regex);
if (matches && matches.length > 1) {
const attributes = matches[matches.length - 1].trim();
const productName = str.replace(matches[0], "").trim();
return { productName, attributes };
}
return { productName: str, attributes: "" };
};
@@ -1,141 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { clickOn } from "./tour_utils";
registry.category("web_tour.tours").add("pos_qr_menu_tour", {
test: true,
steps: [
{
content: "Close the `Pos is Closed` notification",
trigger: "button.o_notification_close",
},
...clickOn("My Orders", { isCheck: true, isNot: true }),
...clickOn("View Menu"),
{
content: "Test that products are present",
trigger: ".o_self_order_item_card",
isCheck: true,
},
...clickOn("Add to Cart", { isCheck: true, isNot: true }),
{
content: "Test that the 'No products found' message is not present",
trigger: "body:not(:has(p:contains('No products found')))",
isCheck: true,
},
// We should be on the products screen now
{
content: "Test that the tag list is present",
trigger: ".o_self_order_searchbar_filter li",
isCheck: true,
},
{
content: "Test that the first tag is active",
trigger: ".o_self_order_searchbar_filter li:nth-child(1) span.active",
isCheck: true,
},
{
content: "Test that the search icon is present",
trigger: ".oi.oi-search",
},
{
content: "Test that the search icon is not present after clicking on it",
trigger: "body:not(:has(.oi.oi-search))",
isCheck: true,
},
{
content: "Test that the tag list is not present after clicking on the search icon",
trigger: "body:not(:has(.o_search_bar_filter li))",
isCheck: true,
},
{
content: "Test that the search box is visible after clicking on the search icon",
trigger: "input[placeholder='Pizza']",
run: "text Desk",
},
{
content: "Test that the search results are present",
trigger: ".o_self_order_item_card",
isCheck: true,
},
{
content: "Test that the 'No products found' message is not present",
trigger: "body:not(:has(p:contains('No products found')))",
isCheck: true,
},
{
trigger: "input",
run: "text xyzxyzxyzxyxyxyxyxyxzxyxyxyxyxyxyxyxyyxzyxzyxzyxyzxyzyx",
},
{
content: "Test that no products are present",
trigger: "body:not(:has(.o_self_order_item_card))",
isCheck: true,
},
{
content: "Test that the 'No products found' message is present",
trigger: "p:contains('No products found')",
isCheck: true,
},
{
content: "Test that the button to exit the search box is present and click on it",
trigger: ".fa.fa-times",
},
{
content: "Test that the search icon is present again",
trigger: ".oi.oi-search",
isCheck: true,
},
{
content: "Test that the tag list is present and click on the 2nd tag",
trigger: ".o_self_order_searchbar_filter li:nth-child(2)",
},
{
content: "Test that the tag is highlighted and click on it to deactivate it",
trigger: ".o_self_order_searchbar_filter li:nth-child(2) span.active",
},
{
content: "Test that the tag is not highlighted anymore",
trigger: ".o_self_order_searchbar_filter li:nth-child(2)",
isCheck: true,
},
// After clicking on the 2nd tag, it will be deactivated and the screen should scroll up to the top
// when we reach the top of the screen, the intersection observer shoould trigger and select the 1st tag
// we check that the 1st tag is now active <-- this would mean that the intersection observer is working properly
{
content: "Test that the first tag is active",
trigger: ".o_self_order_searchbar_filter li:nth-child(1) span.active",
isCheck: true,
},
{
content: "Test that the product description is present and click on it",
trigger: "p.o_self_order_item_card_description",
},
// We should now be on the product screen
{
content: "Test that the product name is present in the product screen",
trigger: ".o_self_order_product_main_view_name",
isCheck: true,
},
...clickOn("Add", { isCheck: true, isNot: true }),
{
content: "Test that the back button is present on the product screen and click on it",
trigger: "nav.o_self_order_navbar > button",
},
// now that we are back on the product list screen, we click the back button again to go to the landing page
{
content:
"Test that the back button is present on the product list screen and click on it",
trigger: "nav.o_self_order_navbar > button",
},
// on the landing page, we look for the View Menu button
// finding it also means that the back button works properly
...clickOn("View Menu", { isCheck: true }),
{
content: "Test that the back button is not present on the landing page",
trigger: "body:not(:has(nav.o_self_order_navbar > button))",
isCheck: true,
},
],
});
@@ -1,64 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { clickOn, clickOnBackButton, clickOnProductCard, addProductsToCart } from "./tour_utils";
registry.category("web_tour.tours").add("self_order_tour", {
test: true,
steps: [
{
content: "Check that the `Pos is Closed` notification is not present",
trigger: "body:not(:has(.o_notification_content:contains(restaurant is closed)))",
isCheck: true,
},
...clickOn("My Orders", { isCheck: true, isNot: true }),
...clickOn("View Menu"),
// We should now be on the product list screen
...addProductsToCart([1, 2]),
// this will allow us to test if the merging of orderlines works
...addProductsToCart([1]),
...clickOn("Review"),
// check that the products are in the card
...[1, 2].map((id) => clickOnProductCard(id, { isCheck: true })).flat(),
...clickOnProductCard(1, { isCheck: true, qty: 2 }),
...clickOn("Order"),
// We should now be on the landing page screen ( because ordering redirects to the landing page )
...clickOn("My Orders"),
{
content: "Check if the status of the first order is `Draft`",
trigger: "span.badge:contains('draft')",
isCheck: true,
},
...[1, 2].map((id) => clickOnProductCard(id, { isCheck: true })).flat(),
...clickOnProductCard(1, { isCheck: true, qty: 2 }),
...clickOnBackButton(),
// We should now be on the Landing Page
// We will now repeat the same steps as above, ordering again.
// The idea is to test that the previous order is not present in the cart
// and that the previous order is present in the `My Orders` screen
// along with the new order.
...clickOn("View Menu"),
// We should now be on the product list screen
...addProductsToCart([2, 3, 4]),
...clickOn("Review"),
// We should now be on the cart screen
...[1].map((id) => clickOnProductCard(id, { isCheck: true, isNot: true })).flat(),
...[2, 3, 4].map((id) => clickOnProductCard(id, { isCheck: true })).flat(),
...clickOn("Order"),
// We should now be on the landing page screen
...clickOn("My Orders"),
// We should now be on the orders screen
// if the product 2 has qty 2, then the order was merged correctly
...[1, 2].map((id) => clickOnProductCard(id, { isCheck: true, qty: 2 })).flat(),
...[3, 4].map((id) => clickOnProductCard(id, { isCheck: true, qty: 1 })).flat(),
],
});
@@ -0,0 +1,82 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { PosSelf } from "./tour_utils";
registry.category("web_tour.tours").add("self_order_after_each_cart_tour", {
test: true,
steps: [
// Verify if the self is open and My Orders is not displayed because we are in "meal" mode
PosSelf.check.isNotNotification(),
PosSelf.check.isPrimaryBtn("My Orders"),
PosSelf.action.clickPrimaryBtn("My Orders"),
PosSelf.check.isNotification("found on the server"),
PosSelf.action.clickBack(),
PosSelf.action.clickPrimaryBtn("View Menu"),
// Add some products
...PosSelf.action.addProduct("Office Chair Black", 1),
...PosSelf.action.addProduct("Office Chair Black", 2, "Description"),
...PosSelf.action.addProduct("Large Cabinet", 2),
PosSelf.action.clickPrimaryBtn("Review"),
// Here we're looking at whether when an orderline is clicked on from
// the cart and edited, the changes are made to the orderline clicked on.
PosSelf.check.isOrderline("Office Chair Black", "138.58", ""),
...PosSelf.action.editOrderline("Office Chair Black", "138.58", "", 5, "no wheels"),
PosSelf.check.isOrderline("Office Chair Black", "692.90", "no wheels"),
...PosSelf.action.editOrderline("Office Chair Black", "692.90", "no wheels", -4, "kidding"),
PosSelf.check.isOrderline("Office Chair Black", "138.58", "kidding"),
PosSelf.action.clickBack(),
// Once an order has been sent to the server, the user can no
// longer reduce the quantity of his orderlines. We check this behaviour.
PosSelf.action.clickPrimaryBtn("Review"),
...PosSelf.action.editOrderline("Large Cabinet", "736.00", "", -1),
PosSelf.check.isOrderline("Large Cabinet", "368.00", ""),
// Here we look at whether the user can reduce the quantity
// and delete a product that has not yet been sent
PosSelf.action.clickBack(),
...PosSelf.action.addProduct("Funghi", 1),
PosSelf.action.clickPrimaryBtn("Review"),
...PosSelf.action.editOrderline("Funghi", "8.05", "", -1),
PosSelf.check.isNotOrderline("Funghi", "8.05", ""),
// Here we're looking at whether adding a product with the same description
// as the same product already in the basket merges the two products.
// We also check whether we can reduce the quantity of the part not yet sent to
// the server and whether the quantity of the part sent to the server is fixed.
PosSelf.action.clickBack(),
...PosSelf.action.addProduct("Office Chair Black", 1, "kidding"),
PosSelf.action.clickPrimaryBtn("Review"),
PosSelf.check.isOrderline("Office Chair Black", "277.16", "kidding"),
...PosSelf.action.editOrderline("Office Chair Black", "277.16", "kidding", -1),
PosSelf.check.isOrderline("Office Chair Black", "138.58", "kidding"),
...PosSelf.action.editOrderline("Office Chair Black", "138.58", "kidding", -1),
PosSelf.check.isNotOrderline("Office Chair Black", "138.58", ""),
PosSelf.action.clickBack(),
// Here we check that the product attributes are correctly selected.
...PosSelf.action.addProduct("Desk Organizer", 1, "kidding", {
radio: { name: "Size", value: "M" },
select: { name: "Fabric", value: "Leather" },
}),
...PosSelf.action.addProduct("Desk Organizer", 2, "okkk", {
radio: { name: "Size", value: "L" },
select: { name: "Fabric", value: "Custom" },
}),
PosSelf.action.clickPrimaryBtn("Review"),
PosSelf.check.isOrderline("Desk Organizer", "5.87", "kidding", "M, Leather"),
PosSelf.check.isOrderline("Desk Organizer", "11.74", "okkk", "L, Custom"),
// Check if we can edit the product attributes, and if the changes are made to the orderline
...PosSelf.action.editOrderline("Desk Organizer", "5.87", "kidding", 0, "dav", {
radio: { name: "Size", value: "S" },
select: { name: "Fabric", value: "Custom" },
}),
PosSelf.check.isOrderline("Desk Organizer", "5.87", "dav", "S, Custom"),
PosSelf.check.isOrderline("Desk Organizer", "11.74", "okkk", "L, Custom"),
PosSelf.action.clickBack(),
],
});
@@ -0,0 +1,88 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { PosSelf } from "./tour_utils";
registry.category("web_tour.tours").add("self_order_after_meal_cart_tour", {
test: true,
steps: [
// Verify if the self is open and My Orders is not displayed because we are in "meal" mode
PosSelf.check.isNotNotification(),
PosSelf.check.isNotPrimaryBtn("My Orders"),
PosSelf.action.clickPrimaryBtn("View Menu"),
// Add some products
...PosSelf.action.addProduct("Office Chair Black", 1),
...PosSelf.action.addProduct("Office Chair Black", 2, "Description"),
...PosSelf.action.addProduct("Large Cabinet", 2),
PosSelf.action.clickPrimaryBtn("Review"),
// Here we're looking at whether when an orderline is clicked on from
// the cart and edited, the changes are made to the orderline clicked on.
PosSelf.check.isOrderline("Office Chair Black", "138.58", ""),
...PosSelf.action.editOrderline("Office Chair Black", "138.58", "", 5, "no wheels"),
PosSelf.check.isOrderline("Office Chair Black", "692.90", "no wheels"),
...PosSelf.action.editOrderline("Office Chair Black", "692.90", "no wheels", -4, "kidding"),
PosSelf.check.isOrderline("Office Chair Black", "138.58", "kidding"),
// Send the order to the server
PosSelf.action.clickPrimaryBtn("Order"),
PosSelf.check.isNotification("Your order has been placed!"),
// Once an order has been sent to the server, the user can no
// longer reduce the quantity of his orderlines. We check this behaviour.
PosSelf.action.clickPrimaryBtn("View Menu"),
PosSelf.action.clickPrimaryBtn("Review"),
...PosSelf.action.editOrderline("Large Cabinet", "736.00", "", -1),
PosSelf.check.isNotification("You cannot reduce the quantity"),
PosSelf.check.isOrderline("Large Cabinet", "736.00", ""),
// Here we look at whether the user can reduce the quantity
// and delete a product that has not yet been sent
PosSelf.action.clickBack(),
...PosSelf.action.addProduct("Funghi", 1),
PosSelf.action.clickPrimaryBtn("Review"),
...PosSelf.action.editOrderline("Funghi", "8.05", "", -1),
PosSelf.check.isNotOrderline("Funghi", "8.05", ""),
// Here we're looking at whether adding a product with the same description
// as the same product already in the basket merges the two products.
// We also check whether we can reduce the quantity of the part not yet sent to
// the server and whether the quantity of the part sent to the server is fixed.
PosSelf.action.clickBack(),
...PosSelf.action.addProduct("Office Chair Black", 1, "kidding"),
PosSelf.action.clickPrimaryBtn("Review"),
PosSelf.check.isOrderline("Office Chair Black", "277.16", "kidding"),
...PosSelf.action.editOrderline("Office Chair Black", "277.16", "kidding", -1),
PosSelf.check.isOrderline("Office Chair Black", "138.58", "kidding"),
...PosSelf.action.editOrderline("Office Chair Black", "138.58", "kidding", -1),
PosSelf.check.isNotification("You cannot reduce the quantity"),
PosSelf.action.clickPrimaryBtn("Order"),
// Here we check that the product attributes are correctly selected.
PosSelf.action.clickPrimaryBtn("View Menu"),
...PosSelf.action.addProduct("Desk Organizer", 1, "kidding", {
radio: { name: "Size", value: "M" },
select: { name: "Fabric", value: "Leather" },
}),
...PosSelf.action.addProduct("Desk Organizer", 2, "okkk", {
radio: { name: "Size", value: "L" },
select: { name: "Fabric", value: "Custom" },
}),
PosSelf.action.clickPrimaryBtn("Review"),
PosSelf.check.isOrderline("Desk Organizer", "5.87", "kidding", "M, Leather"),
PosSelf.check.isOrderline("Desk Organizer", "11.74", "okkk", "L, Custom"),
PosSelf.action.clickPrimaryBtn("Order"),
// Check if we can edit the product attributes, and if the changes are made to the orderline
PosSelf.action.clickPrimaryBtn("View Menu"),
PosSelf.action.clickPrimaryBtn("Review"),
...PosSelf.action.editOrderline("Desk Organizer", "5.87", "kidding", 0, "dav", {
radio: { name: "Size", value: "S" },
select: { name: "Fabric", value: "Custom" },
}),
PosSelf.check.isOrderline("Desk Organizer", "5.87", "dav", "S, Custom"),
PosSelf.check.isOrderline("Desk Organizer", "11.74", "okkk", "L, Custom"),
PosSelf.action.clickPrimaryBtn("Order"),
],
});
@@ -0,0 +1,25 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { PosSelf } from "./tour_utils";
registry.category("web_tour.tours").add("self_order_after_meal_product_tour", {
test: true,
steps: [
// Verify if the self is open
PosSelf.check.isNotNotification(),
PosSelf.check.isNotPrimaryBtn("My Orders"),
// Add some products
PosSelf.action.clickPrimaryBtn("View Menu"),
...PosSelf.action.addProduct("Large Cabinet", 15),
...PosSelf.action.addProduct("Office Chair Black", 3),
...PosSelf.action.addProduct("Conference Chair (Aluminium)", 7),
// Check if products in the products list have their quantity
// They should have because in "meal" mode we add products always to the same order
PosSelf.check.isProductQuantity("Large Cabinet", 15),
PosSelf.check.isProductQuantity("Office Chair Black", 3),
PosSelf.check.isProductQuantity("Conference Chair (Aluminium)", 7),
],
});
@@ -0,0 +1,20 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { PosSelf } from "./tour_utils";
registry.category("web_tour.tours").add("self_order_menu_only_tour", {
test: true,
steps: [
PosSelf.check.isNotPrimaryBtn("My Orders"),
PosSelf.check.isPrimaryBtn("View Menu"),
PosSelf.action.clickPrimaryBtn("View Menu"),
...PosSelf.check.cannotAddProduct("Office Chair"),
PosSelf.action.clickBack(),
...PosSelf.check.cannotAddProduct("Office Chair Black"),
PosSelf.action.clickBack(),
...PosSelf.check.cannotAddProduct("Conference Chair (Aluminium)"),
PosSelf.action.clickBack(),
PosSelf.check.isNotPrimaryBtn("Review"),
],
});
@@ -1,68 +1,167 @@
/** @odoo-module */
// HELPERS ////////////////////////////////
// Each of the export functions below returns an array of steps
// (even those that return a single step, for consistency)
export const PosSelf = {
check: {
isNotification: (text) => {
return {
content: `Check if there is a notification with ${text}`,
trigger: `body:has(.o_notification_content:contains('${text}'))`,
run: () => {},
};
},
isNotNotification: () => {
return {
content: "Check if there is a notification",
trigger: "body:not(:has(.o_notification_content))",
run: () => {},
};
},
isPrimaryBtn: (buttonName) => {
return {
content: `Click on primary button '${buttonName}'`,
trigger: `.btn:contains('${buttonName}')`,
run: () => {},
};
},
isNotPrimaryBtn: (buttonName) => {
return {
content: `Click on primary button '${buttonName}'`,
trigger: `.btn:not(:contains('${buttonName}'))`,
run: () => {},
};
},
isOrderline: (name, price, description = "", attributes = "") => {
return {
content: `Verify is there an orderline with ${name} and ${price} and ${description}`,
trigger: `.o_self_order_item_card:has(h3:contains("${name}")):has(span:contains("${description}")):has(span:contains("${attributes}")):has(span.card-text:contains("${price}"))`,
run: () => {},
};
},
isNotOrderline: (name, price, description = "", attributes = "") => {
return {
content: `Verify is there an orderline with ${name} and ${price} and ${description}`,
trigger: `.o_self_order_item_card:not(:has(h3:contains("${name}")):has(span:contains("${description}")):has(span:contains("${attributes}")):has(span.card-text:contains("${price}")))`,
run: () => {},
};
},
isProductQuantity: (name, quantity) => {
return {
content: `Verify is there a product with ${name} and ${quantity} selected quantity`,
trigger: `.o_self_order_item_card span.text-primary:contains('${quantity}x') ~ h3:contains('${name}')`,
run: () => {},
};
},
cannotAddProduct: (name) => {
return [
{
content: `Click on product '${name}'`,
trigger: `.o_self_order_item_card h3:contains('${name}')`,
},
{
content: `Click on 'Add' button`,
trigger: `.btn:not(:contains('Add'))`,
run: () => {},
},
];
},
},
action: {
clickBack: () => {
return {
content: "Click the navbar back button",
trigger: "nav.o_self_order_navbar > button",
};
},
clickPrimaryBtn: (buttonName) => {
return {
content: `Click on primary button '${buttonName}'`,
trigger: `.btn:contains('${buttonName}')`,
};
},
addProduct: (name, quantity = 1, description, attributes) => {
return [
{
content: `Click on product '${name}'`,
trigger: `.o_self_order_item_card h3:contains('${name}')`,
},
...quantityHelper(quantity),
descriptionHelper(description),
...attributeHelper(attributes),
{
content: `Click on 'Add' button`,
trigger: `.o_self_order_main_button:contains('Add')`,
},
];
},
editOrderline: (name, price, description, addQuantity = 0, newDescription, newAtr) => {
return [
{
content: `Click on orderline ${name}, price ${price} and description ${description}`,
trigger: `.o_self_order_item_card:has(h3:contains("${name}")):has(span:contains("${description}")):has(span.card-text:contains("${price}"))`,
},
...quantityHelper(addQuantity),
descriptionHelper(newDescription),
...attributeHelper(newAtr),
{
content: `Click on 'Add' button`,
trigger: `.o_self_order_main_button:contains('Update Cart')`,
},
];
},
},
};
/**
* START: product list screen
* END: product list screen
* @param {number[]} product_ids
* @returns {Array}
*/
export function addProductsToCart(product_ids) {
return product_ids
.map((id) => [
...clickOnProductCard(id),
// We should now be on the product main view screen
...clickOn("Add"),
// We should now be on the product list screen
])
.flat();
}
/**
* @param {string} selector
* @returns {string}
*/
export function doesNotExist(selector) {
return `body:not(:has(${selector}))`;
}
// This function is used to generate the steps to add attributes to a product.
// Missing the color function, but I didn't have example for the moment see in the future.
const attributeHelper = (attributes = { radio: {}, select: {}, color: {} }) => {
const attributesSteps = [];
export function clickOnProductCard(
product_id,
{ isCheck = false, isNot = false, qty = false } = {}
) {
let productCard = `.o_self_order_item_card:contains(Product ${product_id} test)`;
if (qty) {
productCard = `${productCard}:has(span:contains(${qty} x ))`;
if (attributes.radio.value) {
attributesSteps.push({
content: `Select radio ${attributes.radio.name} with value ${attributes.radio.value}`,
trigger: `.o_self_order_main_options div:contains('${attributes.radio.name}') ~ div input[value='${attributes.radio.value}']`,
});
}
return [
{
content: `${isCheck ? "Test" : "Click"} the product card of Product ${product_id}${
qty ? ` with quantity ${qty}` : ""
}${isNot ? " (should not present)" : ""}`,
trigger: isNot ? doesNotExist(productCard) : productCard,
isCheck,
},
];
}
export function clickOnBackButton() {
return [
{
content: "Click the navbar back button",
trigger: "nav.o_self_order_navbar > button",
},
];
}
if (attributes.select.value) {
attributesSteps.push({
content: `Select radio ${attributes.select.name} with value ${attributes.select.value}`,
trigger: `.o_self_order_main_options div:contains('${attributes.select.name}') ~ select:has(option[value='${attributes.select.value}'])`,
run: `text ${attributes.select.value}`,
});
}
export function clickOn(element, { isCheck = false, isNot = false } = {}) {
const selector = `.btn:contains('${element}')`;
return [
{
content: `Click on '${element}' button`,
trigger: isNot ? doesNotExist(selector) : selector,
isCheck,
},
];
}
return attributesSteps;
};
// This function is used to generate the steps to add a description to a product.
const descriptionHelper = (description = "") => {
return {
content: `Add description ${description}`,
trigger: `.o_self_order_main_options textarea`,
run: description ? `text ${description}` : () => {},
};
};
// This function is used to generate the steps to increase the quantity of a product.
const quantityHelper = (quantity, el) => {
const increaseQuantity = [];
if (quantity > 0) {
for (let i = 1; i < quantity; i++) {
increaseQuantity.push({
content: `Increase quantity`,
trigger: `.o_self_order_incr_button .btn:contains('+')`,
});
}
} else {
for (let i = 0; i > quantity; i--) {
increaseQuantity.push({
content: `Decrease quantity`,
trigger: `.o_self_order_incr_button .btn:contains('-')`,
});
}
}
return increaseQuantity;
};
+31 -12
View File
@@ -19,29 +19,48 @@ class TestFrontendMobile(odoo.tests.HttpCase):
"self_order_table_mode": False,
}
)
basic_product = lambda i: {
"name": f"Product {i} test",
"type": "product",
"available_in_pos": True,
"list_price": i,
"taxes_id": False,
}
self.env["product.product"].create([basic_product(i) for i in range(1, 1000)])
def test_self_order_view_mode_tour(self):
# we need a default tax fixed at 15% to all product because in the test prices are based on this tax.
# some time with the localization this may not be the case. So we force it.
self.env['product.product'].search([]).taxes_id = self.env['account.tax'].create({
'name': 'Default Tax for Self Order',
'amount': 15,
'amount_type': 'percent',
})
def test_self_order_menu_only_tour(self):
self.pos_config.self_order_table_mode = False
self.start_tour(
self.pos_config._get_self_order_route(),
"pos_qr_menu_tour",
"self_order_menu_only_tour",
login=None,
watch=True,
)
def test_self_order_tour(self):
def test_self_order_pay_after_meal_tour(self):
self.pos_config.self_order_table_mode = True
self.pos_config.self_order_pay_after = "meal"
self.pos_config.open_ui()
self.start_tour(
self.pos_config._get_self_order_route(),
"self_order_tour",
"self_order_after_meal_cart_tour",
login=None,
watch=True
)
self.start_tour(
self.pos_config._get_self_order_route(),
"self_order_after_meal_product_tour",
login=None,
)
def test_self_order_pay_after_each_tour(self):
self.pos_config.self_order_table_mode = True
self.pos_config.self_order_pay_after = "each"
self.pos_config.open_ui()
self.start_tour(
self.pos_config._get_self_order_route(),
"self_order_after_each_cart_tour",
login=None,
watch=True
)
@@ -17,6 +17,6 @@
<field name="style"/>
<field name="link_html"/>
</tree>
</field>
</record>
</field>
</record>
</odoo>
+16 -13
View File
@@ -35,17 +35,17 @@
have to keep using "page-break-after" -->
<div style="page-break-after: always;">
<h3 t-if="floor.get('name')" t-out="floor['name']" class="mb-5"/>
<div
t-foreach="floor['rows_of_tables']"
<div
t-foreach="floor['rows_of_tables']"
t-as="row_of_tables"
class="d-flex"
>
<t t-foreach="row_of_tables"
<t t-foreach="row_of_tables"
t-as="table"
t-call="pos_self_order.qr_code"
/>
</div>
</div>
</div>
</t>
</div>
</t>
@@ -55,31 +55,34 @@
<t t-set="qr_code_size" t-value="180"/>
<div style="padding: 0px 40px; margin-bottom:40px; page-break-inside: avoid;">
<!-- This page-break-inside does not seem to work;
It would be nice to have the qr codes so the title is never on
It would be nice to have the qr codes so the title is never on
a different page then the qr code itself -->
<h4 class="fw-bold text-center"
style="margin-top: 20px"
<h4 class="fw-bold text-center"
style="margin-top: 20px"
t-if="table['id']"
>
Table: <t t-esc="table['name']"/>
Table: <t t-esc="table['name']"/>
</h4>
<div class="p-3">
<div class="mt-1 mb-4 position-relative top-0 start-0">
<img t-att-src="'/report/barcode/QR/%s?width=%s&amp;height=%s&amp;barLevel=H' %(table['url'], qr_code_size, qr_code_size)"
<img t-att-src="'/report/barcode/QR/%s?width=%s&amp;height=%s&amp;barLevel=H' %(table['url'], qr_code_size, qr_code_size)"
class="position-relative top-0 start-0"
/>
<t t-call="pos_self_order.first_letter_of_odoo_logo_svg"/>
</div>
</div>
<div class="text-center">
(<t t-esc="table['access_token']"/>)
</div>
</div>
</template>
<template id="first_letter_of_odoo_logo_svg">
<svg
<svg
class="position-absolute top-50 start-50"
style="-webkit-transform: translate(-50%, -50%);"
t-att-width="qr_code_size/4"
t-att-height="qr_code_size/4"
t-att-width="qr_code_size/4"
t-att-height="qr_code_size/4"
viewBox="0 0 120 120" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
@@ -90,6 +93,6 @@
<path d="M59.5166 89.6961C75.8029 89.6961 89.0055 76.4935 89.0055 60.2072C89.0055 43.9209 75.8029 30.7182 59.5166 30.7182C43.2303 30.7182 30.0276 43.9209 30.0276 60.2072C30.0276 76.4935 43.2303 89.6961 59.5166 89.6961Z" fill="white"/>
</g>
</g>
</svg>
</svg>
</template>
</odoo>
@@ -11,7 +11,7 @@
<div class="content-group d-flex gap-5" attrs="{'invisible': [('pos_self_order_view_mode','=',False)]}">
<div class="d-flex flex-column align-items-start w-50">
<button class="btn-link p-0" icon="oi-arrow-right" name="pos_self_order.custom_link_action" type="action" string="Configure Buttons"/>
<button class="btn-link p-0" icon="oi-arrow-right" name="generate_qr_codes_page" type="object" string="Generate Qr Codes"/>
<button class="btn-link p-0" icon="oi-arrow-right" name="generate_qr_codes_page" type="object" string="Generate QR Codes"/>
<button class="btn-link p-0" icon="oi-arrow-right" name="preview_self_order_app" type="object" string="Preview"/>
</div>
<div class="d-flex flex-column align-items-start">