Initial commit: Pos packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:50 +02:00
commit 95dfb9edb0
1301 changed files with 264148 additions and 0 deletions

View file

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import pos_config
from . import pos_order
from . import pos_payment
from . import pos_restaurant
from . import pos_session
from . import res_config_settings

View file

@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
class PosConfig(models.Model):
_inherit = 'pos.config'
iface_splitbill = fields.Boolean(string='Bill Splitting', help='Enables Bill Splitting in the Point of Sale.')
iface_printbill = fields.Boolean(string='Bill Printing', help='Allows to print the Bill before payment.')
iface_orderline_notes = fields.Boolean(string='Internal Notes', help='Allow custom internal notes on Orderlines.')
floor_ids = fields.One2many('restaurant.floor', 'pos_config_id', string='Restaurant Floors', help='The restaurant floors served by this point of sale.')
printer_ids = fields.Many2many('restaurant.printer', 'pos_config_printer_rel', 'config_id', 'printer_id', string='Order Printers')
is_table_management = fields.Boolean('Floors & Tables')
is_order_printer = fields.Boolean('Order Printer')
set_tip_after_payment = fields.Boolean('Set Tip After Payment', help="Adjust the amount authorized by payment terminals to add a tip after the customers left or at the end of the day.")
module_pos_restaurant = fields.Boolean(default=True)
def _force_http(self):
enforce_https = self.env['ir.config_parameter'].sudo().get_param('point_of_sale.enforce_https')
if not enforce_https and self.printer_ids.filtered(lambda pt: pt.printer_type == 'epson_epos'):
return True
return super(PosConfig, self)._force_http()
def get_tables_order_count(self):
""" """
self.ensure_one()
floors = self.env['restaurant.floor'].search([('pos_config_id', 'in', self.ids)])
tables = self.env['restaurant.table'].search([('floor_id', 'in', floors.ids)])
domain = [('state', '=', 'draft'), ('table_id', 'in', tables.ids)]
order_stats = self.env['pos.order'].read_group(domain, ['table_id'], 'table_id')
orders_map = dict((s['table_id'][0], s['table_id_count']) for s in order_stats)
result = []
for table in tables:
result.append({'id': table.id, 'orders': orders_map.get(table.id, 0)})
return result
def _get_forbidden_change_fields(self):
forbidden_keys = super(PosConfig, self)._get_forbidden_change_fields()
forbidden_keys.append('is_table_management')
forbidden_keys.append('floor_ids')
return forbidden_keys
def write(self, vals):
if ('is_table_management' in vals and vals['is_table_management'] == False):
vals['floor_ids'] = [(5, 0, 0)]
if ('is_order_printer' in vals and vals['is_order_printer'] == False):
vals['printer_ids'] = [(5, 0, 0)]
return super(PosConfig, self).write(vals)
@api.model
def add_cash_payment_method(self):
companies = self.env['res.company'].search([])
for company in companies.filtered('chart_template_id'):
pos_configs = self.search([('company_id', '=', company.id), ('module_pos_restaurant', '=', True)])
journal_counter = 2
for pos_config in pos_configs:
if pos_config.payment_method_ids.filtered('is_cash_count'):
continue
cash_journal = self.env['account.journal'].search([('company_id', '=', company.id), ('type', '=', 'cash'), ('pos_payment_method_ids', '=', False)], limit=1)
if not cash_journal:
cash_journal = self.env['account.journal'].create({
'name': _('Cash %s', journal_counter),
'code': 'RCSH%s' % journal_counter,
'type': 'cash',
'company_id': company.id
})
journal_counter += 1
payment_methods = pos_config.payment_method_ids
payment_methods |= self.env['pos.payment.method'].create({
'name': _('Cash Bar'),
'journal_id': cash_journal.id,
'company_id': company.id,
})
pos_config.write({'payment_method_ids': [(6, 0, payment_methods.ids)]})

View file

@ -0,0 +1,287 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tools import groupby
from re import search
from functools import partial
import pytz
from odoo import api, fields, models
class PosOrderLine(models.Model):
_inherit = 'pos.order.line'
note = fields.Char('Internal Note added by the waiter.')
uuid = fields.Char(string='Uuid', readonly=True, copy=False)
mp_skip = fields.Boolean('Skip line when sending ticket to kitchen printers.')
class PosOrder(models.Model):
_inherit = 'pos.order'
table_id = fields.Many2one('restaurant.table', string='Table', help='The table where this order was served', index='btree_not_null')
customer_count = fields.Integer(string='Guests', help='The amount of customers that have been served by this order.')
multiprint_resume = fields.Char(string='Multiprint Resume', help="Last printed state of the order")
def _get_pack_lot_lines(self, order_lines):
"""Add pack_lot_lines to the order_lines.
The function doesn't return anything but adds the results directly to the order_lines.
:param order_lines: order_lines for which the pack_lot_lines are to be requested.
:type order_lines: pos.order.line.
"""
pack_lots = self.env['pos.pack.operation.lot'].search_read(
domain = [('pos_order_line_id', 'in', [order_line['id'] for order_line in order_lines])],
fields = [
'id',
'lot_name',
'pos_order_line_id'
])
for pack_lot in pack_lots:
pack_lot['order_line'] = pack_lot['pos_order_line_id'][0]
pack_lot['server_id'] = pack_lot['id']
del pack_lot['pos_order_line_id']
del pack_lot['id']
for order_line_id, pack_lot_ids in groupby(pack_lots, key=lambda x:x['order_line']):
next(order_line for order_line in order_lines if order_line['id'] == order_line_id)['pack_lot_ids'] = list(pack_lot_ids)
def _get_fields_for_order_line(self):
fields = super(PosOrder, self)._get_fields_for_order_line()
fields.extend([
'id',
'discount',
'product_id',
'price_unit',
'order_id',
'qty',
'note',
'uuid',
'mp_skip',
'full_product_name',
'customer_note',
'price_extra',
])
return fields
def _prepare_order_line(self, order_line):
"""Method that will allow the cleaning of values to send the correct information.
:param order_line: order_line that will be cleaned.
:type order_line: pos.order.line.
:returns: dict -- dict representing the order line's values.
"""
order_line = super()._prepare_order_line(order_line)
order_line["product_id"] = order_line["product_id"][0]
order_line["server_id"] = order_line["id"]
del order_line["id"]
if not "pack_lot_ids" in order_line:
order_line["pack_lot_ids"] = []
else:
order_line["pack_lot_ids"] = [[0, 0, lot] for lot in order_line["pack_lot_ids"]]
return order_line
def _get_order_lines(self, orders):
"""Add pos_order_lines to the orders.
The function doesn't return anything but adds the results directly to the orders.
:param orders: orders for which the order_lines are to be requested.
:type orders: pos.order.
"""
order_lines = self.env['pos.order.line'].search_read(
domain = [('order_id', 'in', [to['id'] for to in orders])],
fields = self._get_fields_for_order_line())
if order_lines != []:
self._get_pack_lot_lines(order_lines)
extended_order_lines = []
for order_line in order_lines:
extended_order_lines.append([0, 0, self._prepare_order_line(order_line)])
for order_id, order_lines in groupby(extended_order_lines, key=lambda x:x[2]['order_id']):
next(order for order in orders if order['id'] == order_id[0])['lines'] = list(order_lines)
def _get_fields_for_payment_lines(self):
return [
'id',
'amount',
'pos_order_id',
'payment_method_id',
'card_type',
'cardholder_name',
'transaction_id',
'payment_status'
]
def _get_payments_lines_list(self, orders):
payment_lines = self.env['pos.payment'].search_read(
domain = [('pos_order_id', 'in', [po['id'] for po in orders])],
fields = self._get_fields_for_payment_lines())
extended_payment_lines = []
for payment_line in payment_lines:
payment_line['server_id'] = payment_line['id']
payment_line['payment_method_id'] = payment_line['payment_method_id'][0]
del payment_line['id']
extended_payment_lines.append([0, 0, payment_line])
return extended_payment_lines
def _get_payment_lines(self, orders):
"""Add account_bank_statement_lines to the orders.
The function doesn't return anything but adds the results directly to the orders.
:param orders: orders for which the payment_lines are to be requested.
:type orders: pos.order.
"""
extended_payment_lines = self._get_payments_lines_list(orders)
for order_id, payment_lines in groupby(extended_payment_lines, key=lambda x:x[2]['pos_order_id']):
next(order for order in orders if order['id'] == order_id[0])['statement_ids'] = list(payment_lines)
def _get_fields_for_draft_order(self):
return [
'id',
'pricelist_id',
'partner_id',
'sequence_number',
'session_id',
'pos_reference',
'create_uid',
'create_date',
'customer_count',
'fiscal_position_id',
'table_id',
'to_invoice',
'multiprint_resume',
'access_token',
]
def _get_domain_for_draft_orders(self, table_ids):
""" Get the domain to search for draft orders on a table.
:param table_ids: Ids of the selected tables.
:type table_ids: list of int.
"returns: list -- list of tuples that represents a domain.
"""
return [('state', '=', 'draft'), ('table_id', 'in', table_ids)]
def _add_activated_coupon_to_draft_orders(self, table_orders):
table_orders = super()._add_activated_coupon_to_draft_orders(table_orders)
return table_orders
@api.model
def get_table_draft_orders(self, table_ids):
"""Generate an object of all draft orders for the given table.
Generate and return an JSON object with all draft orders for the given table, to send to the
front end application.
:param table_ids: Ids of the selected tables.
:type table_ids: list of int.
:returns: list -- list of dict representing the table orders
"""
table_orders = self.search_read(
domain=self._get_domain_for_draft_orders(table_ids),
fields=self._get_fields_for_draft_order())
self._get_order_lines(table_orders)
self._get_payment_lines(table_orders)
for order in table_orders:
order['pos_session_id'] = order['session_id'][0]
order['uid'] = search(r"\d{5,}-\d{3,}-\d{4,}", order['pos_reference']).group(0)
order['name'] = order['pos_reference']
order['creation_date'] = order['create_date']
order['server_id'] = order['id']
if order['fiscal_position_id']:
order['fiscal_position_id'] = order['fiscal_position_id'][0]
if order['pricelist_id']:
order['pricelist_id'] = order['pricelist_id'][0]
if order['partner_id']:
order['partner_id'] = order['partner_id'][0]
if order['table_id']:
order['table_id'] = order['table_id'][0]
if not 'lines' in order:
order['lines'] = []
if not 'statement_ids' in order:
order['statement_ids'] = []
del order['id']
del order['session_id']
del order['pos_reference']
del order['create_date']
return self._add_activated_coupon_to_draft_orders(table_orders)
@api.model
def remove_from_ui(self, server_ids):
""" Remove orders from the frontend PoS application
Remove orders from the server by id.
:param server_ids: list of the id's of orders to remove from the server.
:type server_ids: list.
:returns: list -- list of db-ids for the removed orders.
"""
orders = self.search([('id', 'in', server_ids), ('state', '=', 'draft')])
orders.write({'state': 'cancel'})
# TODO Looks like delete cascade is a better solution.
orders.mapped('payment_ids').sudo().unlink()
orders.sudo().unlink()
return orders.ids
def set_tip(self, tip_line_vals):
"""Set tip to `self` based on values in `tip_line_vals`."""
self.ensure_one()
PosOrderLine = self.env['pos.order.line']
process_line = partial(PosOrderLine._order_line_fields, session_id=self.session_id.id)
# 1. add/modify tip orderline
processed_tip_line_vals = process_line([0, 0, tip_line_vals])[2]
processed_tip_line_vals.update({ "order_id": self.id })
tip_line = self.lines.filtered(lambda line: line.product_id == self.session_id.config_id.tip_product_id)
if not tip_line:
tip_line = PosOrderLine.create(processed_tip_line_vals)
else:
tip_line.write(processed_tip_line_vals)
# 2. modify payment
payment_line = self.payment_ids.filtered(lambda line: not line.is_change)[0]
# TODO it would be better to throw error if there are multiple payment lines
# then ask the user to select which payment to update, no?
payment_line._update_payment_line_for_tip(tip_line.price_subtotal_incl)
# 3. flag order as tipped and update order fields
self.write({
"is_tipped": True,
"tip_amount": tip_line.price_subtotal_incl,
"amount_total": self.amount_total + tip_line.price_subtotal_incl,
"amount_paid": self.amount_paid + tip_line.price_subtotal_incl,
})
def set_no_tip(self):
"""Override this method to introduce action when setting no tip."""
self.ensure_one()
self.write({
"is_tipped": True,
"tip_amount": 0,
})
@api.model
def _order_fields(self, ui_order):
order_fields = super(PosOrder, self)._order_fields(ui_order)
order_fields['table_id'] = ui_order.get('table_id', False)
order_fields['customer_count'] = ui_order.get('customer_count', 0)
order_fields['multiprint_resume'] = ui_order.get('multiprint_resume', False)
return order_fields
def _export_for_ui(self, order):
result = super(PosOrder, self)._export_for_ui(order)
result['table_id'] = order.table_id.id
return result

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class PosConfig(models.Model):
_inherit = 'pos.payment'
def _update_payment_line_for_tip(self, tip_amount):
"""Inherit this method to perform reauthorization or capture on electronic payment."""
self.ensure_one()
self.write({
"amount": self.amount + tip_amount,
})

View file

@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class RestaurantFloor(models.Model):
_name = 'restaurant.floor'
_description = 'Restaurant Floor'
_order = "sequence, name"
name = fields.Char('Floor Name', required=True)
pos_config_id = fields.Many2one('pos.config', string='Point of Sale')
background_image = fields.Binary('Background Image')
background_color = fields.Char('Background Color', help='The background color of the floor in a html-compatible format', default='rgb(210, 210, 210)')
table_ids = fields.One2many('restaurant.table', 'floor_id', string='Tables')
sequence = fields.Integer('Sequence', default=1)
active = fields.Boolean(default=True)
@api.ondelete(at_uninstall=False)
def _unlink_except_active_pos_session(self):
confs = self.mapped('pos_config_id').filtered(lambda c: c.is_table_management == True)
opened_session = self.env['pos.session'].search([('config_id', 'in', confs.ids), ('state', '!=', 'closed')])
if opened_session:
error_msg = _("You cannot remove a floor that is used in a PoS session, close the session(s) first: \n")
for floor in self:
for session in opened_session:
if floor in session.config_id.floor_ids:
error_msg += _("Floor: %s - PoS Config: %s \n") % (floor.name, session.config_id.name)
if confs:
raise UserError(error_msg)
def write(self, vals):
for floor in self:
if floor.pos_config_id.has_active_session and (vals.get('pos_config_id') or vals.get('active')) :
raise UserError(
'Please close and validate the following open PoS Session before modifying this floor.\n'
'Open session: %s' % (' '.join(floor.pos_config_id.mapped('name')),))
if vals.get('pos_config_id') and floor.pos_config_id.id and vals.get('pos_config_id') != floor.pos_config_id.id:
raise UserError(_('The %s is already used in another Pos Config.', floor.name))
return super(RestaurantFloor, self).write(vals)
class RestaurantTable(models.Model):
_name = 'restaurant.table'
_description = 'Restaurant Table'
name = fields.Char('Table Name', required=True, help='An internal identification of a table')
floor_id = fields.Many2one('restaurant.floor', string='Floor')
shape = fields.Selection([('square', 'Square'), ('round', 'Round')], string='Shape', required=True, default='square')
position_h = fields.Float('Horizontal Position', default=10,
help="The table's horizontal position from the left side to the table's center, in pixels")
position_v = fields.Float('Vertical Position', default=10,
help="The table's vertical position from the top to the table's center, in pixels")
width = fields.Float('Width', default=50, help="The table's width in pixels")
height = fields.Float('Height', default=50, help="The table's height in pixels")
seats = fields.Integer('Seats', default=1, help="The default number of customer served at this table.")
color = fields.Char('Color', help="The table's color, expressed as a valid 'background' CSS property value")
active = fields.Boolean('Active', default=True, help='If false, the table is deactivated and will not be available in the point of sale')
@api.model
def create_from_ui(self, table):
""" create or modify a table from the point of sale UI.
table contains the table's fields. If it contains an
id, it will modify the existing table. It then
returns the id of the table.
"""
if table.get('floor_id'):
table['floor_id'] = table['floor_id'][0]
sanitized_table = dict([(key, val) for key, val in table.items() if key in self._fields and val is not None])
table_id = sanitized_table.pop('id', False)
if table_id:
self.browse(table_id).write(sanitized_table)
else:
table_id = self.create(sanitized_table).id
return table_id
@api.ondelete(at_uninstall=False)
def _unlink_except_active_pos_session(self):
confs = self.mapped('floor_id').mapped('pos_config_id').filtered(lambda c: c.is_table_management == True)
opened_session = self.env['pos.session'].search([('config_id', 'in', confs.ids), ('state', '!=', 'closed')])
if opened_session:
error_msg = _("You cannot remove a table that is used in a PoS session, close the session(s) first.")
if confs:
raise UserError(error_msg)
class RestaurantPrinter(models.Model):
_name = 'restaurant.printer'
_description = 'Restaurant Printer'
name = fields.Char('Printer Name', required=True, default='Printer', help='An internal identification of the printer')
printer_type = fields.Selection(string='Printer Type', default='iot',
selection=[('iot', ' Use a printer connected to the IoT Box')])
proxy_ip = fields.Char('Proxy IP Address', help="The IP Address or hostname of the Printer's hardware proxy")
product_categories_ids = fields.Many2many('pos.category', 'printer_category_rel', 'printer_id', 'category_id', string='Printed Product Categories')

View file

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from itertools import groupby
from odoo.osv.expression import AND
class PosSession(models.Model):
_inherit = 'pos.session'
def _pos_ui_models_to_load(self):
result = super()._pos_ui_models_to_load()
if self.config_id.module_pos_restaurant:
result.append('restaurant.printer')
if self.config_id.is_table_management:
result.append('restaurant.floor')
return result
def _loader_params_restaurant_floor(self):
return {
'search_params': {
'domain': [('pos_config_id', '=', self.config_id.id)],
'fields': ['name', 'background_color', 'table_ids', 'sequence'],
'order': 'sequence',
},
}
def _loader_params_restaurant_table(self):
return {
'search_params': {
'domain': [('active', '=', True)],
'fields': [
'name', 'width', 'height', 'position_h', 'position_v',
'shape', 'floor_id', 'color', 'seats', 'active'
],
},
}
def _get_pos_ui_restaurant_floor(self, params):
floors = self.env['restaurant.floor'].search_read(**params['search_params'])
floor_ids = [floor['id'] for floor in floors]
table_params = self._loader_params_restaurant_table()
table_params['search_params']['domain'] = AND([table_params['search_params']['domain'], [('floor_id', 'in', floor_ids)]])
tables = self.env['restaurant.table'].search(table_params['search_params']['domain'], order='floor_id')
tables_by_floor_id = {}
for floor_id, table_group in groupby(tables, key=lambda table: table.floor_id):
floor_tables = self.env['restaurant.table'].concat(*table_group)
tables_by_floor_id[floor_id.id] = floor_tables.read(table_params['search_params']['fields'])
for floor in floors:
floor['tables'] = tables_by_floor_id.get(floor['id'], [])
return floors
def _loader_params_restaurant_printer(self):
return {
'search_params': {
'domain': [('id', 'in', self.config_id.printer_ids.ids)],
'fields': ['name', 'proxy_ip', 'product_categories_ids', 'printer_type'],
},
}
def _get_pos_ui_restaurant_printer(self, params):
return self.env['restaurant.printer'].search_read(**params['search_params'])

View file

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
from odoo import fields, models, api
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
def _get_floors_domain(self):
return ['|', ('pos_config_id', 'in', self.pos_config_id.ids), ('pos_config_id', '=', False)]
pos_floor_ids = fields.One2many(related='pos_config_id.floor_ids', readonly=False, domain=lambda self: self._get_floors_domain())
pos_iface_orderline_notes = fields.Boolean(compute='_compute_pos_module_pos_restaurant', store=True, readonly=False)
pos_iface_printbill = fields.Boolean(compute='_compute_pos_module_pos_restaurant', store=True, readonly=False)
pos_iface_splitbill = fields.Boolean(compute='_compute_pos_module_pos_restaurant', store=True, readonly=False)
pos_is_order_printer = fields.Boolean(compute='_compute_pos_module_pos_restaurant', store=True, readonly=False)
pos_is_table_management = fields.Boolean(compute='_compute_pos_module_pos_restaurant', store=True, readonly=False)
pos_printer_ids = fields.Many2many(related='pos_config_id.printer_ids', readonly=False)
pos_set_tip_after_payment = fields.Boolean(compute='_compute_pos_set_tip_after_payment', store=True, readonly=False)
@api.depends('pos_module_pos_restaurant', 'pos_config_id')
def _compute_pos_module_pos_restaurant(self):
for res_config in self:
if not res_config.pos_module_pos_restaurant:
res_config.update({
'pos_iface_orderline_notes': False,
'pos_iface_printbill': False,
'pos_iface_splitbill': False,
'pos_is_order_printer': False,
'pos_is_table_management': False,
})
else:
res_config.update({
'pos_iface_orderline_notes': res_config.pos_config_id.iface_orderline_notes,
'pos_iface_printbill': res_config.pos_config_id.iface_printbill,
'pos_iface_splitbill': res_config.pos_config_id.iface_splitbill,
'pos_is_order_printer': res_config.pos_config_id.is_order_printer,
'pos_is_table_management': res_config.pos_config_id.is_table_management,
})
@api.depends('pos_iface_tipproduct', 'pos_config_id')
def _compute_pos_set_tip_after_payment(self):
for res_config in self:
if res_config.pos_iface_tipproduct:
res_config.pos_set_tip_after_payment = res_config.pos_config_id.set_tip_after_payment
else:
res_config.pos_set_tip_after_payment = False