[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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user