squashed commit

This commit is contained in:
hoangvv
2026-09-18 13:55:25 +07:00
commit 039c98d4d0
45882 changed files with 24235585 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
from . import controllers
from . import models
def _post_self_order_post_init(env):
sessions = env['pos.session'].search([('state', '!=', 'closed')])
if len(sessions) > 0:
env['pos.session']._create_pos_self_sessions_sequence(sessions)
+93
View File
@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
{
"name": "POS Self Order",
'version': '1.0',
"summary": "Addon for the POS App that allows customers to view the menu on their smartphone.",
"category": "Sales/Point Of Sale",
"depends": ["pos_restaurant", "http_routing"],
"auto_install": ["pos_restaurant"],
"data": [
"security/ir.model.access.csv",
"views/pos_self_order.index.xml",
"views/qr_code.xml",
"views/pos_category_views.xml",
"views/pos_config_view.xml",
"views/pos_session_view.xml",
"views/custom_link_views.xml",
"views/pos_restaurant_views.xml",
"views/product_views.xml",
"data/init_access.xml",
"views/res_config_settings_views.xml",
"views/point_of_sale_dashboard.xml",
],
"demo": [
"data/kiosk_demo_data.xml",
],
"assets": {
# Assets
'point_of_sale._assets_pos': [
'pos_self_order/static/src/backend/qr_order_button/*',
'pos_self_order/static/src/overrides/**/*',
],
'web.assets_backend': [
"pos_self_order/static/src/upgrade_selection_field.js",
'pos_self_order/static/src/backend/qr_order_button/*',
],
"pos_self_order.assets": [
"pos_self_order/static/src/app/primary_variables.scss",
"pos_self_order/static/src/app/bootstrap_overridden.scss",
("include", "point_of_sale.base_app"),
'web/static/src/core/currency.js',
'barcodes/static/src/barcode_service.js',
'point_of_sale/static/src/utils.js',
'point_of_sale/static/src/app/utils/init_lna.js',
'web/static/lib/bootstrap/js/dist/util/index.js',
'web/static/lib/bootstrap/js/dist/dom/data.js',
'web/static/lib/bootstrap/js/dist/dom/event-handler.js',
'web/static/lib/bootstrap/js/dist/dom/manipulator.js',
'web/static/lib/bootstrap/js/dist/dom/selector-engine.js',
'web/static/lib/bootstrap/js/dist/util/config.js',
'web/static/lib/bootstrap/js/dist/util/swipe.js',
'web/static/lib/bootstrap/js/dist/base-component.js',
"web/static/lib/bootstrap/js/dist/carousel.js",
'web/static/lib/bootstrap/js/dist/scrollspy.js',
"point_of_sale/static/src/app/store/models/product_custom_attribute.js",
'web_editor/static/src/js/editor/odoo-editor/src/base_style.scss',
'web_editor/static/src/scss/web_editor.common.scss',
"point_of_sale/static/src/app/generic_components/numpad/*",
"point_of_sale/static/src/app/generic_components/product_card/*",
"point_of_sale/static/src/app/generic_components/order_widget/*",
"point_of_sale/static/src/app/generic_components/orderline/*",
"point_of_sale/static/src/app/generic_components/centered_icon/*",
"point_of_sale/static/src/css/pos_receipts.css",
"point_of_sale/static/src/app/screens/receipt_screen/receipt/**/*",
"pos_self_order/static/src/overrides/components/receipt_header/*",
"point_of_sale/static/src/app/printer/base_printer.js",
"point_of_sale/static/src/app/printer/printer_service.js",
'point_of_sale/static/src/app/utils/html-to-image.js',
"point_of_sale/static/src/app/printer/render_service.js",
"pos_self_order/static/src/app/**/*",
"point_of_sale/static/src/app/printer/hw_printer.js",
"web/static/src/core/utils/render.js",
"pos_self_order/static/src/app/store/order_change_receipt_template.xml",
"account/static/src/helpers/*.js",
"web/static/src/views/fields/parsers.js",
# Related models from point_of_sale
"point_of_sale/static/src/app/models/data_service_options.js",
"point_of_sale/static/src/app/models/utils/indexed_db.js",
"point_of_sale/static/src/app/models/related_models.js",
"point_of_sale/static/src/app/models/data_service.js",
"point_of_sale/static/src/app/models/**/*",
"pos_restaurant/static/src/app/models/restaurant_table.js"
],
# Assets tests
"pos_self_order.assets_tests": [
("include", "point_of_sale.base_tests"),
"pos_self_order/static/tests/**/*",
"point_of_sale/static/tests/tours/utils/numpad_util.js",
],
},
'post_init_hook': '_post_self_order_post_init',
"license": "LGPL-3",
}
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import orders
from . import self_entry
from . import webmanifest
+240
View File
@@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
import re
from datetime import timedelta
from odoo import http, fields, _
from odoo.http import request
from odoo.tools import float_round
from odoo.osv import expression
from werkzeug.exceptions import NotFound, BadRequest, Unauthorized
from odoo.exceptions import MissingError
from odoo.tools import consteq
class PosSelfOrderController(http.Controller):
@http.route("/pos-self-order/process-order/<device_type>/", auth="public", type="json", website=True)
def process_order(self, order, access_token, table_identifier, device_type):
return self.process_order_args(order, access_token, table_identifier, device_type, **{})
@http.route("/pos-self-order/process-order-args/<device_type>/", auth="public", type="json", website=True)
def process_order_args(self, order, access_token, table_identifier, device_type, **kwargs):
is_takeaway = order.get('takeaway')
pos_config, table = self._verify_authorization(access_token, table_identifier, is_takeaway)
pos_session = pos_config.current_session_id
# Create the order
ir_sequence_session = pos_config.env['ir.sequence'].with_context(company_id=pos_config.company_id.id).next_by_code(f'pos.order_{pos_session.id}')
sequence_number = order.get('sequence_number')
if not sequence_number:
sequence_number = re.findall(r'\d+', ir_sequence_session)[0]
order_reference = self._generate_unique_id(pos_session.id, pos_config.id, sequence_number, device_type)
fiscal_position = (
pos_config.takeaway_fp_id
if is_takeaway
else pos_config.default_fiscal_position_id
)
if 'picking_type_id' in order:
del order['picking_type_id']
order['name'] = order_reference
order['pos_reference'] = order_reference
order['sequence_number'] = sequence_number
order['user_id'] = request.session.uid
order['date_order'] = str(fields.Datetime.now())
order['fiscal_position_id'] = fiscal_position.id if fiscal_position else False
results = pos_config.env['pos.order'].sudo().with_company(pos_config.company_id.id).sync_from_ui([order])
line_ids = pos_config.env['pos.order.line'].browse([line['id'] for line in results['pos.order.line']])
order_ids = pos_config.env['pos.order'].browse([order['id'] for order in results['pos.order']])
self._verify_line_price(line_ids, pos_config)
amount_total, amount_untaxed = self._get_order_prices(order_ids.lines)
order_ids.write({
'state': 'paid' if amount_total == 0 else 'draft',
'amount_tax': amount_total - amount_untaxed,
'amount_total': amount_total,
})
if amount_total == 0:
order_ids._process_saved_order(False)
order_ids.send_table_count_notification(order_ids.mapped('table_id'))
return self._generate_return_values(order_ids, pos_config)
def _generate_return_values(self, order, config_id):
return {
'pos.order': order.read(order._load_pos_data_fields(config_id.id), load=False),
'pos.order.line': order.lines.read(order._load_pos_data_fields(config_id.id), load=False),
'pos.payment': order.payment_ids.read(order.payment_ids._load_pos_data_fields(order.config_id.id), load=False),
'pos.payment.method': order.payment_ids.mapped('payment_method_id').read(order.env['pos.payment.method']._load_pos_data_fields(order.config_id.id), load=False),
'product.attribute.custom.value': order.lines.custom_attribute_value_ids.read(order.lines.custom_attribute_value_ids._load_pos_data_fields(config_id.id), load=False),
}
def _verify_line_price(self, lines, pos_config, takeaway=False):
pricelist = pos_config.pricelist_id
sale_price_digits = pos_config.env['decimal.precision'].precision_get('Product Price')
for line in lines:
product = line.product_id
lst_price = pricelist._get_product_price(product, quantity=line.qty) if pricelist else product.lst_price
selected_attributes = line.attribute_value_ids
lst_price += sum(selected_attributes.mapped('price_extra'))
price_extra = sum(attr.price_extra for attr in selected_attributes)
lst_price += price_extra
fiscal_pos = pos_config.default_fiscal_position_id
if takeaway and pos_config.takeaway_fp_id:
fiscal_pos = pos_config.takeaway_fp_id
if len(line.combo_line_ids) > 0:
original_total = sum(line.combo_line_ids.mapped("combo_item_id").combo_id.mapped("base_price"))
remaining_total = lst_price
factor = lst_price / original_total if original_total > 0 else 1
for i, pos_order_line in enumerate(line.combo_line_ids):
child_product = pos_order_line.product_id
price_unit = float_round(pos_order_line.combo_item_id.combo_id.base_price * factor, precision_digits=sale_price_digits)
remaining_total -= price_unit
if i == len(line.combo_line_ids) - 1:
price_unit += remaining_total
selected_attributes = pos_order_line.attribute_value_ids
price_extra_child = sum(attr.price_extra for attr in selected_attributes)
price_unit += pos_order_line.combo_item_id.extra_price + price_extra_child
taxes = fiscal_pos.map_tax(child_product.taxes_id) if fiscal_pos else child_product.taxes_id
pdetails = taxes.compute_all(price_unit, pos_config.currency_id, pos_order_line.qty, child_product)
pos_order_line.write({
'price_unit': price_unit,
'price_subtotal': pdetails.get('total_excluded'),
'price_subtotal_incl': pdetails.get('total_included'),
'price_extra': price_extra_child,
'tax_ids': child_product.taxes_id,
})
lst_price = 0
@http.route('/pos-self-order/remove-order', auth='public', type='json', website=True)
def remove_order(self, access_token, order_id, order_access_token):
pos_config = self._verify_pos_config(access_token)
pos_order = pos_config.env['pos.order'].browse(order_id)
if not pos_order.exists() or not consteq(pos_order.access_token, order_access_token):
raise MissingError(_("Your order does not exist or has been removed"))
if pos_order.state != 'draft':
raise Unauthorized(_("You are not authorized to remove this order"))
pos_order.remove_from_ui([pos_order.id])
@http.route('/pos-self-order/get-orders', auth='public', type='json', website=True)
def get_orders_by_access_token(self, access_token, order_access_tokens, table_identifier=None):
pos_config = self._verify_pos_config(access_token)
session = pos_config.current_session_id
table = pos_config.env["restaurant.table"].search([('identifier', '=', table_identifier)], limit=1)
domain = False
if not table_identifier:
domain = [(False, '=', True)]
else:
domain = ['&', '&',
('table_id', '=', table.id),
('state', '=', 'draft'),
('access_token', 'not in', [data.get('access_token') for data in order_access_tokens])
]
for data in order_access_tokens:
domain = expression.OR([domain, ['&',
('access_token', '=', data.get('access_token')),
('write_date', '>', data.get('write_date'))
]])
orders = session.order_ids.filtered_domain(domain)
if not orders:
return {}
return self._generate_return_values(orders, pos_config)
@http.route('/pos-self-order/get-available-tables', auth='public', type='json', website=True)
def get_available_tables(self, access_token, order_access_tokens):
pos_config = self._verify_pos_config(access_token)
orders = pos_config.current_session_id.order_ids.filtered_domain([
("access_token", "not in", order_access_tokens)
])
available_table_ids = pos_config.floor_ids.table_ids - orders.mapped('table_id')
return available_table_ids.read(['id'])
@http.route('/kiosk/payment/<int:pos_config_id>/<device_type>', auth='public', type='json', website=True)
def pos_self_order_kiosk_payment(self, pos_config_id, order, payment_method_id, access_token, device_type):
pos_config = self._verify_pos_config(access_token)
results = self.process_order(order, access_token, None, device_type)
if not results['pos.order'][0].get('id'):
raise BadRequest("Something went wrong")
# access_token verified in process_new_order
order_sudo = pos_config.env['pos.order'].browse(results['pos.order'][0]['id'])
payment_method_sudo = pos_config.env["pos.payment.method"].browse(payment_method_id)
if not order_sudo or not payment_method_sudo or payment_method_sudo not in order_sudo.config_id.payment_method_ids:
raise NotFound("Order or payment method not found")
status = payment_method_sudo._payment_request_from_kiosk(order_sudo)
if not status:
raise BadRequest("Something went wrong")
return {'order': order_sudo.read(order_sudo._load_pos_data_fields(pos_config.id), load=False), 'payment_status': status}
@http.route('/pos-self-order/change-printer-status', auth='public', type='json', website=True)
def change_printer_status(self, access_token, has_paper):
pos_config = self._verify_pos_config(access_token)
if has_paper != pos_config.has_paper:
pos_config.write({'has_paper': has_paper})
def _get_order_prices(self, lines):
amount_untaxed = sum(lines.mapped('price_subtotal'))
amount_total = sum(lines.mapped('price_subtotal_incl'))
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, config_id, sequence_number, device_type):
first_part = "{:05d}".format(int(pos_session_id))
second_part = "{:03d}".format(int(config_id))
third_part = "{:04d}".format(int(sequence_number))
device = "Kiosk" if device_type == "kiosk" else "Self-Order"
return f"{device} {first_part}-{second_part}-{third_part}"
def _verify_pos_config(self, access_token):
"""
Finds the pos.config with the given access_token and returns a record with reduced privileges.
The record is has no sudo access and is in the context of the record's company and current pos.session's user.
"""
pos_config_sudo = request.env['pos.config'].sudo().search([('access_token', '=', access_token)], limit=1)
if not pos_config_sudo or (not pos_config_sudo.self_ordering_mode == 'mobile' and not pos_config_sudo.self_ordering_mode == 'kiosk') or not pos_config_sudo.has_active_session:
raise Unauthorized("Invalid access token")
company = pos_config_sudo.company_id
user = pos_config_sudo.self_ordering_default_user_id
return pos_config_sudo.sudo(False).with_company(company).with_user(user).with_context(allowed_company_ids=company.ids)
def _verify_authorization(self, access_token, table_identifier, takeaway):
"""
Similar to _verify_pos_config but also looks for the restaurant.table of the given identifier.
The restaurant.table record is also returned with reduced privileges.
"""
pos_config = self._verify_pos_config(access_token)
table_sudo = request.env["restaurant.table"].sudo().search([('identifier', '=', table_identifier)], limit=1)
if not table_sudo and not pos_config.self_ordering_mode == 'kiosk' and pos_config.self_ordering_service_mode == 'table' and not takeaway:
raise Unauthorized("Table not found")
company = pos_config.company_id
user = pos_config.self_ordering_default_user_id
table = table_sudo.sudo(False).with_company(company).with_user(user).with_context(allowed_company_ids=company.ids)
return pos_config, table
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
import werkzeug
from odoo import http
from odoo.http import request
class PosSelfKiosk(http.Controller):
@http.route(["/pos-self/<config_id>", "/pos-self/<config_id>/<path:subpath>"], auth="public", website=True, sitemap=True)
def start_self_ordering(self, config_id=None, access_token=None, table_identifier=None, subpath=None):
pos_config, _, config_access_token = self._verify_entry_access(config_id, access_token, table_identifier)
use_lna = bool(pos_config.sudo().env["ir.config_parameter"].get_param("point_of_sale.use_lna"))
return request.render(
'pos_self_order.index',
{
'use_lna': use_lna,
'access_token': config_access_token,
'session_info': {
**request.env["ir.http"].get_frontend_session_info(),
'currencies': request.env["ir.http"].get_currencies(),
'data': {
'config_id': pos_config.id,
'self_ordering_mode': pos_config.self_ordering_mode,
},
"base_url": request.env['pos.session'].get_base_url(),
"db": request.env.cr.dbname,
}
}
)
@http.route("/pos-self/data/<config_id>", type='json', auth='public', website=True)
def get_self_ordering_data(self, config_id=None, access_token=None, table_identifier=None):
pos_config, _, config_access_token = self._verify_entry_access(config_id, access_token, table_identifier)
data = pos_config.load_self_data()
data['pos.config']['data'][0]['access_token'] = config_access_token
return data
def _verify_entry_access(self, config_id=None, access_token=None, table_identifier=None):
table_sudo = False
if not config_id or not config_id.isnumeric():
raise werkzeug.exceptions.NotFound()
if access_token:
config_access_token = True
pos_config_sudo = request.env["pos.config"].sudo().search([
("id", "=", config_id), ('access_token', '=', access_token)], limit=1)
else:
config_access_token = False
pos_config_sudo = request.env["pos.config"].sudo().search([
("id", "=", config_id)], limit=1)
if not pos_config_sudo or pos_config_sudo.self_ordering_mode == 'nothing':
raise werkzeug.exceptions.NotFound()
company = pos_config_sudo.company_id
user = pos_config_sudo.self_ordering_default_user_id
pos_config = pos_config_sudo.sudo(False).with_company(company).with_user(user).with_context(allowed_company_ids=company.ids, lang=request.cookies.get('frontend_lang'))
if not pos_config:
raise werkzeug.exceptions.NotFound()
if pos_config and pos_config.has_active_session and pos_config.self_ordering_mode == 'mobile':
if config_access_token:
config_access_token = pos_config.access_token
table_sudo = table_identifier and (
request.env["restaurant.table"]
.sudo()
.search([("identifier", "=", table_identifier), ("active", "=", True)], limit=1)
)
if table_sudo and table_sudo.parent_id:
table_sudo = table_sudo.parent_id
elif pos_config.self_ordering_mode == 'kiosk':
if config_access_token:
config_access_token = pos_config.access_token
else:
config_access_token = ''
table = table_sudo.sudo(False).with_company(company).with_user(user) if table_sudo else False
return pos_config, table, config_access_token
@@ -0,0 +1,38 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import mimetypes
import re
from urllib.parse import unquote
from odoo import http
from odoo.http import request
from odoo.addons.web.controllers import webmanifest
class WebManifest(webmanifest.WebManifest):
def _get_scoped_app_name(self, app_id):
if app_id == "pos_self_order":
if match := re.findall(r'pos-self/(\d+)', unquote(request.params['path'])):
if record := request.env['pos.config'].search([('id', '=', match[0])]):
return record.name
return super()._get_scoped_app_name(app_id)
def _get_scoped_app_icons(self, app_id):
if app_id == "pos_self_order":
company = request.env.company
if company.uses_default_logo:
icon_src = '/point_of_sale/static/description/icon.svg'
else:
icon_src = f'/web/image?model=res.company&id={company.id}&field=logo&height=192&width=192'
return [{
'src': icon_src,
'sizes': 'any',
'type': mimetypes.guess_type(icon_src)[0] or 'image/png'
}]
return super()._get_scoped_app_icons(app_id)
@http.route()
def scoped_app_icon_png(self, app_id):
if app_id == "pos_self_order" and request.env.company.uses_default_logo:
return super().scoped_app_icon_png('point_of_sale')
return super().scoped_app_icon_png(app_id)
@@ -0,0 +1,6 @@
<?xml version="1.0"?>
<odoo>
<data noupdate="1">
<function model="restaurant.table" name="_update_identifier" />
</data>
</odoo>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<function model="pos.config" name="load_onboarding_kiosk_scenario" />
</data>
</odoo>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from . import ir_binary
from . import ir_http
from . import pos_category
from . import pos_config
from . import pos_order
from . import pos_restaurant
from . import pos_payment_method
from . import pos_self_order_custom_link
from . import product_product
from . import res_config_settings
from . import pos_session
from . import pos_load_mixin
from . import account_fiscal_position
@@ -0,0 +1,8 @@
from odoo import models
class AccountFiscalPosition(models.Model):
_inherit = 'account.fiscal.position'
def _load_pos_self_data(self, data):
return self._load_pos_data(data)
+10
View File
@@ -0,0 +1,10 @@
from odoo import models
class IrBinary(models.AbstractModel):
_inherit = "ir.binary"
def _find_record_check_access(self, record, access_token, field):
if record._name in ["product.product", "pos.category"] and field in ["image_128", "image_512"]:
return record.sudo()
return super()._find_record_check_access(record, access_token, field)
+48
View File
@@ -0,0 +1,48 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo import api, models
from odoo.http import request
class IrHttp(models.AbstractModel):
_inherit = "ir.http"
@classmethod
def _get_translation_frontend_modules_name(cls):
mods = super()._get_translation_frontend_modules_name()
return mods + ["pos_self_order"]
# With the website module installed, there is an issue where
# the default website's languages override the kiosk languages.
# This override works around the issue.
@api.model
def get_nearest_lang(self, lang_code: str) -> str:
if not lang_code:
return super().get_nearest_lang(lang_code)
referer_url = request.httprequest.headers.get('Referer', '')
path = request.httprequest.path
if '/pos-self/' in path:
path_with_config = path
elif '/website/translations' in path and '/pos-self/' in referer_url:
path_with_config = referer_url
else:
path_with_config = None
if path_with_config:
config_id_match = re.search(r'/pos-self(?:/data)?/(\d+)', path_with_config)
if config_id_match:
pos_config = request.env['pos.config'].sudo().browse(int(config_id_match[1]))
if pos_config.self_ordering_available_language_ids:
self_order_langs = pos_config.self_ordering_available_language_ids.mapped('code')
if lang_code in self_order_langs:
return lang_code
short_code = lang_code.partition('_')[0]
matched_code = next((code for code in self_order_langs if code.startswith(short_code)), None)
if matched_code:
return matched_code
return super().get_nearest_lang(lang_code)
@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import ValidationError
from odoo import models, fields, api, _
class PosCategory(models.Model):
_inherit = "pos.category"
hour_until = fields.Float(string='Availability Until', default=24.0, help="The product will be available until this hour.")
hour_after = fields.Float(string='Availability After', default=0.0, help="The product will be available after this hour.")
@api.model
def _load_pos_data_fields(self, config_id):
fields = super()._load_pos_data_fields(config_id)
fields += ['hour_until', 'hour_after']
return fields
@api.constrains('hour_until', 'hour_after')
def _check_hour(self):
for category in self:
if category.hour_until and not (0.0 <= category.hour_until <= 24.0):
raise ValidationError(_('The Availability Until must be set between 00:00 and 24:00'))
if category.hour_after and not (0.0 <= category.hour_after <= 24.0):
raise ValidationError(_('The Availability After must be set between 00:00 and 24:00'))
if category.hour_until and category.hour_after and category.hour_until < category.hour_after:
raise ValidationError(_('The Availability Until must be greater than Availability After.'))
+427
View File
@@ -0,0 +1,427 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import uuid
import base64
import zipfile
import qrcode
from io import BytesIO
from os.path import join as opj
from typing import Optional, List, Dict
from werkzeug.urls import url_quote, url_unquote
from odoo.exceptions import UserError, ValidationError, AccessError
from odoo import api, fields, models, _, service
from odoo.tools import file_open, split_every
class PosConfig(models.Model):
_inherit = "pos.config"
def _self_order_kiosk_default_languages(self):
return self.env["res.lang"].get_installed()
def _self_order_default_user(self):
users = self.env["res.users"].search(['|', ('company_ids', 'in', self.env.company.id), ('company_id', '=', False)])
for user in users:
if user.sudo().has_group("point_of_sale.group_pos_manager"):
return user
status = fields.Selection(
[("inactive", "Inactive"), ("active", "Active")],
string="Status",
compute="_compute_status",
store=False,
)
self_ordering_url = fields.Char(compute="_compute_self_ordering_url")
self_ordering_takeaway = fields.Boolean("Self Takeaway")
self_ordering_mode = fields.Selection(
[("nothing", "Disable"), ("consultation", "QR menu"), ("mobile", "QR menu + Ordering"), ("kiosk", "Kiosk")],
string="Self Ordering Mode",
default="nothing",
help="Choose the self ordering mode",
required=True,
)
self_ordering_service_mode = fields.Selection(
[("counter", "Pickup zone"), ("table", "Table")],
string="Self Ordering Service Mode",
default="counter",
help="Choose the kiosk mode",
required=True,
)
self_ordering_default_language_id = fields.Many2one(
"res.lang",
string="Default Language",
help="Default language for the kiosk mode",
default=lambda self: self.env["res.lang"].search(
[("code", "=", self.env.lang)], limit=1
),
)
self_ordering_available_language_ids = fields.Many2many(
"res.lang",
string="Available Languages",
help="Languages available for the kiosk mode",
default=_self_order_kiosk_default_languages,
)
self_ordering_image_home_ids = fields.Many2many(
'ir.attachment',
string="Add images",
help="Image to display on the self order screen",
)
self_ordering_default_user_id = fields.Many2one(
"res.users",
string="Default User",
help="Access rights of this user will be used when visiting self order website when no session is open.",
default=_self_order_default_user,
)
self_ordering_pay_after = fields.Selection(
selection=lambda self: self._compute_selection_pay_after(),
string="Pay After:",
default="meal",
help="Choose when the customer will pay",
required=True,
)
self_ordering_image_brand = fields.Image(
string="Self Order Kiosk Image Brand",
help="Image to display on the self order screen",
max_width=1200,
max_height=250,
)
self_ordering_image_brand_name = fields.Char(
string="Self Order Kiosk Image Brand Name",
help="Name of the image to display on the self order screen",
)
has_paper = fields.Boolean("Has paper", default=True)
def _update_access_token(self):
self.access_token = uuid.uuid4().hex[:16]
self.floor_ids.table_ids._update_identifier()
@api.model_create_multi
def create(self, vals_list):
self._prepare_self_order_splash_screen(vals_list)
pos_config_ids = super().create(vals_list)
pos_config_ids._prepare_self_order_custom_btn()
return pos_config_ids
@api.model
def _prepare_self_order_splash_screen(self, vals_list):
for vals in vals_list:
if not vals.get('self_ordering_mode'):
return True
if not vals.get('self_ordering_image_home_ids'):
vals['self_ordering_image_home_ids'] = [(0, 0, {
'name': image_name,
'datas': base64.b64encode(file_open(opj("pos_self_order/static/img", image_name), "rb").read()),
'res_model': 'pos.config',
'type': 'binary',
}) for image_name in ['landing_01.jpg', 'landing_02.jpg', 'landing_03.jpg']]
return True
def _prepare_self_order_custom_btn(self):
for record in self:
exists = record.env['pos_self_order.custom_link'].search_count([
('pos_config_ids', 'in', record.id),
('url', '=', f'/pos-self/{record.id}/products')
])
if not exists:
record.env['pos_self_order.custom_link'].create({
'name': _('Order Now'),
'url': f'/pos-self/{record.id}/products',
'pos_config_ids': [(4, record.id)],
})
def write(self, vals):
self._prepare_self_order_splash_screen([vals])
for record in self:
if vals.get('self_ordering_mode') == 'kiosk' or (vals.get('pos_self_ordering_mode') == 'mobile' and vals.get('pos_self_ordering_service_mode') == 'counter'):
vals['self_ordering_pay_after'] = 'each'
if (not vals.get('module_pos_restaurant') and not record.module_pos_restaurant) and vals.get('self_ordering_mode') == 'mobile':
vals['self_ordering_pay_after'] = 'each'
if (vals.get('self_ordering_service_mode') == 'counter' or record.self_ordering_service_mode == 'counter') and vals.get('self_ordering_mode') == 'mobile':
vals['self_ordering_pay_after'] = 'each'
if vals.get('self_ordering_mode') == 'mobile' and vals.get('self_ordering_pay_after') == 'meal':
vals['self_ordering_service_mode'] = 'table'
res = super().write(vals)
self._prepare_self_order_custom_btn()
return res
@api.depends("module_pos_restaurant")
def _compute_self_order(self):
for record in self:
if not record.module_pos_restaurant and record.self_ordering_mode != 'kiosk':
record.self_ordering_mode = 'nothing'
def _compute_selection_pay_after(self):
selection_each_label = _("Each Order")
version_info = service.common.exp_version()['server_version_info']
if version_info[-1] == '':
selection_each_label = f"{selection_each_label} {_('(require Odoo Enterprise)')}"
return [("meal", _("Meal")), ("each", selection_each_label)]
@api.constrains('self_ordering_default_user_id')
def _check_default_user(self):
for record in self:
if (
record.self_ordering_mode != 'nothing' and (
not record.self_ordering_default_user_id or (
record.self_ordering_default_user_id
and not record.self_ordering_default_user_id.sudo().has_group("point_of_sale.group_pos_user")
and not record.self_ordering_default_user_id.sudo().has_group("point_of_sale.group_pos_manager")))
):
raise UserError(_("The Self-Order default user must be a POS user"))
@api.constrains("payment_method_ids", "self_ordering_mode")
def _onchange_payment_method_ids(self):
if any(record.self_ordering_mode == 'kiosk' and any(pm.is_cash_count for pm in record.payment_method_ids) for record in self):
raise ValidationError(_("You cannot add cash payment methods in kiosk mode."))
def _get_qr_code_data(self):
self.ensure_one()
table_qr_code = []
if self.self_ordering_mode == 'mobile' and self.module_pos_restaurant and self.self_ordering_service_mode == 'table':
table_qr_code.extend([{
'name': floor.name,
'type': 'table',
'tables': [
{
'identifier': table.identifier,
'id': table.id,
'name': table.table_number,
'url': self._get_self_order_url(table.id),
}
for table in floor.table_ids.filtered("active")
]
}
for floor in self.floor_ids]
)
else:
# Here we use "range" to determine the number of QR codes to generate from
# this list, which will then be inserted into a PDF.
table_qr_code.extend([{
'name': _('Generic'),
'type': 'default',
'tables': [{
'id': i,
'url': self._get_self_order_url(),
} for i in range(0, 6)]
}])
return table_qr_code
def _get_self_order_route(self, table_id: Optional[int] = None) -> str:
self.ensure_one()
base_route = f"/pos-self/{self.id}"
table_route = ""
if self.self_ordering_mode == 'consultation':
return base_route
if self.self_ordering_mode == 'mobile':
table = self.env["restaurant.table"].search(
[("active", "=", True), ("id", "=", table_id)], limit=1
)
if table:
table_route = f"&table_identifier={table.identifier}"
return f"{base_route}?access_token={self.access_token}{table_route}"
def _get_self_order_url(self, table_id: Optional[int] = None) -> str:
self.ensure_one()
return url_quote(self.get_base_url() + self._get_self_order_route(table_id))
def preview_self_order_app(self):
self.ensure_one()
return {
"type": "ir.actions.act_url",
"url": self._get_self_order_route(),
"target": "new",
}
def _get_self_ordering_attachment(self, images):
encoded_images = []
for image in images:
encoded_images.append({
'id': image.id,
'data': image.sudo().datas.decode('utf-8'),
})
return encoded_images
def _load_self_data_models(self):
return ['pos.session', 'pos.order', 'pos.order.line', 'pos.payment', 'pos.payment.method', 'res.currency', 'pos.category', 'product.product', 'product.combo', 'product.combo.item',
'res.company', 'account.tax', 'account.tax.group', 'pos.printer', 'res.country', 'product.pricelist', 'product.pricelist.item', 'account.fiscal.position', 'account.fiscal.position.tax',
'res.lang', 'product.template.attribute.line', 'product.attribute', 'product.attribute.custom.value', 'product.template.attribute.value',
'decimal.precision', 'uom.uom', 'pos.printer', 'pos_self_order.custom_link', 'restaurant.floor', 'restaurant.table', 'account.cash.rounding']
def load_self_data(self):
# Init our first record, in case of self_order is pos_config
config_fields = self._load_pos_self_data_fields(self.id)
response = {
'pos.config': {
'data': self.env['pos.config'].search_read([('id', '=', self.id)], config_fields, load=False),
'fields': config_fields,
}
}
response['pos.config']['data'][0]['_self_ordering_image_home_ids'] = self._get_self_ordering_attachment(self.self_ordering_image_home_ids)
response['pos.config']['data'][0]['_pos_special_products_ids'] = self._get_special_products().ids
self.env['pos.session']._load_pos_data_relations('pos.config', response)
# Classic data loading
for model in self._load_self_data_models():
try:
response[model] = self.env[model]._load_pos_self_data(response)
self.env['pos.session']._load_pos_data_relations(model, response)
except AccessError as e:
response[model] = {
'data': [],
'fields': self.env[model]._load_pos_self_data_fields(self.id),
'error': e.args[0]
}
self.env['pos.session']._load_pos_data_relations(model, response)
return response
def _split_qr_codes_list(self, floors: List[Dict], cols: int) -> List[Dict]:
"""
:floors: the list of floors
:cols: the number of qr codes per row
"""
self.ensure_one()
return [
{
"name": floor.get("name"),
"rows_of_tables": list(split_every(cols, floor["tables"], list)),
}
for floor in floors
]
def _compute_self_ordering_url(self):
for record in self:
record.self_ordering_url = record.get_base_url() + record._get_self_order_route()
def action_close_kiosk_session(self):
if self.current_session_id and self.current_session_id.order_ids:
self.current_session_id.order_ids.filtered(lambda o: o.state not in ['paid', 'invoiced']).unlink()
self._notify('STATUS', {'status': 'closed'})
return self.current_session_id.action_pos_session_closing_control()
def _compute_status(self):
for record in self:
record.status = 'active' if record.has_active_session else 'inactive'
def action_open_wizard(self):
self.ensure_one()
if not self.current_session_id:
self._check_before_creating_new_session()
session = self.env['pos.session'].create({'user_id': self.env.uid, 'config_id': self.id})
session.set_opening_control(0, "")
self._notify('STATUS', {'status': 'open'})
ctx = dict(self._context, app_id='pos_self_order', footer=False)
return {
'res_model': 'pos.config',
'type': 'ir.actions.client',
'tag': 'install_kiosk_pwa',
'target': 'new',
'context': ctx
}
def get_kiosk_url(self):
return self.self_ordering_url
@api.model
def load_onboarding_kiosk_scenario(self):
if not bool(self.env.company.chart_template):
return False
journal, payment_methods_ids = self._create_journal_and_payment_methods()
restaurant_categories = self.get_categories([
'pos_restaurant.food',
'pos_restaurant.drinks',
])
not_cash_payment_methods_ids = self.env['pos.payment.method'].search([
('is_cash_count', '=', False),
('id', 'in', payment_methods_ids),
]).ids
self.env['pos.config'].create({
'name': _('Kiosk'),
'company_id': self.env.company.id,
'journal_id': journal.id,
'payment_method_ids': not_cash_payment_methods_ids,
'limit_categories': True,
'iface_available_categ_ids': restaurant_categories,
'iface_splitbill': True,
'module_pos_restaurant': True,
'self_ordering_mode': 'kiosk',
'self_ordering_pay_after': 'each',
})
def __generate_single_qr_code(self, url):
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(url)
qr.make(fit=True)
return qr.make_image(fill_color="black", back_color="transparent")
def get_pos_qr_order_data(self):
url_form = "https://www.odoo.com/app/point-of-sale-restaurant-qr-code"
table_data = []
if self.self_ordering_mode not in ['mobile', 'consultation']:
return {
'success': False,
'error': 'INVALID_SELF_ORDERING_MODE',
}
table_ids = None
if self.module_pos_restaurant:
table_ids = self.floor_ids.table_ids
if table_ids and self.self_ordering_mode == 'mobile':
for table in table_ids:
url = self._get_self_order_url(table.id)
table_data.append({
'url': url,
'name': f"{table.floor_id.name} - {table.table_number}",
'image': self.__generate_single_qr_code(url_unquote(url)),
})
else:
url = self._get_self_order_url()
table_data.append({
'url': url,
'name': "generic",
'image': self.__generate_single_qr_code(url_unquote(url)),
})
zip_buffer = BytesIO()
with zipfile.ZipFile(zip_buffer, "w", 0) as zip_file:
for index, qr_data in enumerate(table_data):
with zip_file.open(f"{qr_data['name']} ({index + 1}).png", "w") as buf:
qr_data['image'].save(buf, format="PNG")
zip_buffer.seek(0)
return {
'success': True,
'table_data': table_data,
'self_ordering_mode': self.self_ordering_mode,
'db_name': self.env.cr.dbname,
'redirect_url': url_form,
'zip_archive': base64.b64encode(zip_buffer.read()).decode('utf-8'),
}
@@ -0,0 +1,22 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, api
class PosLoadMixin(models.AbstractModel):
_inherit = "pos.load.mixin"
@api.model
def _load_pos_self_data_domain(self, data):
return self._load_pos_data_domain(data)
@api.model
def _load_pos_self_data_fields(self, config_id):
return self._load_pos_data_fields(config_id)
def _load_pos_self_data(self, data):
domain = self._load_pos_self_data_domain(data)
fields = self._load_pos_self_data_fields(data['pos.config']['data'][0]['id'])
return {
'data': self.search_read(domain, fields, load=False),
'fields': fields,
}
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class PosOrderLine(models.Model):
_inherit = "pos.order.line"
combo_id = fields.Many2one('product.combo', string='Combo reference')
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if (vals.get('combo_parent_uuid')):
vals.update([
('combo_parent_id', self.search([('uuid', '=', vals.get('combo_parent_uuid'))]).id)
])
if 'combo_parent_uuid' in vals:
del vals['combo_parent_uuid']
return super().create(vals_list)
def write(self, vals):
if (vals.get('combo_parent_uuid')):
vals.update([
('combo_parent_id', self.search([('uuid', '=', vals.get('combo_parent_uuid'))]).id)
])
if 'combo_parent_uuid' in vals:
del vals['combo_parent_uuid']
return super().write(vals)
class PosOrder(models.Model):
_inherit = "pos.order"
table_stand_number = fields.Char(string="Table Stand Number")
@api.model
def _load_pos_self_data_domain(self, data):
return [('id', '=', False)]
@api.model
def sync_from_ui(self, orders):
for order in orders:
if order.get('id'):
order_id = order['id']
if isinstance(order_id, int):
old_order = self.env['pos.order'].browse(order_id)
if old_order.takeaway:
order['takeaway'] = old_order.takeaway
result = super().sync_from_ui(orders)
order_ids = self.browse([order['id'] for order in result['pos.order'] if order.get('id')])
self._send_notification(order_ids)
return result
@api.model
def remove_from_ui(self, server_ids):
order_ids = self.env['pos.order'].browse(server_ids)
order_ids.state = 'cancel'
self._send_notification(order_ids)
return super().remove_from_ui(server_ids)
def _send_notification(self, order_ids):
config_ids = order_ids.config_id
for config in config_ids:
config.notify_synchronisation(config.current_session_id.id, self.env.context.get('login_number', 0))
config._notify('ORDER_STATE_CHANGED', {})
@@ -0,0 +1,16 @@
from odoo import models, api
class PosPaymentMethod(models.Model):
_inherit = "pos.payment.method"
# will be overridden.
def _payment_request_from_kiosk(self, order):
pass
@api.model
def _load_pos_self_data_domain(self, data):
if data['pos.config']['data'][0]['self_ordering_mode'] == 'kiosk':
return [('use_payment_terminal', 'in', ['adyen', 'stripe']), ('id', 'in', data['pos.config']['data'][0]['payment_method_ids'])]
else:
[('id', '=', False)]
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import uuid
from typing import Dict, Callable, List, Optional
from odoo import api, fields, models
class RestaurantTable(models.Model):
_inherit = "restaurant.table"
identifier = fields.Char(
"Security Token",
copy=False,
required=True,
default=lambda self: self._get_identifier(),
)
@staticmethod
def _get_identifier():
return uuid.uuid4().hex[:8]
@api.model
def _update_identifier(self):
tables = self.env["restaurant.table"].search([])
for table in tables:
table.identifier = self._get_identifier()
@api.model
def _load_pos_self_data_fields(self, config_id):
return ['table_number', 'identifier', 'floor_id']
@api.model
def _load_pos_self_data_domain(self, data):
return [('floor_id', 'in', [floor['id'] for floor in data['restaurant.floor']['data']])]
class RestaurantFloor(models.Model):
_inherit = "restaurant.floor"
@api.model
def _load_pos_self_data_fields(self, config_id):
return ['name', 'table_ids']
@api.model
def _load_pos_self_data_domain(self, data):
return [('id', 'in', data['pos.config']['data'][0]['floor_ids'])]
@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
from markupsafe import escape
class PosSelfOrderCustomLink(models.Model):
_name = "pos_self_order.custom_link"
_inherit = "pos.load.mixin"
_description = (
"Custom links that the restaurant can configure to be displayed on the self order screen"
)
name = fields.Char(string="Label", required=True, translate=True)
url = fields.Char(string="URL", required=True)
pos_config_ids = fields.Many2many(
"pos.config",
string="Points of Sale",
domain="[('self_ordering_mode', '!=', 'nothing')]",
help="Select for which points of sale you want to display this link. Leave empty to display it for all points of sale. You have to select among the points of sale that have the 'QR Code Menu' feature enabled.",
)
style = fields.Selection(
[
("primary", "Primary"),
("secondary", "Secondary"),
("success", "Success"),
("warning", "Warning"),
("danger", "Danger"),
("info", "Info"),
("light", "Light"),
("dark", "Dark"),
],
string="Style",
default="primary",
required=True,
)
link_html = fields.Html("Preview", compute="_compute_link_html", store=True, readonly=True)
sequence = fields.Integer("Sequence", default=1)
@api.model
def _load_pos_self_data_domain(self, data):
return [('pos_config_ids', 'in', data['pos.config']['data'][0]['id'])]
@api.model
def _load_pos_self_data_fields(self, config_id):
return ['name', 'url', 'style', 'link_html', 'sequence']
@api.depends("name", "style")
def _compute_link_html(self):
for link in self:
if link.name:
link.link_html = f'<a class="btn btn-{link.style} w-100">{escape(link.name)}</a>'
@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, api, _, fields
class PosSession(models.Model):
_inherit = 'pos.session'
@api.model_create_multi
def create(self, vals_list):
sessions = super(PosSession, self).create(vals_list)
sessions = self._create_pos_self_sessions_sequence(sessions)
return sessions
@api.model
def _create_pos_self_sessions_sequence(self, sessions):
company_id = self.env.company.id
for session in sessions:
session.env['ir.sequence'].sudo().create({
'name': _("PoS Order by Session"),
'padding': 4,
'code': f'pos.order_{session.id}',
'number_next': 1,
'number_increment': 1,
'company_id': company_id,
})
return sessions
@api.model
def _load_pos_self_data_domain(self, data):
return [('config_id', '=', data['pos.config']['data'][0]['id']), ('state', '=', 'opened')]
def _load_pos_self_data(self, data):
result = super()._load_pos_self_data(data)
if result['data']:
result['data'][0]['_base_url'] = self.get_base_url()
return result
def _load_pos_data(self, data):
sessions = super()._load_pos_data(data)
sessions['data'][0]['_self_ordering'] = (
self.env["pos.config"]
.sudo()
.search_count(
[
*self.env["pos.config"]._check_company_domain(self.env.company),
'|', ("self_ordering_mode", "=", "kiosk"),
("self_ordering_mode", "=", "mobile"),
],
limit=1,
)
> 0
)
return sessions
def _get_gc_sequence_prefix(self):
res = super()._get_gc_sequence_prefix()
res.append('pos.order_')
return res
@@ -0,0 +1,141 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from __future__ import annotations
from typing import List, Dict
from odoo import api, models, fields
from odoo.osv.expression import AND
class ProductTemplate(models.Model):
_inherit = 'product.template'
self_order_available = fields.Boolean(
string="Available in Self Order",
help="If this product is available in the Self Order screens",
default=True,
)
@api.onchange('available_in_pos')
def _on_change_available_in_pos(self):
for record in self:
if not record.available_in_pos:
record.self_order_available = False
def write(self, vals_list):
if 'available_in_pos' in vals_list:
if not vals_list['available_in_pos']:
vals_list['self_order_available'] = False
res = super().write(vals_list)
if 'self_order_available' in vals_list:
for record in self:
for product in record.product_variant_ids:
product._send_availability_status()
return res
class ProductProduct(models.Model):
_inherit = "product.product"
@api.model
def _load_pos_data_fields(self, config_id):
params = super()._load_pos_data_fields(config_id)
params += ['self_order_available']
return params
@api.model
def _load_pos_self_data_fields(self, config_id):
params = super()._load_pos_self_data_fields(config_id)
params += ['public_description', 'list_price']
return params
@api.model
def _load_pos_self_data_domain(self, data):
domain = super()._load_pos_self_data_domain(data)
return AND([domain, [('self_order_available', '=', True)]])
def _load_pos_self_data(self, data):
domain = self._load_pos_self_data_domain(data)
config_id = data['pos.config']['data'][0]['id']
# Add custom fields for 'formula' taxes.
fields = set(self._load_pos_self_data_fields(config_id))
taxes = self.env['account.tax'].search(self.env['account.tax']._load_pos_data_domain(data))
product_fields = taxes._eval_taxes_computation_prepare_product_fields()
fields = list(fields.union(product_fields))
config = self.env['pos.config'].browse(config_id)
products = self.with_context(display_default_code=False).search_read(
domain,
fields,
limit=config.get_limited_product_count(),
order='sequence,default_code,name',
load=False
)
combo_products = self.browse((p['id'] for p in products if p["type"]=="combo"))
combo_products_choice = self.with_context(display_default_code=False).search_read(
[("id", 'in', combo_products.combo_ids.combo_item_ids.product_id.ids), ("id", "not in", [p['id'] for p in products])],
fields,
limit=config.get_limited_product_count(),
order='sequence,default_code,name',
load=False
)
products.extend(combo_products_choice)
for product in products:
product['image_128'] = bool(product['image_128'])
data['pos.config']['data'][0]['_product_default_values'] = \
self.env['account.tax']._eval_taxes_computation_prepare_product_default_values(product_fields)
self._compute_product_price_with_pricelist(products, config_id)
return {
'data': products,
'fields': fields,
}
def _compute_product_price_with_pricelist(self, products, config_id):
config = self.env['pos.config'].browse(config_id)
pricelist = config.pricelist_id
product_ids = [product['id'] for product in products]
product_objs = self.env['product.product'].browse(product_ids)
product_map = {product.id: product for product in product_objs}
loaded_product_tmpl_ids = list({p['product_tmpl_id'] for p in products})
archived_combinations = self._get_archived_combinations_per_product_tmpl_id(loaded_product_tmpl_ids)
for product in products:
product_obj = product_map.get(product['id'])
if product_obj:
product['lst_price'] = pricelist._get_product_price(
product_obj, 1.0, currency=config.currency_id
)
if archived_combinations.get(product['product_tmpl_id']):
product['_archived_combinations'] = archived_combinations[product['product_tmpl_id']]
def _filter_applicable_attributes(self, attributes_by_ptal_id: Dict) -> List[Dict]:
"""
The attributes_by_ptal_id is a dictionary that contains all the attributes that have
[('create_variant', '=', 'no_variant')]
This method filters out the attributes that are not applicable to the product in self
"""
self.ensure_one()
return [
attributes_by_ptal_id[id]
for id in self.attribute_line_ids.ids
if attributes_by_ptal_id.get(id) is not None
]
def write(self, vals_list):
res = super().write(vals_list)
if 'self_order_available' in vals_list:
for record in self:
record._send_availability_status()
return res
def _send_availability_status(self):
config_self = self.env['pos.config'].sudo().search([('self_ordering_mode', '!=', 'nothing')])
for config in config_self:
if config.current_session_id and config.access_token:
config._notify('PRODUCT_CHANGED', {
'product.product': self.read(self._load_pos_self_data_fields(config.id), load=False)
})
@@ -0,0 +1,212 @@
# -*- coding: utf-8 -*-
import qrcode
import zipfile
from io import BytesIO
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from odoo.tools.misc import split_every
from odoo.osv.expression import AND
from werkzeug.urls import url_unquote
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
pos_self_ordering_takeaway = fields.Boolean(related="pos_config_id.self_ordering_takeaway", readonly=False)
pos_self_ordering_service_mode = fields.Selection(related="pos_config_id.self_ordering_service_mode", readonly=False, required=True)
pos_self_ordering_mode = fields.Selection(related="pos_config_id.self_ordering_mode", readonly=False, required=True)
pos_self_ordering_default_language_id = fields.Many2one(related="pos_config_id.self_ordering_default_language_id", readonly=False)
pos_self_ordering_available_language_ids = fields.Many2many(related="pos_config_id.self_ordering_available_language_ids", readonly=False)
pos_self_ordering_image_home_ids = fields.Many2many(related="pos_config_id.self_ordering_image_home_ids", readonly=False)
pos_self_ordering_image_brand = fields.Image(related="pos_config_id.self_ordering_image_brand", readonly=False)
pos_self_ordering_image_brand_name = fields.Char(related="pos_config_id.self_ordering_image_brand_name", readonly=False)
pos_self_ordering_pay_after = fields.Selection(related="pos_config_id.self_ordering_pay_after", readonly=False, required=True)
pos_self_ordering_default_user_id = fields.Many2one(related="pos_config_id.self_ordering_default_user_id", readonly=False)
@api.onchange("pos_self_ordering_default_user_id")
def _onchange_default_user(self):
self.ensure_one()
if self.pos_self_ordering_default_user_id and self.pos_self_ordering_mode == 'mobile':
user = self.pos_self_ordering_default_user_id
if not (user.has_group("point_of_sale.group_pos_user")
or user.has_group("point_of_sale.group_pos_manager")):
raise ValidationError(_("The user must be a POS user"))
@api.onchange("pos_self_ordering_service_mode")
def _onchange_pos_self_order_service_mode(self):
if self.pos_self_ordering_service_mode == 'counter':
self.pos_self_ordering_pay_after = "each"
@api.onchange("pos_self_ordering_default_language_id", "pos_self_ordering_available_language_ids")
def _onchange_pos_self_order_kiosk_default_language(self):
if self.pos_self_ordering_default_language_id not in self.pos_self_ordering_available_language_ids:
self.pos_self_ordering_available_language_ids = self.pos_self_ordering_available_language_ids + self.pos_self_ordering_default_language_id
if not self.pos_self_ordering_default_language_id and self.pos_self_ordering_available_language_ids:
self.pos_self_ordering_default_language_id = self.pos_self_ordering_available_language_ids[0]
@api.onchange("pos_self_ordering_mode", "pos_module_pos_restaurant")
def _onchange_pos_self_order_kiosk(self):
if self.pos_self_ordering_mode == 'kiosk':
self.is_kiosk_mode = True
self.pos_module_pos_restaurant = False
self.pos_self_ordering_pay_after = "each"
cash_payment_methods = self.pos_payment_method_ids.filtered(lambda x: x.is_cash_count)
self.pos_payment_method_ids = self.pos_payment_method_ids - cash_payment_methods
else:
self.is_kiosk_mode = False
if not self.pos_module_pos_restaurant:
self.pos_self_ordering_service_mode = 'counter'
@api.onchange("pos_payment_method_ids")
def _onchange_pos_payment_method_ids(self):
if self.pos_self_ordering_mode == 'kiosk' and any(pm.is_cash_count for pm in self.pos_payment_method_ids):
raise ValidationError(_("You cannot add cash payment methods in kiosk mode."))
@api.onchange("pos_self_ordering_pay_after", "pos_self_ordering_mode")
def _onchange_pos_self_order_pay_after(self):
if self.pos_self_ordering_pay_after == "meal" and self.pos_self_ordering_mode == 'kiosk':
raise ValidationError(_("Only pay after each is available with kiosk mode."))
if self.pos_self_ordering_service_mode == 'counter' and self.pos_self_ordering_mode == 'mobile':
self.pos_self_ordering_pay_after = "each"
if self.pos_self_ordering_mode not in ['nothing', 'consultation'] and self.pos_self_ordering_pay_after == "each" and not self.module_pos_preparation_display:
self.module_pos_preparation_display = True
def custom_link_action(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"res_model": "pos_self_order.custom_link",
"views": [[False, "list"]],
"domain": ['|', ['pos_config_ids', 'in', self.pos_config_id.id], ["pos_config_ids", "=", False]],
}
def __generate_single_qr_code(self, url):
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(url)
qr.make(fit=True)
return qr.make_image(fill_color="black", back_color="transparent")
def get_pos_qr_stands(self):
"""Redirect to the get the free stands with the data of QR codes for the current POS config"""
self.ensure_one()
return {
"type": "ir.actions.client",
"tag": "pos_qr_stands",
"params": {
"data": self.pos_config_id.get_pos_qr_order_data(),
},
}
def generate_qr_codes_zip(self):
if not self.pos_self_ordering_mode in ['mobile', 'consultation']:
raise ValidationError(_("QR codes can only be generated in mobile or consultation mode."))
qr_images = []
if self.pos_module_pos_restaurant:
table_ids = self.pos_config_id.floor_ids.table_ids
if not table_ids:
raise ValidationError(_("In Self-Order mode, you must have at least one table to generate QR codes"))
for table in table_ids:
qr_images.append({
'image': self.__generate_single_qr_code(url_unquote(self.pos_config_id._get_self_order_url(table.id))),
'name': f"{table.floor_id.name} - {table.table_number}",
})
else:
qr_images.append({
'image': self.__generate_single_qr_code(url_unquote(self.pos_config_id._get_self_order_url())),
'name': "generic",
})
# Create a zip with all images in qr_images
zip_buffer = BytesIO()
with zipfile.ZipFile(zip_buffer, "w", 0) as zip_file:
for index, qr_image in enumerate(qr_images):
with zip_file.open(f"{qr_image['name']} ({index + 1}).png", "w") as buf:
qr_image['image'].save(buf, format="PNG")
zip_buffer.seek(0)
# Delete previous attachments
self.env["ir.attachment"].search([
("name", "=", "self_order_qr_code.zip"),
]).unlink()
# Create an attachment with the zip
attachment_id = self.env["ir.attachment"].create({
"name": "self_order_qr_code.zip",
"type": "binary",
"raw": zip_buffer.read(),
"res_model": self._name,
"res_id": self.id,
})
return {
"type": "ir.actions.act_url",
"url": f"/web/content/{attachment_id.id}",
"target": "new",
}
def generate_qr_codes_page(self):
"""
Generate the data needed to print the QR codes page
"""
if self.pos_self_ordering_mode == 'mobile' and self.pos_module_pos_restaurant:
table_ids = self.pos_config_id.floor_ids.table_ids
if not table_ids:
raise ValidationError(_("In Self-Order mode, you must have at least one table to generate QR codes"))
url = url_unquote(self.pos_config_id._get_self_order_url(table_ids[0].id))
name = table_ids[0].table_number
else:
url = url_unquote(self.pos_config_id._get_self_order_url())
name = ""
return self.env.ref("pos_self_order.report_self_order_qr_codes_page").report_action(
[], data={
'pos_name': self.pos_config_id.name,
'floors': [
{
"name": floor.get("name"),
"type": floor.get("type"),
"table_rows": list(split_every(3, floor["tables"], list)),
}
for floor in self.pos_config_id._get_qr_code_data()
],
'table_mode': self.pos_self_ordering_mode and self.pos_module_pos_restaurant and self.pos_self_ordering_service_mode == 'table',
'self_order': self.pos_self_ordering_mode == 'mobile',
'table_example': {
'name': name,
'decoded_url': url or "",
}
}
)
def preview_self_order_app(self):
self.ensure_one()
return self.pos_config_id.preview_self_order_app()
def update_access_tokens(self):
self.ensure_one()
self.pos_config_id._update_access_token()
@api.depends('pos_self_ordering_mode')
def _compute_pos_pricelist_id(self):
super()._compute_pos_pricelist_id()
for res_config in self:
if res_config.pos_self_ordering_mode == 'kiosk':
currency_id = res_config.pos_journal_id.currency_id.id if res_config.pos_journal_id.currency_id else res_config.pos_config_id.company_id.currency_id.id
domain = AND([self.env['product.pricelist']._check_company_domain(res_config.pos_config_id.company_id), [('currency_id', '=', currency_id)]])
res_config.pos_available_pricelist_ids = self.env['product.pricelist'].search(domain)
@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_pos_self_order_custom_link_manager,access.pos_self_order.custom_link_manager,model_pos_self_order_custom_link,point_of_sale.group_pos_manager,1,1,1,1
access_pos_self_order_custom_link_user,access.pos_self_order.custom_link_user,model_pos_self_order_custom_link,point_of_sale.group_pos_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_pos_self_order_custom_link_manager access.pos_self_order.custom_link_manager model_pos_self_order_custom_link point_of_sale.group_pos_manager 1 1 1 1
3 access_pos_self_order_custom_link_user access.pos_self_order.custom_link_user model_pos_self_order_custom_link point_of_sale.group_pos_user 1 0 0 0
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,25 @@
$font-size-base: 1rem !default;
$font-size-root: $o-so-font-size-base !default; // ~16px
$font-size-sm: $font-size-base * .875 !default;
$font-size-lg: $font-size-base * 1.25 !default;
$secondary: $o-gray-200 !default;
$body-bg: $o-gray-200 !default;
$spacer: 1rem !default;
$spacers: (
0: 0,
1: $spacer * .25,
2: $spacer * .5,
3: $spacer,
4: $spacer * 1.5,
5: $spacer * 3,
) !default;
$input-btn-padding-y: 0.4125rem !default;
$input-btn-padding-x: 0.825rem !default;
$input-btn-padding-y-lg: 1rem !default;
$input-btn-padding-x-lg: 2rem !default;
$input-btn-font-size-lg: $font-size-lg !default;
@@ -0,0 +1,134 @@
import { Component, onMounted, useRef, useState } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/app/self_order_service";
import { floatIsZero } from "@web/core/utils/numbers";
export class AttributeSelection extends Component {
static template = "pos_self_order.AttributeSelection";
static props = ["product"];
setup() {
this.selfOrder = useSelfOrder();
this.numberOfAttributes = this.props.product.attribute_line_ids.length;
this.currentAttribute = 0;
this.gridsRef = {};
this.valuesRef = {};
for (const attr of this.props.product.attribute_line_ids) {
this.gridsRef[attr.id] = useRef(`attribute_grid_${attr.id}`);
this.valuesRef[attr.id] = {};
for (const value of attr.product_template_value_ids) {
this.valuesRef[attr.id][value.id] = useRef(`value_${attr.id}_${value.id}`);
}
}
this.state = useState({
showNext: false,
showCustomInput: false,
});
this.selectedValues = useState(this.env.selectedValues);
this.initAttribute();
onMounted(this.onMounted);
}
onMounted() {
for (const attr of Object.entries(this.valuesRef)) {
let classicValue = 0;
for (const valueRef of Object.values(attr[1])) {
if (valueRef.el) {
const height = valueRef.el.parentNode.offsetHeight;
if (classicValue === 0) {
classicValue = height;
} else {
if (height !== classicValue || height > window.innerHeight * 0.18) {
this.gridsRef[attr[0]].el.classList.remove(
"row-cols-2",
"row-cols-sm-3",
"row-cols-md-4",
"row-cols-xl-5",
"row-cols-xxl-6"
);
this.gridsRef[attr[0]].el.classList.add("row-cols-1");
for (const gridValueRef of Object.values(attr[1])) {
gridValueRef.el.classList.remove("ratio", "ratio-16x9");
}
break;
}
}
}
}
}
}
get showNextBtn() {
for (const attrSelection of Object.values(this.selectedValues)) {
if (!attrSelection) {
return false;
}
}
return true;
}
availableAttributeValue(attribute) {
return this.selfOrder.config.self_ordering_mode === "kiosk"
? attribute.product_template_value_ids.filter((a) => !a.is_custom)
: attribute.product_template_value_ids;
}
initAttribute() {
const initCustomValue = (value) => {
const selectedValue = this.selfOrder.editedLine?.custom_attribute_value_ids.find(
(v) => v.custom_product_template_attribute_value_id === value.id
);
return {
custom_product_template_attribute_value_id: this.selfOrder.models[
"product.template.attribute.value"
].get(value.id),
custom_value: selectedValue || "",
};
};
const initValue = (value) => {
if (this.selfOrder.editedLine?.attribute_value_ids.includes(value.id)) {
return value.id;
}
return false;
};
for (const attr of this.props.product.attribute_line_ids) {
this.selectedValues[attr.id] = {};
for (const value of attr.product_template_value_ids) {
if (attr.attribute_id.display_type === "multi") {
this.selectedValues[attr.id][value.id] = initValue(value);
} else if (typeof this.selectedValues[attr.id] !== "number") {
this.selectedValues[attr.id] = initValue(value);
}
if (value.is_custom) {
this.env.customValues[value.id] = initCustomValue(value);
}
}
}
}
isChecked(attribute, value) {
return attribute.attribute_id.display_type === "multi"
? this.selectedValues[attribute.id][value.id]
: parseInt(this.selectedValues[attribute.id]) === value.id;
}
shouldShowPriceExtra(value) {
const priceExtra = value.price_extra;
return !floatIsZero(priceExtra, this.selfOrder.currency.decimal_places);
}
getfPriceExtra(value) {
const priceExtra = value.price_extra;
const sign = priceExtra < 0 ? "- " : "+ ";
return sign + this.selfOrder.formatMonetary(Math.abs(priceExtra));
}
}
@@ -0,0 +1,11 @@
.self_order_attribute_selection {
.self_order_attribute_selection_option {
@include media-breakpoint-down(sm) {
--aspect-ratio: #{map-get($aspect-ratios, "21x9")};
}
> .active {
transform: scale(.95);
}
}
}
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.AttributeSelection">
<div class="self_order_attribute_selection d-flex flex-column flex-grow-1">
<div class="attribute-selection-content align-items-center justify-content-start px-3 flex-grow-1">
<div class="d-flex flex-column">
<div t-foreach="props.product.attribute_line_ids" t-as="attribute" t-key="attribute.id" class="attribute-row">
<h2 t-out="attribute.attribute_id.name"/>
<div class="row g-2 g-md-3 g-xl-4 justify-content-between justify-content-md-start mb-5 row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-xl-5 row-cols-xxl-6"
t-ref="attribute_grid_{{attribute.id}}">
<t t-foreach="availableAttributeValue(attribute)" t-as="value" t-key="value.id">
<div class="col" t-att-class="{'opacity-50' : value.excluded}">
<label t-attf-for="{{ attribute.id }}_{{ value.id }}"
t-attf-class="self_order_attribute_selection_option {{ this.isChecked(attribute, value) ? 'text-bg-primary border-primary active' : '' }}
d-flex align-items-center justify-content-center h-100 rounded border ratio ratio-16x9"
t-ref="value_{{attribute.id}}_{{value.id}}">
<div class="name position-relative d-flex flex-column justify-content-center align-items-center flex-grow-1 w-100 p-4 text-center">
<span t-out="value.name"/>
<span t-if="shouldShowPriceExtra(value)">
<t t-esc="getfPriceExtra(value)"/>
</span>
</div>
</label>
<input
type="radio"
class="d-none"
t-if="attribute.attribute_id.display_type !== 'multi'"
t-att-value="value.id"
t-attf-id="{{ attribute.id }}_{{ value.id }}"
t-model="this.selectedValues[attribute.id]" />
<input
type="checkbox"
class="d-none"
t-else=""
t-att-checked="this.isChecked(attribute, value)"
t-att-value="value.id"
t-model="this.selectedValues[attribute.id][value.id]"
t-attf-id="{{ attribute.id }}_{{ value.id }}" />
</div>
<div t-if="this.isChecked(attribute, value) and selfOrder.models['product.template.attribute.value'].get(value.id).is_custom" class="col w-100 order-2">
<input type="text" t-model="this.env.customValues[value.id].custom_value" class="form-control form-control-lg" placeholder="Enter your custom value" />
</div>
</t>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,20 @@
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/app/self_order_service";
export class CancelPopup extends Component {
static template = "pos_self_order.CancelPopup";
static props = {
title: String,
confirm: Function,
close: Function,
};
setup() {
this.selfOrder = useSelfOrder();
}
confirm() {
this.props.close();
this.props.confirm();
}
}
@@ -0,0 +1,6 @@
.self_order_cancel_popup {
.modal-dialog {
height: auto !important;
top: 40%;
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.CancelPopup">
<div class="self_order_cancel_popup o_dialog" t-att-id="id">
<div role="dialog" class="modal d-block" tabindex="-1">
<div class="modal-dialog" role="document">
<div class="modal-content rounded">
<div class="modal-body p-5">
<div class="pb-5 fs-3 text-center">
Are you sure you want to cancel this order? <br/>
<span t-if="selfOrder.config.self_ordering_mode === 'kiosk'" class="text-muted fs-4">All the items will be removed from the cart.</span>
<span t-else="" class="text-muted fs-4">Any items already sent will not be cancelled</span>
</div>
<div class="d-flex align-items-center justify-content-center w-100 gap-3">
<button type="button" class="btn btn-primary btn-lg popup_button" t-on-click="() => this.confirm()">Cancel Order</button>
<button type="button" class="btn btn-secondary btn-lg popup_button" t-on-click="() => this.props.close()">Discard</button>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,47 @@
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/app/self_order_service";
import { AttributeSelection } from "@pos_self_order/app/components/attribute_selection/attribute_selection";
import { useService } from "@web/core/utils/hooks";
import { ProductInfoPopup } from "@pos_self_order/app/components/product_info_popup/product_info_popup";
export class ComboSelection extends Component {
static template = "pos_self_order.ComboSelection";
static props = ["combo", "comboState", "next"];
static components = { AttributeSelection };
setup() {
this.selfOrder = useSelfOrder();
this.dialog = useService("dialog");
}
productClicked(line) {
// Keep track of the current combo item id.
// It servers as additional info for each line so that when calculating prices,
// no need to look for the specific combo item the product belongs to.
this.env.currentComboItemId.value = line.id;
const productSelected = line.product_id;
if (!productSelected.self_order_available) {
return;
}
this.props.comboState.selectedProduct = productSelected;
if (
productSelected.attribute_line_ids.length === 0 ||
productSelected.product_template_variant_value_ids.length !== 0
) {
this.props.next();
return;
}
this.props.comboState.showQtyButtons = true;
}
showProductInfo(line) {
this.dialog.add(ProductInfoPopup, {
product: line.product_id,
isComboLine: true,
addToCart: () => {
this.productClicked(line);
},
});
}
}
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.ComboSelection">
<div t-if="!props.comboState.selectedProduct" class="self_order_attribute_selection p-3 mt-3">
<h2 class="attribute_name mb-5 mb-md-3"><small class="text-muted">Choose your</small> <strong t-esc="props.combo.name" /></h2>
<div class="combo-list align-items-center justify-content-start">
<div class="combo-list-products o-so-products-row">
<t t-foreach="props.combo.combo_item_ids" t-as="line" t-key="line.id" t-if="line.product_id">
<t t-set="product" t-value="line.product_id"/>
<t t-set="isOutOfStock" t-value="!product.self_order_available"/>
<article
t-attf-for="{{ props.combo.id }}_{{ line.id }}"
t-on-click="() => this.productClicked(line)"
class="self_order_product_card d-flex flex-row-reverse flex-md-column align-items-start gap-2 user-select-none"
role="button"
>
<div t-if="line.product_id.public_description" class="product-information-tag" t-on-click.prevent.stop="() => this.showProductInfo(line)">
<i class="product-information-tag-logo fa fa-info fs-4" role="img" aria-label="Product Information" title="Product Information" />
</div>
<div class="ratio ratio-1x1 w-25 w-sm-50 w-md-100" t-att-class="{'d-none d-md-block': !product.image_128}">
<div class="placeholder-glow">
<div class="placeholder w-100 h-100 bg-300 rounded"/>
</div>
<img class="o_self_order_item_card_image w-100 rounded"
t-attf-src="/web/image/product.product/{{ product.id }}/image_512"
alt="Product image"
loading="lazy"
onerror="this.remove()"/>
</div>
<div class="product-infos d-flex flex-column justify-content-between text-start flex-grow-1 w-100 lh-1">
<span t-esc="product.display_name" class="fs-4 fw-bold mb-1 mb-sm-2"/>
<div class="d-flex justify-content-between gap-3">
<span t-if="line.extra_price" class="badge rounded-pill fs-4" t-att-class="isOutOfStock ? 'text-bg-secondary' : 'text-bg-primary'">
+ <t t-out="selfOrder.formatMonetary(line.extra_price)"/>
</span>
<span t-if="isOutOfStock" class="badge text-bg-danger rounded-pill fs-4">
Out of stock
</span>
</div>
</div>
</article>
</t>
</div>
</div>
</div>
<t t-else="">
<AttributeSelection product="props.comboState.selectedProduct" />
</t>
</t>
</templates>
@@ -0,0 +1,27 @@
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/app/self_order_service";
import { cookie } from "@web/core/browser/cookie";
export class LanguagePopup extends Component {
static template = "pos_self_order.LanguagePopup";
static props = {
close: Function,
};
setup() {
this.selfOrder = useSelfOrder();
}
get languages() {
return this.selfOrder.config.self_ordering_available_language_ids;
}
get currentLanguage() {
return this.selfOrder.currentLanguage;
}
onClickLanguage(language) {
cookie.set("frontend_lang", language.code);
window.location.reload();
}
}
@@ -0,0 +1,10 @@
.self_order_language_popup {
.modal-dialog {
top: 40%;
}
img {
max-width: 64px;
aspect-ratio: 4/3;
}
}
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.LanguagePopup">
<div class="self_order_language_popup o_dialog" t-att-id="id">
<div role="dialog" class="modal d-block" tabindex="-1">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-body p-5">
<div class="d-grid gap-3">
<t t-foreach="languages" t-as="lang" t-key="lang.id" >
<t t-if="lang.id !== currentLanguage.id">
<div class="btn btn-light d-flex flex-row align-items-center rounded border p-4" t-on-click="() => this.onClickLanguage(lang)">
<img class="rounded-2" t-attf-src="{{lang.flag_image_url}}" />
<span class="fs-5 ms-4" t-esc="lang.display_name" />
</div>
</t>
</t>
</div>
<div class="d-flex align-items-center justify-content-center w-100 mt-5">
<button type="button" class="btn btn-secondary btn-lg popup_button" t-on-click="() => this.props.close()">Discard</button>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,18 @@
import { Component, onMounted, useState } from "@odoo/owl";
export class LoadingOverlay extends Component {
static template = "pos_self_order.LoadingOverlay";
static props = {};
setup() {
this.state = useState({
loading: false,
});
onMounted(() => {
setTimeout(() => {
this.state.loading = true;
}, 200);
});
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.LoadingOverlay">
<div t-if="state.loading" class="position-absolute top-0 w-100 min-vh-100 d-flex justify-content-center align-items-center bg-black opacity-50" style="z-index: 1000;">
<span class="spinner-border" />
</div>
</t>
</templates>
@@ -0,0 +1,116 @@
import { Component } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/app/self_order_service";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
import { CancelPopup } from "@pos_self_order/app/components/cancel_popup/cancel_popup";
export class OrderWidget extends Component {
static template = "pos_self_order.OrderWidget";
static props = ["action", "removeTopClasses?"];
setup() {
this.selfOrder = useSelfOrder();
this.router = useService("router");
this.dialog = useService("dialog");
}
cancel() {
if (this.selfOrder.config.self_ordering_mode === "kiosk") {
this.dialog.add(CancelPopup, {
title: _t("Cancel order"),
confirm: () => {
this.selfOrder.cancelOrder();
},
});
} else {
this.selfOrder.cancelOrder();
}
}
get cancelAvailable() {
return (
Object.keys(this.currentOrder.changes).length > 0 ||
this.selfOrder.config.self_ordering_mode === "kiosk"
);
}
get buttonToShow() {
const currentPage = this.router.activeSlot;
const payAfter = this.selfOrder.config.self_ordering_pay_after;
const kioskPayment = this.selfOrder.models["pos.payment.method"].getAll();
const isNoLine = this.selfOrder.currentOrder.lines.length === 0;
const hasNotAllLinesSent = this.selfOrder.currentOrder.unsentLines;
const isMobilePayment = kioskPayment.find((p) => p.is_mobile_payment);
let label = "";
let disabled = false;
if (currentPage === "product_list") {
label = _t("Order");
disabled = isNoLine || hasNotAllLinesSent.length == 0;
} else if (
payAfter === "meal" &&
Object.keys(this.selfOrder.currentOrder.changes).length > 0
) {
label = _t("Order");
disabled = isNoLine;
} else {
label = kioskPayment ? _t("Pay") : _t("Order");
disabled = !kioskPayment && !isMobilePayment;
}
return { label, disabled };
}
get lineNotSend() {
const changes = this.selfOrder.currentOrder.changes;
return Object.entries(changes).reduce(
(acc, [key, value]) => {
if (value.qty && value.qty > 0) {
const line = this.selfOrder.models["pos.order.line"].getBy("uuid", key);
acc.count += value.qty;
acc.price += line.get_display_price();
}
return acc;
},
{
price: 0,
count: 0,
}
);
}
get leftButton() {
const order = this.selfOrder.currentOrder;
const back =
Object.keys(order.changes).length === 0 ||
this.router.activeSlot === "cart" ||
order.lines.length === 0;
return {
name: back ? _t("Back") : _t("Cancel"),
icon: back ? "fa fa-arrow-left btn-back" : "btn-close btn-cancel",
};
}
onClickleftButton() {
const order = this.selfOrder.currentOrder;
if (
order.lines.length === 0 ||
Object.keys(order.changes).length === 0 ||
this.router.activeSlot === "cart"
) {
this.router.back();
return;
} else {
this.dialog.add(CancelPopup, {
title: _t("Cancel order"),
confirm: () => {
this.selfOrder.cancelOrder();
this.router.navigate("default");
},
});
}
}
}
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.OrderWidget">
<div
class="page-buttons d-flex flex-nowrap justify-content-between py-2 px-3 gap-2 gap-md-3 bg-view z-1"
t-att-class="{
'shadow-lg border-top' : !props.removeTopClasses
}">
<button t-attf-class="btn btn-secondary btn-lg h-auto w-auto opacity-75 px-4 d-sm-none" t-att-class="leftButton.icon" t-on-click="onClickleftButton">
<span class="px-1"/><!-- Spacer -->
</button>
<button t-attf-class="btn btn-secondary btn-lg d-none d-sm-inline text-nowrap btn-back btn-cancel" t-on-click="onClickleftButton" t-esc="leftButton.name" />
<div class="d-flex align-items-center justify-content-end flex-grow-1 w-100 w-md-auto">
<div class="to-order">
<span>Your Order</span>
<div class="d-flex align-items-center">
<span class="o-so-tabular-nums badge text-bg-secondary rounded" t-esc="lineNotSend.count"/>
<span class="o-so-tabular-nums mx-2" t-esc="selfOrder.formatMonetary(lineNotSend.price)" />
</div>
</div>
</div>
<button t-attf-class="cart btn btn-primary btn-lg flex-grow-1 flex-md-grow-0 {{ buttonToShow.disabled ? 'disabled' : '' }}" t-on-click="props.action" t-esc="buttonToShow.label"/>
</div>
</t>
</templates>
@@ -0,0 +1,15 @@
import { Component } from "@odoo/owl";
export class OutOfPaperPopup extends Component {
static template = "pos_self_order.OutOfPaperPopup";
static props = {
title: String,
close: Function,
};
setup() {
setTimeout(() => {
this.props.close();
}, 10000);
}
}
@@ -0,0 +1,6 @@
.self_order_out_of_paper_popup {
.modal-dialog {
height: auto !important;
top: 40%;
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.OutOfPaperPopup">
<div class="self_order_out_of_paper_popup o_dialog" t-att-id="id">
<div role="dialog" class="modal d-block" tabindex="-1">
<div class="modal-dialog" role="document">
<div class="modal-content rounded">
<div class="modal-body p-5">
<div class="pb-5 fs-3 text-center">
<t t-esc="this.props.title"/>
</div>
<div class="d-flex align-items-center justify-content-center w-100">
<button type="button" class="btn btn-secondary btn-lg popup_button" t-on-click="() => this.props.close()">Close</button>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,38 @@
import { Component, useState } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/app/self_order_service";
import { useService } from "@web/core/utils/hooks";
export class PopupTable extends Component {
static template = "pos_self_order.PopupTable";
static props = { selectTable: Function };
setup() {
this.selfOrder = useSelfOrder();
this.router = useService("router");
this.state = useState({
selectedTable: "0",
});
}
setTable() {
const table = this.selectedTable;
if (!table) {
return;
}
this.props.selectTable(table);
}
close() {
this.props.selectTable(null);
}
get validSelection() {
return Boolean(this.selectedTable);
}
get selectedTable() {
return this.selfOrder.models["restaurant.table"].get(this.state.selectedTable);
}
}
@@ -0,0 +1,15 @@
.self_order_popup_table {
border-radius: 35px 35px 0 0;
animation: popupAnimation 0.2s ease-in-out forwards;
}
@keyframes popupAnimation {
0% {
bottom: -40vh;
opacity: 1;
}
100% {
bottom: 0;
opacity: 1;
}
}
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.PopupTable">
<div class="position-absolute bg-dark bg-opacity-25 w-100 h-100 fixed-top" />
<div class="self_order_popup_table shadow-lg position-absolute fixed-bottom bg-white w-100 p-4 flex-column d-flex justify-content-between">
<div class="mb-5 d-flex justify-content-between align-items-start">
<div>
<h3>Table detective time!</h3>
<span>Could you please confirm your table number?<br/>Thanks a lot!</span>
</div>
<button class="btn btn-close" t-on-click="close"/>
</div>
<select class="form-select form-select-lg mb-5" t-model="state.selectedTable">
<option value="0">
Select a table
</option>
<t t-foreach="selfOrder.models['restaurant.floor'].getAll()" t-as="floor" t-key="floor.id">
<option value="floor" disabled="true">
<t t-esc="floor.name" />
</option>
<option t-foreach="floor.sortedTable" t-as="table" t-key="table.id" t-att-value="table.id">
<t t-esc="table.table_number" />
</option>
</t>
</select>
<a
type="button"
t-on-click="() => this.setTable()"
t-att-class="{'disabled': !this.validSelection}"
class="btn btn-primary py-3 my-2">
<t t-if="this.validSelection">
Continue with table <t t-esc="this.selectedTable.table_number" />
</t>
<t t-else="">
Select a table
</t>
</a>
</div>
</t>
</templates>
@@ -0,0 +1,159 @@
import { Component, useRef } from "@odoo/owl";
import { useSelfOrder } from "@pos_self_order/app/self_order_service";
import { useService, useForwardRefToParent } from "@web/core/utils/hooks";
import { ProductInfoPopup } from "@pos_self_order/app/components/product_info_popup/product_info_popup";
export class ProductCard extends Component {
static template = "pos_self_order.ProductCard";
static props = ["product", "currentProductCard?"];
selfRef = useRef("selfProductCard");
currentProductCardRef = useRef("currentProductCard");
setup() {
this.selfOrder = useSelfOrder();
this.router = useService("router");
this.dialog = useService("dialog");
useForwardRefToParent("currentProductCard");
}
flyToCart() {
const productCardEl = this.selfRef.el;
if (!productCardEl) {
return;
}
const toOrder = document.querySelector(".to-order");
if (!toOrder || window.getComputedStyle(toOrder).display === "none") {
return;
}
let pic = this.selfRef.el.querySelector(".o_self_order_item_card_image");
if (!pic) {
pic = this.selfRef.el.querySelector(".o_self_order_item_card_no_image");
}
const picRect = pic.getBoundingClientRect();
const clonedPic = pic.cloneNode(true);
const toOrderRect = toOrder.getBoundingClientRect();
clonedPic.classList.remove("w-100", "h-100");
clonedPic.classList.add("position-fixed", "border", "border-white", "border-4", "z-1");
clonedPic.style.top = `${picRect.top}px`;
clonedPic.style.left = `${picRect.left}px`;
clonedPic.style.width = `${picRect.width}px`;
clonedPic.style.height = `${picRect.height}px`;
clonedPic.style.transition = "all 400ms cubic-bezier(0.6, 0, 0.9, 1.000)";
document.body.appendChild(clonedPic);
requestAnimationFrame(() => {
const offsetTop = toOrderRect.top - picRect.top - picRect.height * 0.5;
const offsetLeft = toOrderRect.left - picRect.left - picRect.width * 0.25;
clonedPic.style.transform =
"translateY(" + offsetTop + "px) translateX(" + offsetLeft + "px) scale(0.5)";
clonedPic.style.opacity = "0"; // Fading out the card
});
clonedPic.addEventListener("transitionend", () => {
clonedPic.remove();
});
}
get isAvailable() {
if (this.props.product.pos_categ_ids.length === 0) {
return true;
}
return this.props.product.pos_categ_ids.some((categ) =>
this.selfOrder.isCategoryAvailable(categ.id)
);
}
scaleUpPrice() {
const priceElement = document.querySelector(".total-price");
if (!priceElement) {
return;
}
priceElement.classList.add("scale-up");
setTimeout(() => {
priceElement.classList.remove("scale-up");
}, 600);
}
async selectProduct(qty = 1) {
const product = this.props.product;
if (!product.self_order_available || !this.isAvailable) {
return;
}
if (product.isCombo()) {
const selectedCombos = [];
let showComboSelectionPage = false;
for (const combo of product.combo_ids) {
const { combo_item_ids } = combo;
if (combo_item_ids.length > 1 || combo_item_ids[0]?.product_id.isConfigurable()) {
showComboSelectionPage = true;
break;
}
selectedCombos.push({
combo_item_id: this.selfOrder.models["product.combo.item"].get(
combo_item_ids[0].id
),
configuration: {
attribute_custom_values: [],
attribute_value_ids: [],
price_extra: 0,
},
});
}
if (showComboSelectionPage) {
this.router.navigate("combo_selection", { id: product.id });
} else {
this.flyToCart();
this.selfOrder.editedLine?.delete();
this.selfOrder.addToCart(product, 1, "", {}, {}, selectedCombos);
}
} else if (product.isConfigurable()) {
this.router.navigate("product", { id: product.id });
} else {
if (!this.selfOrder.ordering) {
return;
}
this.flyToCart();
this.scaleUpPrice();
const isProductInCart = this.selfOrder.currentOrder.lines.find(
(line) => line.product_id === product.id
);
if (isProductInCart) {
isProductInCart.qty += qty;
} else {
this.selfOrder.addToCart(product, 1);
}
}
}
showProductInfo() {
this.dialog.add(ProductInfoPopup, {
product: this.props.product,
addToCart: (qty) => {
this.selectProduct(qty);
},
});
}
get isHtmlEmpty() {
const div = Object.assign(document.createElement("div"), {
innerHTML: this.props.product.public_description,
});
return div.innerText.trim() === "";
}
}
@@ -0,0 +1,34 @@
.self_order_product_card {
position: relative
}
.scale-up {
transform: scale(1.2);
transition: transform .75s ease-in-out;
}
.product-information-tag {
width: 0;
height: 0;
border-style: solid;
border-width: 0 34px 34px 0;
border-color: transparent #9a9ea180 transparent transparent;
position: absolute;
top: 0;
right: 17px;
color: white;
text-align: center;
z-index: 1;
}
.product-information-tag-logo {
position: absolute;
left: 22px;
top: 4px;
}
.o_self_order_item_card_no_image {
span {
color: #DFDFDF;
}
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_self_order.ProductCard">
<article class="self_order_product_card d-flex flex-row-reverse flex-md-column align-items-start gap-2 user-select-none"
role="button"
t-att-title="props.product.name"
t-on-click="() => this.selectProduct()"
t-ref="selfProductCard">
<div t-if="!this.isHtmlEmpty" class="product-information-tag" t-on-click.prevent.stop="showProductInfo">
<i class="product-information-tag-logo fa fa-info fs-4" role="img" aria-label="Product Information" title="Product Information" />
</div>
<div
class="ratio ratio-1x1 w-25 w-sm-50 w-md-100"
t-att-class="{
'd-md-block': !props.product.image_128
}">
<div class="placeholder-glow o_self_order_item_card_no_image">
<div t-attf-class="{{ props.product.image_128 ? 'placeholder' : 'd-flex align-items-center justify-content-center h-100' }} bg-200 w-100 h-100 rounded">
<span t-if="!props.product.image_128" t-esc="props.product.name" class="text-center text-white fs-2 fw-bold mb-1 mb-sm-2 text-truncate w-100"/>
</div>
</div>
<img
t-if="props.product.image_128"
class="o_self_order_item_card_image w-100 rounded"
t-attf-src="/web/image/product.product/{{ props.product.id }}/image_512?unique={{props.product.write_date}}"
alt="Product image"
loading="lazy"
onerror="this.remove()"/>
</div>
<div class="product-infos d-flex flex-column justify-content-between text-start flex-grow-1 w-100 lh-1 overflow-hidden">
<span t-esc="props.product.name" class="fs-4 fw-bold mb-1 mb-sm-2 text-truncate w-100"/>
<div class="d-flex justify-content-between align-items-end gap-3">
<span t-esc="selfOrder.formatMonetary(selfOrder.getProductDisplayPrice(props.product))" class="o-so-tabular-nums fs-4 text-muted flex-grow-1" />
<div class="text-center ms-2 fs-lighter">
<div t-if="!props.product.self_order_available" class="fs-lighter bg-secondary rounded p-1">Out of stock</div>
<div t-elif="!this.isAvailable" class="fs-lighter bg-secondary rounded p-1">Unavailable</div>
</div>
</div>
</div>
</article>
</t>
</templates>

Some files were not shown because too many files have changed in this diff Show More