summaryrefslogtreecommitdiff
path: root/addons/point_of_sale/models
diff options
context:
space:
mode:
Diffstat (limited to 'addons/point_of_sale/models')
-rw-r--r--addons/point_of_sale/models/__init__.py22
-rw-r--r--addons/point_of_sale/models/account_bank_statement.py30
-rw-r--r--addons/point_of_sale/models/account_journal.py10
-rw-r--r--addons/point_of_sale/models/account_move.py44
-rw-r--r--addons/point_of_sale/models/account_tax.py35
-rw-r--r--addons/point_of_sale/models/barcode_rule.py23
-rw-r--r--addons/point_of_sale/models/chart_template.py18
-rw-r--r--addons/point_of_sale/models/digest.py29
-rw-r--r--addons/point_of_sale/models/pos_category.py36
-rw-r--r--addons/point_of_sale/models/pos_config.py708
-rw-r--r--addons/point_of_sale/models/pos_order.py1021
-rw-r--r--addons/point_of_sale/models/pos_payment.py64
-rw-r--r--addons/point_of_sale/models/pos_payment_method.py81
-rw-r--r--addons/point_of_sale/models/pos_session.py1179
-rw-r--r--addons/point_of_sale/models/product.py50
-rw-r--r--addons/point_of_sale/models/res_company.py37
-rw-r--r--addons/point_of_sale/models/res_config_settings.py20
-rw-r--r--addons/point_of_sale/models/res_partner.py44
-rw-r--r--addons/point_of_sale/models/stock_picking.py165
-rw-r--r--addons/point_of_sale/models/stock_warehouse.py51
20 files changed, 3667 insertions, 0 deletions
diff --git a/addons/point_of_sale/models/__init__.py b/addons/point_of_sale/models/__init__.py
new file mode 100644
index 00000000..f6ec809e
--- /dev/null
+++ b/addons/point_of_sale/models/__init__.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import account_bank_statement
+from . import account_journal
+from . import account_tax
+from . import account_move
+from . import barcode_rule
+from . import chart_template
+from . import digest
+from . import pos_category
+from . import pos_config
+from . import pos_order
+from . import pos_session
+from . import product
+from . import res_partner
+from . import res_company
+from . import res_config_settings
+from . import stock_picking
+from . import stock_warehouse
+from . import pos_payment
+from . import pos_payment_method
diff --git a/addons/point_of_sale/models/account_bank_statement.py b/addons/point_of_sale/models/account_bank_statement.py
new file mode 100644
index 00000000..d32d756c
--- /dev/null
+++ b/addons/point_of_sale/models/account_bank_statement.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+# Copyright (C) 2004-2008 PC Solutions (<http://pcsol.be>). All Rights Reserved
+from odoo import fields, models, api, _
+from odoo.exceptions import UserError
+
+
+class AccountBankStatement(models.Model):
+ _inherit = 'account.bank.statement'
+
+ pos_session_id = fields.Many2one('pos.session', string="Session", copy=False)
+ account_id = fields.Many2one('account.account', related='journal_id.default_account_id', readonly=True)
+
+ def button_validate_or_action(self):
+ # OVERRIDE to check the consistency of the statement's state regarding the session's state.
+ for statement in self:
+ if statement.pos_session_id.state in ('opened', 'closing_control') and statement.state == 'open':
+ raise UserError(_("You can't validate a bank statement that is used in an opened Session of a Point of Sale."))
+ return super(AccountBankStatement, self).button_validate_or_action()
+
+ def unlink(self):
+ for bs in self:
+ if bs.pos_session_id:
+ raise UserError(_("You cannot delete a bank statement linked to Point of Sale session."))
+ return super( AccountBankStatement, self).unlink()
+
+class AccountBankStatementLine(models.Model):
+ _inherit = 'account.bank.statement.line'
+
+ pos_statement_id = fields.Many2one('pos.order', string="POS statement", ondelete='cascade')
diff --git a/addons/point_of_sale/models/account_journal.py b/addons/point_of_sale/models/account_journal.py
new file mode 100644
index 00000000..01371496
--- /dev/null
+++ b/addons/point_of_sale/models/account_journal.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+# Copyright (C) 2004-2008 PC Solutions (<http://pcsol.be>). All Rights Reserved
+from odoo import fields, models, api
+
+
+class AccountJournal(models.Model):
+ _inherit = 'account.journal'
+
+ pos_payment_method_ids = fields.One2many('pos.payment.method', 'cash_journal_id', string='Point of Sale Payment Methods')
diff --git a/addons/point_of_sale/models/account_move.py b/addons/point_of_sale/models/account_move.py
new file mode 100644
index 00000000..396c3d11
--- /dev/null
+++ b/addons/point_of_sale/models/account_move.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, api
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ pos_order_ids = fields.One2many('pos.order', 'account_move')
+
+ def _stock_account_get_last_step_stock_moves(self):
+ stock_moves = super(AccountMove, self)._stock_account_get_last_step_stock_moves()
+ for invoice in self.filtered(lambda x: x.move_type == 'out_invoice'):
+ stock_moves += invoice.sudo().mapped('pos_order_ids.picking_ids.move_lines').filtered(lambda x: x.state == 'done' and x.location_dest_id.usage == 'customer')
+ for invoice in self.filtered(lambda x: x.move_type == 'out_refund'):
+ stock_moves += invoice.sudo().mapped('pos_order_ids.picking_ids.move_lines').filtered(lambda x: x.state == 'done' and x.location_id.usage == 'customer')
+ return stock_moves
+
+
+class AccountMoveLine(models.Model):
+ _inherit = 'account.move.line'
+
+ def _stock_account_get_anglo_saxon_price_unit(self):
+ self.ensure_one()
+ if not self.product_id:
+ return self.price_unit
+ price_unit = super(AccountMoveLine, self)._stock_account_get_anglo_saxon_price_unit()
+ order = self.move_id.pos_order_ids
+ if order:
+ price_unit = order._get_pos_anglo_saxon_price_unit(self.product_id, self.move_id.partner_id.id, self.quantity)
+ return price_unit
+
+ def _get_refund_tax_audit_condition(self, aml):
+ # Overridden so that the returns can be detected as credit notes by the tax audit computation
+ rslt = super()._get_refund_tax_audit_condition(aml)
+
+ if aml.move_id.is_invoice():
+ # We don't need to check the pos orders for this move line if an invoice
+ # is linked to it ; we know that the invoice type tells us whether it's a refund
+ return rslt
+
+ pos_orders_count = self.env['pos.order'].search_count([('account_move', '=', aml.move_id.id)])
+ return rslt or (pos_orders_count and aml.debit > 0)
diff --git a/addons/point_of_sale/models/account_tax.py b/addons/point_of_sale/models/account_tax.py
new file mode 100644
index 00000000..a8ad419a
--- /dev/null
+++ b/addons/point_of_sale/models/account_tax.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+
+from odoo import _, api, models
+from odoo.exceptions import UserError
+
+
+class AccountTax(models.Model):
+ _inherit = 'account.tax'
+
+ def write(self, vals):
+ forbidden_fields = set([
+ 'amount_type', 'amount', 'type_tax_use', 'tax_group_id', 'price_include',
+ 'include_base_amount'
+ ])
+ if forbidden_fields & set(vals.keys()):
+ tax_ids = self.env['pos.order.line'].sudo().search([
+ ('order_id.session_id.state', '!=', 'closed')
+ ]).read(['tax_ids'])
+ # Flatten the list of taxes, see https://stackoverflow.com/questions/952914
+ tax_ids = set([i for sl in [t['tax_ids'] for t in tax_ids] for i in sl])
+ if tax_ids & set(self.ids):
+ raise UserError(_(
+ 'It is forbidden to modify a tax used in a POS order not posted. '
+ 'You must close the POS sessions before modifying the tax.'
+ ))
+ return super(AccountTax, self).write(vals)
+
+ def get_real_tax_amount(self):
+ tax_list = []
+ for tax in self:
+ tax_repartition_lines = tax.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax')
+ total_factor = sum(tax_repartition_lines.mapped('factor'))
+ real_amount = tax.amount * total_factor
+ tax_list.append({'id': tax.id, 'amount': real_amount})
+ return tax_list
diff --git a/addons/point_of_sale/models/barcode_rule.py b/addons/point_of_sale/models/barcode_rule.py
new file mode 100644
index 00000000..e9b67a51
--- /dev/null
+++ b/addons/point_of_sale/models/barcode_rule.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields
+from odoo.tools.translate import _
+
+
+class BarcodeRule(models.Model):
+ _inherit = 'barcode.rule'
+
+ type = fields.Selection(selection_add=[
+ ('weight', 'Weighted Product'),
+ ('price', 'Priced Product'),
+ ('discount', 'Discounted Product'),
+ ('client', 'Client'),
+ ('cashier', 'Cashier')
+ ], ondelete={
+ 'weight': 'set default',
+ 'price': 'set default',
+ 'discount': 'set default',
+ 'client': 'set default',
+ 'cashier': 'set default',
+ })
diff --git a/addons/point_of_sale/models/chart_template.py b/addons/point_of_sale/models/chart_template.py
new file mode 100644
index 00000000..28c9760d
--- /dev/null
+++ b/addons/point_of_sale/models/chart_template.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+from odoo import api, models
+
+
+class AccountChartTemplate(models.Model):
+ _inherit = 'account.chart.template'
+
+ def _load(self, sale_tax_rate, purchase_tax_rate, company):
+ """Remove the payment methods that are created for the company before installing the chart of accounts.
+
+ Keeping these existing pos.payment.method records interferes with the installation of chart of accounts
+ because pos.payment.method model has fields linked to account.journal and account.account records that are
+ deleted during the loading of chart of accounts.
+ """
+ self.env['pos.payment.method'].search([('company_id', '=', company.id)]).unlink()
+ result = super(AccountChartTemplate, self)._load(sale_tax_rate, purchase_tax_rate, company)
+ self.env['pos.config'].post_install_pos_localisation(companies=company)
+ return result
diff --git a/addons/point_of_sale/models/digest.py b/addons/point_of_sale/models/digest.py
new file mode 100644
index 00000000..21a65cc4
--- /dev/null
+++ b/addons/point_of_sale/models/digest.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, _
+from odoo.exceptions import AccessError
+
+
+class Digest(models.Model):
+ _inherit = 'digest.digest'
+
+ kpi_pos_total = fields.Boolean('POS Sales')
+ kpi_pos_total_value = fields.Monetary(compute='_compute_kpi_pos_total_value')
+
+ def _compute_kpi_pos_total_value(self):
+ if not self.env.user.has_group('point_of_sale.group_pos_user'):
+ raise AccessError(_("Do not have access, skip this data for user's digest email"))
+ for record in self:
+ start, end, company = record._get_kpi_compute_parameters()
+ record.kpi_pos_total_value = sum(self.env['pos.order'].search([
+ ('date_order', '>=', start),
+ ('date_order', '<', end),
+ ('state', 'not in', ['draft', 'cancel', 'invoiced']),
+ ('company_id', '=', company.id)
+ ]).mapped('amount_total'))
+
+ def _compute_kpis_actions(self, company, user):
+ res = super(Digest, self)._compute_kpis_actions(company, user)
+ res['kpi_pos_total'] = 'point_of_sale.action_pos_sale_graph&menu_id=%s' % self.env.ref('point_of_sale.menu_point_root').id
+ return res
diff --git a/addons/point_of_sale/models/pos_category.py b/addons/point_of_sale/models/pos_category.py
new file mode 100644
index 00000000..6ae690f6
--- /dev/null
+++ b/addons/point_of_sale/models/pos_category.py
@@ -0,0 +1,36 @@
+# -*- 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 ValidationError, UserError
+
+
+class PosCategory(models.Model):
+ _name = "pos.category"
+ _description = "Point of Sale Category"
+ _order = "sequence, name"
+
+ @api.constrains('parent_id')
+ def _check_category_recursion(self):
+ if not self._check_recursion():
+ raise ValidationError(_('Error ! You cannot create recursive categories.'))
+
+ name = fields.Char(string='Category Name', required=True, translate=True)
+ parent_id = fields.Many2one('pos.category', string='Parent Category', index=True)
+ child_id = fields.One2many('pos.category', 'parent_id', string='Children Categories')
+ sequence = fields.Integer(help="Gives the sequence order when displaying a list of product categories.")
+ image_128 = fields.Image("Image", max_width=128, max_height=128)
+
+ def name_get(self):
+ def get_names(cat):
+ res = []
+ while cat:
+ res.append(cat.name)
+ cat = cat.parent_id
+ return res
+ return [(cat.id, " / ".join(reversed(get_names(cat)))) for cat in self]
+
+ def unlink(self):
+ if self.search_count([('id', 'in', self.ids)]):
+ if self.env['pos.session'].sudo().search_count([('state', '!=', 'closed')]):
+ raise UserError(_('You cannot delete a point of sale category while a session is still opened.'))
+ return super(PosCategory, self).unlink()
diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py
new file mode 100644
index 00000000..e8028cf2
--- /dev/null
+++ b/addons/point_of_sale/models/pos_config.py
@@ -0,0 +1,708 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime
+from uuid import uuid4
+import pytz
+
+from odoo import api, fields, models, tools, _
+from odoo.exceptions import ValidationError, UserError
+
+
+class AccountBankStmtCashWizard(models.Model):
+ _inherit = 'account.bank.statement.cashbox'
+
+ @api.depends('pos_config_ids')
+ @api.depends_context('current_currency_id')
+ def _compute_currency(self):
+ super(AccountBankStmtCashWizard, self)._compute_currency()
+ for cashbox in self:
+ if cashbox.pos_config_ids:
+ cashbox.currency_id = cashbox.pos_config_ids[0].currency_id.id
+ elif self.env.context.get('current_currency_id'):
+ cashbox.currency_id = self.env.context.get('current_currency_id')
+
+ pos_config_ids = fields.One2many('pos.config', 'default_cashbox_id')
+ is_a_template = fields.Boolean(default=False)
+
+ @api.model
+ def default_get(self, fields):
+ vals = super(AccountBankStmtCashWizard, self).default_get(fields)
+ if 'cashbox_lines_ids' not in fields:
+ return vals
+ config_id = self.env.context.get('default_pos_id')
+ if config_id:
+ config = self.env['pos.config'].browse(config_id)
+ if config.last_session_closing_cashbox.cashbox_lines_ids:
+ lines = config.last_session_closing_cashbox.cashbox_lines_ids
+ else:
+ lines = config.default_cashbox_id.cashbox_lines_ids
+ if self.env.context.get('balance', False) == 'start':
+ vals['cashbox_lines_ids'] = [[0, 0, {'coin_value': line.coin_value, 'number': line.number, 'subtotal': line.subtotal}] for line in lines]
+ else:
+ vals['cashbox_lines_ids'] = [[0, 0, {'coin_value': line.coin_value, 'number': 0, 'subtotal': 0.0}] for line in lines]
+ return vals
+
+ def _validate_cashbox(self):
+ super(AccountBankStmtCashWizard, self)._validate_cashbox()
+ session_id = self.env.context.get('pos_session_id')
+ if session_id:
+ current_session = self.env['pos.session'].browse(session_id)
+ if current_session.state == 'new_session':
+ current_session.write({'state': 'opening_control'})
+
+ def set_default_cashbox(self):
+ self.ensure_one()
+ current_session = self.env['pos.session'].browse(self.env.context['pos_session_id'])
+ lines = current_session.config_id.default_cashbox_id.cashbox_lines_ids
+ context = dict(self._context)
+ self.cashbox_lines_ids.unlink()
+ self.cashbox_lines_ids = [[0, 0, {'coin_value': line.coin_value, 'number': line.number, 'subtotal': line.subtotal}] for line in lines]
+
+ return {
+ 'name': _('Cash Control'),
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'account.bank.statement.cashbox',
+ 'view_id': self.env.ref('point_of_sale.view_account_bnk_stmt_cashbox_footer').id,
+ 'type': 'ir.actions.act_window',
+ 'context': context,
+ 'target': 'new',
+ 'res_id': self.id,
+ }
+
+
+class PosConfig(models.Model):
+ _name = 'pos.config'
+ _description = 'Point of Sale Configuration'
+
+ def _default_picking_type_id(self):
+ return self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1).pos_type_id.id
+
+ def _default_sale_journal(self):
+ return self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', self.env.company.id), ('code', '=', 'POSS')], limit=1)
+
+ def _default_invoice_journal(self):
+ return self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', self.env.company.id)], limit=1)
+
+ def _default_payment_methods(self):
+ return self.env['pos.payment.method'].search([('split_transactions', '=', False), ('company_id', '=', self.env.company.id)])
+
+ def _default_pricelist(self):
+ return self.env['product.pricelist'].search([('company_id', 'in', (False, self.env.company.id)), ('currency_id', '=', self.env.company.currency_id.id)], limit=1)
+
+ def _get_group_pos_manager(self):
+ return self.env.ref('point_of_sale.group_pos_manager')
+
+ def _get_group_pos_user(self):
+ return self.env.ref('point_of_sale.group_pos_user')
+
+ def _compute_customer_html(self):
+ for config in self:
+ config.customer_facing_display_html = self.env['ir.qweb']._render('point_of_sale.customer_facing_display_html')
+
+ name = fields.Char(string='Point of Sale', index=True, required=True, help="An internal identification of the point of sale.")
+ is_installed_account_accountant = fields.Boolean(string="Is the Full Accounting Installed",
+ compute="_compute_is_installed_account_accountant")
+ picking_type_id = fields.Many2one(
+ 'stock.picking.type',
+ string='Operation Type',
+ default=_default_picking_type_id,
+ required=True,
+ domain="[('code', '=', 'outgoing'), ('warehouse_id.company_id', '=', company_id)]",
+ ondelete='restrict')
+ journal_id = fields.Many2one(
+ 'account.journal', string='Sales Journal',
+ domain=[('type', '=', 'sale')],
+ help="Accounting journal used to post sales entries.",
+ default=_default_sale_journal,
+ ondelete='restrict')
+ invoice_journal_id = fields.Many2one(
+ 'account.journal', string='Invoice Journal',
+ domain=[('type', '=', 'sale')],
+ help="Accounting journal used to create invoices.",
+ default=_default_invoice_journal)
+ currency_id = fields.Many2one('res.currency', compute='_compute_currency', string="Currency")
+ iface_cashdrawer = fields.Boolean(string='Cashdrawer', help="Automatically open the cashdrawer.")
+ iface_electronic_scale = fields.Boolean(string='Electronic Scale', help="Enables Electronic Scale integration.")
+ iface_vkeyboard = fields.Boolean(string='Virtual KeyBoard', help=u"Don’t turn this option on if you take orders on smartphones or tablets. \n Such devices already benefit from a native keyboard.")
+ iface_customer_facing_display = fields.Boolean(string='Customer Facing Display', help="Show checkout to customers with a remotely-connected screen.")
+ iface_print_via_proxy = fields.Boolean(string='Print via Proxy', help="Bypass browser printing and prints via the hardware proxy.")
+ iface_scan_via_proxy = fields.Boolean(string='Scan via Proxy', help="Enable barcode scanning with a remotely connected barcode scanner and card swiping with a Vantiv card reader.")
+ iface_big_scrollbars = fields.Boolean('Large Scrollbars', help='For imprecise industrial touchscreens.')
+ iface_print_auto = fields.Boolean(string='Automatic Receipt Printing', default=False,
+ help='The receipt will automatically be printed at the end of each order.')
+ iface_print_skip_screen = fields.Boolean(string='Skip Preview Screen', default=True,
+ help='The receipt screen will be skipped if the receipt can be printed automatically.')
+ iface_tax_included = fields.Selection([('subtotal', 'Tax-Excluded Price'), ('total', 'Tax-Included Price')], string="Tax Display", default='subtotal', required=True)
+ iface_start_categ_id = fields.Many2one('pos.category', string='Initial Category',
+ help='The point of sale will display this product category by default. If no category is specified, all available products will be shown.')
+ iface_available_categ_ids = fields.Many2many('pos.category', string='Available PoS Product Categories',
+ help='The point of sale will only display products which are within one of the selected category trees. If no category is specified, all available products will be shown')
+ selectable_categ_ids = fields.Many2many('pos.category', compute='_compute_selectable_categories')
+ iface_display_categ_images = fields.Boolean(string='Display Category Pictures',
+ help="The product categories will be displayed with pictures.")
+ restrict_price_control = fields.Boolean(string='Restrict Price Modifications to Managers',
+ help="Only users with Manager access rights for PoS app can modify the product prices on orders.")
+ cash_control = fields.Boolean(string='Advanced Cash Control', help="Check the amount of the cashbox at opening and closing.")
+ receipt_header = fields.Text(string='Receipt Header', help="A short text that will be inserted as a header in the printed receipt.")
+ receipt_footer = fields.Text(string='Receipt Footer', help="A short text that will be inserted as a footer in the printed receipt.")
+ proxy_ip = fields.Char(string='IP Address', size=45,
+ help='The hostname or ip address of the hardware proxy, Will be autodetected if left empty.')
+ active = fields.Boolean(default=True)
+ uuid = fields.Char(readonly=True, default=lambda self: str(uuid4()), copy=False,
+ help='A globally unique identifier for this pos configuration, used to prevent conflicts in client-generated data.')
+ sequence_id = fields.Many2one('ir.sequence', string='Order IDs Sequence', readonly=True,
+ help="This sequence is automatically created by Odoo but you can change it "
+ "to customize the reference numbers of your orders.", copy=False, ondelete='restrict')
+ sequence_line_id = fields.Many2one('ir.sequence', string='Order Line IDs Sequence', readonly=True,
+ help="This sequence is automatically created by Odoo but you can change it "
+ "to customize the reference numbers of your orders lines.", copy=False)
+ session_ids = fields.One2many('pos.session', 'config_id', string='Sessions')
+ current_session_id = fields.Many2one('pos.session', compute='_compute_current_session', string="Current Session")
+ current_session_state = fields.Char(compute='_compute_current_session')
+ last_session_closing_cash = fields.Float(compute='_compute_last_session')
+ last_session_closing_date = fields.Date(compute='_compute_last_session')
+ last_session_closing_cashbox = fields.Many2one('account.bank.statement.cashbox', compute='_compute_last_session')
+ pos_session_username = fields.Char(compute='_compute_current_session_user')
+ pos_session_state = fields.Char(compute='_compute_current_session_user')
+ pos_session_duration = fields.Char(compute='_compute_current_session_user')
+ pricelist_id = fields.Many2one('product.pricelist', string='Default Pricelist', required=True, default=_default_pricelist,
+ help="The pricelist used if no customer is selected or if the customer has no Sale Pricelist configured.")
+ available_pricelist_ids = fields.Many2many('product.pricelist', string='Available Pricelists', default=_default_pricelist,
+ help="Make several pricelists available in the Point of Sale. You can also apply a pricelist to specific customers from their contact form (in Sales tab). To be valid, this pricelist must be listed here as an available pricelist. Otherwise the default pricelist will apply.")
+ allowed_pricelist_ids = fields.Many2many(
+ 'product.pricelist',
+ string='Allowed Pricelists',
+ compute='_compute_allowed_pricelist_ids',
+ help='This is a technical field used for the domain of pricelist_id.',
+ )
+ company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
+ barcode_nomenclature_id = fields.Many2one('barcode.nomenclature', string='Barcode Nomenclature',
+ help='Defines what kind of barcodes are available and how they are assigned to products, customers and cashiers.',
+ default=lambda self: self.env.company.nomenclature_id, required=True)
+ group_pos_manager_id = fields.Many2one('res.groups', string='Point of Sale Manager Group', default=_get_group_pos_manager,
+ help='This field is there to pass the id of the pos manager group to the point of sale client.')
+ group_pos_user_id = fields.Many2one('res.groups', string='Point of Sale User Group', default=_get_group_pos_user,
+ help='This field is there to pass the id of the pos user group to the point of sale client.')
+ iface_tipproduct = fields.Boolean(string="Product tips")
+ tip_product_id = fields.Many2one('product.product', string='Tip Product',
+ help="This product is used as reference on customer receipts.")
+ fiscal_position_ids = fields.Many2many('account.fiscal.position', string='Fiscal Positions', help='This is useful for restaurants with onsite and take-away services that imply specific tax rates.')
+ default_fiscal_position_id = fields.Many2one('account.fiscal.position', string='Default Fiscal Position')
+ default_cashbox_id = fields.Many2one('account.bank.statement.cashbox', string='Default Balance')
+ customer_facing_display_html = fields.Html(string='Customer facing display content', translate=True, compute=_compute_customer_html)
+ use_pricelist = fields.Boolean("Use a pricelist.")
+ tax_regime = fields.Boolean("Tax Regime")
+ tax_regime_selection = fields.Boolean("Tax Regime Selection value")
+ start_category = fields.Boolean("Start Category", default=False)
+ limit_categories = fields.Boolean("Restrict Product Categories")
+ module_account = fields.Boolean(string='Invoicing', default=True, help='Enables invoice generation from the Point of Sale.')
+ module_pos_restaurant = fields.Boolean("Is a Bar/Restaurant")
+ module_pos_discount = fields.Boolean("Global Discounts")
+ module_pos_loyalty = fields.Boolean("Loyalty Program")
+ module_pos_mercury = fields.Boolean(string="Integrated Card Payments")
+ manage_orders = fields.Boolean(string="Manage Orders")
+ product_configurator = fields.Boolean(string="Product Configurator")
+ is_posbox = fields.Boolean("PosBox")
+ is_header_or_footer = fields.Boolean("Header & Footer")
+ module_pos_hr = fields.Boolean(help="Show employee login screen")
+ amount_authorized_diff = fields.Float('Amount Authorized Difference',
+ help="This field depicts the maximum difference allowed between the ending balance and the theoretical cash when "
+ "closing a session, for non-POS managers. If this maximum is reached, the user will have an error message at "
+ "the closing of his session saying that he needs to contact his manager.")
+ payment_method_ids = fields.Many2many('pos.payment.method', string='Payment Methods', default=lambda self: self._default_payment_methods())
+ company_has_template = fields.Boolean(string="Company has chart of accounts", compute="_compute_company_has_template")
+ current_user_id = fields.Many2one('res.users', string='Current Session Responsible', compute='_compute_current_session_user')
+ other_devices = fields.Boolean(string="Other Devices", help="Connect devices to your PoS without an IoT Box.")
+ rounding_method = fields.Many2one('account.cash.rounding', string="Cash rounding")
+ cash_rounding = fields.Boolean(string="Cash Rounding")
+ only_round_cash_method = fields.Boolean(string="Only apply rounding on cash")
+ has_active_session = fields.Boolean(compute='_compute_current_session')
+ show_allow_invoicing_alert = fields.Boolean(compute="_compute_show_allow_invoicing_alert")
+ manual_discount = fields.Boolean(string="Manual Discounts", default=True)
+
+ @api.depends('use_pricelist', 'available_pricelist_ids')
+ def _compute_allowed_pricelist_ids(self):
+ for config in self:
+ if config.use_pricelist:
+ config.allowed_pricelist_ids = config.available_pricelist_ids.ids
+ else:
+ config.allowed_pricelist_ids = self.env['product.pricelist'].search([]).ids
+
+ @api.depends('company_id')
+ def _compute_company_has_template(self):
+ for config in self:
+ if config.company_id.chart_template_id:
+ config.company_has_template = True
+ else:
+ config.company_has_template = False
+
+ def _compute_is_installed_account_accountant(self):
+ account_accountant = self.env['ir.module.module'].sudo().search([('name', '=', 'account_accountant'), ('state', '=', 'installed')])
+ for pos_config in self:
+ pos_config.is_installed_account_accountant = account_accountant and account_accountant.id
+
+ @api.depends('journal_id.currency_id', 'journal_id.company_id.currency_id', 'company_id', 'company_id.currency_id')
+ def _compute_currency(self):
+ for pos_config in self:
+ if pos_config.journal_id:
+ pos_config.currency_id = pos_config.journal_id.currency_id.id or pos_config.journal_id.company_id.currency_id.id
+ else:
+ pos_config.currency_id = pos_config.company_id.currency_id.id
+
+ @api.depends('session_ids', 'session_ids.state')
+ def _compute_current_session(self):
+ """If there is an open session, store it to current_session_id / current_session_State.
+ """
+ for pos_config in self:
+ opened_sessions = pos_config.session_ids.filtered(lambda s: not s.state == 'closed')
+ session = pos_config.session_ids.filtered(lambda s: s.user_id.id == self.env.uid and \
+ not s.state == 'closed' and not s.rescue)
+ # sessions ordered by id desc
+ pos_config.has_active_session = opened_sessions and True or False
+ pos_config.current_session_id = session and session[0].id or False
+ pos_config.current_session_state = session and session[0].state or False
+
+ @api.depends('module_account', 'manage_orders')
+ def _compute_show_allow_invoicing_alert(self):
+ for pos_config in self:
+ if not pos_config.manage_orders:
+ pos_config.show_allow_invoicing_alert = False
+ else:
+ pos_config.show_allow_invoicing_alert = not pos_config.module_account
+
+ @api.depends('session_ids')
+ def _compute_last_session(self):
+ PosSession = self.env['pos.session']
+ for pos_config in self:
+ session = PosSession.search_read(
+ [('config_id', '=', pos_config.id), ('state', '=', 'closed')],
+ ['cash_register_balance_end_real', 'stop_at', 'cash_register_id'],
+ order="stop_at desc", limit=1)
+ if session:
+ timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC')
+ pos_config.last_session_closing_date = session[0]['stop_at'].astimezone(timezone).date()
+ if session[0]['cash_register_id']:
+ pos_config.last_session_closing_cash = session[0]['cash_register_balance_end_real']
+ pos_config.last_session_closing_cashbox = self.env['account.bank.statement'].browse(session[0]['cash_register_id'][0]).cashbox_end_id
+ else:
+ pos_config.last_session_closing_cash = 0
+ pos_config.last_session_closing_cashbox = False
+ else:
+ pos_config.last_session_closing_cash = 0
+ pos_config.last_session_closing_date = False
+ pos_config.last_session_closing_cashbox = False
+
+ @api.depends('session_ids')
+ def _compute_current_session_user(self):
+ for pos_config in self:
+ session = pos_config.session_ids.filtered(lambda s: s.state in ['opening_control', 'opened', 'closing_control'] and not s.rescue)
+ if session:
+ pos_config.pos_session_username = session[0].user_id.sudo().name
+ pos_config.pos_session_state = session[0].state
+ pos_config.pos_session_duration = (
+ datetime.now() - session[0].start_at
+ ).days if session[0].start_at else 0
+ pos_config.current_user_id = session[0].user_id
+ else:
+ pos_config.pos_session_username = False
+ pos_config.pos_session_state = False
+ pos_config.pos_session_duration = 0
+ pos_config.current_user_id = False
+
+ @api.depends('iface_available_categ_ids')
+ def _compute_selectable_categories(self):
+ for config in self:
+ if config.iface_available_categ_ids:
+ config.selectable_categ_ids = config.iface_available_categ_ids
+ else:
+ config.selectable_categ_ids = self.env['pos.category'].search([])
+
+ @api.constrains('cash_control')
+ def _check_session_state(self):
+ open_session = self.env['pos.session'].search([('config_id', '=', self.id), ('state', '!=', 'closed')])
+ if open_session:
+ raise ValidationError(_("You are not allowed to change the cash control status while a session is already opened."))
+
+ @api.constrains('rounding_method')
+ def _check_rounding_method_strategy(self):
+ if self.cash_rounding and self.rounding_method.strategy != 'add_invoice_line':
+ raise ValidationError(_("Cash rounding strategy must be: 'Add a rounding line'"))
+
+ @api.constrains('company_id', 'journal_id')
+ def _check_company_journal(self):
+ if self.journal_id and self.journal_id.company_id.id != self.company_id.id:
+ raise ValidationError(_("The sales journal and the point of sale must belong to the same company."))
+
+ def _check_profit_loss_cash_journal(self):
+ if self.cash_control and self.payment_method_ids:
+ for method in self.payment_method_ids:
+ if method.is_cash_count and (not method.cash_journal_id.loss_account_id or not method.cash_journal_id.profit_account_id):
+ raise ValidationError(_("You need a loss and profit account on your cash journal."))
+
+ @api.constrains('company_id', 'invoice_journal_id')
+ def _check_company_invoice_journal(self):
+ if self.invoice_journal_id and self.invoice_journal_id.company_id.id != self.company_id.id:
+ raise ValidationError(_("The invoice journal and the point of sale must belong to the same company."))
+
+ @api.constrains('company_id', 'payment_method_ids')
+ def _check_company_payment(self):
+ if self.env['pos.payment.method'].search_count([('id', 'in', self.payment_method_ids.ids), ('company_id', '!=', self.company_id.id)]):
+ raise ValidationError(_("The payment methods and the point of sale must belong to the same company."))
+
+ @api.constrains('pricelist_id', 'use_pricelist', 'available_pricelist_ids', 'journal_id', 'invoice_journal_id', 'payment_method_ids')
+ def _check_currencies(self):
+ for config in self:
+ if config.use_pricelist and config.pricelist_id not in config.available_pricelist_ids:
+ raise ValidationError(_("The default pricelist must be included in the available pricelists."))
+ if any(self.available_pricelist_ids.mapped(lambda pricelist: pricelist.currency_id != self.currency_id)):
+ raise ValidationError(_("All available pricelists must be in the same currency as the company or"
+ " as the Sales Journal set on this point of sale if you use"
+ " the Accounting application."))
+ if self.invoice_journal_id.currency_id and self.invoice_journal_id.currency_id != self.currency_id:
+ raise ValidationError(_("The invoice journal must be in the same currency as the Sales Journal or the company currency if that is not set."))
+ if any(
+ self.payment_method_ids\
+ .filtered(lambda pm: pm.is_cash_count)\
+ .mapped(lambda pm: self.currency_id not in (self.company_id.currency_id | pm.cash_journal_id.currency_id))
+ ):
+ raise ValidationError(_("All payment methods must be in the same currency as the Sales Journal or the company currency if that is not set."))
+
+ @api.constrains('payment_method_ids')
+ def _check_payment_method_receivable_accounts(self):
+ # This is normally not supposed to happen to have a payment method without a receivable account set,
+ # as this is a required field. However, it happens the receivable account cannot be found during upgrades
+ # and this is a bommer to block the upgrade for that point, given the user can correct this by himself,
+ # without requiring a manual intervention from our upgrade support.
+ # However, this must be ensured this receivable is well set before opening a POS session.
+ invalid_payment_methods = self.payment_method_ids.filtered(lambda method: not method.receivable_account_id)
+ if invalid_payment_methods:
+ method_names = ", ".join(method.name for method in invalid_payment_methods)
+ raise ValidationError(
+ _("You must configure an intermediary account for the payment methods: %s.") % method_names
+ )
+
+ def _check_payment_method_ids(self):
+ self.ensure_one()
+ if not self.payment_method_ids:
+ raise ValidationError(
+ _("You must have at least one payment method configured to launch a session.")
+ )
+
+ @api.constrains('company_id', 'available_pricelist_ids')
+ def _check_companies(self):
+ if any(self.available_pricelist_ids.mapped(lambda pl: pl.company_id.id not in (False, self.company_id.id))):
+ raise ValidationError(_("The selected pricelists must belong to no company or the company of the point of sale."))
+
+ @api.onchange('iface_tipproduct')
+ def _onchange_tipproduct(self):
+ if self.iface_tipproduct:
+ self.tip_product_id = self.env.ref('point_of_sale.product_product_tip', False)
+ else:
+ self.tip_product_id = False
+
+ @api.onchange('iface_print_via_proxy')
+ def _onchange_iface_print_via_proxy(self):
+ self.iface_print_auto = self.iface_print_via_proxy
+ if not self.iface_print_via_proxy:
+ self.iface_cashdrawer = False
+
+ @api.onchange('module_account')
+ def _onchange_module_account(self):
+ if self.module_account and not self.invoice_journal_id:
+ self.invoice_journal_id = self._default_invoice_journal()
+
+ @api.onchange('use_pricelist')
+ def _onchange_use_pricelist(self):
+ """
+ If the 'pricelist' box is unchecked, we reset the pricelist_id to stop
+ using a pricelist for this iotbox.
+ """
+ if not self.use_pricelist:
+ self.pricelist_id = self._default_pricelist()
+
+ @api.onchange('available_pricelist_ids')
+ def _onchange_available_pricelist_ids(self):
+ if self.pricelist_id not in self.available_pricelist_ids._origin:
+ self.pricelist_id = False
+
+ @api.onchange('is_posbox')
+ def _onchange_is_posbox(self):
+ if not self.is_posbox:
+ self.proxy_ip = False
+ self.iface_scan_via_proxy = False
+ self.iface_electronic_scale = False
+ self.iface_cashdrawer = False
+ self.iface_print_via_proxy = False
+ self.iface_customer_facing_display = False
+
+ @api.onchange('tax_regime')
+ def _onchange_tax_regime(self):
+ if not self.tax_regime:
+ self.default_fiscal_position_id = False
+
+ @api.onchange('tax_regime_selection')
+ def _onchange_tax_regime_selection(self):
+ if not self.tax_regime_selection:
+ self.fiscal_position_ids = [(5, 0, 0)]
+
+ @api.onchange('start_category')
+ def _onchange_start_category(self):
+ if not self.start_category:
+ self.iface_start_categ_id = False
+
+ @api.onchange('limit_categories', 'iface_available_categ_ids', 'iface_start_categ_id')
+ def _onchange_limit_categories(self):
+ res = {}
+ if not self.limit_categories:
+ self.iface_available_categ_ids = False
+ if self.iface_available_categ_ids and self.iface_start_categ_id.id not in self.iface_available_categ_ids.ids:
+ self.iface_start_categ_id = False
+ return res
+
+ @api.onchange('is_header_or_footer')
+ def _onchange_header_footer(self):
+ if not self.is_header_or_footer:
+ self.receipt_header = False
+ self.receipt_footer = False
+
+ def name_get(self):
+ result = []
+ for config in self:
+ last_session = self.env['pos.session'].search([('config_id', '=', config.id)], limit=1)
+ if (not last_session) or (last_session.state == 'closed'):
+ result.append((config.id, _("%(pos_name)s (not used)", pos_name=config.name)))
+ else:
+ result.append((config.id, "%s (%s)" % (config.name, last_session.user_id.name)))
+ return result
+
+ @api.model
+ def create(self, values):
+ IrSequence = self.env['ir.sequence'].sudo()
+ val = {
+ 'name': _('POS Order %s', values['name']),
+ 'padding': 4,
+ 'prefix': "%s/" % values['name'],
+ 'code': "pos.order",
+ 'company_id': values.get('company_id', False),
+ }
+ # force sequence_id field to new pos.order sequence
+ values['sequence_id'] = IrSequence.create(val).id
+
+ val.update(name=_('POS order line %s', values['name']), code='pos.order.line')
+ values['sequence_line_id'] = IrSequence.create(val).id
+ pos_config = super(PosConfig, self).create(values)
+ pos_config.sudo()._check_modules_to_install()
+ pos_config.sudo()._check_groups_implied()
+ # If you plan to add something after this, use a new environment. The one above is no longer valid after the modules install.
+ return pos_config
+
+ def write(self, vals):
+ opened_session = self.mapped('session_ids').filtered(lambda s: s.state != 'closed')
+ if opened_session:
+ forbidden_fields = []
+ for key in self._get_forbidden_change_fields():
+ if key in vals.keys():
+ field_name = self._fields[key].get_description(self.env)["string"]
+ forbidden_fields.append(field_name)
+ if len(forbidden_fields) > 0:
+ raise UserError(_(
+ "Unable to modify this PoS Configuration because you can't modify %s while a session is open.",
+ ", ".join(forbidden_fields)
+ ))
+ result = super(PosConfig, self).write(vals)
+
+ self.sudo()._set_fiscal_position()
+ self.sudo()._check_modules_to_install()
+ self.sudo()._check_groups_implied()
+ return result
+
+ def _get_forbidden_change_fields(self):
+ forbidden_keys = ['module_pos_hr', 'cash_control', 'module_pos_restaurant', 'available_pricelist_ids',
+ 'limit_categories', 'iface_available_categ_ids', 'use_pricelist', 'module_pos_discount',
+ 'payment_method_ids', 'iface_tipproduc']
+ return forbidden_keys
+
+ def unlink(self):
+ # Delete the pos.config records first then delete the sequences linked to them
+ sequences_to_delete = self.sequence_id | self.sequence_line_id
+ res = super(PosConfig, self).unlink()
+ sequences_to_delete.unlink()
+ return res
+
+ def _set_fiscal_position(self):
+ for config in self:
+ if config.tax_regime and config.default_fiscal_position_id.id not in config.fiscal_position_ids.ids:
+ config.fiscal_position_ids = [(4, config.default_fiscal_position_id.id)]
+ elif not config.tax_regime_selection and not config.tax_regime and config.fiscal_position_ids.ids:
+ config.fiscal_position_ids = [(5, 0, 0)]
+
+ def _check_modules_to_install(self):
+ # determine modules to install
+ expected = [
+ fname[7:] # 'module_account' -> 'account'
+ for fname in self.fields_get_keys()
+ if fname.startswith('module_')
+ if any(pos_config[fname] for pos_config in self)
+ ]
+ if expected:
+ STATES = ('installed', 'to install', 'to upgrade')
+ modules = self.env['ir.module.module'].sudo().search([('name', 'in', expected)])
+ modules = modules.filtered(lambda module: module.state not in STATES)
+ if modules:
+ modules.button_immediate_install()
+ # just in case we want to do something if we install a module. (like a refresh ...)
+ return True
+ return False
+
+ def _check_groups_implied(self):
+ for pos_config in self:
+ for field_name in [f for f in pos_config.fields_get_keys() if f.startswith('group_')]:
+ field = pos_config._fields[field_name]
+ if field.type in ('boolean', 'selection') and hasattr(field, 'implied_group'):
+ field_group_xmlids = getattr(field, 'group', 'base.group_user').split(',')
+ field_groups = self.env['res.groups'].concat(*(self.env.ref(it) for it in field_group_xmlids))
+ field_groups.write({'implied_ids': [(4, self.env.ref(field.implied_group).id)]})
+
+
+ def execute(self):
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'reload',
+ 'params': {'wait': True}
+ }
+
+ def _force_http(self):
+ if self.other_devices:
+ return True
+ return False
+
+ def _get_pos_base_url(self):
+ return '/pos/web' if self._force_http() else '/pos/ui'
+
+ # Methods to open the POS
+ def open_ui(self):
+ """Open the pos interface with config_id as an extra argument.
+
+ In vanilla PoS each user can only have one active session, therefore it was not needed to pass the config_id
+ on opening a session. It is also possible to login to sessions created by other users.
+
+ :returns: dict
+ """
+ self.ensure_one()
+ # check all constraints, raises if any is not met
+ self._validate_fields(set(self._fields) - {"cash_control"})
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': self._get_pos_base_url() + '?config_id=%d' % self.id,
+ 'target': 'self',
+ }
+
+ def open_session_cb(self, check_coa=True):
+ """ new session button
+
+ create one if none exist
+ access cash control interface if enabled or start a session
+ """
+ self.ensure_one()
+ if not self.current_session_id:
+ self._check_company_journal()
+ self._check_company_invoice_journal()
+ self._check_company_payment()
+ self._check_currencies()
+ self._check_profit_loss_cash_journal()
+ self._check_payment_method_ids()
+ self._check_payment_method_receivable_accounts()
+ self.env['pos.session'].create({
+ 'user_id': self.env.uid,
+ 'config_id': self.id
+ })
+ return self.open_ui()
+
+ def open_existing_session_cb(self):
+ """ close session button
+
+ access session form to validate entries
+ """
+ self.ensure_one()
+ return self._open_session(self.current_session_id.id)
+
+ def _open_session(self, session_id):
+ return {
+ 'name': _('Session'),
+ 'view_mode': 'form,tree',
+ 'res_model': 'pos.session',
+ 'res_id': session_id,
+ 'view_id': False,
+ 'type': 'ir.actions.act_window',
+ }
+
+ # All following methods are made to create data needed in POS, when a localisation
+ # is installed, or if POS is installed on database having companies that already have
+ # a localisation installed
+ @api.model
+ def post_install_pos_localisation(self, companies=False):
+ self = self.sudo()
+ if not companies:
+ companies = self.env['res.company'].search([])
+ for company in companies.filtered('chart_template_id'):
+ pos_configs = self.search([('company_id', '=', company.id)])
+ pos_configs.setup_defaults(company)
+
+ def setup_defaults(self, company):
+ """Extend this method to customize the existing pos.config of the company during the installation
+ of a localisation.
+
+ :param self pos.config: pos.config records present in the company during the installation of localisation.
+ :param company res.company: the single company where the pos.config defaults will be setup.
+ """
+ self.assign_payment_journals(company)
+ self.generate_pos_journal(company)
+ self.setup_invoice_journal(company)
+
+ def assign_payment_journals(self, company):
+ for pos_config in self:
+ if pos_config.payment_method_ids or pos_config.has_active_session:
+ continue
+ cash_journal = self.env['account.journal'].search([('company_id', '=', company.id), ('type', '=', 'cash')], limit=1)
+ pos_receivable_account = company.account_default_pos_receivable_account_id
+ payment_methods = self.env['pos.payment.method']
+ if cash_journal:
+ payment_methods |= payment_methods.create({
+ 'name': _('Cash'),
+ 'receivable_account_id': pos_receivable_account.id,
+ 'is_cash_count': True,
+ 'cash_journal_id': cash_journal.id,
+ 'company_id': company.id,
+ })
+ payment_methods |= payment_methods.create({
+ 'name': _('Bank'),
+ 'receivable_account_id': pos_receivable_account.id,
+ 'is_cash_count': False,
+ 'company_id': company.id,
+ })
+ pos_config.write({'payment_method_ids': [(6, 0, payment_methods.ids)]})
+
+ def generate_pos_journal(self, company):
+ for pos_config in self:
+ if pos_config.journal_id:
+ continue
+ pos_journal = self.env['account.journal'].search([('company_id', '=', company.id), ('code', '=', 'POSS')])
+ if not pos_journal:
+ pos_journal = self.env['account.journal'].create({
+ 'type': 'sale',
+ 'name': 'Point of Sale',
+ 'code': 'POSS',
+ 'company_id': company.id,
+ 'sequence': 20
+ })
+ pos_config.write({'journal_id': pos_journal.id})
+
+ def setup_invoice_journal(self, company):
+ for pos_config in self:
+ invoice_journal_id = pos_config.invoice_journal_id or self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', company.id)], limit=1)
+ if invoice_journal_id:
+ pos_config.write({'invoice_journal_id': invoice_journal_id.id})
+ else:
+ pos_config.write({'module_account': False})
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
new file mode 100644
index 00000000..cdcc7a9c
--- /dev/null
+++ b/addons/point_of_sale/models/pos_order.py
@@ -0,0 +1,1021 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import logging
+from datetime import timedelta
+from functools import partial
+
+import psycopg2
+import pytz
+import re
+
+from odoo import api, fields, models, tools, _
+from odoo.tools import float_is_zero, float_round
+from odoo.exceptions import ValidationError, UserError
+from odoo.http import request
+from odoo.osv.expression import AND
+import base64
+
+_logger = logging.getLogger(__name__)
+
+
+class PosOrder(models.Model):
+ _name = "pos.order"
+ _description = "Point of Sale Orders"
+ _order = "date_order desc, name desc, id desc"
+
+ @api.model
+ def _amount_line_tax(self, line, fiscal_position_id):
+ taxes = line.tax_ids.filtered(lambda t: t.company_id.id == line.order_id.company_id.id)
+ taxes = fiscal_position_id.map_tax(taxes, line.product_id, line.order_id.partner_id)
+ price = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
+ taxes = taxes.compute_all(price, line.order_id.pricelist_id.currency_id, line.qty, product=line.product_id, partner=line.order_id.partner_id or False)['taxes']
+ return sum(tax.get('amount', 0.0) for tax in taxes)
+
+ @api.model
+ def _order_fields(self, ui_order):
+ process_line = partial(self.env['pos.order.line']._order_line_fields, session_id=ui_order['pos_session_id'])
+ return {
+ 'user_id': ui_order['user_id'] or False,
+ 'session_id': ui_order['pos_session_id'],
+ 'lines': [process_line(l) for l in ui_order['lines']] if ui_order['lines'] else False,
+ 'pos_reference': ui_order['name'],
+ 'sequence_number': ui_order['sequence_number'],
+ 'partner_id': ui_order['partner_id'] or False,
+ 'date_order': ui_order['creation_date'].replace('T', ' ')[:19],
+ 'fiscal_position_id': ui_order['fiscal_position_id'],
+ 'pricelist_id': ui_order['pricelist_id'],
+ 'amount_paid': ui_order['amount_paid'],
+ 'amount_total': ui_order['amount_total'],
+ 'amount_tax': ui_order['amount_tax'],
+ 'amount_return': ui_order['amount_return'],
+ 'company_id': self.env['pos.session'].browse(ui_order['pos_session_id']).company_id.id,
+ 'to_invoice': ui_order['to_invoice'] if "to_invoice" in ui_order else False,
+ 'is_tipped': ui_order.get('is_tipped', False),
+ 'tip_amount': ui_order.get('tip_amount', 0),
+ }
+
+ @api.model
+ def _payment_fields(self, order, ui_paymentline):
+ return {
+ 'amount': ui_paymentline['amount'] or 0.0,
+ 'payment_date': ui_paymentline['name'],
+ 'payment_method_id': ui_paymentline['payment_method_id'],
+ 'card_type': ui_paymentline.get('card_type'),
+ 'cardholder_name': ui_paymentline.get('cardholder_name'),
+ 'transaction_id': ui_paymentline.get('transaction_id'),
+ 'payment_status': ui_paymentline.get('payment_status'),
+ 'ticket': ui_paymentline.get('ticket'),
+ 'pos_order_id': order.id,
+ }
+
+ # This deals with orders that belong to a closed session. In order
+ # to recover from this situation we create a new rescue session,
+ # making it obvious that something went wrong.
+ # A new, separate, rescue session is preferred for every such recovery,
+ # to avoid adding unrelated orders to live sessions.
+ def _get_valid_session(self, order):
+ PosSession = self.env['pos.session']
+ closed_session = PosSession.browse(order['pos_session_id'])
+
+ _logger.warning('session %s (ID: %s) was closed but received order %s (total: %s) belonging to it',
+ closed_session.name,
+ closed_session.id,
+ order['name'],
+ order['amount_total'])
+ rescue_session = PosSession.search([
+ ('state', 'not in', ('closed', 'closing_control')),
+ ('rescue', '=', True),
+ ('config_id', '=', closed_session.config_id.id),
+ ], limit=1)
+ if rescue_session:
+ _logger.warning('reusing recovery session %s for saving order %s', rescue_session.name, order['name'])
+ return rescue_session
+
+ _logger.warning('attempting to create recovery session for saving order %s', order['name'])
+ new_session = PosSession.create({
+ 'config_id': closed_session.config_id.id,
+ 'name': _('(RESCUE FOR %(session)s)') % {'session': closed_session.name},
+ 'rescue': True, # avoid conflict with live sessions
+ })
+ # bypass opening_control (necessary when using cash control)
+ new_session.action_pos_session_open()
+
+ return new_session
+
+
+ @api.model
+ def _process_order(self, order, draft, existing_order):
+ """Create or update an pos.order from a given dictionary.
+
+ :param dict order: dictionary representing the order.
+ :param bool draft: Indicate that the pos_order is not validated yet.
+ :param existing_order: order to be updated or False.
+ :type existing_order: pos.order.
+ :returns: id of created/updated pos.order
+ :rtype: int
+ """
+ order = order['data']
+ pos_session = self.env['pos.session'].browse(order['pos_session_id'])
+ if pos_session.state == 'closing_control' or pos_session.state == 'closed':
+ order['pos_session_id'] = self._get_valid_session(order).id
+
+ pos_order = False
+ if not existing_order:
+ pos_order = self.create(self._order_fields(order))
+ else:
+ pos_order = existing_order
+ pos_order.lines.unlink()
+ order['user_id'] = pos_order.user_id.id
+ pos_order.write(self._order_fields(order))
+
+ pos_order = pos_order.with_company(pos_order.company_id)
+ self = self.with_company(pos_order.company_id)
+ self._process_payment_lines(order, pos_order, pos_session, draft)
+
+ if not draft:
+ try:
+ pos_order.action_pos_order_paid()
+ except psycopg2.DatabaseError:
+ # do not hide transactional errors, the order(s) won't be saved!
+ raise
+ except Exception as e:
+ _logger.error('Could not fully process the POS Order: %s', tools.ustr(e))
+ pos_order._create_order_picking()
+
+ if pos_order.to_invoice and pos_order.state == 'paid':
+ pos_order.action_pos_order_invoice()
+
+ return pos_order.id
+
+
+ def _process_payment_lines(self, pos_order, order, pos_session, draft):
+ """Create account.bank.statement.lines from the dictionary given to the parent function.
+
+ If the payment_line is an updated version of an existing one, the existing payment_line will first be
+ removed before making a new one.
+ :param pos_order: dictionary representing the order.
+ :type pos_order: dict.
+ :param order: Order object the payment lines should belong to.
+ :type order: pos.order
+ :param pos_session: PoS session the order was created in.
+ :type pos_session: pos.session
+ :param draft: Indicate that the pos_order is not validated yet.
+ :type draft: bool.
+ """
+ prec_acc = order.pricelist_id.currency_id.decimal_places
+
+ order_bank_statement_lines= self.env['pos.payment'].search([('pos_order_id', '=', order.id)])
+ order_bank_statement_lines.unlink()
+ for payments in pos_order['statement_ids']:
+ order.add_payment(self._payment_fields(order, payments[2]))
+
+ order.amount_paid = sum(order.payment_ids.mapped('amount'))
+
+ if not draft and not float_is_zero(pos_order['amount_return'], prec_acc):
+ cash_payment_method = pos_session.payment_method_ids.filtered('is_cash_count')[:1]
+ if not cash_payment_method:
+ raise UserError(_("No cash statement found for this session. Unable to record returned cash."))
+ return_payment_vals = {
+ 'name': _('return'),
+ 'pos_order_id': order.id,
+ 'amount': -pos_order['amount_return'],
+ 'payment_date': fields.Datetime.now(),
+ 'payment_method_id': cash_payment_method.id,
+ 'is_change': True,
+ }
+ order.add_payment(return_payment_vals)
+
+ def _prepare_invoice_line(self, order_line):
+ return {
+ 'product_id': order_line.product_id.id,
+ 'quantity': order_line.qty if self.amount_total >= 0 else -order_line.qty,
+ 'discount': order_line.discount,
+ 'price_unit': order_line.price_unit,
+ 'name': order_line.product_id.display_name,
+ 'tax_ids': [(6, 0, order_line.tax_ids_after_fiscal_position.ids)],
+ 'product_uom_id': order_line.product_uom_id.id,
+ }
+
+ def _get_pos_anglo_saxon_price_unit(self, product, partner_id, quantity):
+ moves = self.filtered(lambda o: o.partner_id.id == partner_id)\
+ .mapped('picking_ids.move_lines')\
+ ._filter_anglo_saxon_moves(product)\
+ .sorted(lambda x: x.date)
+ price_unit = product._compute_average_price(0, quantity, moves)
+ return price_unit
+
+ name = fields.Char(string='Order Ref', required=True, readonly=True, copy=False, default='/')
+ date_order = fields.Datetime(string='Date', readonly=True, index=True, default=fields.Datetime.now)
+ user_id = fields.Many2one(
+ comodel_name='res.users', string='Responsible',
+ help="Person who uses the cash register. It can be a reliever, a student or an interim employee.",
+ default=lambda self: self.env.uid,
+ states={'done': [('readonly', True)], 'invoiced': [('readonly', True)]},
+ )
+ amount_tax = fields.Float(string='Taxes', digits=0, readonly=True, required=True)
+ amount_total = fields.Float(string='Total', digits=0, readonly=True, required=True)
+ amount_paid = fields.Float(string='Paid', states={'draft': [('readonly', False)]},
+ readonly=True, digits=0, required=True)
+ amount_return = fields.Float(string='Returned', digits=0, required=True, readonly=True)
+ lines = fields.One2many('pos.order.line', 'order_id', string='Order Lines', states={'draft': [('readonly', False)]}, readonly=True, copy=True)
+ company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True)
+ pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', required=True, states={
+ 'draft': [('readonly', False)]}, readonly=True)
+ partner_id = fields.Many2one('res.partner', string='Customer', change_default=True, index=True, states={'draft': [('readonly', False)], 'paid': [('readonly', False)]})
+ sequence_number = fields.Integer(string='Sequence Number', help='A session-unique sequence number for the order', default=1)
+
+ session_id = fields.Many2one(
+ 'pos.session', string='Session', required=True, index=True,
+ domain="[('state', '=', 'opened')]", states={'draft': [('readonly', False)]},
+ readonly=True)
+ config_id = fields.Many2one('pos.config', related='session_id.config_id', string="Point of Sale", readonly=False)
+ currency_id = fields.Many2one('res.currency', related='config_id.currency_id', string="Currency")
+ currency_rate = fields.Float("Currency Rate", compute='_compute_currency_rate', compute_sudo=True, store=True, digits=0, readonly=True,
+ help='The rate of the currency to the currency of rate applicable at the date of the order')
+
+ invoice_group = fields.Boolean(related="config_id.module_account", readonly=False)
+ state = fields.Selection(
+ [('draft', 'New'), ('cancel', 'Cancelled'), ('paid', 'Paid'), ('done', 'Posted'), ('invoiced', 'Invoiced')],
+ 'Status', readonly=True, copy=False, default='draft')
+
+ account_move = fields.Many2one('account.move', string='Invoice', readonly=True, copy=False)
+ picking_ids = fields.One2many('stock.picking', 'pos_order_id')
+ picking_count = fields.Integer(compute='_compute_picking_count')
+ failed_pickings = fields.Boolean(compute='_compute_picking_count')
+ picking_type_id = fields.Many2one('stock.picking.type', related='session_id.config_id.picking_type_id', string="Operation Type", readonly=False)
+
+ note = fields.Text(string='Internal Notes')
+ nb_print = fields.Integer(string='Number of Print', readonly=True, copy=False, default=0)
+ pos_reference = fields.Char(string='Receipt Number', readonly=True, copy=False)
+ sale_journal = fields.Many2one('account.journal', related='session_id.config_id.journal_id', string='Sales Journal', store=True, readonly=True, ondelete='restrict')
+ fiscal_position_id = fields.Many2one(
+ comodel_name='account.fiscal.position', string='Fiscal Position',
+ readonly=True,
+ states={'draft': [('readonly', False)]},
+ )
+ payment_ids = fields.One2many('pos.payment', 'pos_order_id', string='Payments', readonly=True)
+ session_move_id = fields.Many2one('account.move', string='Session Journal Entry', related='session_id.move_id', readonly=True, copy=False)
+ to_invoice = fields.Boolean('To invoice')
+ is_invoiced = fields.Boolean('Is Invoiced', compute='_compute_is_invoiced')
+ is_tipped = fields.Boolean('Is this already tipped?', readonly=True)
+ tip_amount = fields.Float(string='Tip Amount', digits=0, readonly=True)
+
+ @api.depends('account_move')
+ def _compute_is_invoiced(self):
+ for order in self:
+ order.is_invoiced = bool(order.account_move)
+
+ @api.depends('picking_ids', 'picking_ids.state')
+ def _compute_picking_count(self):
+ for order in self:
+ order.picking_count = len(order.picking_ids)
+ order.failed_pickings = bool(order.picking_ids.filtered(lambda p: p.state != 'done'))
+
+ @api.depends('date_order', 'company_id', 'currency_id', 'company_id.currency_id')
+ def _compute_currency_rate(self):
+ for order in self:
+ order.currency_rate = self.env['res.currency']._get_conversion_rate(order.company_id.currency_id, order.currency_id, order.company_id, order.date_order)
+
+ @api.onchange('payment_ids', 'lines')
+ def _onchange_amount_all(self):
+ for order in self:
+ currency = order.pricelist_id.currency_id
+ order.amount_paid = sum(payment.amount for payment in order.payment_ids)
+ order.amount_return = sum(payment.amount < 0 and payment.amount or 0 for payment in order.payment_ids)
+ order.amount_tax = currency.round(sum(self._amount_line_tax(line, order.fiscal_position_id) for line in order.lines))
+ amount_untaxed = currency.round(sum(line.price_subtotal for line in order.lines))
+ order.amount_total = order.amount_tax + amount_untaxed
+
+ def _compute_batch_amount_all(self):
+ """
+ Does essentially the same thing as `_onchange_amount_all` but only for actually existing records
+ It is intended as a helper method , not as a business one
+ Practical to be used for migrations
+ """
+ amounts = {order_id: {'paid': 0, 'return': 0, 'taxed': 0, 'taxes': 0} for order_id in self.ids}
+ for order in self.env['pos.payment'].read_group([('pos_order_id', 'in', self.ids)], ['pos_order_id', 'amount'], ['pos_order_id']):
+ amounts[order['pos_order_id'][0]]['paid'] = order['amount']
+ for order in self.env['pos.payment'].read_group(['&', ('pos_order_id', 'in', self.ids), ('amount', '<', 0)], ['pos_order_id', 'amount'], ['pos_order_id']):
+ amounts[order['pos_order_id'][0]]['return'] = order['amount']
+ for order in self.env['pos.order.line'].read_group([('order_id', 'in', self.ids)], ['order_id', 'price_subtotal', 'price_subtotal_incl'], ['order_id']):
+ amounts[order['order_id'][0]]['taxed'] = order['price_subtotal_incl']
+ amounts[order['order_id'][0]]['taxes'] = order['price_subtotal_incl'] - order['price_subtotal']
+
+ for order in self:
+ currency = order.pricelist_id.currency_id
+ order.write({
+ 'amount_paid': amounts[order.id]['paid'],
+ 'amount_return': amounts[order.id]['return'],
+ 'amount_tax': currency.round(amounts[order.id]['taxes']),
+ 'amount_total': currency.round(amounts[order.id]['taxed'])
+ })
+
+ @api.onchange('partner_id')
+ def _onchange_partner_id(self):
+ if self.partner_id:
+ self.pricelist_id = self.partner_id.property_product_pricelist.id
+
+ def unlink(self):
+ for pos_order in self.filtered(lambda pos_order: pos_order.state not in ['draft', 'cancel']):
+ raise UserError(_('In order to delete a sale, it must be new or cancelled.'))
+ return super(PosOrder, self).unlink()
+
+ @api.model
+ def create(self, values):
+ session = self.env['pos.session'].browse(values['session_id'])
+ values = self._complete_values_from_session(session, values)
+ return super(PosOrder, self).create(values)
+
+ @api.model
+ def _complete_values_from_session(self, session, values):
+ if values.get('state') and values['state'] == 'paid':
+ values['name'] = session.config_id.sequence_id._next()
+ values.setdefault('pricelist_id', session.config_id.pricelist_id.id)
+ values.setdefault('fiscal_position_id', session.config_id.default_fiscal_position_id.id)
+ values.setdefault('company_id', session.config_id.company_id.id)
+ return values
+
+ def write(self, vals):
+ for order in self:
+ if vals.get('state') and vals['state'] == 'paid' and order.name == '/':
+ vals['name'] = order.config_id.sequence_id._next()
+ return super(PosOrder, self).write(vals)
+
+ def action_stock_picking(self):
+ self.ensure_one()
+ action = self.env['ir.actions.act_window']._for_xml_id('stock.action_picking_tree_ready')
+ action['context'] = {}
+ action['domain'] = [('id', 'in', self.picking_ids.ids)]
+ return action
+
+ def action_view_invoice(self):
+ return {
+ 'name': _('Customer Invoice'),
+ 'view_mode': 'form',
+ 'view_id': self.env.ref('account.view_move_form').id,
+ 'res_model': 'account.move',
+ 'context': "{'move_type':'out_invoice'}",
+ 'type': 'ir.actions.act_window',
+ 'res_id': self.account_move.id,
+ }
+
+ def _is_pos_order_paid(self):
+ return float_is_zero(self._get_rounded_amount(self.amount_total) - self.amount_paid, precision_rounding=self.currency_id.rounding)
+
+ def _get_rounded_amount(self, amount):
+ if self.config_id.cash_rounding:
+ amount = float_round(amount, precision_rounding=self.config_id.rounding_method.rounding, rounding_method=self.config_id.rounding_method.rounding_method)
+ currency = self.currency_id
+ return currency.round(amount) if currency else amount
+
+ def _create_invoice(self, move_vals):
+ self.ensure_one()
+ new_move = self.env['account.move'].sudo().with_company(self.company_id).with_context(default_move_type=move_vals['move_type']).create(move_vals)
+ message = _("This invoice has been created from the point of sale session: <a href=# data-oe-model=pos.order data-oe-id=%d>%s</a>") % (self.id, self.name)
+ new_move.message_post(body=message)
+ if self.config_id.cash_rounding:
+ rounding_applied = float_round(self.amount_paid - self.amount_total,
+ precision_rounding=new_move.currency_id.rounding)
+ rounding_line = new_move.line_ids.filtered(lambda line: line.is_rounding_line)
+ if rounding_line and rounding_line.debit > 0:
+ rounding_line_difference = rounding_line.debit + rounding_applied
+ elif rounding_line and rounding_line.credit > 0:
+ rounding_line_difference = -rounding_line.credit + rounding_applied
+ else:
+ rounding_line_difference = rounding_applied
+ if rounding_applied:
+ if rounding_applied > 0.0:
+ account_id = new_move.invoice_cash_rounding_id.loss_account_id.id
+ else:
+ account_id = new_move.invoice_cash_rounding_id.profit_account_id.id
+ if rounding_line:
+ if rounding_line_difference:
+ rounding_line.with_context(check_move_validity=False).write({
+ 'debit': rounding_applied < 0.0 and -rounding_applied or 0.0,
+ 'credit': rounding_applied > 0.0 and rounding_applied or 0.0,
+ 'account_id': account_id,
+ 'price_unit': rounding_applied,
+ })
+
+ else:
+ self.env['account.move.line'].with_context(check_move_validity=False).create({
+ 'debit': rounding_applied < 0.0 and -rounding_applied or 0.0,
+ 'credit': rounding_applied > 0.0 and rounding_applied or 0.0,
+ 'quantity': 1.0,
+ 'amount_currency': rounding_applied,
+ 'partner_id': new_move.partner_id.id,
+ 'move_id': new_move.id,
+ 'currency_id': new_move.currency_id if new_move.currency_id != new_move.company_id.currency_id else False,
+ 'company_id': new_move.company_id.id,
+ 'company_currency_id': new_move.company_id.currency_id.id,
+ 'is_rounding_line': True,
+ 'sequence': 9999,
+ 'name': new_move.invoice_cash_rounding_id.name,
+ 'account_id': account_id,
+ })
+ else:
+ if rounding_line:
+ rounding_line.with_context(check_move_validity=False).unlink()
+ if rounding_line_difference:
+ existing_terms_line = new_move.line_ids.filtered(
+ lambda line: line.account_id.user_type_id.type in ('receivable', 'payable'))
+ if existing_terms_line.debit > 0:
+ existing_terms_line_new_val = float_round(
+ existing_terms_line.debit + rounding_line_difference,
+ precision_rounding=new_move.currency_id.rounding)
+ else:
+ existing_terms_line_new_val = float_round(
+ -existing_terms_line.credit + rounding_line_difference,
+ precision_rounding=new_move.currency_id.rounding)
+ existing_terms_line.write({
+ 'debit': existing_terms_line_new_val > 0.0 and existing_terms_line_new_val or 0.0,
+ 'credit': existing_terms_line_new_val < 0.0 and -existing_terms_line_new_val or 0.0,
+ })
+
+ new_move._recompute_payment_terms_lines()
+ return new_move
+
+ def action_pos_order_paid(self):
+ self.ensure_one()
+
+ # TODO: add support for mix of cash and non-cash payments when both cash_rounding and only_round_cash_method are True
+ if not self.config_id.cash_rounding \
+ or self.config_id.only_round_cash_method \
+ and not any(p.payment_method_id.is_cash_count for p in self.payment_ids):
+ total = self.amount_total
+ else:
+ total = float_round(self.amount_total, precision_rounding=self.config_id.rounding_method.rounding, rounding_method=self.config_id.rounding_method.rounding_method)
+
+ isPaid = float_is_zero(total - self.amount_paid, precision_rounding=self.currency_id.rounding)
+
+ if not isPaid and not self.config_id.cash_rounding:
+ raise UserError(_("Order %s is not fully paid.", self.name))
+ elif not isPaid and self.config_id.cash_rounding:
+ currency = self.currency_id
+ if self.config_id.rounding_method.rounding_method == "HALF-UP":
+ maxDiff = currency.round(self.config_id.rounding_method.rounding / 2)
+ else:
+ maxDiff = currency.round(self.config_id.rounding_method.rounding)
+
+ diff = currency.round(self.amount_total - self.amount_paid)
+ if not abs(diff) < maxDiff:
+ raise UserError(_("Order %s is not fully paid.", self.name))
+
+ self.write({'state': 'paid'})
+
+ return True
+
+ def _prepare_invoice_vals(self):
+ self.ensure_one()
+ timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC')
+ note = self.note or ''
+ terms = ''
+ if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') and self.env.company.invoice_terms:
+ terms = self.with_context(lang=self.partner_id.lang).env.company.invoice_terms
+
+ narration = note + '\n' + terms if note else terms
+
+ vals = {
+ 'payment_reference': self.name,
+ 'invoice_origin': self.name,
+ 'journal_id': self.session_id.config_id.invoice_journal_id.id,
+ 'move_type': 'out_invoice' if self.amount_total >= 0 else 'out_refund',
+ 'ref': self.name,
+ 'partner_id': self.partner_id.id,
+ 'narration': narration,
+ # considering partner's sale pricelist's currency
+ 'currency_id': self.pricelist_id.currency_id.id,
+ 'invoice_user_id': self.user_id.id,
+ 'invoice_date': self.date_order.astimezone(timezone).date(),
+ 'fiscal_position_id': self.fiscal_position_id.id,
+ 'invoice_line_ids': [(0, None, self._prepare_invoice_line(line)) for line in self.lines],
+ 'invoice_cash_rounding_id': self.config_id.rounding_method.id
+ if self.config_id.cash_rounding and (not self.config_id.only_round_cash_method or any(p.payment_method_id.is_cash_count for p in self.payment_ids))
+ else False
+ }
+ return vals
+
+ def action_pos_order_invoice(self):
+ moves = self.env['account.move']
+
+ for order in self:
+ # Force company for all SUPERUSER_ID action
+ if order.account_move:
+ moves += order.account_move
+ continue
+
+ if not order.partner_id:
+ raise UserError(_('Please provide a partner for the sale.'))
+
+ move_vals = order._prepare_invoice_vals()
+ new_move = order._create_invoice(move_vals)
+ order.write({'account_move': new_move.id, 'state': 'invoiced'})
+ new_move.sudo().with_company(order.company_id)._post()
+ moves += new_move
+
+ if not moves:
+ return {}
+
+ return {
+ 'name': _('Customer Invoice'),
+ 'view_mode': 'form',
+ 'view_id': self.env.ref('account.view_move_form').id,
+ 'res_model': 'account.move',
+ 'context': "{'move_type':'out_invoice'}",
+ 'type': 'ir.actions.act_window',
+ 'nodestroy': True,
+ 'target': 'current',
+ 'res_id': moves and moves.ids[0] or False,
+ }
+
+ # this method is unused, and so is the state 'cancel'
+ def action_pos_order_cancel(self):
+ return self.write({'state': 'cancel'})
+
+ @api.model
+ def create_from_ui(self, orders, draft=False):
+ """ Create and update Orders from the frontend PoS application.
+
+ Create new orders and update orders that are in draft status. If an order already exists with a status
+ diferent from 'draft'it will be discareded, otherwise it will be saved to the database. If saved with
+ 'draft' status the order can be overwritten later by this function.
+
+ :param orders: dictionary with the orders to be created.
+ :type orders: dict.
+ :param draft: Indicate if the orders are ment to be finalised or temporarily saved.
+ :type draft: bool.
+ :Returns: list -- list of db-ids for the created and updated orders.
+ """
+ order_ids = []
+ for order in orders:
+ existing_order = False
+ if 'server_id' in order['data']:
+ existing_order = self.env['pos.order'].search(['|', ('id', '=', order['data']['server_id']), ('pos_reference', '=', order['data']['name'])], limit=1)
+ if (existing_order and existing_order.state == 'draft') or not existing_order:
+ order_ids.append(self._process_order(order, draft, existing_order))
+
+ return self.env['pos.order'].search_read(domain = [('id', 'in', order_ids)], fields = ['id', 'pos_reference'])
+
+ def _create_order_picking(self):
+ self.ensure_one()
+ if not self.session_id.update_stock_at_closing or (self.company_id.anglo_saxon_accounting and self.to_invoice):
+ picking_type = self.config_id.picking_type_id
+ if self.partner_id.property_stock_customer:
+ destination_id = self.partner_id.property_stock_customer.id
+ elif not picking_type or not picking_type.default_location_dest_id:
+ destination_id = self.env['stock.warehouse']._get_partner_locations()[0].id
+ else:
+ destination_id = picking_type.default_location_dest_id.id
+
+ pickings = self.env['stock.picking']._create_picking_from_pos_order_lines(destination_id, self.lines, picking_type, self.partner_id)
+ pickings.write({'pos_session_id': self.session_id.id, 'pos_order_id': self.id, 'origin': self.name})
+
+ def add_payment(self, data):
+ """Create a new payment for the order"""
+ self.ensure_one()
+ self.env['pos.payment'].create(data)
+ self.amount_paid = sum(self.payment_ids.mapped('amount'))
+
+ def _prepare_refund_values(self, current_session):
+ self.ensure_one()
+ return {
+ 'name': self.name + _(' REFUND'),
+ 'session_id': current_session.id,
+ 'date_order': fields.Datetime.now(),
+ 'pos_reference': self.pos_reference,
+ 'lines': False,
+ 'amount_tax': -self.amount_tax,
+ 'amount_total': -self.amount_total,
+ 'amount_paid': 0,
+ }
+
+ def refund(self):
+ """Create a copy of order for refund order"""
+ refund_orders = self.env['pos.order']
+ for order in self:
+ # When a refund is performed, we are creating it in a session having the same config as the original
+ # order. It can be the same session, or if it has been closed the new one that has been opened.
+ current_session = order.session_id.config_id.current_session_id
+ if not current_session:
+ raise UserError(_('To return product(s), you need to open a session in the POS %s', order.session_id.config_id.display_name))
+ refund_order = order.copy(
+ order._prepare_refund_values(current_session)
+ )
+ for line in order.lines:
+ PosOrderLineLot = self.env['pos.pack.operation.lot']
+ for pack_lot in line.pack_lot_ids:
+ PosOrderLineLot += pack_lot.copy()
+ line.copy(line._prepare_refund_data(refund_order, PosOrderLineLot))
+ refund_orders |= refund_order
+
+ return {
+ 'name': _('Return Products'),
+ 'view_mode': 'form',
+ 'res_model': 'pos.order',
+ 'res_id': refund_orders.ids[0],
+ 'view_id': False,
+ 'context': self.env.context,
+ 'type': 'ir.actions.act_window',
+ 'target': 'current',
+ }
+
+ def action_receipt_to_customer(self, name, client, ticket):
+ if not self:
+ return False
+ if not client.get('email'):
+ return False
+
+ message = _("<p>Dear %s,<br/>Here is your electronic ticket for the %s. </p>") % (client['name'], name)
+ filename = 'Receipt-' + name + '.jpg'
+ receipt = self.env['ir.attachment'].create({
+ 'name': filename,
+ 'type': 'binary',
+ 'datas': ticket,
+ 'res_model': 'pos.order',
+ 'res_id': self.ids[0],
+ 'store_fname': filename,
+ 'mimetype': 'image/jpeg',
+ })
+ mail_values = {
+ 'subject': _('Receipt %s', name),
+ 'body_html': message,
+ 'author_id': self.env.user.partner_id.id,
+ 'email_from': self.env.company.email or self.env.user.email_formatted,
+ 'email_to': client['email'],
+ 'attachment_ids': [(4, receipt.id)],
+ }
+
+ if self.mapped('account_move'):
+ report = self.env.ref('point_of_sale.pos_invoice_report')._render_qweb_pdf(self.ids[0])
+ filename = name + '.pdf'
+ attachment = self.env['ir.attachment'].create({
+ 'name': filename,
+ 'type': 'binary',
+ 'datas': base64.b64encode(report[0]),
+ 'store_fname': filename,
+ 'res_model': 'pos.order',
+ 'res_id': self.ids[0],
+ 'mimetype': 'application/x-pdf'
+ })
+ mail_values['attachment_ids'] += [(4, attachment.id)]
+
+ mail = self.env['mail.mail'].sudo().create(mail_values)
+ mail.send()
+
+ @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
+
+ @api.model
+ def search_paid_order_ids(self, config_id, domain, limit, offset):
+ """Search for 'paid' orders that satisfy the given domain, limit and offset."""
+ default_domain = ['&', ('config_id', '=', config_id), '!', '|', ('state', '=', 'draft'), ('state', '=', 'cancelled')]
+ real_domain = AND([domain, default_domain])
+ ids = self.search(AND([domain, default_domain]), limit=limit, offset=offset).ids
+ totalCount = self.search_count(real_domain)
+ return {'ids': ids, 'totalCount': totalCount}
+
+ def _export_for_ui(self, order):
+ timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC')
+ return {
+ 'lines': [[0, 0, line] for line in order.lines.export_for_ui()],
+ 'statement_ids': [[0, 0, payment] for payment in order.payment_ids.export_for_ui()],
+ 'name': order.pos_reference,
+ 'uid': re.search('([0-9]|-){14}', order.pos_reference).group(0),
+ 'amount_paid': order.amount_paid,
+ 'amount_total': order.amount_total,
+ 'amount_tax': order.amount_tax,
+ 'amount_return': order.amount_return,
+ 'pos_session_id': order.session_id.id,
+ 'is_session_closed': order.session_id.state == 'closed',
+ 'pricelist_id': order.pricelist_id.id,
+ 'partner_id': order.partner_id.id,
+ 'user_id': order.user_id.id,
+ 'sequence_number': order.sequence_number,
+ 'creation_date': order.date_order.astimezone(timezone),
+ 'fiscal_position_id': order.fiscal_position_id.id,
+ 'to_invoice': order.to_invoice,
+ 'state': order.state,
+ 'account_move': order.account_move.id,
+ 'id': order.id,
+ 'is_tipped': order.is_tipped,
+ 'tip_amount': order.tip_amount,
+ }
+
+ def export_for_ui(self):
+ """ Returns a list of dict with each item having similar signature as the return of
+ `export_as_JSON` of models.Order. This is useful for back-and-forth communication
+ between the pos frontend and backend.
+ """
+ return self.mapped(self._export_for_ui) if self else []
+
+
+class PosOrderLine(models.Model):
+ _name = "pos.order.line"
+ _description = "Point of Sale Order Lines"
+ _rec_name = "product_id"
+
+ def _order_line_fields(self, line, session_id=None):
+ if line and 'name' not in line[2]:
+ session = self.env['pos.session'].browse(session_id).exists() if session_id else None
+ if session and session.config_id.sequence_line_id:
+ # set name based on the sequence specified on the config
+ line[2]['name'] = session.config_id.sequence_line_id._next()
+ else:
+ # fallback on any pos.order.line sequence
+ line[2]['name'] = self.env['ir.sequence'].next_by_code('pos.order.line')
+
+ if line and 'tax_ids' not in line[2]:
+ product = self.env['product.product'].browse(line[2]['product_id'])
+ line[2]['tax_ids'] = [(6, 0, [x.id for x in product.taxes_id])]
+ # Clean up fields sent by the JS
+ line = [
+ line[0], line[1], {k: v for k, v in line[2].items() if k in self.env['pos.order.line']._fields}
+ ]
+ return line
+
+ company_id = fields.Many2one('res.company', string='Company', related="order_id.company_id", store=True)
+ name = fields.Char(string='Line No', required=True, copy=False)
+ notice = fields.Char(string='Discount Notice')
+ product_id = fields.Many2one('product.product', string='Product', domain=[('sale_ok', '=', True)], required=True, change_default=True)
+ price_unit = fields.Float(string='Unit Price', digits=0)
+ qty = fields.Float('Quantity', digits='Product Unit of Measure', default=1)
+ price_subtotal = fields.Float(string='Subtotal w/o Tax', digits=0,
+ readonly=True, required=True)
+ price_subtotal_incl = fields.Float(string='Subtotal', digits=0,
+ readonly=True, required=True)
+ discount = fields.Float(string='Discount (%)', digits=0, default=0.0)
+ order_id = fields.Many2one('pos.order', string='Order Ref', ondelete='cascade', required=True)
+ tax_ids = fields.Many2many('account.tax', string='Taxes', readonly=True)
+ tax_ids_after_fiscal_position = fields.Many2many('account.tax', compute='_get_tax_ids_after_fiscal_position', string='Taxes to Apply')
+ pack_lot_ids = fields.One2many('pos.pack.operation.lot', 'pos_order_line_id', string='Lot/serial Number')
+ product_uom_id = fields.Many2one('uom.uom', string='Product UoM', related='product_id.uom_id')
+ currency_id = fields.Many2one('res.currency', related='order_id.currency_id')
+ full_product_name = fields.Char('Full Product Name')
+
+ def _prepare_refund_data(self, refund_order, PosOrderLineLot):
+ """
+ This prepares data for refund order line. Inheritance may inject more data here
+
+ @param refund_order: the pre-created refund order
+ @type refund_order: pos.order
+
+ @param PosOrderLineLot: the pre-created Pack operation Lot
+ @type PosOrderLineLot: pos.pack.operation.lot
+
+ @return: dictionary of data which is for creating a refund order line from the original line
+ @rtype: dict
+ """
+ self.ensure_one()
+ return {
+ 'name': self.name + _(' REFUND'),
+ 'qty': -self.qty,
+ 'order_id': refund_order.id,
+ 'price_subtotal': -self.price_subtotal,
+ 'price_subtotal_incl': -self.price_subtotal_incl,
+ 'pack_lot_ids': PosOrderLineLot,
+ }
+
+ @api.model
+ def create(self, values):
+ if values.get('order_id') and not values.get('name'):
+ # set name based on the sequence specified on the config
+ config = self.env['pos.order'].browse(values['order_id']).session_id.config_id
+ if config.sequence_line_id:
+ values['name'] = config.sequence_line_id._next()
+ if not values.get('name'):
+ # fallback on any pos.order sequence
+ values['name'] = self.env['ir.sequence'].next_by_code('pos.order.line')
+ return super(PosOrderLine, self).create(values)
+
+ def write(self, values):
+ if values.get('pack_lot_line_ids'):
+ for pl in values.get('pack_lot_ids'):
+ if pl[2].get('server_id'):
+ pl[2]['id'] = pl[2]['server_id']
+ del pl[2]['server_id']
+ return super().write(values)
+
+ @api.onchange('price_unit', 'tax_ids', 'qty', 'discount', 'product_id')
+ def _onchange_amount_line_all(self):
+ for line in self:
+ res = line._compute_amount_line_all()
+ line.update(res)
+
+ def _compute_amount_line_all(self):
+ self.ensure_one()
+ fpos = self.order_id.fiscal_position_id
+ tax_ids_after_fiscal_position = fpos.map_tax(self.tax_ids, self.product_id, self.order_id.partner_id)
+ price = self.price_unit * (1 - (self.discount or 0.0) / 100.0)
+ taxes = tax_ids_after_fiscal_position.compute_all(price, self.order_id.pricelist_id.currency_id, self.qty, product=self.product_id, partner=self.order_id.partner_id)
+ return {
+ 'price_subtotal_incl': taxes['total_included'],
+ 'price_subtotal': taxes['total_excluded'],
+ }
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ if self.product_id:
+ if not self.order_id.pricelist_id:
+ raise UserError(
+ _('You have to select a pricelist in the sale form !\n'
+ 'Please set one before choosing a product.'))
+ price = self.order_id.pricelist_id.get_product_price(
+ self.product_id, self.qty or 1.0, self.order_id.partner_id)
+ self._onchange_qty()
+ self.tax_ids = self.product_id.taxes_id.filtered(lambda r: not self.company_id or r.company_id == self.company_id)
+ tax_ids_after_fiscal_position = self.order_id.fiscal_position_id.map_tax(self.tax_ids, self.product_id, self.order_id.partner_id)
+ self.price_unit = self.env['account.tax']._fix_tax_included_price_company(price, self.product_id.taxes_id, tax_ids_after_fiscal_position, self.company_id)
+
+ @api.onchange('qty', 'discount', 'price_unit', 'tax_ids')
+ def _onchange_qty(self):
+ if self.product_id:
+ if not self.order_id.pricelist_id:
+ raise UserError(_('You have to select a pricelist in the sale form.'))
+ price = self.price_unit * (1 - (self.discount or 0.0) / 100.0)
+ self.price_subtotal = self.price_subtotal_incl = price * self.qty
+ if (self.product_id.taxes_id):
+ taxes = self.product_id.taxes_id.compute_all(price, self.order_id.pricelist_id.currency_id, self.qty, product=self.product_id, partner=False)
+ self.price_subtotal = taxes['total_excluded']
+ self.price_subtotal_incl = taxes['total_included']
+
+ @api.depends('order_id', 'order_id.fiscal_position_id')
+ def _get_tax_ids_after_fiscal_position(self):
+ for line in self:
+ line.tax_ids_after_fiscal_position = line.order_id.fiscal_position_id.map_tax(line.tax_ids, line.product_id, line.order_id.partner_id)
+
+ def _export_for_ui(self, orderline):
+ return {
+ 'qty': orderline.qty,
+ 'price_unit': orderline.price_unit,
+ 'price_subtotal': orderline.price_subtotal,
+ 'price_subtotal_incl': orderline.price_subtotal_incl,
+ 'product_id': orderline.product_id.id,
+ 'discount': orderline.discount,
+ 'tax_ids': [[6, False, orderline.tax_ids.mapped(lambda tax: tax.id)]],
+ 'id': orderline.id,
+ 'pack_lot_ids': [[0, 0, lot] for lot in orderline.pack_lot_ids.export_for_ui()],
+ }
+
+ def export_for_ui(self):
+ return self.mapped(self._export_for_ui) if self else []
+
+
+class PosOrderLineLot(models.Model):
+ _name = "pos.pack.operation.lot"
+ _description = "Specify product lot/serial number in pos order line"
+ _rec_name = "lot_name"
+
+ pos_order_line_id = fields.Many2one('pos.order.line')
+ order_id = fields.Many2one('pos.order', related="pos_order_line_id.order_id", readonly=False)
+ lot_name = fields.Char('Lot Name')
+ product_id = fields.Many2one('product.product', related='pos_order_line_id.product_id', readonly=False)
+
+ def _export_for_ui(self, lot):
+ return {
+ 'lot_name': lot.lot_name,
+ }
+
+ def export_for_ui(self):
+ return self.mapped(self._export_for_ui) if self else []
+
+class ReportSaleDetails(models.AbstractModel):
+
+ _name = 'report.point_of_sale.report_saledetails'
+ _description = 'Point of Sale Details'
+
+
+ @api.model
+ def get_sale_details(self, date_start=False, date_stop=False, config_ids=False, session_ids=False):
+ """ Serialise the orders of the requested time period, configs and sessions.
+
+ :param date_start: The dateTime to start, default today 00:00:00.
+ :type date_start: str.
+ :param date_stop: The dateTime to stop, default date_start + 23:59:59.
+ :type date_stop: str.
+ :param config_ids: Pos Config id's to include.
+ :type config_ids: list of numbers.
+ :param session_ids: Pos Config id's to include.
+ :type session_ids: list of numbers.
+
+ :returns: dict -- Serialised sales.
+ """
+ domain = [('state', 'in', ['paid','invoiced','done'])]
+
+ if (session_ids):
+ domain = AND([domain, [('session_id', 'in', session_ids)]])
+ else:
+ if date_start:
+ date_start = fields.Datetime.from_string(date_start)
+ else:
+ # start by default today 00:00:00
+ user_tz = pytz.timezone(self.env.context.get('tz') or self.env.user.tz or 'UTC')
+ today = user_tz.localize(fields.Datetime.from_string(fields.Date.context_today(self)))
+ date_start = today.astimezone(pytz.timezone('UTC'))
+
+ if date_stop:
+ date_stop = fields.Datetime.from_string(date_stop)
+ # avoid a date_stop smaller than date_start
+ if (date_stop < date_start):
+ date_stop = date_start + timedelta(days=1, seconds=-1)
+ else:
+ # stop by default today 23:59:59
+ date_stop = date_start + timedelta(days=1, seconds=-1)
+
+ domain = AND([domain,
+ [('date_order', '>=', fields.Datetime.to_string(date_start)),
+ ('date_order', '<=', fields.Datetime.to_string(date_stop))]
+ ])
+
+ if config_ids:
+ domain = AND([domain, [('config_id', 'in', config_ids)]])
+
+ orders = self.env['pos.order'].search(domain)
+
+ user_currency = self.env.company.currency_id
+
+ total = 0.0
+ products_sold = {}
+ taxes = {}
+ for order in orders:
+ if user_currency != order.pricelist_id.currency_id:
+ total += order.pricelist_id.currency_id._convert(
+ order.amount_total, user_currency, order.company_id, order.date_order or fields.Date.today())
+ else:
+ total += order.amount_total
+ currency = order.session_id.currency_id
+
+ for line in order.lines:
+ key = (line.product_id, line.price_unit, line.discount)
+ products_sold.setdefault(key, 0.0)
+ products_sold[key] += line.qty
+
+ if line.tax_ids_after_fiscal_position:
+ line_taxes = line.tax_ids_after_fiscal_position.sudo().compute_all(line.price_unit * (1-(line.discount or 0.0)/100.0), currency, line.qty, product=line.product_id, partner=line.order_id.partner_id or False)
+ for tax in line_taxes['taxes']:
+ taxes.setdefault(tax['id'], {'name': tax['name'], 'tax_amount':0.0, 'base_amount':0.0})
+ taxes[tax['id']]['tax_amount'] += tax['amount']
+ taxes[tax['id']]['base_amount'] += tax['base']
+ else:
+ taxes.setdefault(0, {'name': _('No Taxes'), 'tax_amount':0.0, 'base_amount':0.0})
+ taxes[0]['base_amount'] += line.price_subtotal_incl
+
+ payment_ids = self.env["pos.payment"].search([('pos_order_id', 'in', orders.ids)]).ids
+ if payment_ids:
+ self.env.cr.execute("""
+ SELECT method.name, sum(amount) total
+ FROM pos_payment AS payment,
+ pos_payment_method AS method
+ WHERE payment.payment_method_id = method.id
+ AND payment.id IN %s
+ GROUP BY method.name
+ """, (tuple(payment_ids),))
+ payments = self.env.cr.dictfetchall()
+ else:
+ payments = []
+
+ return {
+ 'currency_precision': user_currency.decimal_places,
+ 'total_paid': user_currency.round(total),
+ 'payments': payments,
+ 'company_name': self.env.company.name,
+ 'taxes': list(taxes.values()),
+ 'products': sorted([{
+ 'product_id': product.id,
+ 'product_name': product.name,
+ 'code': product.default_code,
+ 'quantity': qty,
+ 'price_unit': price_unit,
+ 'discount': discount,
+ 'uom': product.uom_id.name
+ } for (product, price_unit, discount), qty in products_sold.items()], key=lambda l: l['product_name'])
+ }
+
+ @api.model
+ def _get_report_values(self, docids, data=None):
+ data = dict(data or {})
+ configs = self.env['pos.config'].browse(data['config_ids'])
+ data.update(self.get_sale_details(data['date_start'], data['date_stop'], configs.ids))
+ return data
+
+class AccountCashRounding(models.Model):
+ _inherit = 'account.cash.rounding'
+
+ @api.constrains('rounding', 'rounding_method', 'strategy')
+ def _check_session_state(self):
+ open_session = self.env['pos.session'].search([('config_id.rounding_method', '=', self.id), ('state', '!=', 'closed')])
+ if open_session:
+ raise ValidationError(
+ _("You are not allowed to change the cash rounding configuration while a pos session using it is already opened."))
diff --git a/addons/point_of_sale/models/pos_payment.py b/addons/point_of_sale/models/pos_payment.py
new file mode 100644
index 00000000..fe43e07d
--- /dev/null
+++ b/addons/point_of_sale/models/pos_payment.py
@@ -0,0 +1,64 @@
+from odoo import api, fields, models, _
+from odoo.tools import formatLang
+from odoo.exceptions import ValidationError
+
+
+class PosPayment(models.Model):
+ """ Used to register payments made in a pos.order.
+
+ See `payment_ids` field of pos.order model.
+ The main characteristics of pos.payment can be read from
+ `payment_method_id`.
+ """
+
+ _name = "pos.payment"
+ _description = "Point of Sale Payments"
+ _order = "id desc"
+
+ name = fields.Char(string='Label', readonly=True)
+ pos_order_id = fields.Many2one('pos.order', string='Order', required=True)
+ amount = fields.Monetary(string='Amount', required=True, currency_field='currency_id', readonly=True, help="Total amount of the payment.")
+ payment_method_id = fields.Many2one('pos.payment.method', string='Payment Method', required=True)
+ payment_date = fields.Datetime(string='Date', required=True, readonly=True, default=lambda self: fields.Datetime.now())
+ currency_id = fields.Many2one('res.currency', string='Currency', related='pos_order_id.currency_id')
+ currency_rate = fields.Float(string='Conversion Rate', related='pos_order_id.currency_rate', help='Conversion rate from company currency to order currency.')
+ partner_id = fields.Many2one('res.partner', string='Customer', related='pos_order_id.partner_id')
+ session_id = fields.Many2one('pos.session', string='Session', related='pos_order_id.session_id', store=True, index=True)
+ company_id = fields.Many2one('res.company', string='Company', related='pos_order_id.company_id') # TODO: add store=True in master
+ card_type = fields.Char('Type of card used')
+ cardholder_name = fields.Char('Cardholder Name')
+ transaction_id = fields.Char('Payment Transaction ID')
+ payment_status = fields.Char('Payment Status')
+ ticket = fields.Char('Payment Receipt Info')
+ is_change = fields.Boolean(string='Is this payment change?', default=False)
+
+ @api.model
+ def name_get(self):
+ res = []
+ for payment in self:
+ if payment.name:
+ res.append((payment.id, '%s %s' % (payment.name, formatLang(self.env, payment.amount, currency_obj=payment.currency_id))))
+ else:
+ res.append((payment.id, formatLang(self.env, payment.amount, currency_obj=payment.currency_id)))
+ return res
+
+ @api.constrains('payment_method_id')
+ def _check_payment_method_id(self):
+ for payment in self:
+ if payment.payment_method_id not in payment.session_id.config_id.payment_method_ids:
+ raise ValidationError(_('The payment method selected is not allowed in the config of the POS session.'))
+
+ def _export_for_ui(self, payment):
+ return {
+ 'payment_method_id': payment.payment_method_id.id,
+ 'amount': payment.amount,
+ 'payment_status': payment.payment_status,
+ 'card_type': payment.card_type,
+ 'cardholder_name': payment.cardholder_name,
+ 'transaction_id': payment.transaction_id,
+ 'ticket': payment.ticket,
+ 'is_change': payment.is_change,
+ }
+
+ def export_for_ui(self):
+ return self.mapped(self._export_for_ui) if self else []
diff --git a/addons/point_of_sale/models/pos_payment_method.py b/addons/point_of_sale/models/pos_payment_method.py
new file mode 100644
index 00000000..42891c6a
--- /dev/null
+++ b/addons/point_of_sale/models/pos_payment_method.py
@@ -0,0 +1,81 @@
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+
+class PosPaymentMethod(models.Model):
+ """ Used to classify pos.payment.
+
+ Generic characteristics of a pos.payment is described in this model.
+ E.g. A cash payment can be described by a pos.payment.method with
+ fields: is_cash_count = True and a cash_journal_id set to an
+ `account.journal` (type='cash') record.
+
+ When a pos.payment.method is cash, cash_journal_id is required as
+ it will be the journal where the account.bank.statement.line records
+ will be created.
+ """
+
+ _name = "pos.payment.method"
+ _description = "Point of Sale Payment Methods"
+ _order = "id asc"
+
+ def _get_payment_terminal_selection(self):
+ return []
+
+ name = fields.Char(string="Payment Method", required=True, translate=True)
+ receivable_account_id = fields.Many2one('account.account',
+ string='Intermediary Account',
+ required=True,
+ domain=[('reconcile', '=', True), ('user_type_id.type', '=', 'receivable')],
+ default=lambda self: self.env.company.account_default_pos_receivable_account_id,
+ ondelete='restrict',
+ help='Account used as counterpart of the income account in the accounting entry representing the pos sales.')
+ is_cash_count = fields.Boolean(string='Cash')
+ cash_journal_id = fields.Many2one('account.journal',
+ string='Cash Journal',
+ domain=[('type', '=', 'cash')],
+ ondelete='restrict',
+ help='The payment method is of type cash. A cash statement will be automatically generated.')
+ split_transactions = fields.Boolean(
+ string='Split Transactions',
+ default=False,
+ help='If ticked, each payment will generate a separated journal item. Ticking that option will slow the closing of the PoS.')
+ open_session_ids = fields.Many2many('pos.session', string='Pos Sessions', compute='_compute_open_session_ids', help='Open PoS sessions that are using this payment method.')
+ config_ids = fields.Many2many('pos.config', string='Point of Sale Configurations')
+ company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
+ use_payment_terminal = fields.Selection(selection=lambda self: self._get_payment_terminal_selection(), string='Use a Payment Terminal', help='Record payments with a terminal on this journal.')
+ hide_use_payment_terminal = fields.Boolean(compute='_compute_hide_use_payment_terminal', help='Technical field which is used to '
+ 'hide use_payment_terminal when no payment interfaces are installed.')
+ active = fields.Boolean(default=True)
+
+ @api.depends('is_cash_count')
+ def _compute_hide_use_payment_terminal(self):
+ no_terminals = not bool(self._fields['use_payment_terminal'].selection(self))
+ for payment_method in self:
+ payment_method.hide_use_payment_terminal = no_terminals or payment_method.is_cash_count
+
+ @api.onchange('use_payment_terminal')
+ def _onchange_use_payment_terminal(self):
+ """Used by inheriting model to unset the value of the field related to the unselected payment terminal."""
+ pass
+
+ @api.depends('config_ids')
+ def _compute_open_session_ids(self):
+ for payment_method in self:
+ payment_method.open_session_ids = self.env['pos.session'].search([('config_id', 'in', payment_method.config_ids.ids), ('state', '!=', 'closed')])
+
+ @api.onchange('is_cash_count')
+ def _onchange_is_cash_count(self):
+ if not self.is_cash_count:
+ self.cash_journal_id = False
+ else:
+ self.use_payment_terminal = False
+
+ def _is_write_forbidden(self, fields):
+ return bool(fields and self.open_session_ids)
+
+ def write(self, vals):
+ if self._is_write_forbidden(set(vals.keys())):
+ raise UserError('Please close and validate the following open PoS Sessions before modifying this payment method.\n'
+ 'Open sessions: %s' % (' '.join(self.open_session_ids.mapped('name')),))
+ return super(PosPaymentMethod, self).write(vals)
diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py
new file mode 100644
index 00000000..14546c4f
--- /dev/null
+++ b/addons/point_of_sale/models/pos_session.py
@@ -0,0 +1,1179 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import defaultdict
+from datetime import timedelta
+
+from odoo import api, fields, models, _
+from odoo.exceptions import AccessError, UserError, ValidationError
+from odoo.tools import float_is_zero, float_compare
+
+
+class PosSession(models.Model):
+ _name = 'pos.session'
+ _order = 'id desc'
+ _description = 'Point of Sale Session'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+
+ POS_SESSION_STATE = [
+ ('opening_control', 'Opening Control'), # method action_pos_session_open
+ ('opened', 'In Progress'), # method action_pos_session_closing_control
+ ('closing_control', 'Closing Control'), # method action_pos_session_close
+ ('closed', 'Closed & Posted'),
+ ]
+
+ company_id = fields.Many2one('res.company', related='config_id.company_id', string="Company", readonly=True)
+
+ config_id = fields.Many2one(
+ 'pos.config', string='Point of Sale',
+ help="The physical point of sale you will use.",
+ required=True,
+ index=True)
+ name = fields.Char(string='Session ID', required=True, readonly=True, default='/')
+ user_id = fields.Many2one(
+ 'res.users', string='Opened By',
+ required=True,
+ index=True,
+ readonly=True,
+ states={'opening_control': [('readonly', False)]},
+ default=lambda self: self.env.uid,
+ ondelete='restrict')
+ currency_id = fields.Many2one('res.currency', related='config_id.currency_id', string="Currency", readonly=False)
+ start_at = fields.Datetime(string='Opening Date', readonly=True)
+ stop_at = fields.Datetime(string='Closing Date', readonly=True, copy=False)
+
+ state = fields.Selection(
+ POS_SESSION_STATE, string='Status',
+ required=True, readonly=True,
+ index=True, copy=False, default='opening_control')
+
+ sequence_number = fields.Integer(string='Order Sequence Number', help='A sequence number that is incremented with each order', default=1)
+ login_number = fields.Integer(string='Login Sequence Number', help='A sequence number that is incremented each time a user resumes the pos session', default=0)
+
+ cash_control = fields.Boolean(compute='_compute_cash_all', string='Has Cash Control', compute_sudo=True)
+ cash_journal_id = fields.Many2one('account.journal', compute='_compute_cash_all', string='Cash Journal', store=True)
+ cash_register_id = fields.Many2one('account.bank.statement', compute='_compute_cash_all', string='Cash Register', store=True)
+
+ cash_register_balance_end_real = fields.Monetary(
+ related='cash_register_id.balance_end_real',
+ string="Ending Balance",
+ help="Total of closing cash control lines.",
+ readonly=True)
+ cash_register_balance_start = fields.Monetary(
+ related='cash_register_id.balance_start',
+ string="Starting Balance",
+ help="Total of opening cash control lines.",
+ readonly=True)
+ cash_register_total_entry_encoding = fields.Monetary(
+ compute='_compute_cash_balance',
+ string='Total Cash Transaction',
+ readonly=True,
+ help="Total of all paid sales orders")
+ cash_register_balance_end = fields.Monetary(
+ compute='_compute_cash_balance',
+ string="Theoretical Closing Balance",
+ help="Sum of opening balance and transactions.",
+ readonly=True)
+ cash_register_difference = fields.Monetary(
+ compute='_compute_cash_balance',
+ string='Before Closing Difference',
+ help="Difference between the theoretical closing balance and the real closing balance.",
+ readonly=True)
+ cash_real_difference = fields.Monetary(string='Difference', readonly=True)
+ cash_real_transaction = fields.Monetary(string='Transaction', readonly=True)
+ cash_real_expected = fields.Monetary(string="Expected", readonly=True)
+
+ order_ids = fields.One2many('pos.order', 'session_id', string='Orders')
+ order_count = fields.Integer(compute='_compute_order_count')
+ statement_ids = fields.One2many('account.bank.statement', 'pos_session_id', string='Cash Statements', readonly=True)
+ failed_pickings = fields.Boolean(compute='_compute_picking_count')
+ picking_count = fields.Integer(compute='_compute_picking_count')
+ picking_ids = fields.One2many('stock.picking', 'pos_session_id')
+ rescue = fields.Boolean(string='Recovery Session',
+ help="Auto-generated session for orphan orders, ignored in constraints",
+ readonly=True,
+ copy=False)
+ move_id = fields.Many2one('account.move', string='Journal Entry')
+ payment_method_ids = fields.Many2many('pos.payment.method', related='config_id.payment_method_ids', string='Payment Methods')
+ total_payments_amount = fields.Float(compute='_compute_total_payments_amount', string='Total Payments Amount')
+ is_in_company_currency = fields.Boolean('Is Using Company Currency', compute='_compute_is_in_company_currency')
+ update_stock_at_closing = fields.Boolean('Stock should be updated at closing')
+
+ _sql_constraints = [('uniq_name', 'unique(name)', "The name of this POS Session must be unique !")]
+
+ @api.depends('currency_id', 'company_id.currency_id')
+ def _compute_is_in_company_currency(self):
+ for session in self:
+ session.is_in_company_currency = session.currency_id == session.company_id.currency_id
+
+ @api.depends('payment_method_ids', 'order_ids', 'cash_register_balance_start', 'cash_register_id')
+ def _compute_cash_balance(self):
+ for session in self:
+ cash_payment_method = session.payment_method_ids.filtered('is_cash_count')[:1]
+ if cash_payment_method:
+ total_cash_payment = 0.0
+ result = self.env['pos.payment'].read_group([('session_id', '=', session.id), ('payment_method_id', '=', cash_payment_method.id)], ['amount'], ['session_id'])
+ if result:
+ total_cash_payment = result[0]['amount']
+ session.cash_register_total_entry_encoding = session.cash_register_id.total_entry_encoding + (
+ 0.0 if session.state == 'closed' else total_cash_payment
+ )
+ session.cash_register_balance_end = session.cash_register_balance_start + session.cash_register_total_entry_encoding
+ session.cash_register_difference = session.cash_register_balance_end_real - session.cash_register_balance_end
+ else:
+ session.cash_register_total_entry_encoding = 0.0
+ session.cash_register_balance_end = 0.0
+ session.cash_register_difference = 0.0
+
+ @api.depends('order_ids.payment_ids.amount')
+ def _compute_total_payments_amount(self):
+ result = self.env['pos.payment'].read_group([('session_id', 'in', self.ids)], ['amount'], ['session_id'])
+ session_amount_map = dict((data['session_id'][0], data['amount']) for data in result)
+ for session in self:
+ session.total_payments_amount = session_amount_map.get(session.id) or 0
+
+ def _compute_order_count(self):
+ orders_data = self.env['pos.order'].read_group([('session_id', 'in', self.ids)], ['session_id'], ['session_id'])
+ sessions_data = {order_data['session_id'][0]: order_data['session_id_count'] for order_data in orders_data}
+ for session in self:
+ session.order_count = sessions_data.get(session.id, 0)
+
+ @api.depends('picking_ids', 'picking_ids.state')
+ def _compute_picking_count(self):
+ for session in self:
+ session.picking_count = self.env['stock.picking'].search_count([('pos_session_id', '=', session.id)])
+ session.failed_pickings = bool(self.env['stock.picking'].search([('pos_session_id', '=', session.id), ('state', '!=', 'done')], limit=1))
+
+ def action_stock_picking(self):
+ self.ensure_one()
+ action = self.env['ir.actions.act_window']._for_xml_id('stock.action_picking_tree_ready')
+ action['context'] = {}
+ action['domain'] = [('id', 'in', self.picking_ids.ids)]
+ return action
+
+ @api.depends('config_id', 'statement_ids', 'payment_method_ids')
+ def _compute_cash_all(self):
+ # Only one cash register is supported by point_of_sale.
+ for session in self:
+ session.cash_journal_id = session.cash_register_id = session.cash_control = False
+ cash_payment_methods = session.payment_method_ids.filtered('is_cash_count')
+ if not cash_payment_methods:
+ continue
+ for statement in session.statement_ids:
+ if statement.journal_id == cash_payment_methods[0].cash_journal_id:
+ session.cash_control = session.config_id.cash_control
+ session.cash_journal_id = statement.journal_id.id
+ session.cash_register_id = statement.id
+ break # stop iteration after finding the cash journal
+
+ @api.constrains('config_id')
+ def _check_pos_config(self):
+ if self.search_count([
+ ('state', '!=', 'closed'),
+ ('config_id', '=', self.config_id.id),
+ ('rescue', '=', False)
+ ]) > 1:
+ raise ValidationError(_("Another session is already opened for this point of sale."))
+
+ @api.constrains('start_at')
+ def _check_start_date(self):
+ for record in self:
+ company = record.config_id.journal_id.company_id
+ start_date = record.start_at.date()
+ if (company.period_lock_date and start_date <= company.period_lock_date) or (company.fiscalyear_lock_date and start_date <= company.fiscalyear_lock_date):
+ raise ValidationError(_("You cannot create a session before the accounting lock date."))
+
+ def _check_bank_statement_state(self):
+ for session in self:
+ closed_statement_ids = session.statement_ids.filtered(lambda x: x.state != "open")
+ if closed_statement_ids:
+ raise UserError(_("Some Cash Registers are already posted. Please reset them to new in order to close the session.\n"
+ "Cash Registers: %r", list(statement.name for statement in closed_statement_ids)))
+
+ @api.model
+ def create(self, values):
+ config_id = values.get('config_id') or self.env.context.get('default_config_id')
+ if not config_id:
+ raise UserError(_("You should assign a Point of Sale to your session."))
+
+ # journal_id is not required on the pos_config because it does not
+ # exists at the installation. If nothing is configured at the
+ # installation we do the minimal configuration. Impossible to do in
+ # the .xml files as the CoA is not yet installed.
+ pos_config = self.env['pos.config'].browse(config_id)
+ ctx = dict(self.env.context, company_id=pos_config.company_id.id)
+
+ pos_name = self.env['ir.sequence'].with_context(ctx).next_by_code('pos.session')
+ if values.get('name'):
+ pos_name += ' ' + values['name']
+
+ cash_payment_methods = pos_config.payment_method_ids.filtered(lambda pm: pm.is_cash_count)
+ statement_ids = self.env['account.bank.statement']
+ if self.user_has_groups('point_of_sale.group_pos_user'):
+ statement_ids = statement_ids.sudo()
+ for cash_journal in cash_payment_methods.mapped('cash_journal_id'):
+ ctx['journal_id'] = cash_journal.id if pos_config.cash_control and cash_journal.type == 'cash' else False
+ st_values = {
+ 'journal_id': cash_journal.id,
+ 'user_id': self.env.user.id,
+ 'name': pos_name,
+ }
+ statement_ids |= statement_ids.with_context(ctx).create(st_values)
+
+ update_stock_at_closing = pos_config.company_id.point_of_sale_update_stock_quantities == "closing"
+
+ values.update({
+ 'name': pos_name,
+ 'statement_ids': [(6, 0, statement_ids.ids)],
+ 'config_id': config_id,
+ 'update_stock_at_closing': update_stock_at_closing,
+ })
+
+ if self.user_has_groups('point_of_sale.group_pos_user'):
+ res = super(PosSession, self.with_context(ctx).sudo()).create(values)
+ else:
+ res = super(PosSession, self.with_context(ctx)).create(values)
+ res.action_pos_session_open()
+
+ return res
+
+ def unlink(self):
+ for session in self.filtered(lambda s: s.statement_ids):
+ session.statement_ids.unlink()
+ return super(PosSession, self).unlink()
+
+ def login(self):
+ self.ensure_one()
+ login_number = self.login_number + 1
+ self.write({
+ 'login_number': login_number,
+ })
+ return login_number
+
+ def action_pos_session_open(self):
+ # second browse because we need to refetch the data from the DB for cash_register_id
+ # we only open sessions that haven't already been opened
+ for session in self.filtered(lambda session: session.state in ('new_session', 'opening_control')):
+ values = {}
+ if not session.start_at:
+ values['start_at'] = fields.Datetime.now()
+ if session.config_id.cash_control and not session.rescue:
+ last_sessions = self.env['pos.session'].search([('config_id', '=', self.config_id.id)]).ids
+ # last session includes the new one already.
+ self.cash_register_id.balance_start = self.env['pos.session'].browse(last_sessions[1]).cash_register_id.balance_end_real if len(last_sessions) > 1 else 0
+ values['state'] = 'opening_control'
+ else:
+ values['state'] = 'opened'
+ session.write(values)
+ return True
+
+ def action_pos_session_closing_control(self):
+ self._check_pos_session_balance()
+ for session in self:
+ if any(order.state == 'draft' for order in session.order_ids):
+ raise UserError(_("You cannot close the POS when orders are still in draft"))
+ if session.state == 'closed':
+ raise UserError(_('This session is already closed.'))
+ session.write({'state': 'closing_control', 'stop_at': fields.Datetime.now()})
+ if not session.config_id.cash_control:
+ session.action_pos_session_close()
+
+ def _check_pos_session_balance(self):
+ for session in self:
+ for statement in session.statement_ids:
+ if (statement != session.cash_register_id) and (statement.balance_end != statement.balance_end_real):
+ statement.write({'balance_end_real': statement.balance_end})
+
+ def action_pos_session_validate(self):
+ self._check_pos_session_balance()
+ return self.action_pos_session_close()
+
+ def action_pos_session_close(self):
+ # Session without cash payment method will not have a cash register.
+ # However, there could be other payment methods, thus, session still
+ # needs to be validated.
+ self._check_bank_statement_state()
+ if not self.cash_register_id:
+ return self._validate_session()
+
+ if self.cash_control and abs(self.cash_register_difference) > self.config_id.amount_authorized_diff:
+ # Only pos manager can close statements with cash_register_difference greater than amount_authorized_diff.
+ if not self.user_has_groups("point_of_sale.group_pos_manager"):
+ raise UserError(_(
+ "Your ending balance is too different from the theoretical cash closing (%.2f), "
+ "the maximum allowed is: %.2f. You can contact your manager to force it."
+ ) % (self.cash_register_difference, self.config_id.amount_authorized_diff))
+ else:
+ return self._warning_balance_closing()
+ else:
+ return self._validate_session()
+
+ def _validate_session(self):
+ self.ensure_one()
+ sudo = self.user_has_groups('point_of_sale.group_pos_user')
+ if self.order_ids or self.statement_ids.line_ids:
+ self.cash_real_transaction = self.cash_register_total_entry_encoding
+ self.cash_real_expected = self.cash_register_balance_end
+ self.cash_real_difference = self.cash_register_difference
+ if self.state == 'closed':
+ raise UserError(_('This session is already closed.'))
+ self._check_if_no_draft_orders()
+ if self.update_stock_at_closing:
+ self._create_picking_at_end_of_session()
+ # Users without any accounting rights won't be able to create the journal entry. If this
+ # case, switch to sudo for creation and posting.
+ try:
+ self.with_company(self.company_id)._create_account_move()
+ except AccessError as e:
+ if sudo:
+ self.sudo().with_company(self.company_id)._create_account_move()
+ else:
+ raise e
+ if self.move_id.line_ids:
+ # Set the uninvoiced orders' state to 'done'
+ self.env['pos.order'].search([('session_id', '=', self.id), ('state', '=', 'paid')]).write({'state': 'done'})
+ else:
+ self.move_id.unlink()
+ else:
+ statement = self.cash_register_id
+ if not self.config_id.cash_control:
+ statement.write({'balance_end_real': statement.balance_end})
+ statement.button_post()
+ statement.button_validate()
+ self.write({'state': 'closed'})
+ return {
+ 'type': 'ir.actions.client',
+ 'name': 'Point of Sale Menu',
+ 'tag': 'reload',
+ 'params': {'menu_id': self.env.ref('point_of_sale.menu_point_root').id},
+ }
+
+ def _create_picking_at_end_of_session(self):
+ self.ensure_one()
+ lines_grouped_by_dest_location = {}
+ picking_type = self.config_id.picking_type_id
+
+ if not picking_type or not picking_type.default_location_dest_id:
+ session_destination_id = self.env['stock.warehouse']._get_partner_locations()[0].id
+ else:
+ session_destination_id = picking_type.default_location_dest_id.id
+
+ for order in self.order_ids:
+ if order.company_id.anglo_saxon_accounting and order.is_invoiced:
+ continue
+ destination_id = order.partner_id.property_stock_customer.id or session_destination_id
+ if destination_id in lines_grouped_by_dest_location:
+ lines_grouped_by_dest_location[destination_id] |= order.lines
+ else:
+ lines_grouped_by_dest_location[destination_id] = order.lines
+
+ for location_dest_id, lines in lines_grouped_by_dest_location.items():
+ pickings = self.env['stock.picking']._create_picking_from_pos_order_lines(location_dest_id, lines, picking_type)
+ pickings.write({'pos_session_id': self.id, 'origin': self.name})
+
+ def _create_balancing_line(self, data):
+ imbalance_amount = 0
+ for line in self.move_id.line_ids:
+ # it is an excess debit so it should be credited
+ imbalance_amount += line.debit - line.credit
+
+ if (not float_is_zero(imbalance_amount, precision_rounding=self.currency_id.rounding)):
+ balancing_vals = self._prepare_balancing_line_vals(imbalance_amount, self.move_id)
+ MoveLine = data.get('MoveLine')
+ MoveLine.create(balancing_vals)
+
+ return data
+
+ def _prepare_balancing_line_vals(self, imbalance_amount, move):
+ account = self._get_balancing_account()
+ partial_vals = {
+ 'name': _('Difference at closing PoS session'),
+ 'account_id': account.id,
+ 'move_id': move.id,
+ 'partner_id': False,
+ }
+ # `imbalance_amount` is already in terms of company currency so it is the amount_converted
+ # param when calling `_credit_amounts`. amount param will be the converted value of
+ # `imbalance_amount` from company currency to the session currency.
+ imbalance_amount_session = 0
+ if (not self.is_in_company_currency):
+ imbalance_amount_session = self.company_id.currency_id._convert(imbalance_amount, self.currency_id, self.company_id, fields.Date.context_today(self))
+ return self._credit_amounts(partial_vals, imbalance_amount_session, imbalance_amount)
+
+ def _get_balancing_account(self):
+ propoerty_account = self.env['ir.property']._get('property_account_receivable_id', 'res.partner')
+ return self.company_id.account_default_pos_receivable_account_id or propoerty_account or self.env['account.account']
+
+ def _create_account_move(self):
+ """ Create account.move and account.move.line records for this session.
+
+ Side-effects include:
+ - setting self.move_id to the created account.move record
+ - creating and validating account.bank.statement for cash payments
+ - reconciling cash receivable lines, invoice receivable lines and stock output lines
+ """
+ journal = self.config_id.journal_id
+ # Passing default_journal_id for the calculation of default currency of account move
+ # See _get_default_currency in the account/account_move.py.
+ account_move = self.env['account.move'].with_context(default_journal_id=journal.id).create({
+ 'journal_id': journal.id,
+ 'date': fields.Date.context_today(self),
+ 'ref': self.name,
+ })
+ self.write({'move_id': account_move.id})
+
+ data = {}
+ data = self._accumulate_amounts(data)
+ data = self._create_non_reconciliable_move_lines(data)
+ data = self._create_cash_statement_lines_and_cash_move_lines(data)
+ data = self._create_invoice_receivable_lines(data)
+ data = self._create_stock_output_lines(data)
+ data = self._create_balancing_line(data)
+
+ if account_move.line_ids:
+ account_move._post()
+
+ data = self._reconcile_account_move_lines(data)
+
+ def _accumulate_amounts(self, data):
+ # Accumulate the amounts for each accounting lines group
+ # Each dict maps `key` -> `amounts`, where `key` is the group key.
+ # E.g. `combine_receivables` is derived from pos.payment records
+ # in the self.order_ids with group key of the `payment_method_id`
+ # field of the pos.payment record.
+ amounts = lambda: {'amount': 0.0, 'amount_converted': 0.0}
+ tax_amounts = lambda: {'amount': 0.0, 'amount_converted': 0.0, 'base_amount': 0.0, 'base_amount_converted': 0.0}
+ split_receivables = defaultdict(amounts)
+ split_receivables_cash = defaultdict(amounts)
+ combine_receivables = defaultdict(amounts)
+ combine_receivables_cash = defaultdict(amounts)
+ invoice_receivables = defaultdict(amounts)
+ sales = defaultdict(amounts)
+ taxes = defaultdict(tax_amounts)
+ stock_expense = defaultdict(amounts)
+ stock_return = defaultdict(amounts)
+ stock_output = defaultdict(amounts)
+ rounding_difference = {'amount': 0.0, 'amount_converted': 0.0}
+ # Track the receivable lines of the invoiced orders' account moves for reconciliation
+ # These receivable lines are reconciled to the corresponding invoice receivable lines
+ # of this session's move_id.
+ order_account_move_receivable_lines = defaultdict(lambda: self.env['account.move.line'])
+ rounded_globally = self.company_id.tax_calculation_rounding_method == 'round_globally'
+ for order in self.order_ids:
+ # Combine pos receivable lines
+ # Separate cash payments for cash reconciliation later.
+ for payment in order.payment_ids:
+ amount, date = payment.amount, payment.payment_date
+ if payment.payment_method_id.split_transactions:
+ if payment.payment_method_id.is_cash_count:
+ split_receivables_cash[payment] = self._update_amounts(split_receivables_cash[payment], {'amount': amount}, date)
+ else:
+ split_receivables[payment] = self._update_amounts(split_receivables[payment], {'amount': amount}, date)
+ else:
+ key = payment.payment_method_id
+ if payment.payment_method_id.is_cash_count:
+ combine_receivables_cash[key] = self._update_amounts(combine_receivables_cash[key], {'amount': amount}, date)
+ else:
+ combine_receivables[key] = self._update_amounts(combine_receivables[key], {'amount': amount}, date)
+
+ if order.is_invoiced:
+ # Combine invoice receivable lines
+ key = order.partner_id
+ if self.config_id.cash_rounding:
+ invoice_receivables[key] = self._update_amounts(invoice_receivables[key], {'amount': order.amount_paid}, order.date_order)
+ else:
+ invoice_receivables[key] = self._update_amounts(invoice_receivables[key], {'amount': order.amount_total}, order.date_order)
+ # side loop to gather receivable lines by account for reconciliation
+ for move_line in order.account_move.line_ids.filtered(lambda aml: aml.account_id.internal_type == 'receivable' and not aml.reconciled):
+ order_account_move_receivable_lines[move_line.account_id.id] |= move_line
+ else:
+ order_taxes = defaultdict(tax_amounts)
+ for order_line in order.lines:
+ line = self._prepare_line(order_line)
+ # Combine sales/refund lines
+ sale_key = (
+ # account
+ line['income_account_id'],
+ # sign
+ -1 if line['amount'] < 0 else 1,
+ # for taxes
+ tuple((tax['id'], tax['account_id'], tax['tax_repartition_line_id']) for tax in line['taxes']),
+ line['base_tags'],
+ )
+ sales[sale_key] = self._update_amounts(sales[sale_key], {'amount': line['amount']}, line['date_order'])
+ # Combine tax lines
+ for tax in line['taxes']:
+ tax_key = (tax['account_id'], tax['tax_repartition_line_id'], tax['id'], tuple(tax['tag_ids']))
+ order_taxes[tax_key] = self._update_amounts(
+ order_taxes[tax_key],
+ {'amount': tax['amount'], 'base_amount': tax['base']},
+ tax['date_order'],
+ round=not rounded_globally
+ )
+ for tax_key, amounts in order_taxes.items():
+ if rounded_globally:
+ amounts = self._round_amounts(amounts)
+ for amount_key, amount in amounts.items():
+ taxes[tax_key][amount_key] += amount
+
+ if self.company_id.anglo_saxon_accounting and order.picking_ids.ids:
+ # Combine stock lines
+ stock_moves = self.env['stock.move'].sudo().search([
+ ('picking_id', 'in', order.picking_ids.ids),
+ ('company_id.anglo_saxon_accounting', '=', True),
+ ('product_id.categ_id.property_valuation', '=', 'real_time')
+ ])
+ for move in stock_moves:
+ exp_key = move.product_id._get_product_accounts()['expense']
+ out_key = move.product_id.categ_id.property_stock_account_output_categ_id
+ amount = -sum(move.sudo().stock_valuation_layer_ids.mapped('value'))
+ stock_expense[exp_key] = self._update_amounts(stock_expense[exp_key], {'amount': amount}, move.picking_id.date, force_company_currency=True)
+ if move.location_id.usage == 'customer':
+ stock_return[out_key] = self._update_amounts(stock_return[out_key], {'amount': amount}, move.picking_id.date, force_company_currency=True)
+ else:
+ stock_output[out_key] = self._update_amounts(stock_output[out_key], {'amount': amount}, move.picking_id.date, force_company_currency=True)
+
+ if self.config_id.cash_rounding:
+ diff = order.amount_paid - order.amount_total
+ rounding_difference = self._update_amounts(rounding_difference, {'amount': diff}, order.date_order)
+
+ # Increasing current partner's customer_rank
+ partners = (order.partner_id | order.partner_id.commercial_partner_id)
+ partners._increase_rank('customer_rank')
+
+ if self.company_id.anglo_saxon_accounting:
+ global_session_pickings = self.picking_ids.filtered(lambda p: not p.pos_order_id)
+ if global_session_pickings:
+ stock_moves = self.env['stock.move'].sudo().search([
+ ('picking_id', 'in', global_session_pickings.ids),
+ ('company_id.anglo_saxon_accounting', '=', True),
+ ('product_id.categ_id.property_valuation', '=', 'real_time'),
+ ])
+ for move in stock_moves:
+ exp_key = move.product_id._get_product_accounts()['expense']
+ out_key = move.product_id.categ_id.property_stock_account_output_categ_id
+ amount = -sum(move.stock_valuation_layer_ids.mapped('value'))
+ stock_expense[exp_key] = self._update_amounts(stock_expense[exp_key], {'amount': amount}, move.picking_id.date)
+ if move.location_id.usage == 'customer':
+ stock_return[out_key] = self._update_amounts(stock_return[out_key], {'amount': amount}, move.picking_id.date)
+ else:
+ stock_output[out_key] = self._update_amounts(stock_output[out_key], {'amount': amount}, move.picking_id.date)
+ MoveLine = self.env['account.move.line'].with_context(check_move_validity=False)
+
+ data.update({
+ 'taxes': taxes,
+ 'sales': sales,
+ 'stock_expense': stock_expense,
+ 'split_receivables': split_receivables,
+ 'combine_receivables': combine_receivables,
+ 'split_receivables_cash': split_receivables_cash,
+ 'combine_receivables_cash': combine_receivables_cash,
+ 'invoice_receivables': invoice_receivables,
+ 'stock_return': stock_return,
+ 'stock_output': stock_output,
+ 'order_account_move_receivable_lines': order_account_move_receivable_lines,
+ 'rounding_difference': rounding_difference,
+ 'MoveLine': MoveLine
+ })
+ return data
+
+ def _create_non_reconciliable_move_lines(self, data):
+ # Create account.move.line records for
+ # - sales
+ # - taxes
+ # - stock expense
+ # - non-cash split receivables (not for automatic reconciliation)
+ # - non-cash combine receivables (not for automatic reconciliation)
+ taxes = data.get('taxes')
+ sales = data.get('sales')
+ stock_expense = data.get('stock_expense')
+ split_receivables = data.get('split_receivables')
+ combine_receivables = data.get('combine_receivables')
+ rounding_difference = data.get('rounding_difference')
+ MoveLine = data.get('MoveLine')
+
+ tax_vals = [self._get_tax_vals(key, amounts['amount'], amounts['amount_converted'], amounts['base_amount_converted']) for key, amounts in taxes.items() if amounts['amount']]
+ # Check if all taxes lines have account_id assigned. If not, there are repartition lines of the tax that have no account_id.
+ tax_names_no_account = [line['name'] for line in tax_vals if line['account_id'] == False]
+ if len(tax_names_no_account) > 0:
+ error_message = _(
+ 'Unable to close and validate the session.\n'
+ 'Please set corresponding tax account in each repartition line of the following taxes: \n%s'
+ ) % ', '.join(tax_names_no_account)
+ raise UserError(error_message)
+ rounding_vals = []
+
+ if not float_is_zero(rounding_difference['amount'], precision_rounding=self.currency_id.rounding) or not float_is_zero(rounding_difference['amount_converted'], precision_rounding=self.currency_id.rounding):
+ rounding_vals = [self._get_rounding_difference_vals(rounding_difference['amount'], rounding_difference['amount_converted'])]
+
+ MoveLine.create(
+ tax_vals
+ + [self._get_sale_vals(key, amounts['amount'], amounts['amount_converted']) for key, amounts in sales.items()]
+ + [self._get_stock_expense_vals(key, amounts['amount'], amounts['amount_converted']) for key, amounts in stock_expense.items()]
+ + [self._get_split_receivable_vals(key, amounts['amount'], amounts['amount_converted']) for key, amounts in split_receivables.items()]
+ + [self._get_combine_receivable_vals(key, amounts['amount'], amounts['amount_converted']) for key, amounts in combine_receivables.items()]
+ + rounding_vals
+ )
+ return data
+
+ def _create_cash_statement_lines_and_cash_move_lines(self, data):
+ # Create the split and combine cash statement lines and account move lines.
+ # Keep the reference by statement for reconciliation.
+ # `split_cash_statement_lines` maps `statement` -> split cash statement lines
+ # `combine_cash_statement_lines` maps `statement` -> combine cash statement lines
+ # `split_cash_receivable_lines` maps `statement` -> split cash receivable lines
+ # `combine_cash_receivable_lines` maps `statement` -> combine cash receivable lines
+ MoveLine = data.get('MoveLine')
+ split_receivables_cash = data.get('split_receivables_cash')
+ combine_receivables_cash = data.get('combine_receivables_cash')
+
+ statements_by_journal_id = {statement.journal_id.id: statement for statement in self.statement_ids}
+ # handle split cash payments
+ split_cash_statement_line_vals = defaultdict(list)
+ split_cash_receivable_vals = defaultdict(list)
+ for payment, amounts in split_receivables_cash.items():
+ statement = statements_by_journal_id[payment.payment_method_id.cash_journal_id.id]
+ split_cash_statement_line_vals[statement].append(self._get_statement_line_vals(statement, payment.payment_method_id.receivable_account_id, amounts['amount'], date=payment.payment_date, partner=payment.pos_order_id.partner_id))
+ split_cash_receivable_vals[statement].append(self._get_split_receivable_vals(payment, amounts['amount'], amounts['amount_converted']))
+ # handle combine cash payments
+ combine_cash_statement_line_vals = defaultdict(list)
+ combine_cash_receivable_vals = defaultdict(list)
+ for payment_method, amounts in combine_receivables_cash.items():
+ if not float_is_zero(amounts['amount'] , precision_rounding=self.currency_id.rounding):
+ statement = statements_by_journal_id[payment_method.cash_journal_id.id]
+ combine_cash_statement_line_vals[statement].append(self._get_statement_line_vals(statement, payment_method.receivable_account_id, amounts['amount']))
+ combine_cash_receivable_vals[statement].append(self._get_combine_receivable_vals(payment_method, amounts['amount'], amounts['amount_converted']))
+ # create the statement lines and account move lines
+ BankStatementLine = self.env['account.bank.statement.line']
+ split_cash_statement_lines = {}
+ combine_cash_statement_lines = {}
+ split_cash_receivable_lines = {}
+ combine_cash_receivable_lines = {}
+ for statement in self.statement_ids:
+ split_cash_statement_lines[statement] = BankStatementLine.create(split_cash_statement_line_vals[statement])
+ combine_cash_statement_lines[statement] = BankStatementLine.create(combine_cash_statement_line_vals[statement])
+ split_cash_receivable_lines[statement] = MoveLine.create(split_cash_receivable_vals[statement])
+ combine_cash_receivable_lines[statement] = MoveLine.create(combine_cash_receivable_vals[statement])
+
+ data.update(
+ {'split_cash_statement_lines': split_cash_statement_lines,
+ 'combine_cash_statement_lines': combine_cash_statement_lines,
+ 'split_cash_receivable_lines': split_cash_receivable_lines,
+ 'combine_cash_receivable_lines': combine_cash_receivable_lines
+ })
+ return data
+
+ def _create_invoice_receivable_lines(self, data):
+ # Create invoice receivable lines for this session's move_id.
+ # Keep reference of the invoice receivable lines because
+ # they are reconciled with the lines in order_account_move_receivable_lines
+ MoveLine = data.get('MoveLine')
+ invoice_receivables = data.get('invoice_receivables')
+
+ invoice_receivable_vals = defaultdict(list)
+ invoice_receivable_lines = {}
+ for partner, amounts in invoice_receivables.items():
+ commercial_partner = partner.commercial_partner_id
+ account_id = commercial_partner.property_account_receivable_id.id
+ invoice_receivable_vals[commercial_partner].append(self._get_invoice_receivable_vals(account_id, amounts['amount'], amounts['amount_converted'], partner=commercial_partner))
+ for commercial_partner, vals in invoice_receivable_vals.items():
+ account_id = commercial_partner.property_account_receivable_id.id
+ receivable_lines = MoveLine.create(vals)
+ for receivable_line in receivable_lines:
+ if (not receivable_line.reconciled):
+ if account_id not in invoice_receivable_lines:
+ invoice_receivable_lines[account_id] = receivable_line
+ else:
+ invoice_receivable_lines[account_id] |= receivable_line
+
+ data.update({'invoice_receivable_lines': invoice_receivable_lines})
+ return data
+
+ def _create_stock_output_lines(self, data):
+ # Keep reference to the stock output lines because
+ # they are reconciled with output lines in the stock.move's account.move.line
+ MoveLine = data.get('MoveLine')
+ stock_output = data.get('stock_output')
+ stock_return = data.get('stock_return')
+
+ stock_output_vals = defaultdict(list)
+ stock_output_lines = {}
+ for stock_moves in [stock_output, stock_return]:
+ for account, amounts in stock_moves.items():
+ stock_output_vals[account].append(self._get_stock_output_vals(account, amounts['amount'], amounts['amount_converted']))
+
+ for output_account, vals in stock_output_vals.items():
+ stock_output_lines[output_account] = MoveLine.create(vals)
+
+ data.update({'stock_output_lines': stock_output_lines})
+ return data
+
+ def _reconcile_account_move_lines(self, data):
+ # reconcile cash receivable lines
+ split_cash_statement_lines = data.get('split_cash_statement_lines')
+ combine_cash_statement_lines = data.get('combine_cash_statement_lines')
+ split_cash_receivable_lines = data.get('split_cash_receivable_lines')
+ combine_cash_receivable_lines = data.get('combine_cash_receivable_lines')
+ order_account_move_receivable_lines = data.get('order_account_move_receivable_lines')
+ invoice_receivable_lines = data.get('invoice_receivable_lines')
+ stock_output_lines = data.get('stock_output_lines')
+
+ for statement in self.statement_ids:
+ if not self.config_id.cash_control:
+ statement.write({'balance_end_real': statement.balance_end})
+ statement.button_post()
+ all_lines = (
+ split_cash_statement_lines[statement].mapped('move_id.line_ids').filtered(lambda aml: aml.account_id.internal_type == 'receivable')
+ | combine_cash_statement_lines[statement].mapped('move_id.line_ids').filtered(lambda aml: aml.account_id.internal_type == 'receivable')
+ | split_cash_receivable_lines[statement]
+ | combine_cash_receivable_lines[statement]
+ )
+ accounts = all_lines.mapped('account_id')
+ lines_by_account = [all_lines.filtered(lambda l: l.account_id == account and not l.reconciled) for account in accounts]
+ for lines in lines_by_account:
+ lines.reconcile()
+ # We try to validate the statement after the reconciliation is done
+ # because validating the statement requires each statement line to be
+ # reconciled.
+ # Furthermore, if the validation failed, which is caused by unreconciled
+ # cash difference statement line, we just ignore that. Leaving the statement
+ # not yet validated. Manual reconciliation and validation should be made
+ # by the user in the accounting app.
+ try:
+ statement.button_validate()
+ except UserError:
+ pass
+
+ # reconcile invoice receivable lines
+ for account_id in order_account_move_receivable_lines:
+ ( order_account_move_receivable_lines[account_id]
+ | invoice_receivable_lines.get(account_id, self.env['account.move.line'])
+ ).reconcile()
+
+ # reconcile stock output lines
+ pickings = self.picking_ids.filtered(lambda p: not p.pos_order_id)
+ pickings |= self.order_ids.filtered(lambda o: not o.is_invoiced).mapped('picking_ids')
+ stock_moves = self.env['stock.move'].search([('picking_id', 'in', pickings.ids)])
+ stock_account_move_lines = self.env['account.move'].search([('stock_move_id', 'in', stock_moves.ids)]).mapped('line_ids')
+ for account_id in stock_output_lines:
+ ( stock_output_lines[account_id]
+ | stock_account_move_lines.filtered(lambda aml: aml.account_id == account_id)
+ ).filtered(lambda aml: not aml.reconciled).reconcile()
+ return data
+
+ def _prepare_line(self, order_line):
+ """ Derive from order_line the order date, income account, amount and taxes information.
+
+ These information will be used in accumulating the amounts for sales and tax lines.
+ """
+ def get_income_account(order_line):
+ product = order_line.product_id
+ income_account = product.with_company(order_line.company_id)._get_product_accounts()['income']
+ if not income_account:
+ raise UserError(_('Please define income account for this product: "%s" (id:%d).')
+ % (product.name, product.id))
+ return order_line.order_id.fiscal_position_id.map_account(income_account)
+
+ tax_ids = order_line.tax_ids_after_fiscal_position\
+ .filtered(lambda t: t.company_id.id == order_line.order_id.company_id.id)
+ sign = -1 if order_line.qty >= 0 else 1
+ price = sign * order_line.price_unit * (1 - (order_line.discount or 0.0) / 100.0)
+ # The 'is_refund' parameter is used to compute the tax tags. Ultimately, the tags are part
+ # of the key used for summing taxes. Since the POS UI doesn't support the tags, inconsistencies
+ # may arise in 'Round Globally'.
+ check_refund = lambda x: x.qty * x.price_unit < 0
+ if self.company_id.tax_calculation_rounding_method == 'round_globally':
+ is_refund = all(check_refund(line) for line in order_line.order_id.lines)
+ else:
+ is_refund = check_refund(order_line)
+ tax_data = tax_ids.compute_all(price_unit=price, quantity=abs(order_line.qty), currency=self.currency_id, is_refund=is_refund)
+ taxes = tax_data['taxes']
+ # For Cash based taxes, use the account from the repartition line immediately as it has been paid already
+ for tax in taxes:
+ tax_rep = self.env['account.tax.repartition.line'].browse(tax['tax_repartition_line_id'])
+ tax['account_id'] = tax_rep.account_id.id
+ date_order = order_line.order_id.date_order
+ taxes = [{'date_order': date_order, **tax} for tax in taxes]
+ return {
+ 'date_order': order_line.order_id.date_order,
+ 'income_account_id': get_income_account(order_line).id,
+ 'amount': order_line.price_subtotal,
+ 'taxes': taxes,
+ 'base_tags': tuple(tax_data['base_tags']),
+ }
+
+ def _get_rounding_difference_vals(self, amount, amount_converted):
+ if self.config_id.cash_rounding:
+ partial_args = {
+ 'name': 'Rounding line',
+ 'move_id': self.move_id.id,
+ }
+ if float_compare(0.0, amount, precision_rounding=self.currency_id.rounding) > 0: # loss
+ partial_args['account_id'] = self.config_id.rounding_method.loss_account_id.id
+ return self._debit_amounts(partial_args, -amount, -amount_converted)
+
+ if float_compare(0.0, amount, precision_rounding=self.currency_id.rounding) < 0: # profit
+ partial_args['account_id'] = self.config_id.rounding_method.profit_account_id.id
+ return self._credit_amounts(partial_args, amount, amount_converted)
+
+ def _get_split_receivable_vals(self, payment, amount, amount_converted):
+ partial_vals = {
+ 'account_id': payment.payment_method_id.receivable_account_id.id,
+ 'move_id': self.move_id.id,
+ 'partner_id': self.env["res.partner"]._find_accounting_partner(payment.partner_id).id,
+ 'name': '%s - %s' % (self.name, payment.payment_method_id.name),
+ }
+ return self._debit_amounts(partial_vals, amount, amount_converted)
+
+ def _get_combine_receivable_vals(self, payment_method, amount, amount_converted):
+ partial_vals = {
+ 'account_id': payment_method.receivable_account_id.id,
+ 'move_id': self.move_id.id,
+ 'name': '%s - %s' % (self.name, payment_method.name)
+ }
+ return self._debit_amounts(partial_vals, amount, amount_converted)
+
+ def _get_invoice_receivable_vals(self, account_id, amount, amount_converted, **kwargs):
+ partner = kwargs.get('partner', False)
+ partial_vals = {
+ 'account_id': account_id,
+ 'move_id': self.move_id.id,
+ 'name': 'From invoiced orders',
+ 'partner_id': partner and partner.id or False,
+ }
+ return self._credit_amounts(partial_vals, amount, amount_converted)
+
+ def _get_sale_vals(self, key, amount, amount_converted):
+ account_id, sign, tax_keys, base_tag_ids = key
+ tax_ids = set(tax[0] for tax in tax_keys)
+ applied_taxes = self.env['account.tax'].browse(tax_ids)
+ title = 'Sales' if sign == 1 else 'Refund'
+ name = '%s untaxed' % title
+ if applied_taxes:
+ name = '%s with %s' % (title, ', '.join([tax.name for tax in applied_taxes]))
+ partial_vals = {
+ 'name': name,
+ 'account_id': account_id,
+ 'move_id': self.move_id.id,
+ 'tax_ids': [(6, 0, tax_ids)],
+ 'tax_tag_ids': [(6, 0, base_tag_ids)],
+ }
+ return self._credit_amounts(partial_vals, amount, amount_converted)
+
+ def _get_tax_vals(self, key, amount, amount_converted, base_amount_converted):
+ account_id, repartition_line_id, tax_id, tag_ids = key
+ tax = self.env['account.tax'].browse(tax_id)
+ partial_args = {
+ 'name': tax.name,
+ 'account_id': account_id,
+ 'move_id': self.move_id.id,
+ 'tax_base_amount': abs(base_amount_converted),
+ 'tax_repartition_line_id': repartition_line_id,
+ 'tax_tag_ids': [(6, 0, tag_ids)],
+ }
+ return self._debit_amounts(partial_args, amount, amount_converted)
+
+ def _get_stock_expense_vals(self, exp_account, amount, amount_converted):
+ partial_args = {'account_id': exp_account.id, 'move_id': self.move_id.id}
+ return self._debit_amounts(partial_args, amount, amount_converted, force_company_currency=True)
+
+ def _get_stock_output_vals(self, out_account, amount, amount_converted):
+ partial_args = {'account_id': out_account.id, 'move_id': self.move_id.id}
+ return self._credit_amounts(partial_args, amount, amount_converted, force_company_currency=True)
+
+ def _get_statement_line_vals(self, statement, receivable_account, amount, date=False, partner=False):
+ return {
+ 'date': fields.Date.context_today(self, timestamp=date),
+ 'amount': amount,
+ 'payment_ref': self.name,
+ 'statement_id': statement.id,
+ 'journal_id': statement.journal_id.id,
+ 'counterpart_account_id': receivable_account.id,
+ 'partner_id': partner and self.env["res.partner"]._find_accounting_partner(partner).id
+ }
+
+ def _update_amounts(self, old_amounts, amounts_to_add, date, round=True, force_company_currency=False):
+ """Responsible for adding `amounts_to_add` to `old_amounts` considering the currency of the session.
+
+ old_amounts { new_amounts {
+ amount amounts_to_add { amount
+ amount_converted + amount -> amount_converted
+ [base_amount [base_amount] [base_amount
+ base_amount_converted] } base_amount_converted]
+ } }
+
+ NOTE:
+ - Notice that `amounts_to_add` does not have `amount_converted` field.
+ This function is responsible in calculating the `amount_converted` from the
+ `amount` of `amounts_to_add` which is used to update the values of `old_amounts`.
+ - Values of `amount` and/or `base_amount` should always be in session's currency [1].
+ - Value of `amount_converted` should be in company's currency
+
+ [1] Except when `force_company_currency` = True. It means that values in `amounts_to_add`
+ is in company currency.
+
+ :params old_amounts dict:
+ Amounts to update
+ :params amounts_to_add dict:
+ Amounts used to update the old_amounts
+ :params date date:
+ Date used for conversion
+ :params round bool:
+ Same as round parameter of `res.currency._convert`.
+ Defaults to True because that is the default of `res.currency._convert`.
+ We put it to False if we want to round globally.
+ :params force_company_currency bool:
+ If True, the values in amounts_to_add are in company's currency.
+ Defaults to False because it is only used to anglo-saxon lines.
+
+ :return dict: new amounts combining the values of `old_amounts` and `amounts_to_add`.
+ """
+ # make a copy of the old amounts
+ new_amounts = { **old_amounts }
+
+ amount = amounts_to_add.get('amount')
+ if self.is_in_company_currency or force_company_currency:
+ amount_converted = amount
+ else:
+ amount_converted = self._amount_converter(amount, date, round)
+
+ # update amount and amount converted
+ new_amounts['amount'] += amount
+ new_amounts['amount_converted'] += amount_converted
+
+ # consider base_amount if present
+ if not amounts_to_add.get('base_amount') == None:
+ base_amount = amounts_to_add.get('base_amount')
+ if self.is_in_company_currency or force_company_currency:
+ base_amount_converted = base_amount
+ else:
+ base_amount_converted = self._amount_converter(base_amount, date, round)
+
+ # update base_amount and base_amount_converted
+ new_amounts['base_amount'] += base_amount
+ new_amounts['base_amount_converted'] += base_amount_converted
+
+ return new_amounts
+
+ def _round_amounts(self, amounts):
+ new_amounts = {}
+ for key, amount in amounts.items():
+ if key == 'amount_converted':
+ # round the amount_converted using the company currency.
+ new_amounts[key] = self.company_id.currency_id.round(amount)
+ else:
+ new_amounts[key] = self.currency_id.round(amount)
+ return new_amounts
+
+ def _credit_amounts(self, partial_move_line_vals, amount, amount_converted, force_company_currency=False):
+ """ `partial_move_line_vals` is completed by `credit`ing the given amounts.
+
+ NOTE Amounts in PoS are in the currency of journal_id in the session.config_id.
+ This means that amount fields in any pos record are actually equivalent to amount_currency
+ in account module. Understanding this basic is important in correctly assigning values for
+ 'amount' and 'amount_currency' in the account.move.line record.
+
+ :param partial_move_line_vals dict:
+ initial values in creating account.move.line
+ :param amount float:
+ amount derived from pos.payment, pos.order, or pos.order.line records
+ :param amount_converted float:
+ converted value of `amount` from the given `session_currency` to company currency
+
+ :return dict: complete values for creating 'amount.move.line' record
+ """
+ if self.is_in_company_currency or force_company_currency:
+ additional_field = {}
+ else:
+ additional_field = {
+ 'amount_currency': -amount,
+ 'currency_id': self.currency_id.id,
+ }
+ return {
+ 'debit': -amount_converted if amount_converted < 0.0 else 0.0,
+ 'credit': amount_converted if amount_converted > 0.0 else 0.0,
+ **partial_move_line_vals,
+ **additional_field,
+ }
+
+ def _debit_amounts(self, partial_move_line_vals, amount, amount_converted, force_company_currency=False):
+ """ `partial_move_line_vals` is completed by `debit`ing the given amounts.
+
+ See _credit_amounts docs for more details.
+ """
+ if self.is_in_company_currency or force_company_currency:
+ additional_field = {}
+ else:
+ additional_field = {
+ 'amount_currency': amount,
+ 'currency_id': self.currency_id.id,
+ }
+ return {
+ 'debit': amount_converted if amount_converted > 0.0 else 0.0,
+ 'credit': -amount_converted if amount_converted < 0.0 else 0.0,
+ **partial_move_line_vals,
+ **additional_field,
+ }
+
+ def _amount_converter(self, amount, date, round):
+ # self should be single record as this method is only called in the subfunctions of self._validate_session
+ return self.currency_id._convert(amount, self.company_id.currency_id, self.company_id, date, round=round)
+
+ def show_journal_items(self):
+ self.ensure_one()
+ all_related_moves = self._get_related_account_moves()
+ return {
+ 'name': _('Journal Items'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move.line',
+ 'view_mode': 'tree',
+ 'view_id':self.env.ref('account.view_move_line_tree_grouped').id,
+ 'domain': [('id', 'in', all_related_moves.mapped('line_ids').ids)],
+ 'context': {
+ 'journal_type':'general',
+ 'search_default_group_by_move': 1,
+ 'group_by':'move_id', 'search_default_posted':1,
+ 'name_groupby':1,
+ },
+ }
+
+ def _get_related_account_moves(self):
+ def get_matched_move_lines(aml):
+ if aml.credit > 0:
+ return [r.debit_move_id.id for r in aml.matched_debit_ids]
+ else:
+ return [r.credit_move_id.id for r in aml.matched_credit_ids]
+
+ session_move = self.move_id
+ # get all the linked move lines to this account move.
+ non_reconcilable_lines = session_move.line_ids.filtered(lambda aml: not aml.account_id.reconcile)
+ reconcilable_lines = session_move.line_ids - non_reconcilable_lines
+ fully_reconciled_lines = reconcilable_lines.filtered(lambda aml: aml.full_reconcile_id)
+ partially_reconciled_lines = reconcilable_lines - fully_reconciled_lines
+
+ cash_move_lines = self.env['account.move.line'].search([('statement_id', '=', self.cash_register_id.id)])
+
+ ids = (non_reconcilable_lines.ids
+ + fully_reconciled_lines.mapped('full_reconcile_id').mapped('reconciled_line_ids').ids
+ + sum(partially_reconciled_lines.mapped(get_matched_move_lines), partially_reconciled_lines.ids)
+ + cash_move_lines.ids)
+
+ return self.env['account.move.line'].browse(ids).mapped('move_id')
+
+ def action_show_payments_list(self):
+ return {
+ 'name': _('Payments'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'pos.payment',
+ 'view_mode': 'tree,form',
+ 'domain': [('session_id', '=', self.id)],
+ 'context': {'search_default_group_by_payment_method': 1}
+ }
+
+ def open_frontend_cb(self):
+ """Open the pos interface with config_id as an extra argument.
+
+ In vanilla PoS each user can only have one active session, therefore it was not needed to pass the config_id
+ on opening a session. It is also possible to login to sessions created by other users.
+
+ :returns: dict
+ """
+ if not self.ids:
+ return {}
+ return {
+ 'type': 'ir.actions.act_url',
+ 'target': 'self',
+ 'url': self.config_id._get_pos_base_url() + '?config_id=%d' % self.config_id.id,
+ }
+
+ def open_cashbox_pos(self):
+ self.ensure_one()
+ action = self.cash_register_id.open_cashbox_id()
+ action['view_id'] = self.env.ref('point_of_sale.view_account_bnk_stmt_cashbox_footer').id
+ action['context']['pos_session_id'] = self.id
+ action['context']['default_pos_id'] = self.config_id.id
+ return action
+
+ def set_cashbox_pos(self, cashbox_value, notes):
+ self.state = 'opened'
+ self.cash_register_id.balance_start = cashbox_value
+ if notes:
+ self.env['mail.message'].create({
+ 'body': notes,
+ 'model': 'account.bank.statement',
+ 'res_id': self.cash_register_id.id,
+ })
+
+ def action_view_order(self):
+ return {
+ 'name': _('Orders'),
+ 'res_model': 'pos.order',
+ 'view_mode': 'tree,form',
+ 'views': [
+ (self.env.ref('point_of_sale.view_pos_order_tree_no_session_id').id, 'tree'),
+ (self.env.ref('point_of_sale.view_pos_pos_form').id, 'form'),
+ ],
+ 'type': 'ir.actions.act_window',
+ 'domain': [('session_id', 'in', self.ids)],
+ }
+
+ @api.model
+ def _alert_old_session(self):
+ # If the session is open for more then one week,
+ # log a next activity to close the session.
+ sessions = self.sudo().search([('start_at', '<=', (fields.datetime.now() - timedelta(days=7))), ('state', '!=', 'closed')])
+ for session in sessions:
+ if self.env['mail.activity'].search_count([('res_id', '=', session.id), ('res_model', '=', 'pos.session')]) == 0:
+ session.activity_schedule(
+ 'point_of_sale.mail_activity_old_session',
+ user_id=session.user_id.id,
+ note=_(
+ "Your PoS Session is open since %(date)s, we advise you to close it and to create a new one.",
+ date=session.start_at,
+ )
+ )
+
+ def _warning_balance_closing(self):
+ self.ensure_one()
+
+ context = dict(self._context)
+ context['session_id'] = self.id
+
+ return {
+ 'name': _('Balance control'),
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'closing.balance.confirm.wizard',
+ 'views': [(False, 'form')],
+ 'type': 'ir.actions.act_window',
+ 'context': context,
+ 'target': 'new'
+ }
+
+ def _check_if_no_draft_orders(self):
+ draft_orders = self.order_ids.filtered(lambda order: order.state == 'draft')
+ if draft_orders:
+ raise UserError(_(
+ 'There are still orders in draft state in the session. '
+ 'Pay or cancel the following orders to validate the session:\n%s'
+ ) % ', '.join(draft_orders.mapped('name'))
+ )
+ return True
+
+class ProcurementGroup(models.Model):
+ _inherit = 'procurement.group'
+
+ @api.model
+ def _run_scheduler_tasks(self, use_new_cursor=False, company_id=False):
+ super(ProcurementGroup, self)._run_scheduler_tasks(use_new_cursor=use_new_cursor, company_id=company_id)
+ self.env['pos.session']._alert_old_session()
+ if use_new_cursor:
+ self.env.cr.commit()
+
+class ClosingBalanceConfirm(models.TransientModel):
+ _name = 'closing.balance.confirm.wizard'
+ _description = 'This wizard is used to display a warning message if the manager wants to close a session with a too high difference between real and expected closing balance'
+
+ def confirm_closing_balance(self):
+ current_session = self.env['pos.session'].browse(self._context['session_id'])
+ return current_session._validate_session()
diff --git a/addons/point_of_sale/models/product.py b/addons/point_of_sale/models/product.py
new file mode 100644
index 00000000..b367c766
--- /dev/null
+++ b/addons/point_of_sale/models/product.py
@@ -0,0 +1,50 @@
+# -*- 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 ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ available_in_pos = fields.Boolean(string='Available in POS', help='Check if you want this product to appear in the Point of Sale.', default=False)
+ to_weight = fields.Boolean(string='To Weigh With Scale', help="Check if the product should be weighted using the hardware scale integration.")
+ pos_categ_id = fields.Many2one(
+ 'pos.category', string='Point of Sale Category',
+ help="Category used in the Point of Sale.")
+
+ def unlink(self):
+ product_ctx = dict(self.env.context or {}, active_test=False)
+ if self.with_context(product_ctx).search_count([('id', 'in', self.ids), ('available_in_pos', '=', True)]):
+ if self.env['pos.session'].sudo().search_count([('state', '!=', 'closed')]):
+ raise UserError(_('You cannot delete a product saleable in point of sale while a session is still opened.'))
+ return super(ProductTemplate, self).unlink()
+
+ @api.onchange('sale_ok')
+ def _onchange_sale_ok(self):
+ if not self.sale_ok:
+ self.available_in_pos = False
+
+
+class ProductProduct(models.Model):
+ _inherit = 'product.product'
+
+ def unlink(self):
+ product_ctx = dict(self.env.context or {}, active_test=False)
+ if self.env['pos.session'].sudo().search_count([('state', '!=', 'closed')]):
+ if self.with_context(product_ctx).search_count([('id', 'in', self.ids), ('product_tmpl_id.available_in_pos', '=', True)]):
+ raise UserError(_('You cannot delete a product saleable in point of sale while a session is still opened.'))
+ return super(ProductProduct, self).unlink()
+
+
+class UomCateg(models.Model):
+ _inherit = 'uom.category'
+
+ is_pos_groupable = fields.Boolean(string='Group Products in POS',
+ help="Check if you want to group products of this category in point of sale orders")
+
+
+class Uom(models.Model):
+ _inherit = 'uom.uom'
+
+ is_pos_groupable = fields.Boolean(related='category_id.is_pos_groupable', readonly=False)
diff --git a/addons/point_of_sale/models/res_company.py b/addons/point_of_sale/models/res_company.py
new file mode 100644
index 00000000..56df65b9
--- /dev/null
+++ b/addons/point_of_sale/models/res_company.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, models, fields, _
+from odoo.exceptions import ValidationError
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ point_of_sale_update_stock_quantities = fields.Selection([
+ ('closing', 'At the session closing (advised)'),
+ ('real', 'In real time'),
+ ], default='closing', string="Update quantities in stock",
+ help="At the session closing: A picking is created for the entire session when it's closed\n In real time: Each order sent to the server create its own picking")
+
+ @api.constrains('period_lock_date', 'fiscalyear_lock_date')
+ def validate_period_lock_date(self):
+ """ This constrains makes it impossible to change the period lock date if
+ some open POS session exists into it. Without that, these POS sessions
+ would trigger an error message saying that the period has been locked when
+ trying to close them.
+ """
+ pos_session_model = self.env['pos.session'].sudo()
+ for record in self:
+ sessions_in_period = pos_session_model.search(
+ [
+ "&",
+ "&",
+ ("company_id", "=", record.id),
+ ("state", "!=", "closed"),
+ "|",
+ ("start_at", "<=", record.period_lock_date),
+ ("start_at", "<=", record.fiscalyear_lock_date),
+ ]
+ )
+ if sessions_in_period:
+ sessions_str = ', '.join(sessions_in_period.mapped('name'))
+ raise ValidationError(_("Please close all the point of sale sessions in this period before closing it. Open sessions are: %s ") % (sessions_str))
diff --git a/addons/point_of_sale/models/res_config_settings.py b/addons/point_of_sale/models/res_config_settings.py
new file mode 100644
index 00000000..30b92b95
--- /dev/null
+++ b/addons/point_of_sale/models/res_config_settings.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax", related='company_id.account_sale_tax_id', readonly=False)
+ module_pos_mercury = fields.Boolean(string="Vantiv Payment Terminal", help="The transactions are processed by Vantiv. Set your Vantiv credentials on the related payment method.")
+ module_pos_adyen = fields.Boolean(string="Adyen Payment Terminal", help="The transactions are processed by Adyen. Set your Adyen credentials on the related payment method.")
+ module_pos_six = fields.Boolean(string="Six Payment Terminal", help="The transactions are processed by Six. Set the IP address of the terminal on the related payment method.")
+ update_stock_quantities = fields.Selection(related="company_id.point_of_sale_update_stock_quantities", readonly=False)
+
+ def set_values(self):
+ super(ResConfigSettings, self).set_values()
+ if not self.group_product_pricelist:
+ configs = self.env['pos.config'].search([('use_pricelist', '=', True)])
+ for config in configs:
+ config.use_pricelist = False
diff --git a/addons/point_of_sale/models/res_partner.py b/addons/point_of_sale/models/res_partner.py
new file mode 100644
index 00000000..a0246c6f
--- /dev/null
+++ b/addons/point_of_sale/models/res_partner.py
@@ -0,0 +1,44 @@
+# -*- 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 ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ pos_order_count = fields.Integer(
+ compute='_compute_pos_order',
+ help="The number of point of sales orders related to this customer",
+ groups="point_of_sale.group_pos_user",
+ )
+ pos_order_ids = fields.One2many('pos.order', 'partner_id', readonly=True)
+
+ def _compute_pos_order(self):
+ partners_data = self.env['pos.order'].read_group([('partner_id', 'in', self.ids)], ['partner_id'], ['partner_id'])
+ mapped_data = dict([(partner['partner_id'][0], partner['partner_id_count']) for partner in partners_data])
+ for partner in self:
+ partner.pos_order_count = mapped_data.get(partner.id, 0)
+
+ @api.model
+ def create_from_ui(self, partner):
+ """ create or modify a partner from the point of sale ui.
+ partner contains the partner's fields. """
+ # image is a dataurl, get the data after the comma
+ if partner.get('image_1920'):
+ partner['image_1920'] = partner['image_1920'].split(',')[1]
+ partner_id = partner.pop('id', False)
+ if partner_id: # Modifying existing partner
+ self.browse(partner_id).write(partner)
+ else:
+ partner_id = self.create(partner).id
+ return partner_id
+
+ def unlink(self):
+ running_sessions = self.env['pos.session'].sudo().search([('state', '!=', 'closed')])
+ if running_sessions:
+ raise UserError(
+ _("You cannot delete contacts while there are active PoS sessions. Close the session(s) %s first.")
+ % ", ".join(session.name for session in running_sessions)
+ )
+ return super(ResPartner, self).unlink()
diff --git a/addons/point_of_sale/models/stock_picking.py b/addons/point_of_sale/models/stock_picking.py
new file mode 100644
index 00000000..6017f613
--- /dev/null
+++ b/addons/point_of_sale/models/stock_picking.py
@@ -0,0 +1,165 @@
+# -*- 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, ValidationError
+from odoo.tools import float_is_zero, float_compare
+
+from itertools import groupby
+
+class StockPicking(models.Model):
+ _inherit='stock.picking'
+
+ pos_session_id = fields.Many2one('pos.session')
+ pos_order_id = fields.Many2one('pos.order')
+
+ def _prepare_picking_vals(self, partner, picking_type, location_id, location_dest_id):
+ return {
+ 'partner_id': partner.id if partner else False,
+ 'user_id': False,
+ 'picking_type_id': picking_type.id,
+ 'move_type': 'direct',
+ 'location_id': location_id,
+ 'location_dest_id': location_dest_id,
+ }
+
+
+ @api.model
+ def _create_picking_from_pos_order_lines(self, location_dest_id, lines, picking_type, partner=False):
+ """We'll create some picking based on order_lines"""
+
+ pickings = self.env['stock.picking']
+ stockable_lines = lines.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not float_is_zero(l.qty, precision_rounding=l.product_id.uom_id.rounding))
+ if not stockable_lines:
+ return pickings
+ positive_lines = stockable_lines.filtered(lambda l: l.qty > 0)
+ negative_lines = stockable_lines - positive_lines
+
+ if positive_lines:
+ location_id = picking_type.default_location_src_id.id
+ positive_picking = self.env['stock.picking'].create(
+ self._prepare_picking_vals(partner, picking_type, location_id, location_dest_id)
+ )
+
+ positive_picking._create_move_from_pos_order_lines(positive_lines)
+ try:
+ with self.env.cr.savepoint():
+ positive_picking._action_done()
+ except (UserError, ValidationError):
+ pass
+
+ pickings |= positive_picking
+ if negative_lines:
+ if picking_type.return_picking_type_id:
+ return_picking_type = picking_type.return_picking_type_id
+ return_location_id = return_picking_type.default_location_dest_id.id
+ else:
+ return_picking_type = picking_type
+ return_location_id = picking_type.default_location_src_id.id
+
+ negative_picking = self.env['stock.picking'].create(
+ self._prepare_picking_vals(partner, return_picking_type, location_dest_id, return_location_id)
+ )
+ negative_picking._create_move_from_pos_order_lines(negative_lines)
+ try:
+ with self.env.cr.savepoint():
+ negative_picking._action_done()
+ except (UserError, ValidationError):
+ pass
+ pickings |= negative_picking
+ return pickings
+
+ def _prepare_stock_move_vals(self, first_line, order_lines):
+ return {
+ 'name': first_line.name,
+ 'product_uom': first_line.product_id.uom_id.id,
+ 'picking_id': self.id,
+ 'picking_type_id': self.picking_type_id.id,
+ 'product_id': first_line.product_id.id,
+ 'product_uom_qty': abs(sum(order_lines.mapped('qty'))),
+ 'state': 'draft',
+ 'location_id': self.location_id.id,
+ 'location_dest_id': self.location_dest_id.id,
+ 'company_id': self.company_id.id,
+ }
+
+ def _create_move_from_pos_order_lines(self, lines):
+ self.ensure_one()
+ lines_by_product = groupby(sorted(lines, key=lambda l: l.product_id.id), key=lambda l: l.product_id.id)
+ for product, lines in lines_by_product:
+ order_lines = self.env['pos.order.line'].concat(*lines)
+ first_line = order_lines[0]
+ current_move = self.env['stock.move'].create(
+ self._prepare_stock_move_vals(first_line, order_lines)
+ )
+ confirmed_moves = current_move._action_confirm()
+ for move in confirmed_moves:
+ if first_line.product_id == move.product_id and first_line.product_id.tracking != 'none':
+ if self.picking_type_id.use_existing_lots or self.picking_type_id.use_create_lots:
+ for line in order_lines:
+ sum_of_lots = 0
+ for lot in line.pack_lot_ids.filtered(lambda l: l.lot_name):
+ if line.product_id.tracking == 'serial':
+ qty = 1
+ else:
+ qty = abs(line.qty)
+ ml_vals = move._prepare_move_line_vals()
+ ml_vals.update({'qty_done':qty})
+ if self.picking_type_id.use_existing_lots:
+ existing_lot = self.env['stock.production.lot'].search([
+ ('company_id', '=', self.company_id.id),
+ ('product_id', '=', line.product_id.id),
+ ('name', '=', lot.lot_name)
+ ])
+ if not existing_lot and self.picking_type_id.use_create_lots:
+ existing_lot = self.env['stock.production.lot'].create({
+ 'company_id': self.company_id.id,
+ 'product_id': line.product_id.id,
+ 'name': lot.lot_name,
+ })
+ quant = existing_lot.quant_ids.filtered(lambda q: q.quantity > 0.0 and q.location_id.parent_path.startswith(move.location_id.parent_path))[-1:]
+ ml_vals.update({
+ 'lot_id': existing_lot.id,
+ 'location_id': quant.location_id.id or move.location_id.id
+ })
+ else:
+ ml_vals.update({
+ 'lot_name': lot.lot_name,
+ })
+ self.env['stock.move.line'].create(ml_vals)
+ sum_of_lots += qty
+ if abs(line.qty) != sum_of_lots:
+ difference_qty = abs(line.qty) - sum_of_lots
+ ml_vals = current_move._prepare_move_line_vals()
+ if line.product_id.tracking == 'serial':
+ ml_vals.update({'qty_done': 1})
+ for i in range(int(difference_qty)):
+ self.env['stock.move.line'].create(ml_vals)
+ else:
+ ml_vals.update({'qty_done': difference_qty})
+ self.env['stock.move.line'].create(ml_vals)
+ else:
+ move._action_assign()
+ for move_line in move.move_line_ids:
+ move_line.qty_done = move_line.product_uom_qty
+ if float_compare(move.product_uom_qty, move.quantity_done, precision_rounding=move.product_uom.rounding) > 0:
+ remaining_qty = move.product_uom_qty - move.quantity_done
+ ml_vals = move._prepare_move_line_vals()
+ ml_vals.update({'qty_done':remaining_qty})
+ self.env['stock.move.line'].create(ml_vals)
+
+ else:
+ move._action_assign()
+ for move_line in move.move_line_ids:
+ move_line.qty_done = move_line.product_uom_qty
+ if float_compare(move.product_uom_qty, move.quantity_done, precision_rounding=move.product_uom.rounding) > 0:
+ remaining_qty = move.product_uom_qty - move.quantity_done
+ ml_vals = move._prepare_move_line_vals()
+ ml_vals.update({'qty_done':remaining_qty})
+ self.env['stock.move.line'].create(ml_vals)
+ move.quantity_done = move.product_uom_qty
+
+ def _send_confirmation_email(self):
+ # Avoid sending Mail/SMS for POS deliveries
+ pickings = self.filtered(lambda p: p.picking_type_id != p.picking_type_id.warehouse_id.pos_type_id)
+ return super(StockPicking, pickings)._send_confirmation_email()
diff --git a/addons/point_of_sale/models/stock_warehouse.py b/addons/point_of_sale/models/stock_warehouse.py
new file mode 100644
index 00000000..f0058b32
--- /dev/null
+++ b/addons/point_of_sale/models/stock_warehouse.py
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+
+
+class Warehouse(models.Model):
+ _inherit = "stock.warehouse"
+
+ pos_type_id = fields.Many2one('stock.picking.type', string="Point of Sale Operation Type")
+
+ def _get_sequence_values(self):
+ sequence_values = super(Warehouse, self)._get_sequence_values()
+ sequence_values.update({
+ 'pos_type_id': {
+ 'name': self.name + ' ' + _('Picking POS'),
+ 'prefix': self.code + '/POS/',
+ 'padding': 5,
+ 'company_id': self.company_id.id,
+ }
+ })
+ return sequence_values
+
+ def _get_picking_type_update_values(self):
+ picking_type_update_values = super(Warehouse, self)._get_picking_type_update_values()
+ picking_type_update_values.update({
+ 'pos_type_id': {'default_location_src_id': self.lot_stock_id.id}
+ })
+ return picking_type_update_values
+
+ def _get_picking_type_create_values(self, max_sequence):
+ picking_type_create_values, max_sequence = super(Warehouse, self)._get_picking_type_create_values(max_sequence)
+ picking_type_create_values.update({
+ 'pos_type_id': {
+ 'name': _('PoS Orders'),
+ 'code': 'outgoing',
+ 'default_location_src_id': self.lot_stock_id.id,
+ 'default_location_dest_id': self.env.ref('stock.stock_location_customers').id,
+ 'sequence': max_sequence + 1,
+ 'sequence_code': 'POS',
+ 'company_id': self.company_id.id,
+ 'show_operations': False,
+ }
+ })
+ return picking_type_create_values, max_sequence + 2
+
+ @api.model
+ def _create_missing_pos_picking_types(self):
+ warehouses = self.env['stock.warehouse'].search([('pos_type_id', '=', False)])
+ for warehouse in warehouses:
+ new_vals = warehouse._create_or_update_sequences_and_picking_types()
+ warehouse.write(new_vals)