[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] 35d6c58f86
[3] odoo/enterprise@a1eaf200d0

OPW-3040171
OPW-3071238

X-original-commit: 84733fb89faffe2480def97f076633056e16572f
Part-of: odoo/odoo#107908
This commit is contained in:
Adrien Widart (awt)
2022-12-14 10:35:28 +01:00
parent 7a6b903830
commit 48f6cb4597
3 changed files with 207 additions and 46 deletions
+135 -1
View File
@@ -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."""
@@ -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)
@@ -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)