In some cases, when billing a received quantity that is already
delivered, the account move lines of Stock Interim (Received) will
not be reconciled
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
- 5 x P at $50
4. Process the receipt R
5. Create and confirm a SO with
- 5 X P
6. Process the delivery
7. Create and post partial bill B01 related to PO:
- 1 x P @ $60
8. Accounting > Accounting > Journal Items
- Group By: Account
- Note: There are three lines in Stock Interim (Receipt):
- [AML01] Debit: 0, Credit: 250 (receipt R)
- [AML02] Debit: 60, Credit: 0 (bill B01)
- [AML03] Debit: 0, Credit: 10 (price diff from B01 because
quantity already delivered)
There is a partial reconcile between AML01 and AML02, AML03 is
not part of this partial reconciliation
9. Create and post partial bill B02 related to PO:
- 4 x P @ $60
10. Accounting > Accounting > Journal Items
- Group By: Account
Error: The lines of account 'Stock Interim (Received)' are not
reconciled while it should
When confirming B01, we try to reconcile AML01, AML02, AML03. In the
reconciliation process, we first sort the AMLs and try to create a
partial reconciliation:
https://github.com/odoo/odoo/blob/01cb7e960f912c4758d30c04653b599937785799/addons/account/models/account_move_line.py#L2282-L2293
Because of the sorting, here is the order of the AMLs:
- [AML01] Debit: 0, Credit: 250
- [AML03] Debit: 0, Credit: 10
- [AML02] Debit: 60, Credit: 0
In `_create_reconciliation_partials`, we consume the AMLs in that
specific order. So, it first uses one credit line and a debit one:
https://github.com/odoo/odoo/blob/01cb7e960f912c4758d30c04653b599937785799/addons/account/models/account_move_line.py#L1863-L1872
(i.e. AML01 and AML02) and creates a partial reconciliation for
theses AMLs. It gives a temporary credit line of 190, but there is
no more debit lines, so the partial reconciliation is stopped (this
explains the note at step 8)
Later on, while posting B02, we try again to reconcile the lines of
Stock Interim (Received)
https://github.com/odoo/odoo/blob/493020b9317a439c0a61a34f37cc92b6779ef633/addons/stock_account/models/account_move.py#L249
At that point, we have three AMLs:
- [AML01] Debit: 0, Credit: 250 (same as above)
- [AML04] Debit: 0, Credit: 40 (price diff from B02)
- [AML05] Debit: 240, Credit: 0 (B02)
Back in the reconciliation progress, we try to get all involved AMLs:
https://github.com/odoo/odoo/blob/01cb7e960f912c4758d30c04653b599937785799/addons/account/models/account_move_line.py#L2284-L2287
It will be used later for the full reconciliation. To get the
involved ones, we recursively get the AMLs implied in the partial
reconciliation of AML01, AML04, AML05:
https://github.com/odoo/odoo/blob/01cb7e960f912c4758d30c04653b599937785799/addons/account/models/account_move_line.py#L2481-L2489
Therefore, because of the first partial reconciliation explained above,
we will find AML02 but not AML03. This is the reason why the full
reconciliation will not happen.
Working on the assumption that the note of step 8 is not a bug (i.e.,
AML03 is not expected to be part of the first partial reconciliation),
we need to provide as much AML as we can when calling the
reconciliation process.
OPW-3040171
closes odoo/odoo#107908
X-original-commit: 3050e23f94c865f9938863f191076f761b80fedb
Signed-off-by: William Henrotin (whe) <whe@odoo.com>
Signed-off-by: Adrien Widart <awt@odoo.com>
202 lines
11 KiB
Python
202 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.tools.float_utils import float_round, float_is_zero
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
class StockMove(models.Model):
|
|
_inherit = 'stock.move'
|
|
|
|
purchase_line_id = fields.Many2one(
|
|
'purchase.order.line', 'Purchase Order Line',
|
|
ondelete='set null', index='btree_not_null', readonly=True)
|
|
created_purchase_line_id = fields.Many2one(
|
|
'purchase.order.line', 'Created Purchase Order Line',
|
|
ondelete='set null', index='btree_not_null', readonly=True, copy=False)
|
|
|
|
@api.model
|
|
def _prepare_merge_moves_distinct_fields(self):
|
|
distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields()
|
|
distinct_fields += ['purchase_line_id', 'created_purchase_line_id']
|
|
return distinct_fields
|
|
|
|
@api.model
|
|
def _prepare_merge_negative_moves_excluded_distinct_fields(self):
|
|
return super()._prepare_merge_negative_moves_excluded_distinct_fields() + ['created_purchase_line_id']
|
|
|
|
def _get_price_unit(self):
|
|
""" Returns the unit price for the move"""
|
|
self.ensure_one()
|
|
if not self.purchase_line_id or not self.product_id.id:
|
|
return super(StockMove, self)._get_price_unit()
|
|
price_unit_prec = self.env['decimal.precision'].precision_get('Product Price')
|
|
line = self.purchase_line_id
|
|
order = line.order_id
|
|
received_qty = line.qty_received
|
|
if self.state == 'done':
|
|
received_qty -= self.product_uom._compute_quantity(self.quantity_done, line.product_uom)
|
|
if line.qty_invoiced > received_qty:
|
|
move_layer = line.move_ids.stock_valuation_layer_ids
|
|
invoiced_layer = line.invoice_lines.stock_valuation_layer_ids
|
|
receipt_value = sum(move_layer.mapped('value')) + sum(invoiced_layer.mapped('value'))
|
|
invoiced_value = 0
|
|
invoiced_qty = 0
|
|
for invoice_line in line.invoice_lines:
|
|
if invoice_line.tax_ids:
|
|
invoiced_value += invoice_line.tax_ids.with_context(round=False).compute_all(
|
|
invoice_line.price_unit, currency=invoice_line.account_id.currency_id, quantity=invoice_line.quantity)['total_void']
|
|
else:
|
|
invoiced_value += invoice_line.price_unit * invoice_line.quantity
|
|
invoiced_qty += invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_id.uom_id)
|
|
# TODO currency check
|
|
remaining_value = invoiced_value - receipt_value
|
|
# TODO qty_received in product uom
|
|
remaining_qty = invoiced_qty - line.product_uom._compute_quantity(received_qty, line.product_id.uom_id)
|
|
price_unit = float_round(remaining_value / remaining_qty, precision_digits=price_unit_prec)
|
|
else:
|
|
price_unit = line.price_unit
|
|
if line.taxes_id:
|
|
qty = line.product_qty or 1
|
|
price_unit = line.taxes_id.with_context(round=False).compute_all(price_unit, currency=line.order_id.currency_id, quantity=qty)['total_void']
|
|
price_unit = float_round(price_unit / qty, precision_digits=price_unit_prec)
|
|
if line.product_uom.id != line.product_id.uom_id.id:
|
|
price_unit *= line.product_uom.factor / line.product_id.uom_id.factor
|
|
if order.currency_id != order.company_id.currency_id:
|
|
# The date must be today, and not the date of the move since the move move is still
|
|
# in assigned state. However, the move date is the scheduled date until move is
|
|
# done, then date of actual move processing. See:
|
|
# https://github.com/odoo/odoo/blob/2f789b6863407e63f90b3a2d4cc3be09815f7002/addons/stock/models/stock_move.py#L36
|
|
price_unit = order.currency_id._convert(
|
|
price_unit, order.company_id.currency_id, order.company_id, fields.Date.context_today(self), round=False)
|
|
return price_unit
|
|
|
|
def _generate_valuation_lines_data(self, partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id, svl_id, description):
|
|
""" Overridden from stock_account to support amount_currency on valuation lines generated from po
|
|
"""
|
|
self.ensure_one()
|
|
|
|
rslt = super(StockMove, self)._generate_valuation_lines_data(partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id, svl_id, description)
|
|
purchase_currency = self.purchase_line_id.currency_id
|
|
if not self.purchase_line_id or purchase_currency == self.company_id.currency_id:
|
|
return rslt
|
|
svl = self.env['stock.valuation.layer'].browse(svl_id)
|
|
if not svl.account_move_line_id:
|
|
# Do not use price_unit since we want the price tax excluded. And by the way, qty
|
|
# is in the UOM of the product, not the UOM of the PO line.
|
|
purchase_price_unit = (
|
|
self.purchase_line_id.price_subtotal / self.purchase_line_id.product_uom_qty
|
|
if self.purchase_line_id.product_uom_qty
|
|
else self.purchase_line_id.price_unit
|
|
)
|
|
currency_move_valuation = purchase_currency.round(purchase_price_unit * abs(qty))
|
|
rslt['credit_line_vals']['amount_currency'] = rslt['credit_line_vals']['balance'] < 0 and -currency_move_valuation or currency_move_valuation
|
|
rslt['debit_line_vals']['amount_currency'] = rslt['debit_line_vals']['balance'] < 0 and -currency_move_valuation or currency_move_valuation
|
|
rslt['debit_line_vals']['currency_id'] = purchase_currency.id
|
|
rslt['credit_line_vals']['currency_id'] = purchase_currency.id
|
|
else:
|
|
rslt['credit_line_vals']['amount_currency'] = 0
|
|
rslt['debit_line_vals']['amount_currency'] = 0
|
|
rslt['debit_line_vals']['currency_id'] = purchase_currency.id
|
|
rslt['credit_line_vals']['currency_id'] = purchase_currency.id
|
|
if not svl.price_diff_value:
|
|
return rslt
|
|
# The idea is to force using the company currency during the reconciliation process
|
|
rslt['debit_line_vals_curr'] = {
|
|
'name': _("Currency exchange rate difference"),
|
|
'product_id': self.product_id.id,
|
|
'quantity': 0,
|
|
'product_uom_id': self.product_id.uom_id.id,
|
|
'partner_id': partner_id,
|
|
'balance': 0,
|
|
'account_id': debit_account_id,
|
|
'currency_id': purchase_currency.id,
|
|
'amount_currency': -svl.price_diff_value,
|
|
}
|
|
rslt['credit_line_vals_curr'] = {
|
|
'name': _("Currency exchange rate difference"),
|
|
'product_id': self.product_id.id,
|
|
'quantity': 0,
|
|
'product_uom_id': self.product_id.uom_id.id,
|
|
'partner_id': partner_id,
|
|
'balance': 0,
|
|
'account_id': credit_account_id,
|
|
'currency_id': purchase_currency.id,
|
|
'amount_currency': svl.price_diff_value,
|
|
}
|
|
return rslt
|
|
|
|
def _prepare_extra_move_vals(self, qty):
|
|
vals = super(StockMove, self)._prepare_extra_move_vals(qty)
|
|
vals['purchase_line_id'] = self.purchase_line_id.id
|
|
return vals
|
|
|
|
def _prepare_move_split_vals(self, uom_qty):
|
|
vals = super(StockMove, self)._prepare_move_split_vals(uom_qty)
|
|
vals['purchase_line_id'] = self.purchase_line_id.id
|
|
return vals
|
|
|
|
def _prepare_procurement_values(self):
|
|
proc_values = super()._prepare_procurement_values()
|
|
if self.restrict_partner_id:
|
|
proc_values['supplierinfo_name'] = self.restrict_partner_id
|
|
self.restrict_partner_id = False
|
|
return proc_values
|
|
|
|
def _clean_merged(self):
|
|
super(StockMove, self)._clean_merged()
|
|
self.write({'created_purchase_line_id': False})
|
|
|
|
def _get_upstream_documents_and_responsibles(self, visited):
|
|
if self.created_purchase_line_id and self.created_purchase_line_id.state not in ('done', 'cancel') \
|
|
and (self.created_purchase_line_id.state != 'draft' or self._context.get('include_draft_documents')):
|
|
return [(self.created_purchase_line_id.order_id, self.created_purchase_line_id.order_id.user_id, visited)]
|
|
elif self.purchase_line_id and self.purchase_line_id.state not in ('done', 'cancel'):
|
|
return[(self.purchase_line_id.order_id, self.purchase_line_id.order_id.user_id, visited)]
|
|
else:
|
|
return super(StockMove, self)._get_upstream_documents_and_responsibles(visited)
|
|
|
|
def _get_related_invoices(self):
|
|
""" Overridden to return the vendor bills related to this stock move.
|
|
"""
|
|
rslt = super(StockMove, self)._get_related_invoices()
|
|
rslt += self.mapped('picking_id.purchase_id.invoice_ids').filtered(lambda x: x.state == 'posted')
|
|
return rslt
|
|
|
|
def _get_source_document(self):
|
|
res = super()._get_source_document()
|
|
return self.purchase_line_id.order_id or res
|
|
|
|
def _get_valuation_price_and_qty(self, related_aml, to_curr):
|
|
valuation_price_unit_total = 0
|
|
valuation_total_qty = 0
|
|
for val_stock_move in self:
|
|
# In case val_stock_move is a return move, its valuation entries have been made with the
|
|
# currency rate corresponding to the original stock move
|
|
valuation_date = val_stock_move.origin_returned_move_id.date or val_stock_move.date
|
|
svl = val_stock_move.with_context(active_test=False).mapped('stock_valuation_layer_ids').filtered(
|
|
lambda l: l.quantity)
|
|
layers_qty = sum(svl.mapped('quantity'))
|
|
layers_values = sum(svl.mapped('value'))
|
|
valuation_price_unit_total += related_aml.company_currency_id._convert(
|
|
layers_values, to_curr, related_aml.company_id, valuation_date, round=False,
|
|
)
|
|
valuation_total_qty += layers_qty
|
|
if float_is_zero(valuation_total_qty, precision_rounding=related_aml.product_uom_id.rounding or related_aml.product_id.uom_id.rounding):
|
|
raise UserError(
|
|
_('Odoo is not able to generate the anglo saxon entries. The total valuation of %s is zero.') % related_aml.product_id.display_name)
|
|
return valuation_price_unit_total, valuation_total_qty
|
|
|
|
def _is_purchase_return(self):
|
|
self.ensure_one()
|
|
return self.location_dest_id.usage == "supplier" or (
|
|
self.location_dest_id.usage == "internal"
|
|
and self.location_id.usage != "supplier"
|
|
and self.warehouse_id
|
|
and self.location_dest_id not in self.env["stock.location"].search([("id", "child_of", self.warehouse_id.view_location_id.id)])
|
|
)
|
|
|
|
def _get_all_related_aml(self):
|
|
return super()._get_all_related_aml() | self.purchase_line_id.invoice_lines.move_id.line_ids
|