Odoo18-Base/addons/account/models/account_partial_reconcile.py
2025-01-06 10:57:38 +07:00

649 lines
32 KiB
Python

# -*- coding: utf-8 -*-
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError, ValidationError
from odoo.tools import frozendict
from datetime import date
class AccountPartialReconcile(models.Model):
_name = "account.partial.reconcile"
_description = "Partial Reconcile"
# ==== Reconciliation fields ====
debit_move_id = fields.Many2one(
comodel_name='account.move.line',
index=True, required=True)
credit_move_id = fields.Many2one(
comodel_name='account.move.line',
index=True, required=True)
full_reconcile_id = fields.Many2one(
comodel_name='account.full.reconcile',
string="Full Reconcile", copy=False, index='btree_not_null')
exchange_move_id = fields.Many2one(comodel_name='account.move', index='btree_not_null')
# ==== Currency fields ====
company_currency_id = fields.Many2one(
comodel_name='res.currency',
string="Company Currency",
related='company_id.currency_id',
help="Utility field to express amount currency")
debit_currency_id = fields.Many2one(
comodel_name='res.currency',
store=True,
related='debit_move_id.currency_id', precompute=True,
string="Currency of the debit journal item.")
credit_currency_id = fields.Many2one(
comodel_name='res.currency',
store=True,
related='credit_move_id.currency_id', precompute=True,
string="Currency of the credit journal item.")
# ==== Amount fields ====
amount = fields.Monetary(
currency_field='company_currency_id',
help="Always positive amount concerned by this matching expressed in the company currency.")
debit_amount_currency = fields.Monetary(
currency_field='debit_currency_id',
help="Always positive amount concerned by this matching expressed in the debit line foreign currency.")
credit_amount_currency = fields.Monetary(
currency_field='credit_currency_id',
help="Always positive amount concerned by this matching expressed in the credit line foreign currency.")
# ==== Other fields ====
company_id = fields.Many2one(
comodel_name='res.company',
string="Company", store=True, readonly=False,
precompute=True,
compute='_compute_company_id')
max_date = fields.Date(
string="Max Date of Matched Lines", store=True,
precompute=True,
compute='_compute_max_date')
# used to determine at which date this reconciliation needs to be shown on the aged receivable/payable reports
# -------------------------------------------------------------------------
# CONSTRAINT METHODS
# -------------------------------------------------------------------------
@api.constrains('debit_currency_id', 'credit_currency_id')
def _check_required_computed_currencies(self):
bad_partials = self.filtered(lambda partial: not partial.debit_currency_id or not partial.credit_currency_id)
if bad_partials:
raise ValidationError(_("Missing foreign currencies on partials having ids: %s", bad_partials.ids))
# -------------------------------------------------------------------------
# COMPUTE METHODS
# -------------------------------------------------------------------------
@api.depends('debit_move_id.date', 'credit_move_id.date')
def _compute_max_date(self):
for partial in self:
partial.max_date = max(
partial.debit_move_id.date,
partial.credit_move_id.date
)
@api.depends('debit_move_id', 'credit_move_id')
def _compute_company_id(self):
for partial in self:
# Potential exchange diff and caba entries should be created on the invoice side if any
if partial.debit_move_id.move_id.is_invoice(True):
partial.company_id = partial.debit_move_id.company_id
else:
partial.company_id = partial.credit_move_id.company_id
# -------------------------------------------------------------------------
# LOW-LEVEL METHODS
# -------------------------------------------------------------------------
def unlink(self):
# OVERRIDE to unlink full reconcile linked to the current partials
# and reverse the tax cash basis journal entries.
# Avoid cyclic unlink calls when removing the partials that could remove some full reconcile
# and then, loop again and again.
if not self:
return True
# Retrieve the matching number to unlink.
full_to_unlink = self.full_reconcile_id
all_reconciled = self.debit_move_id + self.credit_move_id
# Retrieve the CABA entries to reverse.
moves_to_reverse = self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', self.ids)])
# Same for the exchange difference entries.
moves_to_reverse += self.exchange_move_id
# Unlink partials before doing anything else to avoid 'Record has already been deleted' due to the recursion.
res = super().unlink()
# Remove the matching numbers before reversing the moves to avoid trying to remove the full twice.
full_to_unlink.unlink()
# Reverse CABA entries.
if moves_to_reverse:
default_values_list = [{
'date': move._get_accounting_date(move.date, move._affect_tax_report()),
'ref': move.env._('Reversal of: %s', move.name),
} for move in moves_to_reverse]
moves_to_reverse._reverse_moves(default_values_list, cancel=True)
self._update_matching_number(all_reconciled)
return res
@api.model_create_multi
def create(self, vals_list):
partials = super().create(vals_list)
self._pay_matched_payment(partials)
self._update_matching_number(partials.debit_move_id + partials.credit_move_id)
return partials
@api.model
def _pay_matched_payment(self, partials):
for partial in partials:
matched_payments = (partial.credit_move_id | partial.debit_move_id).move_id.matched_payment_ids
to_check_payments = matched_payments.filtered(lambda payment: not payment.outstanding_account_id and payment.state == 'in_process')
for payment in to_check_payments:
if payment.payment_type == 'inbound':
amount = partial.debit_amount_currency
else:
amount = -partial.credit_amount_currency
if not payment.currency_id.compare_amounts(payment.amount_signed, amount):
payment.state = 'paid'
break
@api.model
def _update_matching_number(self, amls):
amls = amls._all_reconciled_lines()
all_partials = amls.matched_debit_ids | amls.matched_credit_ids
# The matchings form a set of graphs, which can be numbered: this is the matching number.
# We iterate on each edge of the graphs, giving it a number (min of its edge ids).
# By iterating, we either simply add a node (move line) to the graph and asign the number to
# it or we merge the two graphs.
# At the end, we have an index for the number to assign of all lines.
number2lines = {}
line2number = {}
for partial in all_partials.sorted('id'):
debit_min_id = line2number.get(partial.debit_move_id.id)
credit_min_id = line2number.get(partial.credit_move_id.id)
if debit_min_id and credit_min_id: # merging the 2 graph into the one with smalles number
if debit_min_id != credit_min_id:
min_min_id = min(debit_min_id, credit_min_id)
max_min_id = max(debit_min_id, credit_min_id)
for line_id in number2lines[max_min_id]:
line2number[line_id] = min_min_id
number2lines[min_min_id].extend(number2lines.pop(max_min_id))
elif debit_min_id: # adding a new node to a graph
number2lines[debit_min_id].append(partial.credit_move_id.id)
line2number[partial.credit_move_id.id] = debit_min_id
elif credit_min_id: # adding a new node to a graph
number2lines[credit_min_id].append(partial.debit_move_id.id)
line2number[partial.debit_move_id.id] = credit_min_id
else: # creating a new graph
number2lines[partial.id] = [partial.debit_move_id.id, partial.credit_move_id.id]
line2number[partial.debit_move_id.id] = partial.id
line2number[partial.credit_move_id.id] = partial.id
amls.flush_recordset(['full_reconcile_id'])
self.env.cr.execute_values("""
UPDATE account_move_line l
SET matching_number = CASE
WHEN l.full_reconcile_id IS NOT NULL THEN l.full_reconcile_id::text
ELSE 'P' || source.number
END
FROM (VALUES %s) AS source(number, ids)
WHERE l.id = ANY(source.ids)
""", list(number2lines.items()), page_size=1000)
processed_amls = self.env['account.move.line'].browse([_id for ids in number2lines.values() for _id in ids])
processed_amls.invalidate_recordset(['matching_number'])
(amls - processed_amls).matching_number = False
# -------------------------------------------------------------------------
# RECONCILIATION METHODS
# -------------------------------------------------------------------------
def _collect_tax_cash_basis_values(self):
''' Collect all information needed to create the tax cash basis journal entries on the current partials.
:return: A dictionary mapping each move_id to the result of 'account_move._collect_tax_cash_basis_values'.
Also, add the 'partials' keys being a list of dictionary, one for each partial to process:
* partial: The account.partial.reconcile record.
* percentage: The reconciled percentage represented by the partial.
* payment_rate: The applied rate of this partial.
'''
tax_cash_basis_values_per_move = {}
if not self:
return {}
for partial in self:
for move in {partial.debit_move_id.move_id, partial.credit_move_id.move_id}:
# Collect data about cash basis.
if move.id in tax_cash_basis_values_per_move:
move_values = tax_cash_basis_values_per_move[move.id]
else:
move_values = move._collect_tax_cash_basis_values()
# Nothing to process on the move.
if not move_values:
continue
# Check the cash basis configuration only when at least one cash basis tax entry need to be created.
journal = partial.company_id.tax_cash_basis_journal_id
if not journal:
raise UserError(_("There is no tax cash basis journal defined for the '%s' company.\n"
"Configure it in Accounting/Configuration/Settings",
partial.company_id.display_name))
partial_amount = 0.0
partial_amount_currency = 0.0
rate_amount = 0.0
rate_amount_currency = 0.0
if partial.debit_move_id.move_id == move:
partial_amount += partial.amount
partial_amount_currency += partial.debit_amount_currency
rate_amount -= partial.credit_move_id.balance
rate_amount_currency -= partial.credit_move_id.amount_currency
source_line = partial.debit_move_id
counterpart_line = partial.credit_move_id
if partial.credit_move_id.move_id == move:
partial_amount += partial.amount
partial_amount_currency += partial.credit_amount_currency
rate_amount += partial.debit_move_id.balance
rate_amount_currency += partial.debit_move_id.amount_currency
source_line = partial.credit_move_id
counterpart_line = partial.debit_move_id
if partial.debit_move_id.move_id.is_invoice(include_receipts=True) and partial.credit_move_id.move_id.is_invoice(include_receipts=True):
# Will match when reconciling a refund with an invoice.
# In this case, we want to use the rate of each businness document to compute its cash basis entry,
# not the rate of what it's reconciled with.
rate_amount = source_line.balance
rate_amount_currency = source_line.amount_currency
payment_date = move.date
else:
payment_date = counterpart_line.date
if move_values['currency'] == move.company_id.currency_id:
# Ignore the exchange difference.
if move.company_currency_id.is_zero(partial_amount):
continue
# Percentage made on company's currency.
percentage = partial_amount / move_values['total_balance']
else:
# Ignore the exchange difference.
if move.currency_id.is_zero(partial_amount_currency):
continue
# Percentage made on foreign currency.
percentage = partial_amount_currency / move_values['total_amount_currency']
if source_line.currency_id != counterpart_line.currency_id:
# When the invoice and the payment are not sharing the same foreign currency, the rate is computed
# on-the-fly using the payment date.
if 'forced_rate_from_register_payment' in self._context:
payment_rate = self._context['forced_rate_from_register_payment']
else:
payment_rate = self.env['res.currency']._get_conversion_rate(
counterpart_line.company_currency_id,
source_line.currency_id,
counterpart_line.company_id,
payment_date,
)
elif rate_amount:
payment_rate = rate_amount_currency / rate_amount
else:
payment_rate = 0.0
tax_cash_basis_values_per_move[move.id] = move_values
partial_vals = {
'partial': partial,
'percentage': percentage,
'payment_rate': payment_rate,
}
# Add partials.
move_values.setdefault('partials', [])
move_values['partials'].append(partial_vals)
# Clean-up moves having nothing to process.
return {k: v for k, v in tax_cash_basis_values_per_move.items() if v}
@api.model
def _prepare_cash_basis_base_line_vals(self, base_line, balance, amount_currency):
''' Prepare the values to be used to create the cash basis journal items for the tax base line
passed as parameter.
:param base_line: An account.move.line being the base of some taxes.
:param balance: The balance to consider for this line.
:param amount_currency: The balance in foreign currency to consider for this line.
:return: A python dictionary that could be passed to the create method of
account.move.line.
'''
account = base_line.company_id.account_cash_basis_base_account_id or base_line.account_id
tax_ids = base_line.tax_ids.flatten_taxes_hierarchy().filtered(lambda x: x.tax_exigibility == 'on_payment')
is_refund = base_line.is_refund
tax_tags = tax_ids.get_tax_tags(is_refund, 'base')
product_tags = base_line.tax_tag_ids.filtered(lambda x: x.applicability == 'products')
all_tags = tax_tags + product_tags
return {
'name': base_line.move_id.name,
'debit': balance if balance > 0.0 else 0.0,
'credit': -balance if balance < 0.0 else 0.0,
'amount_currency': amount_currency,
'currency_id': base_line.currency_id.id,
'partner_id': base_line.partner_id.id,
'account_id': account.id,
'tax_ids': [Command.set(tax_ids.ids)],
'tax_tag_ids': [Command.set(all_tags.ids)],
'analytic_distribution': base_line.analytic_distribution,
}
@api.model
def _prepare_cash_basis_counterpart_base_line_vals(self, cb_base_line_vals):
''' Prepare the move line used as a counterpart of the line created by
_prepare_cash_basis_base_line_vals.
:param cb_base_line_vals: The line returned by _prepare_cash_basis_base_line_vals.
:return: A python dictionary that could be passed to the create method of
account.move.line.
'''
return {
'name': cb_base_line_vals['name'],
'debit': cb_base_line_vals['credit'],
'credit': cb_base_line_vals['debit'],
'account_id': cb_base_line_vals['account_id'],
'amount_currency': -cb_base_line_vals['amount_currency'],
'currency_id': cb_base_line_vals['currency_id'],
'partner_id': cb_base_line_vals['partner_id'],
'analytic_distribution': cb_base_line_vals['analytic_distribution'],
}
@api.model
def _prepare_cash_basis_tax_line_vals(self, tax_line, balance, amount_currency):
''' Prepare the move line corresponding to a tax in the cash basis entry.
:param tax_line: An account.move.line record being a tax line.
:param balance: The balance to consider for this line.
:param amount_currency: The balance in foreign currency to consider for this line.
:return: A python dictionary that could be passed to the create method of
account.move.line.
'''
tax_ids = tax_line.tax_ids.filtered(lambda x: x.tax_exigibility == 'on_payment')
base_tags = tax_ids.get_tax_tags(tax_line.tax_repartition_line_id.filtered(lambda rl: rl.document_type == 'refund').tax_id, 'base')
product_tags = tax_line.tax_tag_ids.filtered(lambda x: x.applicability == 'products')
all_tags = base_tags + tax_line.tax_repartition_line_id.tag_ids + product_tags
return {
'name': tax_line.name,
'debit': balance if balance > 0.0 else 0.0,
'credit': -balance if balance < 0.0 else 0.0,
'tax_base_amount': tax_line.tax_base_amount,
'tax_repartition_line_id': tax_line.tax_repartition_line_id.id,
'tax_ids': [Command.set(tax_ids.ids)],
'tax_tag_ids': [Command.set(all_tags.ids)],
'account_id': tax_line.tax_repartition_line_id.account_id.id or tax_line.company_id.account_cash_basis_base_account_id.id or tax_line.account_id.id,
'amount_currency': amount_currency,
'currency_id': tax_line.currency_id.id,
'partner_id': tax_line.partner_id.id,
'analytic_distribution': tax_line.analytic_distribution,
# No need to set tax_tag_invert as on the base line; it will be computed from the repartition line
}
@api.model
def _prepare_cash_basis_counterpart_tax_line_vals(self, tax_line, cb_tax_line_vals):
''' Prepare the move line used as a counterpart of the line created by
_prepare_cash_basis_tax_line_vals.
:param tax_line: An account.move.line record being a tax line.
:param cb_tax_line_vals: The result of _prepare_cash_basis_counterpart_tax_line_vals.
:return: A python dictionary that could be passed to the create method of
account.move.line.
'''
return {
'name': cb_tax_line_vals['name'],
'debit': cb_tax_line_vals['credit'],
'credit': cb_tax_line_vals['debit'],
'account_id': tax_line.account_id.id,
'amount_currency': -cb_tax_line_vals['amount_currency'],
'currency_id': cb_tax_line_vals['currency_id'],
'partner_id': cb_tax_line_vals['partner_id'],
'analytic_distribution': cb_tax_line_vals['analytic_distribution'],
}
@api.model
def _get_cash_basis_base_line_grouping_key_from_vals(self, base_line_vals):
''' Get the grouping key of a cash basis base line that hasn't yet been created.
:param base_line_vals: The values to create a new account.move.line record.
:return: The grouping key as a tuple.
'''
tax_ids = base_line_vals['tax_ids'][0][2] # Decode [(6, 0, [...])] command
base_taxes = self.env['account.tax'].browse(tax_ids)
return (
base_line_vals['currency_id'],
base_line_vals['partner_id'],
base_line_vals['account_id'],
tuple(base_taxes.filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
frozendict(base_line_vals['analytic_distribution'] or {}),
)
@api.model
def _get_cash_basis_base_line_grouping_key_from_record(self, base_line, account=None):
''' Get the grouping key of a journal item being a base line.
:param base_line: An account.move.line record.
:param account: Optional account to shadow the current base_line one.
:return: The grouping key as a tuple.
'''
return (
base_line.currency_id.id,
base_line.partner_id.id,
(account or base_line.account_id).id,
tuple(base_line.tax_ids.flatten_taxes_hierarchy().filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
frozendict(base_line.analytic_distribution or {}),
)
@api.model
def _get_cash_basis_tax_line_grouping_key_from_vals(self, tax_line_vals):
''' Get the grouping key of a cash basis tax line that hasn't yet been created.
:param tax_line_vals: The values to create a new account.move.line record.
:return: The grouping key as a tuple.
'''
tax_ids = tax_line_vals['tax_ids'][0][2] # Decode [(6, 0, [...])] command
base_taxes = self.env['account.tax'].browse(tax_ids)
return (
tax_line_vals['currency_id'],
tax_line_vals['partner_id'],
tax_line_vals['account_id'],
tuple(base_taxes.filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
tax_line_vals['tax_repartition_line_id'],
frozendict(tax_line_vals['analytic_distribution'] or {}),
)
@api.model
def _get_cash_basis_tax_line_grouping_key_from_record(self, tax_line, account=None):
''' Get the grouping key of a journal item being a tax line.
:param tax_line: An account.move.line record.
:param account: Optional account to shadow the current tax_line one.
:return: The grouping key as a tuple.
'''
return (
tax_line.currency_id.id,
tax_line.partner_id.id,
(account or tax_line.account_id).id,
tuple(tax_line.tax_ids.filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
tax_line.tax_repartition_line_id.id,
frozendict(tax_line.analytic_distribution or {}),
)
def _create_tax_cash_basis_moves(self):
''' Create the tax cash basis journal entries.
:return: The newly created journal entries.
'''
tax_cash_basis_values_per_move = self._collect_tax_cash_basis_values()
today = fields.Date.context_today(self)
moves_to_create = []
to_reconcile_after = []
for move_values in tax_cash_basis_values_per_move.values():
move = move_values['move']
pending_cash_basis_lines = []
for partial_values in move_values['partials']:
partial = partial_values['partial']
# Init the journal entry.
journal = partial.company_id.tax_cash_basis_journal_id
lock_date = move.company_id._get_user_fiscal_lock_date(journal)
move_date = partial.max_date if partial.max_date > lock_date else today
move_vals = {
'move_type': 'entry',
'date': move_date,
'ref': move.name,
'journal_id': journal.id,
'company_id': partial.company_id.id,
'line_ids': [],
'tax_cash_basis_rec_id': partial.id,
'tax_cash_basis_origin_move_id': move.id,
'fiscal_position_id': move.fiscal_position_id.id,
}
# Tracking of lines grouped all together.
# Used to reduce the number of generated lines and to avoid rounding issues.
partial_lines_to_create = {}
for caba_treatment, line in move_values['to_process_lines']:
# ==========================================================================
# Compute the balance of the current line on the cash basis entry.
# This balance is a percentage representing the part of the journal entry
# that is actually paid by the current partial.
# ==========================================================================
# Percentage expressed in the foreign currency.
amount_currency = line.currency_id.round(line.amount_currency * partial_values['percentage'])
balance = partial_values['payment_rate'] and amount_currency / partial_values['payment_rate'] or 0.0
# ==========================================================================
# Prepare the mirror cash basis journal item of the current line.
# Group them all together as much as possible to reduce the number of
# generated journal items.
# Also track the computed balance in order to avoid rounding issues when
# the journal entry will be fully paid. At that case, we expect the exact
# amount of each line has been covered by the cash basis journal entries
# and well reported in the Tax Report.
# ==========================================================================
if caba_treatment == 'tax':
# Tax line.
cb_line_vals = self._prepare_cash_basis_tax_line_vals(line, balance, amount_currency)
grouping_key = self._get_cash_basis_tax_line_grouping_key_from_vals(cb_line_vals)
elif caba_treatment == 'base':
# Base line.
cb_line_vals = self._prepare_cash_basis_base_line_vals(line, balance, amount_currency)
grouping_key = self._get_cash_basis_base_line_grouping_key_from_vals(cb_line_vals)
if grouping_key in partial_lines_to_create:
aggregated_vals = partial_lines_to_create[grouping_key]['vals']
debit = aggregated_vals['debit'] + cb_line_vals['debit']
credit = aggregated_vals['credit'] + cb_line_vals['credit']
balance = debit - credit
aggregated_vals.update({
'debit': balance if balance > 0 else 0,
'credit': -balance if balance < 0 else 0,
'amount_currency': aggregated_vals['amount_currency'] + cb_line_vals['amount_currency'],
})
if caba_treatment == 'tax':
aggregated_vals.update({
'tax_base_amount': aggregated_vals['tax_base_amount'] + cb_line_vals['tax_base_amount'],
})
partial_lines_to_create[grouping_key]['tax_line'] += line
else:
partial_lines_to_create[grouping_key] = {
'vals': cb_line_vals,
}
if caba_treatment == 'tax':
partial_lines_to_create[grouping_key].update({
'tax_line': line,
})
# ==========================================================================
# Create the counterpart journal items.
# ==========================================================================
# To be able to retrieve the correct matching between the tax lines to reconcile
# later, the lines will be created using a specific sequence.
sequence = 0
for grouping_key, aggregated_vals in partial_lines_to_create.items():
line_vals = aggregated_vals['vals']
line_vals['sequence'] = sequence
pending_cash_basis_lines.append((grouping_key, line_vals['amount_currency']))
if 'tax_repartition_line_id' in line_vals:
# Tax line.
tax_line = aggregated_vals['tax_line']
counterpart_line_vals = self._prepare_cash_basis_counterpart_tax_line_vals(tax_line, line_vals)
counterpart_line_vals['sequence'] = sequence + 1
if tax_line.account_id.reconcile:
move_index = len(moves_to_create)
to_reconcile_after.append((tax_line, move_index, counterpart_line_vals['sequence']))
else:
# Base line.
counterpart_line_vals = self._prepare_cash_basis_counterpart_base_line_vals(line_vals)
counterpart_line_vals['sequence'] = sequence + 1
sequence += 2
move_vals['line_ids'] += [(0, 0, counterpart_line_vals), (0, 0, line_vals)]
moves_to_create.append(move_vals)
moves = self.env['account.move']\
.with_context(
skip_invoice_sync=True,
skip_invoice_line_sync=True,
skip_account_move_synchronization=True,
)\
.create(moves_to_create)
moves._post(soft=False)
# Reconcile the tax lines being on a reconcile tax basis transfer account.
reconciliation_plan = []
for lines, move_index, sequence in to_reconcile_after:
# In expenses, all move lines are created manually without any grouping on tax lines.
# In that case, 'lines' could be already reconciled.
lines = lines.filtered(lambda x: not x.reconciled)
if not lines:
continue
counterpart_line = moves[move_index].line_ids.filtered(lambda line: line.sequence == sequence)
# When dealing with tiny amounts, the line could have a zero amount and then, be already reconciled.
if counterpart_line.reconciled:
continue
reconciliation_plan.append((counterpart_line + lines))
self.env['account.move.line']._reconcile_plan(reconciliation_plan)
return moves