diff --git a/addons/pos_restaurant/data/pos_restaurant_demo.xml b/addons/pos_restaurant/data/pos_restaurant_demo.xml index b073911b094..e25964359eb 100644 --- a/addons/pos_restaurant/data/pos_restaurant_demo.xml +++ b/addons/pos_restaurant/data/pos_restaurant_demo.xml @@ -196,7 +196,6 @@ - Main Floor rgb(136,137,242) diff --git a/addons/pos_self_order/__manifest__.py b/addons/pos_self_order/__manifest__.py index 713db7c505d..cee81b3a640 100644 --- a/addons/pos_self_order/__manifest__.py +++ b/addons/pos_self_order/__manifest__.py @@ -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", diff --git a/addons/pos_self_order/controllers/__init__.py b/addons/pos_self_order/controllers/__init__.py index 7d91bf1afc7..0aa6faafeb3 100644 --- a/addons/pos_self_order/controllers/__init__.py +++ b/addons/pos_self_order/controllers/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- -from . import qr_menu -from . import ordering +from . import entry +from . import orders diff --git a/addons/pos_self_order/controllers/qr_menu.py b/addons/pos_self_order/controllers/entry.py similarity index 67% rename from addons/pos_self_order/controllers/qr_menu.py rename to addons/pos_self_order/controllers/entry.py index 7601e573442..a065183677d 100644 --- a/addons/pos_self_order/controllers/qr_menu.py +++ b/addons/pos_self_order/controllers/entry.py @@ -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/", - "/menu//products", - "/menu//products/", - "/menu//cart", - "/menu//orders", - ], + [ + "/menu/", + "/menu//" + ], 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 {} - ), - } diff --git a/addons/pos_self_order/controllers/ordering.py b/addons/pos_self_order/controllers/ordering.py deleted file mode 100644 index 903fd58f571..00000000000 --- a/addons/pos_self_order/controllers/ordering.py +++ /dev/null @@ -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 diff --git a/addons/pos_self_order/controllers/orders.py b/addons/pos_self_order/controllers/orders.py new file mode 100644 index 00000000000..e5144c947e3 --- /dev/null +++ b/addons/pos_self_order/controllers/orders.py @@ -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 diff --git a/addons/pos_self_order/controllers/utils.py b/addons/pos_self_order/controllers/utils.py index b2f8463a3d1..07e6296b096 100644 --- a/addons/pos_self_order/controllers/utils.py +++ b/addons/pos_self_order/controllers/utils.py @@ -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) ) diff --git a/addons/pos_self_order/data/custom_link_data.xml b/addons/pos_self_order/data/custom_link_data.xml deleted file mode 100644 index 1a440a52e0b..00000000000 --- a/addons/pos_self_order/data/custom_link_data.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - View Menu - /products - - - diff --git a/addons/pos_self_order/demo/custom_link_data.xml b/addons/pos_self_order/demo/custom_link_data.xml new file mode 100644 index 00000000000..e66ccc69535 --- /dev/null +++ b/addons/pos_self_order/demo/custom_link_data.xml @@ -0,0 +1,10 @@ + + + + + View Menu + + + + + diff --git a/addons/pos_self_order/demo/pos_restaurant_demo.xml b/addons/pos_self_order/demo/pos_restaurant_demo.xml index 30de26cfd9e..4ce760cfa1f 100644 --- a/addons/pos_self_order/demo/pos_restaurant_demo.xml +++ b/addons/pos_self_order/demo/pos_restaurant_demo.xml @@ -4,12 +4,12 @@ True True - - diff --git a/addons/pos_self_order/models/pos_config.py b/addons/pos_self_order/models/pos_config.py index 984c72d6ebd..12ab9faca1a 100644 --- a/addons/pos_self_order/models/pos_config.py +++ b/addons/pos_self_order/models/pos_config.py @@ -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]]: diff --git a/addons/pos_self_order/models/pos_order.py b/addons/pos_self_order/models/pos_order.py index 552ce181baf..1ded0c55ea9 100644 --- a/addons/pos_self_order/models/pos_order.py +++ b/addons/pos_self_order/models/pos_order.py @@ -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, - } diff --git a/addons/pos_self_order/models/pos_restaurant.py b/addons/pos_self_order/models/pos_restaurant.py index 8f21f0fd1a1..a3adcbe03b7 100644 --- a/addons/pos_self_order/models/pos_restaurant.py +++ b/addons/pos_self_order/models/pos_restaurant.py @@ -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), diff --git a/addons/pos_self_order/models/product_product.py b/addons/pos_self_order/models/product_product.py index e1cf42aaefb..781374cca29 100644 --- a/addons/pos_self_order/models/product_product.py +++ b/addons/pos_self_order/models/product_product.py @@ -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]: diff --git a/addons/pos_self_order/models/res_config_settings.py b/addons/pos_self_order/models/res_config_settings.py index 713bde19d8b..806b683e8c4 100644 --- a/addons/pos_self_order/models/res_config_settings.py +++ b/addons/pos_self_order/models/res_config_settings.py @@ -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) ) diff --git a/addons/pos_self_order/static/src/Components/IncrementCounter/IncrementCounter.js b/addons/pos_self_order/static/src/Components/IncrementCounter/IncrementCounter.js deleted file mode 100644 index a1a7af3d3ec..00000000000 --- a/addons/pos_self_order/static/src/Components/IncrementCounter/IncrementCounter.js +++ /dev/null @@ -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"]; -} diff --git a/addons/pos_self_order/static/src/Components/IncrementCounter/IncrementCounter.scss b/addons/pos_self_order/static/src/Components/IncrementCounter/IncrementCounter.scss deleted file mode 100644 index 1593cdecea1..00000000000 --- a/addons/pos_self_order/static/src/Components/IncrementCounter/IncrementCounter.scss +++ /dev/null @@ -1,4 +0,0 @@ -.o_self_order_incr_button button { - width: 40px; - height: 40px; -} \ No newline at end of file diff --git a/addons/pos_self_order/static/src/Components/IncrementCounter/IncrementCounter.xml b/addons/pos_self_order/static/src/Components/IncrementCounter/IncrementCounter.xml deleted file mode 100644 index 61f24267e8c..00000000000 --- a/addons/pos_self_order/static/src/Components/IncrementCounter/IncrementCounter.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - -
- -
- -
- - diff --git a/addons/pos_self_order/static/src/Components/Loading/Loading.js b/addons/pos_self_order/static/src/Components/Loading/Loading.js deleted file mode 100644 index cd9a9ef9422..00000000000 --- a/addons/pos_self_order/static/src/Components/Loading/Loading.js +++ /dev/null @@ -1,8 +0,0 @@ -/** @odoo-module */ - -import { Component } from "@odoo/owl"; - -export class Loading extends Component { - static template = "pos_self_order.Loading"; - static props = []; -} diff --git a/addons/pos_self_order/static/src/Components/Loading/Loading.xml b/addons/pos_self_order/static/src/Components/Loading/Loading.xml deleted file mode 100644 index ee214fef972..00000000000 --- a/addons/pos_self_order/static/src/Components/Loading/Loading.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - -
-
- Loading... -
-
-
-
diff --git a/addons/pos_self_order/static/src/Components/MainButton/MainButton.js b/addons/pos_self_order/static/src/Components/MainButton/MainButton.js deleted file mode 100644 index cfd5f986076..00000000000 --- a/addons/pos_self_order/static/src/Components/MainButton/MainButton.js +++ /dev/null @@ -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 }; -} diff --git a/addons/pos_self_order/static/src/Components/MainButton/MainButton.xml b/addons/pos_self_order/static/src/Components/MainButton/MainButton.xml deleted file mode 100644 index 0cafd90996a..00000000000 --- a/addons/pos_self_order/static/src/Components/MainButton/MainButton.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - -
- - -
-
-
diff --git a/addons/pos_self_order/static/src/Components/Order/Order.js b/addons/pos_self_order/static/src/Components/Order/Order.js deleted file mode 100644 index ae724cac57b..00000000000 --- a/addons/pos_self_order/static/src/Components/Order/Order.js +++ /dev/null @@ -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(); - } -} diff --git a/addons/pos_self_order/static/src/Components/Order/Order.xml b/addons/pos_self_order/static/src/Components/Order/Order.xml deleted file mode 100644 index 8f29cf23f72..00000000000 --- a/addons/pos_self_order/static/src/Components/Order/Order.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - -
-
-
-
- -
-

-

-
- -
- -
-
-
-
-
diff --git a/addons/pos_self_order/static/src/Components/OrderLines/OrderLines.js b/addons/pos_self_order/static/src/Components/OrderLines/OrderLines.js deleted file mode 100644 index fec8bb38c3c..00000000000 --- a/addons/pos_self_order/static/src/Components/OrderLines/OrderLines.js +++ /dev/null @@ -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 - ); - } -} diff --git a/addons/pos_self_order/static/src/Components/OrderLines/OrderLines.xml b/addons/pos_self_order/static/src/Components/OrderLines/OrderLines.xml deleted file mode 100644 index b8972280e11..00000000000 --- a/addons/pos_self_order/static/src/Components/OrderLines/OrderLines.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/addons/pos_self_order/static/src/Components/ProductCard/ProductCard.js b/addons/pos_self_order/static/src/Components/ProductCard/ProductCard.js deleted file mode 100644 index d5cefd93af5..00000000000 --- a/addons/pos_self_order/static/src/Components/ProductCard/ProductCard.js +++ /dev/null @@ -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)) - )}`; - } -} diff --git a/addons/pos_self_order/static/src/Components/ProductCard/ProductCard.xml b/addons/pos_self_order/static/src/Components/ProductCard/ProductCard.xml deleted file mode 100644 index b5a67016d5f..00000000000 --- a/addons/pos_self_order/static/src/Components/ProductCard/ProductCard.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - -
-
-
- -

- - - -
-