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)