Files
odoo_source/addons/purchase_stock/models/stock_move.py
T
Adrien Widart (awt) c321f825f9 [FIX] purchase_stock,stock_account: reconcile lines
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>
2022-12-14 10:35:28 +01:00

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