From 48f6cb4597ff8319761e5d4cab61b6f856cfa4ec Mon Sep 17 00:00:00 2001 From: "Adrien Widart (awt)" Date: Thu, 8 Dec 2022 10:03:05 +0000 Subject: [PATCH] [FIX] purchase_stock,stock_account: balance Stock Interim (Received) In auto-avco, if a user delivers a products before billing its receipt, the account Stock Interim (Received) will be unbalanced To reproduce the issue: (Need account_accountant, sale_management) 1. Create a product category PC: - Costing Method: FIFO - Inventory Valuation: Automated 2. Create a product P - Type: Storable - Category: PC 3. Create and confirm a PO with - 1 x P at $50 4. Process the receipt R 5. Create and confirm a SO with - 1 X P 6. Process the delivery 7. Create the bill B related to PO 8. Set the bill line unit price to $60 9. Confirm 10. Accounting > Accounting > Journal Items - Group By: Account Error: The account 'Stock Interim (Received)' is not balanced (Debit: 60, Credit: 50) The account entries are: - Credit 50 related to R - Debit 60 related to B There isn't any line for the price difference. Since [1], we don't generate any price difference layer for the already-delivered quantities. Before this commit, a layer was generated and posted on account-side: https://github.com/odoo/odoo/blob/a5b985e8449a1e858c3fb9a0a90a6d203e16f739/addons/stock_account/models/account_move.py#L68-L69 But, as explained in [1], we should generate the price diff layers only for the remaining quantities, otherwise it would lead to some errors on both stock and account side. That being said, we still have something to do with the price diff related to the out quantities. This is what this commit is about. In such case, we generate some journal entries: - we credit the account 'Stock Interim (Received)' with the price diff (the account is then balanced) - we debit the account 'Expenses' To do so, we: - Bring back the method `_stock_account_prepare_anglo_saxon_in_lines_vals` from [2] - Fix that method because both fields `analytic_account_id` and `analytic_tag_ids` do not exist anymore [3] - Update it so it behaves as described above Note 01: in the steps, we have removed the case 'Receive, Return and Receive again'. Such a flow breaks the whole accounting and would not work with some other features. Therefore, we consider such a flow as invalid (the user could rather create a new purchase order to receive the quantities again) Note 02: This behaviour is not supposed to work with the kits. This commit prevents the lines to be posted in such situation. Suppose the flow is in a "standard" order (PO, Bill, SO), we also do not create layers for the price difference: [1] 18912b05239e6fc5dab26ec4c930f9080882d127 [2] 35d6c58f863e9418511c905674780a7220cebd1f [3] odoo/enterprise@a1eaf200d0ca8b67df38413fe76689d4fcda9bc2 OPW-3040171 OPW-3071238 X-original-commit: 84733fb89faffe2480def97f076633056e16572f Part-of: odoo/odoo#107908 --- .../purchase_stock/models/account_invoice.py | 136 +++++++++++++++++- .../models/account_move_line.py | 14 ++ .../tests/test_stockvaluation.py | 103 +++++++------ 3 files changed, 207 insertions(+), 46 deletions(-) diff --git a/addons/purchase_stock/models/account_invoice.py b/addons/purchase_stock/models/account_invoice.py index 4768a3d9012..d8b135005ac 100644 --- a/addons/purchase_stock/models/account_invoice.py +++ b/addons/purchase_stock/models/account_invoice.py @@ -1,12 +1,146 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import models +from odoo import fields, models +from odoo.tools import float_compare class AccountMove(models.Model): _inherit = 'account.move' + def _stock_account_prepare_anglo_saxon_in_lines_vals(self): + ''' Prepare values used to create the journal items (account.move.line) corresponding to the price difference + lines for vendor bills. It only concerns the quantities that have been delivered before the bill + Example: + Buy a product having a cost of 9 and a supplier price of 10 and being a storable product and having a perpetual + valuation in FIFO. Deliver the product and then post the bill. The vendor bill's journal entries looks like: + Account | Debit | Credit + --------------------------------------------------------------- + 101120 Stock Interim Account (Received) | 10.0 | + --------------------------------------------------------------- + 101100 Account Payable | | 10.0 + --------------------------------------------------------------- + This method computes values used to make two additional journal items: + --------------------------------------------------------------- + 101120 Stock Interim Account (Received) | | 1.0 + --------------------------------------------------------------- + xxxxxx Expenses | 1.0 | + --------------------------------------------------------------- + :return: A list of Python dictionary to be passed to env['account.move.line'].create. + ''' + lines_vals_list = [] + price_unit_prec = self.env['decimal.precision'].precision_get('Product Price') + + for move in self: + if move.move_type not in ('in_invoice', 'in_refund', 'in_receipt') or not move.company_id.anglo_saxon_accounting: + continue + + move = move.with_company(move.company_id) + for line in move.invoice_line_ids: + # Filter out lines being not eligible for price difference. + if line.product_id.type != 'product' or line.product_id.valuation != 'real_time': + continue + + # Retrieve accounts needed to generate the price difference. + accounts = line.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=move.fiscal_position_id) + debit_expense_account = accounts['expense'] + if not debit_expense_account: + continue + + if line.product_id.cost_method != 'standard' and line.purchase_line_id: + + # Retrieve stock valuation moves. + valuation_stock_moves = self.env['stock.move'].search([ + ('purchase_line_id', '=', line.purchase_line_id.id), + ('state', '=', 'done'), + ('product_qty', '!=', 0.0), + ('product_id', '=', line.product_id.id), # kits must be handled manually + ]) + + if move.move_type == 'in_refund': + valuation_stock_moves = valuation_stock_moves.filtered(lambda stock_move: stock_move._is_out()) + else: + valuation_stock_moves = valuation_stock_moves.filtered(lambda stock_move: stock_move._is_in()) + + if not valuation_stock_moves: + continue + + valuation_price_unit_total, valuation_total_qty = valuation_stock_moves._get_valuation_price_and_qty(line, move.currency_id) + valuation_price_unit = valuation_price_unit_total / valuation_total_qty + valuation_price_unit = line.product_id.uom_id._compute_price(valuation_price_unit, line.product_uom_id) + + else: + continue + + + price_unit = line._get_gross_unit_price() + + price_unit_val_dif = price_unit - valuation_price_unit + # If there are some valued moves, we only consider their quantity already used + relevant_qty = line._get_out_and_not_invoiced_qty(valuation_stock_moves) + price_subtotal = relevant_qty * price_unit_val_dif + + # We consider there is a price difference if the subtotal is not zero. In case a + # discount has been applied, we can't round the price unit anymore, and hence we + # can't compare them. + if ( + not move.currency_id.is_zero(price_subtotal) + and float_compare(line["price_unit"], line.price_unit, precision_digits=price_unit_prec) == 0 + ): + + # Add price difference account line. + vals = { + 'name': line.name[:64], + 'move_id': move.id, + 'partner_id': line.partner_id.id or move.commercial_partner_id.id, + 'currency_id': line.currency_id.id, + 'product_id': line.product_id.id, + 'product_uom_id': line.product_uom_id.id, + 'quantity': relevant_qty, + 'price_unit': price_unit_val_dif, + 'price_subtotal': relevant_qty * price_unit_val_dif, + 'amount_currency': relevant_qty * price_unit_val_dif * line.move_id.direction_sign, + 'balance': line.currency_id._convert( + relevant_qty * price_unit_val_dif * line.move_id.direction_sign, + line.company_currency_id, + line.company_id, fields.Date.today(), + ), + 'account_id': debit_expense_account.id, + 'analytic_distribution': line.analytic_distribution, + 'display_type': 'cogs', + } + lines_vals_list.append(vals) + + # Correct the amount of the current line. + vals = { + 'name': line.name[:64], + 'move_id': move.id, + 'partner_id': line.partner_id.id or move.commercial_partner_id.id, + 'currency_id': line.currency_id.id, + 'product_id': line.product_id.id, + 'product_uom_id': line.product_uom_id.id, + 'quantity': relevant_qty, + 'price_unit': -price_unit_val_dif, + 'price_subtotal': relevant_qty * -price_unit_val_dif, + 'amount_currency': relevant_qty * -price_unit_val_dif * line.move_id.direction_sign, + 'balance': line.currency_id._convert( + relevant_qty * -price_unit_val_dif * line.move_id.direction_sign, + line.company_currency_id, + line.company_id, fields.Date.today(), + ), + 'account_id': line.account_id.id, + 'analytic_distribution': line.analytic_distribution, + 'display_type': 'cogs', + } + lines_vals_list.append(vals) + return lines_vals_list + + def _post(self, soft=True): + if self._context.get('move_reverse_cancel'): + return super()._post(soft) + self.env['account.move.line'].create(self._stock_account_prepare_anglo_saxon_in_lines_vals()) + return super()._post(soft) + def _stock_account_get_last_step_stock_moves(self): """ Overridden from stock_account. Returns the stock moves associated to this invoice.""" diff --git a/addons/purchase_stock/models/account_move_line.py b/addons/purchase_stock/models/account_move_line.py index 2dcc6fe8607..96064adb5bc 100644 --- a/addons/purchase_stock/models/account_move_line.py +++ b/addons/purchase_stock/models/account_move_line.py @@ -11,3 +11,17 @@ class AccountMoveLine(models.Model): self.ensure_one() return self.purchase_line_id.move_ids.filtered( lambda m: m.state == 'done' and m.product_qty != 0) + + def _get_out_and_not_invoiced_qty(self, in_moves): + self.ensure_one() + if not in_moves: + return 0 + aml_qty = self.product_uom_id._compute_quantity(self.quantity, self.product_id.uom_id) + invoiced_qty = sum(line.product_uom_id._compute_quantity(line.quantity, line.product_id.uom_id) + for line in self.purchase_line_id.invoice_lines - self) + layers = in_moves.stock_valuation_layer_ids + layers_qty = sum(layers.mapped('quantity')) + out_qty = layers_qty - sum(layers.mapped('remaining_qty')) + total_out_and_not_invoiced_qty = max(0, out_qty - invoiced_qty) + out_and_not_invoiced_qty = min(aml_qty, total_out_and_not_invoiced_qty) + return self.product_id.uom_id._compute_quantity(out_and_not_invoiced_qty, self.product_uom_id) diff --git a/addons/purchase_stock/tests/test_stockvaluation.py b/addons/purchase_stock/tests/test_stockvaluation.py index 64f9591ae69..60c13be78b0 100644 --- a/addons/purchase_stock/tests/test_stockvaluation.py +++ b/addons/purchase_stock/tests/test_stockvaluation.py @@ -1282,24 +1282,25 @@ class TestStockValuationWithCOA(AccountTestInvoicingCommon): stock_val_aml = invoice.line_ids.filtered(lambda l: l.account_id == self.stock_valuation_account) self.assertEqual(len(stock_val_aml), 0, "No line should have been generated in the stock valuation account.") - def test_price_diff_with_return_backorder_and_partial_bill(self): + def test_price_diff_with_partial_bills_and_delivered_qties(self): """ Fifo + Real time. Default UoM of the product is Unit. Company in USD. 1 USD = 2 EUR. Receive 10 Hundred @ $50: - Receive 10 Hundred (R1) - Return 10 Hundred (RET) - Receive 7 Hundred (R2) - Receive 3 Hundred (R3) + Receive 7 Hundred (R1) + Receive 3 Hundred (R2) Deliver 5 Hundred Bill - 1 Hundred @ 120€ -> already out, should not generate any SVL - 3 Hundred @ 120€ -> already out, should not generate any SVL - 2 Hundred @ 120€ -> only one out, should generate an SVL for the other one and - this SVL should be linked to SVL_R1 - 4 Hundred @ 120€ -> should generate two SVL: one for one product and attached to - SVL_R1 and another one for the three last products of SVL_R2 + 1 Hundred @ 120€ -> already out + 3 Hundred @ 120€ -> already out + 2 Hundred @ 120€ -> one is out, the other is in the stock + 4 Hundred @ 120€ -> nothing out + When billing: + - The already-delivered qty should not generate any SVL for the + price difference and we should directly post some COGS entries + - The in-stock qty should generate an SVL for the price difference, + and we should post the journal entries related to that SVL Deliver 2 Hundred The SVL should include: - 2 x 50 (cost by hundred) @@ -1347,32 +1348,10 @@ class TestStockValuationWithCOA(AccountTestInvoicingCommon): po = po_form.save() po.button_confirm() - # Receive 10 Hundred + # Receive 7 Hundred receipt01 = po.picking_ids[0] - receipt01.move_ids._set_quantities_to_reservation() - receipt01.button_validate() - - expected_svl_values += [10 * 50] - self.assertEqual(self.product1.stock_valuation_layer_ids.mapped('value'), expected_svl_values) - - # Return 10 Hundred - return_form = Form(self.env['stock.return.picking'].with_context(active_id=receipt01.id, active_model='stock.picking')) - return_wizard = return_form.save() - return_picking_id, _pick_type_id = return_wizard._create_returns() - return_picking = self.env['stock.picking'].browse(return_picking_id) - return_picking.move_ids._set_quantities_to_reservation() - return_picking.button_validate() - - expected_svl_values += [-10 * 50] - self.assertEqual(self.product1.stock_valuation_layer_ids.mapped('value'), expected_svl_values) - - # Receive 7 Hundred with backorder - return_form = Form(self.env['stock.return.picking'].with_context(active_id=return_picking_id, active_model='stock.picking')) - return_wizard = return_form.save() - receipt_id, _pick_type_id = return_wizard._create_returns() - receipt02 = self.env['stock.picking'].browse(receipt_id) - receipt02.move_ids.move_line_ids.qty_done = 700 - action = receipt02.button_validate() + receipt01.move_ids.move_line_ids.qty_done = 700 + action = receipt01.button_validate() backorder_wizard = Form(self.env['stock.backorder.confirmation'].with_context(action['context'])).save() backorder_wizard.process() @@ -1380,9 +1359,9 @@ class TestStockValuationWithCOA(AccountTestInvoicingCommon): self.assertEqual(self.product1.stock_valuation_layer_ids.mapped('value'), expected_svl_values) # Receive 3 Hundred - receipt03 = receipt02.backorder_ids - receipt03.move_ids._set_quantities_to_reservation() - receipt03.button_validate() + receipt02 = receipt01.backorder_ids + receipt02.move_ids._set_quantities_to_reservation() + receipt02.button_validate() expected_svl_values += [3 * 50] self.assertEqual(self.product1.stock_valuation_layer_ids.mapped('value'), expected_svl_values) @@ -1410,7 +1389,18 @@ class TestStockValuationWithCOA(AccountTestInvoicingCommon): # We will create a price diff SVL only for the remaining quantities not yet billed # On the bill, price unit is 120€, i.e. $60 -> price diff equal to $10 - for qty, new_svl_expected in [(1, []), (3, []), (2, [1 * 10.0]), (4, [1 * 10.0, 3 * 10.0])]: + expense_account = self.company_data['default_account_expense'] + valuation_amls = self.env['account.move.line'].search([('account_id', '=', self.stock_valuation_account.id)]) + expense_amls = self.env['account.move.line'].search([('account_id', '=', expense_account.id)]) + input_amls = self.env['account.move.line'].search([('account_id', '=', self.stock_input_account.id)]) + bills = self.env['account.move'] + # pylint: disable=bad-whitespace + for qty, new_svl_expected, expected_valuations, expected_expenses in [ + (1, [], [], [10.0]), # 1 hundred already out + (3, [], [], [30.0]), # 3 hundred already out + (2, [1 * 10.0], [10.0], [10.0]), # 1 hundred already out and 1 hundred in stock (from R1) + (4, [1 * 10.0, 3 * 10.0], [3 * 10.0, 1 * 10.0], []), # 4 hundred in stock, 1 from R1 and 3 from R2 + ]: bill_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) bill_form.invoice_date = bill_form.date bill_form.purchase_vendor_bill_id = self.env['purchase.bill.union'].browse(-po.id) @@ -1421,8 +1411,31 @@ class TestStockValuationWithCOA(AccountTestInvoicingCommon): bill.currency_id = eur_curr bill.action_post() + bills |= bill + err_msg = 'Incorrect while billing %s hundred' % qty + + # stock side expected_svl_values += new_svl_expected - self.assertEqual(self.product1.stock_valuation_layer_ids.mapped('value'), expected_svl_values) + self.assertEqual(self.product1.stock_valuation_layer_ids.mapped('value'), expected_svl_values, err_msg) + + # account side + new_valuation_amls = self.env['account.move.line'].search([('account_id', '=', self.stock_valuation_account.id), ('id', 'not in', valuation_amls.ids)]) + new_expense_amls = self.env['account.move.line'].search([('account_id', '=', expense_account.id), ('id', 'not in', expense_amls.ids)]) + new_input_amls = self.env['account.move.line'].search([('account_id', '=', self.stock_input_account.id), ('id', 'not in', input_amls.ids)]) + valuation_amls |= new_valuation_amls + expense_amls |= new_expense_amls + input_amls |= new_input_amls + + self.assertEqual(new_valuation_amls.mapped('debit'), expected_valuations, err_msg) + self.assertEqual(new_expense_amls.mapped('debit'), expected_expenses, err_msg) + self.assertEqual(new_input_amls.filtered(lambda aml: aml.credit > 0).mapped('credit'), expected_expenses + expected_valuations, err_msg) + self.assertEqual(new_input_amls.filtered(lambda aml: aml.debit > 0).debit, qty * 60, err_msg) + + # All AML of Stock Interim Receipt should be reconciled + input_amls = bills.line_ids.filtered(lambda aml: aml.account_id == self.stock_input_account) + full_reconcile = input_amls[0].full_reconcile_id + self.assertTrue(full_reconcile) + self.assertTrue(all(aml.full_reconcile_id == full_reconcile for aml in input_amls)) # Delivery 2 Hundred delivery02 = self.env['stock.picking'].create({ @@ -1445,7 +1458,7 @@ class TestStockValuationWithCOA(AccountTestInvoicingCommon): expected_svl_values += [-2 * 50 + -2 * 10] self.assertEqual(self.product1.stock_valuation_layer_ids.mapped('value'), expected_svl_values) - _svl_r01, _svl_ret, svl_r02, svl_r03, _svl_d01, svl_diff_01, svl_diff_02, svl_diff_03, _svl_d02 = self.product1.stock_valuation_layer_ids - self.assertEqual(svl_diff_01.stock_valuation_layer_id, svl_r02) - self.assertEqual(svl_diff_02.stock_valuation_layer_id, svl_r02) - self.assertEqual(svl_diff_03.stock_valuation_layer_id, svl_r03) + svl_r01, svl_r02, _svl_d01, svl_diff_01, svl_diff_02, svl_diff_03, _svl_d02 = self.product1.stock_valuation_layer_ids + self.assertEqual(svl_diff_01.stock_valuation_layer_id, svl_r01) + self.assertEqual(svl_diff_02.stock_valuation_layer_id, svl_r01) + self.assertEqual(svl_diff_03.stock_valuation_layer_id, svl_r02)