From 42425996e36d9cb1fc1391402cf6d00df4aea019 Mon Sep 17 00:00:00 2001 From: Robin Heinz Date: Thu, 21 Nov 2019 13:44:46 +0000 Subject: [PATCH] [IMP] point_of_sale: Use the cash rounding in the pos Because of the law of the 1st december 2019, we can't pay with 1 or 2 cents anymore. We have to round the total price to the 0.05 unit. If the customer pay without rounding values, it triggers an error. Before the order is finalized, we add a line with a product rounding to round the total amount. Task-id: 2035010 X-original-commit: 58477e5503a18cc98fe686f8857233063eb26f4c --- addons/point_of_sale/models/pos_config.py | 8 ++ addons/point_of_sale/models/pos_order.py | 25 +++++- addons/point_of_sale/models/pos_session.py | 31 +++++++- addons/point_of_sale/static/src/js/models.js | 46 ++++++++++- addons/point_of_sale/static/src/js/screens.js | 11 ++- addons/point_of_sale/static/src/xml/pos.xml | 10 ++- .../tests/test_pos_basic_config.py | 77 +++++++++++++++++++ .../point_of_sale/views/pos_config_view.xml | 17 ++++ addons/point_of_sale/views/pos_order_view.xml | 18 +++++ .../views/res_config_settings_views.xml | 19 +++++ 10 files changed, 249 insertions(+), 13 deletions(-) diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index 4b74985d8bd..ed19a306515 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -214,6 +214,9 @@ class PosConfig(models.Model): 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") @api.depends('use_pricelist', 'available_pricelist_ids') def _compute_allowed_pricelist_ids(self): @@ -307,6 +310,11 @@ class PosConfig(models.Model): 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: diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index c558da249ba..df5ad7db13f 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -8,8 +8,8 @@ import psycopg2 import pytz from odoo import api, fields, models, tools, _ -from odoo.tools import float_is_zero -from odoo.exceptions import UserError +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 @@ -271,7 +271,6 @@ class PosOrder(models.Model): 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: @@ -358,8 +357,15 @@ class PosOrder(models.Model): def action_pos_order_paid(self): self.ensure_one() - if not float_is_zero(self.amount_total - self.amount_paid, precision_rounding=self.currency_id.rounding): + + if not self.config_id.cash_rounding: + 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) + + if not float_is_zero(total - self.amount_paid, precision_rounding=self.currency_id.rounding): raise UserError(_("Order %s is not fully paid.") % self.name) + self.write({'state': 'paid'}) return True @@ -389,6 +395,7 @@ class PosOrder(models.Model): 'invoice_date': order.date_order.date(), 'fiscal_position_id': order.fiscal_position_id.id, 'invoice_line_ids': [(0, None, order._prepare_invoice_line(line)) for line in order.lines], + 'invoice_cash_rounding_id': order.config_id.rounding_method.id if order.config_id.cash_rounding else False } new_move = moves.sudo()\ .with_company(order.company_id)\ @@ -824,3 +831,13 @@ class ReportSaleDetails(models.AbstractModel): configs = self.env['pos.config'].browse(data['config_ids']) data.update(self.get_sale_details(data['date_start'], data['date_stop'], configs)) 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_session.py b/addons/point_of_sale/models/pos_session.py index 7b8bc3b1c6e..f7235a8584e 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -6,7 +6,7 @@ from datetime import timedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError -from odoo.tools import float_is_zero +from odoo.tools import float_is_zero, float_compare class PosSession(models.Model): @@ -375,6 +375,7 @@ class PosSession(models.Model): taxes = defaultdict(tax_amounts) stock_expense = 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. @@ -400,7 +401,10 @@ class PosSession(models.Model): if order.is_invoiced: # Combine invoice receivable lines key = order.partner_id.property_account_receivable_id.id - invoice_receivables[key] = self._update_amounts(invoice_receivables[key], {'amount': order.amount_total}, order.date_order) + 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'): order_account_move_receivable_lines[move_line.account_id.id] |= move_line @@ -447,6 +451,10 @@ class PosSession(models.Model): stock_expense[exp_key] = self._update_amounts(stock_expense[exp_key], {'amount': amount}, move.picking_id.date) stock_output[out_key] = self._update_amounts(stock_output[out_key], {'amount': amount}, move.picking_id.date) + 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) + 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: @@ -480,6 +488,10 @@ class PosSession(models.Model): '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 @@ -487,6 +499,7 @@ class PosSession(models.Model): + [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 ) ## SECTION: Create cash statement lines and cash move lines @@ -603,6 +616,20 @@ class PosSession(models.Model): 'taxes': taxes, } + 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, diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index b0f75a37a93..a81eb81d235 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -419,6 +419,13 @@ exports.PosModel = Backbone.Model.extend({ return new exports.Product({}, product); })); }, + },{ + model: 'account.cash.rounding', + fields: ['name', 'rounding', 'rounding_method'], + domain: function(self){return [['id', '=', self.config.rounding_method[0]]]; }, + loaded: function(self, cash_rounding) { + self.cash_rounding = cash_rounding; + } },{ model: 'pos.payment.method', fields: ['name', 'is_cash_count', 'use_payment_terminal'], @@ -2446,10 +2453,12 @@ exports.Order = Backbone.Model.extend({ paymentlines: paymentlines, subtotal: this.get_subtotal(), total_with_tax: this.get_total_with_tax(), + total_rounded: this.get_total_with_tax() + this.get_rounding_applied(), total_without_tax: this.get_total_without_tax(), total_tax: this.get_total_tax(), total_paid: this.get_total_paid(), total_discount: this.get_total_discount(), + rounding_applied: this.get_rounding_applied(), tax_details: this.get_tax_details(), change: this.get_change(), name : this.get_name(), @@ -2734,7 +2743,7 @@ exports.Order = Backbone.Model.extend({ this.assert_editable(); var newPaymentline = new exports.Paymentline({},{order: this, payment_method:payment_method, pos: this.pos}); if(!payment_method.is_cash_count || this.pos.config.iface_precompute_cash){ - newPaymentline.set_amount( this.get_due() ); + newPaymentline.set_amount(this.get_due() ); }; this.paymentlines.add(newPaymentline); this.select_paymentline(newPaymentline); @@ -2812,7 +2821,7 @@ exports.Order = Backbone.Model.extend({ } }, /* ---- Payment Status --- */ - get_subtotal : function(){ + get_subtotal: function(){ return round_pr(this.orderlines.reduce((function(sum, orderLine){ return sum + orderLine.get_display_price(); }), 0), this.pos.currency.rounding); @@ -2942,7 +2951,7 @@ exports.Order = Backbone.Model.extend({ }, get_change: function(paymentline) { if (!paymentline) { - var change = this.get_total_paid() - this.get_total_with_tax(); + var change = this.get_total_paid() - this.get_total_with_tax() - this.get_rounding_applied(); } else { var change = -this.get_total_with_tax(); var lines = this.paymentlines.models; @@ -2957,7 +2966,7 @@ exports.Order = Backbone.Model.extend({ }, get_due: function(paymentline) { if (!paymentline) { - var due = this.get_total_with_tax() - this.get_total_paid(); + var due = this.get_total_with_tax() - this.get_total_paid() + this.get_rounding_applied(); } else { var due = this.get_total_with_tax(); var lines = this.paymentlines.models; @@ -2971,6 +2980,35 @@ exports.Order = Backbone.Model.extend({ } return round_pr(due, this.pos.currency.rounding); }, + get_rounding_applied: function() { + if(this.pos.config.cash_rounding) { + var total = round_pr(this.get_total_with_tax(), this.pos.cash_rounding[0].rounding); + + var rounding_applied = total - (this.pos.config['iface_tax_included'] === "total"? this.get_subtotal(): this.get_total_with_tax()); + // because floor and ceil doesn't include decimals in calculation, we reuse the value of the half-up and adapt it. + if(this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied < 0) { + rounding_applied += this.pos.cash_rounding[0].rounding; + } + else if(this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied > 0){ + rounding_applied -= this.pos.cash_rounding[0].rounding; + } + return rounding_applied; + } + return 0; + }, + has_not_valid_rounding: function() { + if(!this.pos.config.cash_rounding) + return false; + + var lines = this.paymentlines.models; + + for(var i = 0; i < lines.length; i++) { + var line = lines[i]; + if(!utils.float_is_zero(line.amount - round_pr(line.amount, this.pos.cash_rounding[0].rounding), 6)) + return line; + } + return false; + }, is_paid: function(){ return this.get_due() <= 0; }, diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index d7982b538ed..4700df3c0f8 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -2093,7 +2093,7 @@ var PaymentScreenWidget = ScreenWidget.extend({ var extradue = this.compute_extradue(order); this.$('.paymentlines-container').empty(); - var lines = $(QWeb.render('PaymentScreen-Paymentlines', { + var lines = $(QWeb.render('PaymentScreen-Paymentlines', { widget: this, order: order, paymentlines: lines, @@ -2302,6 +2302,15 @@ var PaymentScreenWidget = ScreenWidget.extend({ return false; } + if(order.has_not_valid_rounding()) { + var line = order.has_not_valid_rounding(); + this.gui.show_popup('error',{ + title: _t('Incorrect rounding'), + body: _t('You have to round your payments lines.' + line.amount + ' is not rounded.'), + }); + return false; + } + // The exact amount must be paid if there is no cash payment method defined. if (Math.abs(order.get_total_with_tax() - order.get_total_paid()) > 0.00001) { var cash = false; diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml index ad77d69ee05..610eb14f447 100644 --- a/addons/point_of_sale/static/src/xml/pos.xml +++ b/addons/point_of_sale/static/src/xml/pos.xml @@ -658,7 +658,7 @@
- +
Please select a payment method. @@ -678,7 +678,7 @@
Total Due - +
@@ -994,6 +994,12 @@ TOTAL
+ +
+ ROUNDED + +
+


diff --git a/addons/point_of_sale/tests/test_pos_basic_config.py b/addons/point_of_sale/tests/test_pos_basic_config.py index fa2bb6e01b7..77365383caf 100644 --- a/addons/point_of_sale/tests/test_pos_basic_config.py +++ b/addons/point_of_sale/tests/test_pos_basic_config.py @@ -21,6 +21,7 @@ class TestPoSBasicConfig(TestPoSCommon): self.product1 = self.create_product('Product 1', self.categ_basic, 10.0, 5) self.product2 = self.create_product('Product 2', self.categ_basic, 20.0, 10) self.product3 = self.create_product('Product 3', self.categ_basic, 30.0, 15) + self.product4 = self.create_product('Product_4', self.categ_basic, 9.96, 4.98) self.adjust_inventory([self.product1, self.product2, self.product3], [100, 50, 50]) def test_orders_no_invoiced(self): @@ -441,3 +442,79 @@ class TestPoSBasicConfig(TestPoSCommon): for line in cash_receivable_lines: self.assertTrue(line.full_reconcile_id, msg='Each cash receivable line should be fully-reconciled.') + + def test_rounding_method(self): + # set the cash rounding method + self.config.cash_rounding = True + self.config.rounding_method = self.env['account.cash.rounding'].create({ + 'name': 'add_invoice_line', + 'rounding': 0.05, + 'strategy': 'add_invoice_line', + 'profit_account_id': self.company['default_cash_difference_income_account_id'].copy().id, + 'loss_account_id': self.company['default_cash_difference_expense_account_id'].copy().id, + 'rounding_method': 'HALF-UP', + }) + + self.open_new_session() + + """ Test for orders: one with invoice + + 3 orders + - order 1, paid by cash + - order 2, paid by bank + - order 3, paid by bank, invoiced + + Orders + ====== + +---------+----------+---------------+----------+-----+-------+ + | order | payments | invoiced? | product | qty | total | + +---------+----------+---------------+----------+-----+-------+ + | order 1 | bank | no | product1 | 6 | 60 | + | | | | product4 | 4 | 39.84 | + +---------+----------+---------------+----------+-----+-------+ + | order 2 | bank | yes | product4 | 3 | 29.88 | + | | | | product2 | 20 | 400 | + +---------+----------+---------------+----------+-----+-------+ + + Expected Result + =============== + +---------------------+---------+ + | account | balance | + +---------------------+---------+ + | sale | -596,56 | + | pos receivable bank | 516,64 | + | Rounding applied | -0,01 | + +---------------------+---------+ + | Total balance | 0.0 | + +---------------------+---------+ + """ + + # create orders + orders = [] + + # create orders + orders = [] + orders.append(self.create_ui_order_data( + [(self.product4, 3), (self.product2, 20)], + payments=[(self.bank_pm, 429.90)] + )) + + orders.append(self.create_ui_order_data( + [(self.product1, 6), (self.product4, 4)], + payments=[(self.bank_pm, 99.85)] + )) + + # sync orders + order = self.env['pos.order'].create_from_ui(orders) + + self.assertEqual(orders[0]['data']['amount_return'], 0, msg='The amount return should be 0') + self.assertEqual(orders[1]['data']['amount_return'], 0, msg='The amount return should be 0') + + # close the session + self.pos_session.action_pos_session_validate() + + # check values after the session is closed + session_account_move = self.pos_session.move_id + + rounding_line = session_account_move.line_ids.filtered(lambda line: line.name == 'Rounding line') + self.assertAlmostEqual(rounding_line.credit, 0.03, msg='The credit should be equals to 0.03') diff --git a/addons/point_of_sale/views/pos_config_view.xml b/addons/point_of_sale/views/pos_config_view.xml index e87b270f7b0..55b13fb2250 100644 --- a/addons/point_of_sale/views/pos_config_view.xml +++ b/addons/point_of_sale/views/pos_config_view.xml @@ -342,6 +342,23 @@ +
+
+ +
+
+
+
diff --git a/addons/point_of_sale/views/pos_order_view.xml b/addons/point_of_sale/views/pos_order_view.xml index 726d88664c9..79d7b4a79e8 100644 --- a/addons/point_of_sale/views/pos_order_view.xml +++ b/addons/point_of_sale/views/pos_order_view.xml @@ -84,6 +84,11 @@ nolabel="1" class="oe_subtotal_footer_separator" widget="monetary"/> +
@@ -347,4 +352,17 @@ + + + pos.cash.rounding.form.inherited + account.cash.rounding + + + + + + + diff --git a/addons/point_of_sale/views/res_config_settings_views.xml b/addons/point_of_sale/views/res_config_settings_views.xml index 41810f4ded1..8a273ed2f53 100644 --- a/addons/point_of_sale/views/res_config_settings_views.xml +++ b/addons/point_of_sale/views/res_config_settings_views.xml @@ -83,6 +83,25 @@
+

Payments

+
+
+
+ +
+
+
+
+

Payment Terminals