467 lines
18 KiB
Python
467 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import io
|
|
import uuid
|
|
import base64
|
|
from os.path import join as opj
|
|
from PIL import Image
|
|
from typing import Optional, List, Dict
|
|
from werkzeug.urls import url_quote
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import image_to_base64
|
|
|
|
from odoo import api, fields, models, _, service, Command
|
|
from odoo.tools import file_open, split_every
|
|
|
|
|
|
class PosConfig(models.Model):
|
|
_inherit = "pos.config"
|
|
|
|
_ALLOWED_PAYMENT_METHODS = ['adyen', 'stripe']
|
|
|
|
def _self_order_kiosk_default_languages(self):
|
|
return self.env["res.lang"].get_installed()
|
|
|
|
def _self_order_default_user(self):
|
|
user_ids = self.env["res.users"].search(['|', ('company_id', '=', self.env.company.id), ('company_id', '=', False)])
|
|
for user_id in user_ids:
|
|
if user_id.has_group("point_of_sale.group_pos_user") or user_id.has_group("point_of_sale.group_pos_manager"):
|
|
return user_id
|
|
|
|
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("Takeaway")
|
|
self_ordering_alternative_fp_id = fields.Many2one(
|
|
'account.fiscal.position',
|
|
string='Alternative Fiscal Position',
|
|
help='This is useful for restaurants with onsite and take-away services that imply specific tax rates.',
|
|
)
|
|
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="Service",
|
|
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",
|
|
)
|
|
access_token = fields.Char(
|
|
"Security Token",
|
|
copy=False,
|
|
required=True,
|
|
readonly=True,
|
|
default=lambda self: self._get_access_token(),
|
|
)
|
|
|
|
@staticmethod
|
|
def _get_access_token():
|
|
return uuid.uuid4().hex[:16]
|
|
|
|
def _update_access_token(self):
|
|
self.access_token = self._get_access_token()
|
|
self.floor_ids.table_ids._update_identifier()
|
|
|
|
@api.model
|
|
def _init_access_token(self):
|
|
pos_config_ids = self.env["pos.config"].search([])
|
|
for pos_config_id in pos_config_ids:
|
|
pos_config_id.access_token = self._get_access_token()
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
pos_config_ids = super().create(vals_list)
|
|
|
|
for pos_config_id in pos_config_ids:
|
|
for image_name in ['landing_01.jpg', 'landing_02.jpg', 'landing_03.jpg']:
|
|
image_path = opj("pos_self_order/static/img", image_name)
|
|
attachment = self.env['ir.attachment'].create({
|
|
'name': image_name,
|
|
'datas': base64.b64encode(file_open(image_path, "rb").read()),
|
|
'res_model': 'pos.config',
|
|
'res_id': pos_config_id.id,
|
|
'type': 'binary',
|
|
})
|
|
pos_config_id.self_ordering_image_home_ids = [(4, attachment.id)]
|
|
|
|
self.env['pos_self_order.custom_link'].create({
|
|
'name': _('Order Now'),
|
|
'url': f'/pos-self/{pos_config_id.id}/products',
|
|
'pos_config_ids': [(4, pos_config_id.id)],
|
|
})
|
|
|
|
if pos_config_id.module_pos_restaurant:
|
|
pos_config_id.self_ordering_mode = 'mobile'
|
|
|
|
return pos_config_ids
|
|
|
|
def _get_allowed_payment_methods(self):
|
|
if self.self_ordering_mode == 'kiosk':
|
|
return self.payment_method_ids.filtered(lambda p: p.use_payment_terminal in self._ALLOWED_PAYMENT_METHODS)
|
|
return []
|
|
|
|
def write(self, 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'
|
|
return super().write(vals)
|
|
|
|
@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 == 'mobile' and not record.self_ordering_default_user_id.has_group("point_of_sale.group_pos_user") and not record.self_ordering_default_user_id.has_group("point_of_sale.group_pos_manager"):
|
|
raise UserError(_("The Self-Order default user must be a POS user"))
|
|
|
|
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.name,
|
|
'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_order_custom_links(self):
|
|
"""
|
|
On the landing page of the app we can have a number of custom links
|
|
that are defined by the restaurant employee in the backend.
|
|
This function returns a list of dictionaries with the attributes of each link
|
|
that is available for the POS with id pos_config_id.
|
|
"""
|
|
self.ensure_one()
|
|
return (
|
|
self.env["pos_self_order.custom_link"]
|
|
.search_read(
|
|
[
|
|
"|",
|
|
("pos_config_ids", "in", [self.id]),
|
|
("pos_config_ids", "=", False),
|
|
],
|
|
fields=["name", "url", "style"],
|
|
order="sequence",
|
|
)
|
|
)
|
|
|
|
def _get_available_products(self):
|
|
self.ensure_one()
|
|
combo_product_ids = self.env["product.product"].search([
|
|
("detailed_type", "=", 'combo'),
|
|
*(
|
|
self.limit_categories
|
|
and self.iface_available_categ_ids
|
|
and [("pos_categ_ids", "in", self.iface_available_categ_ids.ids)]
|
|
or []
|
|
),
|
|
])
|
|
product_ids = combo_product_ids.combo_ids.combo_line_ids.product_id
|
|
|
|
available_product_ids = (
|
|
self.env["product.product"]
|
|
.search(
|
|
[
|
|
("id", "not in", product_ids.ids),
|
|
("available_in_pos", "=", True),
|
|
("self_order_available", "=", True),
|
|
*(
|
|
self.limit_categories
|
|
and self.iface_available_categ_ids
|
|
and [("pos_categ_ids", "in", self.iface_available_categ_ids.ids)]
|
|
or []
|
|
),
|
|
],
|
|
)
|
|
)
|
|
|
|
return product_ids + available_product_ids
|
|
|
|
def _get_available_categories(self):
|
|
self.ensure_one()
|
|
return (
|
|
self.env["pos.category"]
|
|
.search(
|
|
[
|
|
*(
|
|
self.limit_categories
|
|
and self.iface_available_categ_ids
|
|
and [("id", "in", self.iface_available_categ_ids.ids)]
|
|
or []
|
|
),
|
|
],
|
|
order="sequence",
|
|
)
|
|
)
|
|
|
|
def _get_kitchen_printer(self):
|
|
self.ensure_one()
|
|
printerData = {}
|
|
for printer in self.printer_ids:
|
|
printerData[printer.id] = {
|
|
"printer_type": printer.printer_type,
|
|
"proxy_ip": printer.proxy_ip,
|
|
"product_categories_ids": printer.product_categories_ids.ids,
|
|
}
|
|
return printerData
|
|
|
|
def _get_self_ordering_payment_methods_data(self, payment_methods):
|
|
excluded_fields = ['image']
|
|
payment_search_fields = self.current_session_id._loader_params_pos_payment_method()['search_params']['fields']
|
|
filtered_fields = [field for field in payment_search_fields if field not in excluded_fields]
|
|
return payment_methods.read(filtered_fields)
|
|
|
|
def _get_self_ordering_data(self):
|
|
self.ensure_one()
|
|
payment_methods = self._get_self_ordering_payment_methods_data(self._get_allowed_payment_methods())
|
|
default_language = self.self_ordering_default_language_id.read(["code", "name", "iso_code", "flag_image_url"])
|
|
|
|
return {
|
|
"pos_config_id": self.id,
|
|
"pos_session": self.current_session_id.read(["id", "access_token"])[0] if self.current_session_id and self.current_session_id.state == 'opened' else False,
|
|
"company": {
|
|
**self.company_id.read(["name", "color", "email", "website", "vat", "name", "phone", "point_of_sale_use_ticket_qr_code", "point_of_sale_ticket_unique_code"])[0],
|
|
"partner_id": [None, self.company_id.partner_id.contact_address],
|
|
"country": self.company_id.country_id.read(["vat_label"])[0],
|
|
},
|
|
"base_url": self.get_base_url(),
|
|
"custom_links": self._get_self_order_custom_links(),
|
|
"currency_id": self.currency_id.id,
|
|
"pos_payment_methods": payment_methods if self.self_ordering_mode == "kiosk" else [],
|
|
"currency_decimals": self.currency_id.decimal_places,
|
|
"pos_category": self._get_available_categories().read(["name", "sequence", "has_image"]),
|
|
"products": self._get_available_products()._get_self_order_data(self),
|
|
"combos": self._get_combos_data(),
|
|
"config": {
|
|
"iface_start_categ_id": self.iface_start_categ_id.id,
|
|
"iface_tax_included": self.iface_tax_included == "total",
|
|
"self_ordering_mode": self.self_ordering_mode,
|
|
"self_ordering_takeaway": self.self_ordering_takeaway,
|
|
"self_ordering_service_mode": self.self_ordering_service_mode,
|
|
"self_ordering_default_language_id": default_language[0] if default_language else [],
|
|
"self_ordering_available_language_ids": self.self_ordering_available_language_ids.read(["code", "display_name", "iso_code", "flag_image_url"]),
|
|
"self_ordering_image_home_ids": self._get_self_ordering_attachment(self.self_ordering_image_home_ids),
|
|
"self_ordering_image_brand": self._get_self_ordering_image(self.self_ordering_image_brand),
|
|
"self_ordering_pay_after": self.self_ordering_pay_after,
|
|
"receipt_header": self.receipt_header,
|
|
"receipt_footer": self.receipt_footer,
|
|
},
|
|
"kitchen_printers": self._get_kitchen_printer(),
|
|
}
|
|
|
|
def _get_combos_data(self):
|
|
self.ensure_one()
|
|
combos = self.env["pos.combo"].search([])
|
|
|
|
return[{
|
|
'id': combo.id,
|
|
'name': combo.name,
|
|
'combo_line_ids': combo.combo_line_ids.read(['product_id', 'combo_price', 'lst_price', 'combo_id'])
|
|
} for combo in combos]
|
|
|
|
|
|
def _get_self_ordering_image(self, image):
|
|
image = Image.open(io.BytesIO(base64.b64decode(image))) if image else False
|
|
return image_to_base64(image, 'PNG').decode('utf-8') if image else False
|
|
|
|
def _get_self_ordering_attachment(self, images):
|
|
encoded_images = []
|
|
for image in images:
|
|
encoded_images.append({
|
|
'id': image.id,
|
|
'data': image.datas.decode('utf-8'),
|
|
})
|
|
return encoded_images
|
|
|
|
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 = self.get_base_url() + self._get_self_order_route()
|
|
|
|
def action_close_kiosk_session(self):
|
|
current_session_id = self.current_session_id
|
|
|
|
if current_session_id:
|
|
if current_session_id.order_ids:
|
|
current_session_id.order_ids.filtered(lambda o: o.state not in ['paid', 'invoiced']).unlink()
|
|
|
|
self.env['bus.bus']._sendone(f'pos_config-{self.access_token}', 'STATUS', {
|
|
'status': 'closed',
|
|
})
|
|
|
|
return 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_kiosk(self):
|
|
self.ensure_one()
|
|
|
|
return {
|
|
'name': _('Self Kiosk'),
|
|
'type': 'ir.actions.act_url',
|
|
'url': self._get_self_order_route(),
|
|
}
|
|
|
|
def action_open_wizard(self):
|
|
self.ensure_one()
|
|
|
|
if not self.current_session_id:
|
|
pos_session = self.env['pos.session'].create({'user_id': self.env.uid, 'config_id': self.id})
|
|
pos_session._ensure_access_token()
|
|
self.env['bus.bus']._sendone(f'pos_config-{self.access_token}', 'STATUS', {
|
|
'status': 'open',
|
|
'pos_session': pos_session.read(['id', 'access_token'])[0],
|
|
})
|
|
|
|
return {
|
|
'name': _('Self Kiosk'),
|
|
'type': 'ir.actions.act_window',
|
|
'view_type': 'form',
|
|
'view_mode': 'form',
|
|
'res_model': 'pos.config',
|
|
'res_id': self.id,
|
|
'target': 'new',
|
|
'views': [(self.env.ref('pos_self_order.pos_self_order_kiosk_read_only_form_dialog').id, 'form')],
|
|
}
|