diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index cd08a380aba..df754628195 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -142,6 +142,7 @@ class PosOrder(models.Model): except Exception as e: _logger.error('Could not fully process the POS Order: %s', tools.ustr(e)) pos_order._create_order_picking() + pos_order._compute_total_cost_in_real_time() if pos_order.to_invoice and pos_order.state == 'paid': pos_order.action_pos_order_invoice() @@ -230,6 +231,10 @@ class PosOrder(models.Model): 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) + margin = fields.Monetary(string="Margin", compute='_compute_margin') + margin_percent = fields.Float(string="Margin (%)", compute='_compute_margin', digits=(12, 4)) + is_total_cost_computed = fields.Boolean(compute='_compute_is_total_cost_computed', + help="Allows to know if all the total cost of the order lines have already been computed") 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={ @@ -291,6 +296,45 @@ 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.depends('lines.is_total_cost_computed') + def _compute_is_total_cost_computed(self): + for order in self: + order.is_total_cost_computed = not False in order.lines.mapped('is_total_cost_computed') + + def _compute_total_cost_in_real_time(self): + """ + Compute the total cost of the order when it's processed by the server. It will compute the total cost of all the lines + if it's possible. If a margin of one of the order's lines cannot be computed (because of session_id.update_stock_at_closing), + then the margin of said order is not computed (it will be computed when closing the session). + """ + for order in self: + lines = order.lines + if not order._should_create_picking_real_time(): + storable_fifo_avco_lines = lines.filtered(lambda l: l._is_product_storable_fifo_avco()) + lines -= storable_fifo_avco_lines + stock_moves = order.picking_ids.move_lines + lines._compute_total_cost(stock_moves) + + def _compute_total_cost_at_session_closing(self, stock_moves): + """ + Compute the margin at the end of the session. This method should be called to compute the remaining lines margin + containing a storable product with a fifo/avco cost method and then compute the order margin + """ + for order in self: + storable_fifo_avco_lines = order.lines.filtered(lambda l: l._is_product_storable_fifo_avco()) + storable_fifo_avco_lines._compute_total_cost(stock_moves) + + @api.depends('lines.margin', 'is_total_cost_computed') + def _compute_margin(self): + for order in self: + if order.is_total_cost_computed: + order.margin = sum(order.lines.mapped('margin')) + amount_untaxed = order.currency_id.round(sum(line.price_subtotal for line in order.lines)) + order.margin_percent = not float_is_zero(amount_untaxed, order.currency_id.rounding) and order.margin / amount_untaxed or 0 + else: + order.margin = 0 + order.margin_percent = 0 + @api.onchange('payment_ids', 'lines') def _onchange_amount_all(self): for order in self: @@ -566,12 +610,15 @@ class PosOrder(models.Model): return self.env['pos.order'].search_read(domain = [('id', 'in', order_ids)], fields = ['id', 'pos_reference']) + def _should_create_picking_real_time(self): + return not self.session_id.update_stock_at_closing or (self.company_id.anglo_saxon_accounting and self.to_invoice) + def _create_order_picking(self): self.ensure_one() if self.to_ship: self.lines._launch_stock_rule_from_pos_order_lines() else: - if not self.session_id.update_stock_at_closing or (self.company_id.anglo_saxon_accounting and self.to_invoice): + if self._should_create_picking_real_time(): picking_type = self.config_id.picking_type_id if self.partner_id.property_stock_customer: destination_id = self.partner_id.property_stock_customer.id @@ -600,6 +647,7 @@ class PosOrder(models.Model): 'amount_tax': -self.amount_tax, 'amount_total': -self.amount_total, 'amount_paid': 0, + 'is_total_cost_computed': False } def refund(self): @@ -770,6 +818,10 @@ class PosOrderLine(models.Model): readonly=True, required=True) price_subtotal_incl = fields.Float(string='Subtotal', digits=0, readonly=True, required=True) + margin = fields.Monetary(string="Margin", compute='_compute_margin') + margin_percent = fields.Float(string="Margin (%)", compute='_compute_margin', digits=(12, 4)) + total_cost = fields.Float(string='Total cost', digits='Product Price', readonly=True) + is_total_cost_computed = fields.Boolean(help="Allows to know if the total cost has already been computed or not") 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) @@ -801,6 +853,7 @@ class PosOrderLine(models.Model): 'price_subtotal': -self.price_subtotal, 'price_subtotal_incl': -self.price_subtotal_incl, 'pack_lot_ids': PosOrderLineLot, + 'is_total_cost_computed': False } @api.model @@ -952,6 +1005,36 @@ class PosOrderLine(models.Model): pickings_to_confirm.action_confirm() return True + def _is_product_storable_fifo_avco(self): + self.ensure_one() + return self.product_id.type == 'product' and self.product_id.cost_method in ['fifo', 'average'] + + def _compute_total_cost(self, stock_moves): + """ + Compute the total cost of the order lines. + :param stock_moves: recordset of `stock.move`, used for fifo/avco lines + """ + for line in self.filtered(lambda l: not l.is_total_cost_computed): + product = line.product_id + if line._is_product_storable_fifo_avco() and stock_moves: + product_cost = product._compute_average_price(0, line.qty, stock_moves.filtered(lambda ml: ml.product_id == product)) + else: + product_cost = product.standard_price + line.total_cost = line.qty * product.cost_currency_id._convert( + from_amount=product_cost, + to_currency=line.currency_id, + company=line.company_id or self.env.company, + date=line.order_id.date_order or fields.Date.today(), + round=False, + ) + line.is_total_cost_computed = True + + @api.depends('price_subtotal', 'total_cost') + def _compute_margin(self): + for line in self: + line.margin = line.price_subtotal - line.total_cost + line.margin_percent = not float_is_zero(line.price_subtotal, line.currency_id.rounding) and line.margin / line.price_subtotal or 0 + class PosOrderLineLot(models.Model): _name = "pos.pack.operation.lot" diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index 02de2bc4a36..316fa5a864a 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -319,6 +319,7 @@ class PosSession(models.Model): self._check_if_no_draft_orders() if self.update_stock_at_closing: self._create_picking_at_end_of_session() + self.order_ids.filtered(lambda o: not o.is_total_cost_computed)._compute_total_cost_at_session_closing(self.picking_ids.move_lines) try: data = self.with_company(self.company_id)._create_account_move(balancing_account, amount_to_balance) except AccessError as e: diff --git a/addons/point_of_sale/report/pos_order_report.py b/addons/point_of_sale/report/pos_order_report.py index 1ad50a021ec..c0b483de991 100644 --- a/addons/point_of_sale/report/pos_order_report.py +++ b/addons/point_of_sale/report/pos_order_report.py @@ -35,6 +35,7 @@ class PosOrderReport(models.Model): pos_categ_id = fields.Many2one('pos.category', string='PoS Category', readonly=True) pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', readonly=True) session_id = fields.Many2one('pos.session', string='Session', readonly=True) + margin = fields.Float(string='Margin', readonly=True) def _select(self): return """ @@ -64,7 +65,8 @@ class PosOrderReport(models.Model): pt.pos_categ_id, s.pricelist_id, s.session_id, - s.account_move IS NOT NULL AS invoiced + s.account_move IS NOT NULL AS invoiced, + SUM(l.price_subtotal - l.total_cost / CASE COALESCE(s.currency_rate, 0) WHEN 0 THEN 1.0 ELSE s.currency_rate END) AS margin """ def _from(self): diff --git a/addons/point_of_sale/tests/__init__.py b/addons/point_of_sale/tests/__init__.py index d108782cf17..54ccc6091ce 100644 --- a/addons/point_of_sale/tests/__init__.py +++ b/addons/point_of_sale/tests/__init__.py @@ -9,6 +9,7 @@ from . import test_point_of_sale from . import test_pos_setup from . import test_pos_basic_config from . import test_pos_products_with_tax +from . import test_pos_margin from . import test_pos_multiple_sale_accounts from . import test_pos_multiple_receivable_accounts from . import test_pos_other_currency_config diff --git a/addons/point_of_sale/tests/test_pos_margin.py b/addons/point_of_sale/tests/test_pos_margin.py new file mode 100644 index 00000000000..db838600695 --- /dev/null +++ b/addons/point_of_sale/tests/test_pos_margin.py @@ -0,0 +1,398 @@ +# -*- coding: utf-8 -*- + +import odoo +from odoo.addons.point_of_sale.tests.common import TestPoSCommon + + +@odoo.tests.tagged('post_install', '-at_install') +class TestPosMargin(TestPoSCommon): + """ + Test the margin computation on orders with basic configuration + The tests contain the base scenarios. + """ + + def setUp(self): + super(TestPosMargin, self).setUp() + self.config = self.basic_config + + self.stock_location = self.env['stock.warehouse'].create({ + 'partner_id': self.env.user.partner_id.id, + 'name': 'Stock location', + 'code': 'WH' + }).lot_stock_id + self.customer_location = self.env.ref('stock.stock_location_customers') + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.uom_unit = self.env.ref('uom.product_uom_unit') + + + def test_positive_margin(self): + """ + Test margin where it should be more than zero + """ + + product1 = self.create_product('Product 1', self.categ_basic, 10, 5) + product2 = self.create_product('Product 2', self.categ_basic, 50, 30) + + # open a session + self.open_new_session() + + # create orders + orders = [self.create_ui_order_data([(product1, 1)]), + self.create_ui_order_data([(product2, 1)]), + self.create_ui_order_data([(product1, 2), (product2, 2)])] + + # sync orders + self.env['pos.order'].create_from_ui(orders) + + # check margins + self.assertEqual(self.pos_session.order_ids[0].margin, 5) + self.assertEqual(self.pos_session.order_ids[1].margin, 20) + self.assertEqual(self.pos_session.order_ids[2].margin, 50) + + # check margins percent + self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.5) + self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.4) + self.assertEqual(round(self.pos_session.order_ids[2].margin_percent, 2), 0.42) + + # close session + self.pos_session.action_pos_session_validate() + + def test_negative_margin(self): + """ + Test margin where it should be less than zero + """ + + product1 = self.create_product('Product 1', self.categ_basic, 10, 15) + product2 = self.create_product('Product 2', self.categ_basic, 50, 100) + + # open a session + self.open_new_session() + + # create orders + orders = [self.create_ui_order_data([(product1, 1)]), + self.create_ui_order_data([(product2, 1)]), + self.create_ui_order_data([(product1, 2), (product2, 2)])] + + # sync orders + self.env['pos.order'].create_from_ui(orders) + + # check margins + self.assertEqual(self.pos_session.order_ids[0].margin, -5) + self.assertEqual(self.pos_session.order_ids[1].margin, -50) + self.assertEqual(self.pos_session.order_ids[2].margin, -110) + + # check margins percent + self.assertEqual(self.pos_session.order_ids[0].margin_percent, -0.5) + self.assertEqual(self.pos_session.order_ids[1].margin_percent, -1) + self.assertEqual(round(self.pos_session.order_ids[2].margin_percent, 2), -0.92) + + # close session + self.pos_session.action_pos_session_validate() + + def test_full_margin(self): + """ + Test margin where the product cost is always 0 + """ + + product1 = self.create_product('Product 1', self.categ_basic, 10) + product2 = self.create_product('Product 2', self.categ_basic, 50) + + # open a session + self.open_new_session() + + # create orders + orders = [self.create_ui_order_data([(product1, 1)]), + self.create_ui_order_data([(product2, 1)]), + self.create_ui_order_data([(product1, 2), (product2, 2)])] + + # sync orders + self.env['pos.order'].create_from_ui(orders) + + # check margins + self.assertEqual(self.pos_session.order_ids[0].margin, 10) + self.assertEqual(self.pos_session.order_ids[1].margin, 50) + self.assertEqual(self.pos_session.order_ids[2].margin, 120) + + # check margins percent + self.assertEqual(self.pos_session.order_ids[0].margin_percent, 1) + self.assertEqual(self.pos_session.order_ids[1].margin_percent, 1) + self.assertEqual(self.pos_session.order_ids[2].margin_percent, 1) + + # close session + self.pos_session.action_pos_session_validate() + + def test_tax_margin(self): + """ + Test margin with tax on products + Product 1 price without tax = 10 + Product 2 price without tax = 50 + """ + + product1 = self.create_product('Product 1', self.categ_basic, 10, 5, self.taxes['tax7'].ids) + product2 = self.create_product('Product 2', self.categ_basic, 55, 30, self.taxes['tax10'].ids) + + # open a session + self.open_new_session() + + # create orders + orders = [self.create_ui_order_data([(product1, 1)]), + self.create_ui_order_data([(product2, 1)]), + self.create_ui_order_data([(product1, 2), (product2, 2)])] + + # sync orders + self.env['pos.order'].create_from_ui(orders) + + # check margins + self.assertEqual(self.pos_session.order_ids[0].margin, 5) + self.assertEqual(self.pos_session.order_ids[1].margin, 20) + self.assertEqual(self.pos_session.order_ids[2].margin, 50) + + # check margins percent + self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.5) + self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.4) + self.assertEqual(round(self.pos_session.order_ids[2].margin_percent, 2), 0.42) + + # close session + self.pos_session.action_pos_session_validate() + + def test_other_currency_margin(self): + """ + Test margin with tax on products and with different currency + The currency rate is 0.5 so the product price is halved in this currency. + """ + + # change the config + current_config = self.config + self.config = self.other_currency_config + + # same parameters as test_positive_margin + product1 = self.create_product('Product 1', self.categ_basic, 10, 5) + product2 = self.create_product('Product 2', self.categ_basic, 50, 30) + + # open a session + self.open_new_session() + + # create orders + orders = [self.create_ui_order_data([(product1, 1)]), + self.create_ui_order_data([(product2, 1)]), + self.create_ui_order_data([(product1, 2), (product2, 2)])] + + # sync orders + self.env['pos.order'].create_from_ui(orders) + + # check margins in the config currency + self.assertEqual(self.pos_session.order_ids[0].margin, 2.5) + self.assertEqual(self.pos_session.order_ids[1].margin, 10) + self.assertEqual(self.pos_session.order_ids[2].margin, 25) + + # check margins percent which should be the same as test_positive_margin + self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.5) + self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.4) + self.assertEqual(round(self.pos_session.order_ids[2].margin_percent, 2), 0.42) + + # close session + self.pos_session.action_pos_session_validate() + + # set the config back + self.config = current_config + + def test_tax_and_other_currency_margin(self): + """ + Test margin with different currency between products and config with taxes. + Product 1 price without tax = 10 + Product 2 price without tax = 50 + The currency rate is 0.5 so the product price is halved in this currency. + """ + + # change the config + current_config = self.config + self.config = self.other_currency_config + + product1 = self.create_product('Product 1', self.categ_basic, 10, 5, self.taxes['tax7'].ids) + product2 = self.create_product('Product 2', self.categ_basic, 55, 30, self.taxes['tax10'].ids) + + # open a session + self.open_new_session() + + # create orders + orders = [self.create_ui_order_data([(product1, 1)]), + self.create_ui_order_data([(product2, 1)]), + self.create_ui_order_data([(product1, 2), (product2, 2)])] + + # sync orders + self.env['pos.order'].create_from_ui(orders) + + # check margins in the config currency + self.assertEqual(self.pos_session.order_ids[0].margin, 2.5) + self.assertEqual(self.pos_session.order_ids[1].margin, 10) + self.assertEqual(self.pos_session.order_ids[2].margin, 25) + + # check margins percent which should be the same as test_tax_margin + self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.5) + self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.4) + self.assertEqual(self.pos_session.order_ids[2].margin_percent, 0.4167) + + # close session + self.pos_session.action_pos_session_validate() + + # set the config back + self.config = current_config + + def test_return_margin(self): + """ + Test margin where we return product (negative line quantity) + """ + + product1 = self.create_product('Product 1', self.categ_basic, 10, 5) + product2 = self.create_product('Product 2', self.categ_basic, 50, 30) + + # open a session + self.open_new_session() + + # create orders + orders = [self.create_ui_order_data([(product1, -1)]), + self.create_ui_order_data([(product2, -1)]), + self.create_ui_order_data([(product1, -2), (product2, -2)])] + + # sync orders + self.env['pos.order'].create_from_ui(orders) + + # check margins + self.assertEqual(self.pos_session.order_ids[0].margin, -5) + self.assertEqual(self.pos_session.order_ids[1].margin, -20) + self.assertEqual(self.pos_session.order_ids[2].margin, -50) + + # check margins percent + self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.5) + self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.4) + self.assertEqual(round(self.pos_session.order_ids[2].margin_percent, 2), 0.42) + + # close session + self.pos_session.action_pos_session_validate() + + def test_fifo_margin_real_time(self): + """ + Test margin where there is product in FIFO with stock update in real time + """ + + product1 = self.create_product('Product 1', self.categ_anglo, 10, 5) + product2 = self.create_product('Product 2', self.categ_basic, 50, 30) + + move1 = self.env['stock.move'].create({ + 'name': 'IN 2 unit @ 3 per unit', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + 'product_id': product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 2, + 'price_unit': 3, + }).sudo() + move1._action_confirm() + move1._action_assign() + move1.move_line_ids.qty_done = 2 + move1._action_done() + + move2 = self.env['stock.move'].create({ + 'name': 'IN 1 unit @ 7 per unit', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + 'product_id': product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1, + 'price_unit': 7, + }).sudo() + move2._action_confirm() + move2._action_assign() + move2.move_line_ids.qty_done = 1 + move2._action_done() + + # open a session + self.open_new_session() + + # create orders + orders = [self.create_ui_order_data([(product1, 1), (product2, 1)]), + self.create_ui_order_data([(product1, 2)])] + + # sync orders + self.env['pos.order'].create_from_ui(orders) + + # check margins + self.assertEqual(self.pos_session.order_ids[0].margin, 27) + self.assertEqual(self.pos_session.order_ids[1].margin, 10) + + # check margins percent + self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.45) + self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.5) + + # close session + self.pos_session.action_pos_session_validate() + + def test_avco_margin_closing_time(self): + """ + Test margin where there is product in AVCO with stock update in closing + """ + + self.categ_anglo.property_cost_method = 'average' + product1 = self.create_product('Product 1', self.categ_anglo, 10, 5) + product2 = self.create_product('Product 2', self.categ_basic, 50, 30) + self.env.company.point_of_sale_update_stock_quantities = 'closing' + + + move1 = self.env['stock.move'].create({ + 'name': 'IN 2 unit @ 3 per unit', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + 'product_id': product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 2, + 'price_unit': 3, + }).sudo() + move1._action_confirm() + move1._action_assign() + move1.move_line_ids.qty_done = 2 + move1._action_done() + + move2 = self.env['stock.move'].create({ + 'name': 'IN 1 unit @ 6 per unit', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + 'product_id': product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1, + 'price_unit': 6, + }).sudo() + move2._action_confirm() + move2._action_assign() + move2.move_line_ids.qty_done = 1 + move2._action_done() + + # open a session + self.open_new_session() + + # create orders + orders = [self.create_ui_order_data([(product1, 1), (product2, 1)]), + self.create_ui_order_data([(product1, 2)])] + + # sync orders + self.env['pos.order'].create_from_ui(orders) + + # check margins which are not really computed so it should be 0 + self.assertEqual(self.pos_session.order_ids[0].margin, 0) + self.assertEqual(self.pos_session.order_ids[1].margin, 0) + + # check margins percent (same as above) + self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0) + self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0) + + # close session + self.pos_session.action_pos_session_validate() + + # check margins + self.assertEqual(self.pos_session.order_ids[0].margin, 26) + self.assertEqual(self.pos_session.order_ids[1].margin, 12) + + # check margins percent + self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.4333) + self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.6) + + self.env.company.point_of_sale_update_stock_quantities = 'real' diff --git a/addons/point_of_sale/views/pos_order_view.xml b/addons/point_of_sale/views/pos_order_view.xml index 38b34dece8b..e7e49e3078c 100644 --- a/addons/point_of_sale/views/pos_order_view.xml +++ b/addons/point_of_sale/views/pos_order_view.xml @@ -51,6 +51,10 @@ + + + + @@ -93,6 +97,15 @@ class="oe_subtotal_footer_separator" widget="monetary" attrs="{'invisible': [('amount_paid','=', 'amount_total')]}"/> +