From 6bcf9ac2ee3a52c9f0977d8cb4f4bf2cefe4ad70 Mon Sep 17 00:00:00 2001 From: Simon Lejeune Date: Tue, 27 Feb 2018 14:57:18 +0100 Subject: [PATCH] [FIX] stock_account, sale_stock, stock_dropshipping: dropshipping valuation Following the previous fixes in stock where we had to handle non-valued move lines inside a valued move, the valuation of the dropshipping was bypassed because a dropshipping move is not an in move, nor an out move. We now value the dropshipping move always positively. We adapt the sale_stock code that generate the anglo saxon entry to use the abs value of the move, not -1 * value. It was done this way because all out moves are negative. We add 8 tests to test all the valuation cases: continental or anglo saxon, ordered or delivered, fifo or standard. opw-1817363 fixes #23214 --- addons/sale_stock/models/account_invoice.py | 4 +- addons/stock_account/models/stock.py | 27 +- addons/stock_dropshipping/tests/__init__.py | 4 + .../tests/test_stockvaluation.py | 268 ++++++++++++++++++ 4 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 addons/stock_dropshipping/tests/__init__.py create mode 100644 addons/stock_dropshipping/tests/test_stockvaluation.py diff --git a/addons/sale_stock/models/account_invoice.py b/addons/sale_stock/models/account_invoice.py index 4b9212beb8f..3b4c60cc413 100644 --- a/addons/sale_stock/models/account_invoice.py +++ b/addons/sale_stock/models/account_invoice.py @@ -51,7 +51,9 @@ class AccountInvoiceLine(models.Model): qty_to_consider = invoiced_qty - qty_done qty_to_consider = min(qty_to_consider, quantity - qty_delivered) qty_delivered += qty_to_consider - average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + (-1 * move.price_unit) * qty_to_consider) / qty_delivered + # `move.price_unit` is negative if the move is out and positive if the move is + # dropshipped. Use its absolute value to compute the average price unit. + average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + abs(move.price_unit) * qty_to_consider) / qty_delivered if qty_delivered == quantity: break return average_price_unit diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index b1e5f2b931d..a680bd4d8c1 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -200,6 +200,14 @@ class StockMove(models.Model): return True return False + def _is_dropshipped(self): + """ Check if the move should be considered as a dropshipping move so that the cost method + will be able to apply the correct logic. + + :return: True if the move is a dropshipping one else False + """ + return self.location_id.usage == 'supplier' and self.location_dest_id.usage == 'customer' + @api.model def _run_fifo(self, move, quantity=None): move.ensure_one() @@ -298,6 +306,21 @@ class StockMove(models.Model): 'value': value if quantity is None else self.value + value, 'price_unit': value / valued_quantity, }) + elif self._is_dropshipped(): + curr_rounding = self.company_id.currency_id.rounding + if self.product_id.cost_method in ['fifo']: + price_unit = self._get_price_unit() + # see test_dropship_fifo_perpetual_anglosaxon_ordered + self.product_id.standard_price = price_unit + else: + price_unit = self.product_id.standard_price + value = float_round(self.product_qty * price_unit, precision_rounding=curr_rounding) + # In move have a positive value, out move have a negative value, let's arbitrary say + # dropship are positive. + self.write({ + 'value': value, + 'price_unit': price_unit, + }) def _action_done(self): self.product_price_update_before_done() @@ -319,7 +342,7 @@ class StockMove(models.Model): if company_src and company_dst and company_src.id != company_dst.id: raise UserError(_("The move lines are not in a consistent states: they are doing an intercompany in a single step while they should go through the intercompany transit location.")) move._run_valuation() - for move in res.filtered(lambda m: m.product_id.valuation == 'real_time' and (m._is_in() or m._is_out())): + for move in res.filtered(lambda m: m.product_id.valuation == 'real_time' and (m._is_in() or m._is_out() or m._is_dropshipped())): move._account_entry_move() return res @@ -589,7 +612,7 @@ class StockMove(models.Model): else: self.with_context(force_company=company_from.id)._create_account_move_line(acc_valuation, acc_dest, journal_id) - if self.company_id.anglo_saxon_accounting and self.location_id.usage == 'supplier' and self.location_dest_id.usage == 'customer': + if self.company_id.anglo_saxon_accounting and self._is_dropshipped(): # Creates an account entry from stock_input to stock_output on a dropship move. https://github.com/odoo/odoo/issues/12687 journal_id, acc_src, acc_dest, acc_valuation = self._get_accounting_data_for_valuation() self.with_context(force_company=self.company_id.id)._create_account_move_line(acc_src, acc_dest, journal_id) diff --git a/addons/stock_dropshipping/tests/__init__.py b/addons/stock_dropshipping/tests/__init__.py new file mode 100644 index 00000000000..4df849a1468 --- /dev/null +++ b/addons/stock_dropshipping/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import test_stockvaluation + diff --git a/addons/stock_dropshipping/tests/test_stockvaluation.py b/addons/stock_dropshipping/tests/test_stockvaluation.py new file mode 100644 index 00000000000..2eb1ced2756 --- /dev/null +++ b/addons/stock_dropshipping/tests/test_stockvaluation.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.account.tests.account_test_classes import AccountingTestCase + + +class TestStockValuation(AccountingTestCase): + def setUp(self): + super(TestStockValuation, self).setUp() + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.stock_location = self.env.ref('stock.stock_location_stock') + self.partner_id = self.env.ref('base.res_partner_1') + self.product1 = self.env.ref('product.product_product_8') + + self.acc_payable = self.env['account.account'].search([('name', '=', 'Account Payable')]).id + self.acc_expense = self.env['account.account'].search([('name', '=', 'Expenses')]).id + self.acc_receivable = self.env['account.account'].search([('name', '=', 'Account Receivable')]).id + self.acc_sale = self.env['account.account'].search([('name', '=', 'Product Sales')]).id + self.acc_stock_in = self.env['account.account'].search([('name', '=', 'Stock Interim Account (Received)')]).id + self.acc_stock_out = self.env['account.account'].search([('name', '=', 'Stock Interim Account (Delivered)')]).id + + def _dropship_product1(self): + # enable the dropship and MTO route on the product + dropshipping_route = self.env.ref('stock_dropshipping.route_drop_shipping') + mto_route = self.env.ref('stock.route_warehouse0_mto') + self.product1.write({'route_ids': [(6, 0, [dropshipping_route.id, mto_route.id])]}) + + # add a vendor + vendor1 = self.env['res.partner'].create({'name': 'vendor1'}) + seller1 = self.env['product.supplierinfo'].create({ + 'name': vendor1.id, + 'price': 8, + }) + self.product1.write({'seller_ids': [(6, 0, [seller1.id])]}) + + # sell one unit of this product + customer1 = self.env['res.partner'].create({'name': 'customer1'}) + sale_order1 = self.env['sale.order'].create({ + 'partner_id': customer1.id, + 'partner_invoice_id': customer1.id, + 'partner_shipping_id': customer1.id, + 'order_line': [(0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_uom_qty': 1, + 'product_uom': self.product1.uom_id.id, + 'price_unit': 12, + })], + 'pricelist_id': self.env.ref('product.list0').id, + 'picking_policy': 'direct', + }) + sale_order1.action_confirm() + + # confirm the purchase order + purchase_order1 = self.env['purchase.order'].search([('group_id', '=', sale_order1.procurement_group_id.id)]) + purchase_order1.button_confirm() + + # validate the dropshipping picking + self.assertEqual(len(sale_order1.picking_ids), 1) + self.assertEqual(sale_order1.picking_ids.move_lines._is_dropshipped(), True) + wizard = sale_order1.picking_ids.button_validate() + immediate_transfer = self.env[wizard['res_model']].browse(wizard['res_id']) + immediate_transfer.process() + self.assertEqual(sale_order1.picking_ids.state, 'done') + + # create the vendor bill + vendor_bill1 = self.env['account.invoice'].create({ + 'partner_id': vendor1.id, + 'purchase_id': purchase_order1.id, + 'account_id': vendor1.property_account_payable_id.id, + 'type': 'in_invoice', + }) + vendor_bill1.purchase_order_change() + vendor_bill1.action_invoice_open() + + # create the customer invoice + customer_invoice1_id = sale_order1.action_invoice_create() + customer_invoice1 = self.env['account.invoice'].browse(customer_invoice1_id) + customer_invoice1.action_invoice_open() + + all_amls = vendor_bill1.move_id.line_ids + customer_invoice1.move_id.line_ids + if sale_order1.picking_ids.move_lines.account_move_ids: + all_amls |= sale_order1.picking_ids.move_lines.account_move_ids.line_ids + return all_amls + + def _check_results(self, expected_aml, expected_aml_count, all_amls): + # Construct a dict similar to `expected_aml` with `all_amls` in order to + # compare them. + result_aml = {} + for aml in all_amls: + account_id = aml.account_id.id + if result_aml.get(account_id): + debit = result_aml[account_id][0] + credit = result_aml[account_id][1] + result_aml[account_id] = (debit + aml.debit, credit + aml.credit) + else: + result_aml[account_id] = (aml.debit, aml.credit) + + self.assertEqual(len(all_amls), expected_aml_count) + + for k, v in expected_aml.items(): + self.assertEqual(result_aml[k], v) + + # ------------------------------------------------------------------------- + # Continental + # ------------------------------------------------------------------------- + def test_dropship_standard_perpetual_continental_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = False + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + def test_dropship_standard_perpetual_continental_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = False + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + def test_dropship_fifo_perpetual_continental_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = False + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + def test_dropship_fifo_perpetual_continental_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = False + + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + # ------------------------------------------------------------------------- + # Anglosaxon + # ------------------------------------------------------------------------- + def test_dropship_standard_perpetual_anglosaxon_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (10.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 10.0), + self.acc_stock_out: (10.0, 10.0), + } + # Interim IN is not balanced because because there's a difference between the po line + # price unit and the standard price. We could set a price difference account on the + # category to compensate. + + self._check_results(expected_aml, 8, all_amls) + + def test_dropship_standard_perpetual_anglosaxon_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (10.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 10.0), + self.acc_stock_out: (10.0, 10.0), + } + # Interim IN is not balanced because because there's a difference between the po line + # price unit and the standard price. We could set a price difference account on the + # category to compensate. + + self._check_results(expected_aml, 8, all_amls) + + def test_dropship_fifo_perpetual_anglosaxon_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 8.0), + self.acc_stock_out: (8.0, 8.0), + } + + self._check_results(expected_aml, 8, all_amls) + + def test_dropship_fifo_perpetual_anglosaxon_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 8.0), + self.acc_stock_out: (8.0, 8.0), + } + + self._check_results(expected_aml, 8, all_amls) +