[ADD] account_reconcile_model_oca

pull/738/head
Enric Tobella 2024-02-08 19:25:28 +01:00 committed by Duy (Đỗ Anh)
parent 7419bc5b56
commit 835e0c9b16
14 changed files with 3061 additions and 0 deletions

View File

@ -0,0 +1,80 @@
===========================
Account Reconcile Model Oca
===========================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:62683a913039d5afe96202b49fdd537d57edf8ac49edc89bf9c9e4512971888f
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--reconcile-lightgray.png?logo=github
:target: https://github.com/OCA/account-reconcile/tree/17.0/account_reconcile_model_oca
:alt: OCA/account-reconcile
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/account-reconcile-17-0/account-reconcile-17-0-account_reconcile_model_oca
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/account-reconcile&target_branch=17.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
This module restores account reconciliation models functions moved from
Odoo community to enterpise in V. 17.0
**Table of contents**
.. contents::
:local:
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/account-reconcile/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/account-reconcile/issues/new?body=module:%20account_reconcile_model_oca%0Aversion:%2017.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
-------
* Dixmit
* Odoo
Contributors
------------
- Dixmit
- Enric Tobella
Maintainers
-----------
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
This module is part of the `OCA/account-reconcile <https://github.com/OCA/account-reconcile/tree/17.0/account_reconcile_model_oca>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,15 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Account Reconcile Model Oca",
"summary": """
This includes the logic moved from Odoo Community to Odoo Enterprise""",
"version": "17.0.1.0.0",
"license": "LGPL-3",
"author": "Dixmit,Odoo,Odoo Community Association (OCA)",
"website": "https://github.com/OCA/account-reconcile",
"depends": ["account"],
"data": [],
"demo": [],
}

View File

@ -0,0 +1,2 @@
from . import account_reconcile_model
from . import account_bank_statement_line

View File

@ -0,0 +1,128 @@
# Copyright 2023 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import models
from odoo.osv.expression import get_unaccent_wrapper
from odoo.tools import html2plaintext
from odoo.addons.base.models.res_bank import sanitize_account_number
class AccountBankStatementLine(models.Model):
_inherit = ("account.bank.statement.line",)
def _retrieve_partner(self):
self.ensure_one()
# Retrieve the partner from the statement line.
if self.partner_id:
return self.partner_id
# Retrieve the partner from the bank account.
if self.account_number:
account_number_nums = sanitize_account_number(self.account_number)
if account_number_nums:
domain = [("sanitized_acc_number", "ilike", account_number_nums)]
for extra_domain in ([("company_id", "=", self.company_id.id)], []):
bank_accounts = self.env["res.partner.bank"].search(
extra_domain + domain
)
if len(bank_accounts.partner_id) == 1:
return bank_accounts.partner_id
# Retrieve the partner from the partner name.
if self.partner_name:
domain = [
("parent_id", "=", False),
("name", "ilike", self.partner_name),
]
for extra_domain in ([("company_id", "=", self.company_id.id)], []):
partner = self.env["res.partner"].search(extra_domain + domain, limit=1)
if partner:
return partner
# Retrieve the partner from the 'reconcile models'.
rec_models = self.env["account.reconcile.model"].search(
[
("rule_type", "!=", "writeoff_button"),
("company_id", "=", self.company_id.id),
]
)
for rec_model in rec_models:
partner = rec_model._get_partner_from_mapping(self)
if partner and rec_model._is_applicable_for(self, partner):
return partner
# Retrieve the partner from statement line text values.
st_line_text_values = self._get_st_line_strings_for_matching()
unaccent = get_unaccent_wrapper(self._cr)
sub_queries = []
params = []
for text_value in st_line_text_values:
if not text_value:
continue
# Find a partner having a name contained inside the statement line values.
# Take care a partner could contain some special characters in its name that needs to be escaped.
sub_queries.append(
rf"""
{unaccent("%s")} ~* ('^' || (
SELECT STRING_AGG(CONCAT('(?=.*\m', chunk[1], '\M)'), '')
FROM regexp_matches({unaccent('partner.name')}, '\w{{3,}}', 'g') AS chunk
))
"""
)
params.append(text_value)
if sub_queries:
self.env["res.partner"].flush_model(["company_id", "name"])
self.env["account.move.line"].flush_model(["partner_id", "company_id"])
self._cr.execute(
"""
SELECT aml.partner_id
FROM account_move_line aml
JOIN res_partner partner ON
aml.partner_id = partner.id
AND partner.name IS NOT NULL
AND partner.active
AND (("""
+ ") OR (".join(sub_queries)
+ """))
WHERE aml.company_id = %s
LIMIT 1
""",
params + [self.company_id.id],
)
row = self._cr.fetchone()
if row:
return self.env["res.partner"].browse(row[0])
return self.env["res.partner"]
def _get_st_line_strings_for_matching(self, allowed_fields=None):
"""Collect the strings that could be used on the statement line to perform some matching.
:param allowed_fields: A explicit list of fields to consider.
:return: A list of strings.
"""
self.ensure_one()
def _get_text_value(field_name):
if self._fields[field_name].type == "html":
return self[field_name] and html2plaintext(self[field_name])
else:
return self[field_name]
st_line_text_values = []
if allowed_fields is None or "payment_ref" in allowed_fields:
value = _get_text_value("payment_ref")
if value:
st_line_text_values.append(value)
if allowed_fields is None or "narration" in allowed_fields:
value = _get_text_value("narration")
if value:
st_line_text_values.append(value)
if allowed_fields is None or "ref" in allowed_fields:
value = _get_text_value("ref")
if value:
st_line_text_values.append(value)
return st_line_text_values

View File

@ -0,0 +1,667 @@
import re
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import Command, fields, models, tools
class AccountReconcileModel(models.Model):
_inherit = "account.reconcile.model"
####################################################
# RECONCILIATION PROCESS
####################################################
def _apply_lines_for_bank_widget(self, residual_amount_currency, partner, st_line):
"""Apply the reconciliation model lines to the statement line passed as parameter.
:param residual_amount_currency: The open balance of the statement line in the bank reconciliation widget
expressed in the statement line currency.
:param partner: The partner set on the wizard.
:param st_line: The statement line processed by the bank reconciliation widget.
:return: A list of python dictionaries (one per reconcile model line) representing
the journal items to be created by the current reconcile model.
"""
self.ensure_one()
currency = (
st_line.foreign_currency_id
or st_line.journal_id.currency_id
or st_line.company_currency_id
)
if currency.is_zero(residual_amount_currency):
return []
vals_list = []
for line in self.line_ids:
vals = line._apply_in_bank_widget(
residual_amount_currency, partner, st_line
)
amount_currency = vals["amount_currency"]
if currency.is_zero(amount_currency):
continue
vals_list.append(vals)
residual_amount_currency -= amount_currency
return vals_list
def _get_taxes_move_lines_dict(self, tax, base_line_dict):
"""Get move.lines dict (to be passed to the create()) corresponding to a tax.
:param tax: An account.tax record.
:param base_line_dict: A dict representing the move.line containing the base amount.
:return: A list of dict representing move.lines to be created corresponding to the tax.
"""
self.ensure_one()
balance = base_line_dict["balance"]
tax_type = tax.type_tax_use
is_refund = (tax_type == "sale" and balance < 0) or (
tax_type == "purchase" and balance > 0
)
res = tax.compute_all(balance, is_refund=is_refund)
new_aml_dicts = []
for tax_res in res["taxes"]:
tax = self.env["account.tax"].browse(tax_res["id"])
balance = tax_res["amount"]
name = " ".join(
[x for x in [base_line_dict.get("name", ""), tax_res["name"]] if x]
)
new_aml_dicts.append(
{
"account_id": tax_res["account_id"] or base_line_dict["account_id"],
"journal_id": base_line_dict.get("journal_id", False),
"name": name,
"partner_id": base_line_dict.get("partner_id"),
"balance": balance,
"debit": balance > 0 and balance or 0,
"credit": balance < 0 and -balance or 0,
"analytic_distribution": tax.analytic
and base_line_dict["analytic_distribution"],
"tax_repartition_line_id": tax_res["tax_repartition_line_id"],
"tax_ids": [(6, 0, tax_res["tax_ids"])],
"tax_tag_ids": [(6, 0, tax_res["tag_ids"])],
"group_tax_id": tax_res["group"].id if tax_res["group"] else False,
"currency_id": False,
"reconcile_model_id": self.id,
}
)
# Handle price included taxes.
base_balance = tax_res["base"]
base_line_dict.update(
{
"balance": base_balance,
"debit": base_balance > 0 and base_balance or 0,
"credit": base_balance < 0 and -base_balance or 0,
}
)
base_line_dict["tax_tag_ids"] = [(6, 0, res["base_tags"])]
return new_aml_dicts
def _get_write_off_move_lines_dict(self, residual_balance, partner_id):
"""Get move.lines dict corresponding to the reconciliation model's write-off lines.
:param residual_balance: The residual balance of the account on the manual reconciliation widget.
:return: A list of dict representing move.lines to be created corresponding to the write-off lines.
"""
self.ensure_one()
if self.rule_type == "invoice_matching" and (
not self.allow_payment_tolerance or self.payment_tolerance_param == 0
):
return []
currency = self.company_id.currency_id
lines_vals_list = []
for line in self.line_ids:
balance = 0
if line.amount_type == "percentage":
balance = currency.round(residual_balance * (line.amount / 100.0))
elif line.amount_type == "fixed":
balance = currency.round(
line.amount * (1 if residual_balance > 0.0 else -1)
)
if currency.is_zero(balance):
continue
writeoff_line = {
"name": line.label,
"balance": balance,
"debit": balance > 0 and balance or 0,
"credit": balance < 0 and -balance or 0,
"account_id": line.account_id.id,
"currency_id": currency.id,
"analytic_distribution": line.analytic_distribution,
"reconcile_model_id": self.id,
"journal_id": line.journal_id.id,
"tax_ids": [],
}
lines_vals_list.append(writeoff_line)
residual_balance -= balance
if line.tax_ids:
taxes = line.tax_ids
detected_fiscal_position = self.env[
"account.fiscal.position"
]._get_fiscal_position(self.env["res.partner"].browse(partner_id))
if detected_fiscal_position:
taxes = detected_fiscal_position.map_tax(taxes)
writeoff_line["tax_ids"] += [Command.set(taxes.ids)]
# Multiple taxes with force_tax_included results in wrong computation, so we
# only allow to set the force_tax_included field if we have one tax selected
if line.force_tax_included:
taxes = taxes[0].with_context(force_price_include=True)
tax_vals_list = self._get_taxes_move_lines_dict(taxes, writeoff_line)
lines_vals_list += tax_vals_list
if not line.force_tax_included:
for tax_line in tax_vals_list:
residual_balance -= tax_line["balance"]
return lines_vals_list
####################################################
# RECONCILIATION CRITERIA
####################################################
def _apply_rules(self, st_line, partner):
"""Apply criteria to get candidates for all reconciliation models.
This function is called in enterprise by the reconciliation widget to match
the statement line with the available candidates (using the reconciliation models).
:param st_line: The statement line to match.
:param partner: The partner to consider.
:return: A dict mapping each statement line id with:
* aml_ids: A list of account.move.line ids.
* model: An account.reconcile.model record (optional).
* status: 'reconciled' if the lines has been already reconciled, 'write_off' if the write-off
must be applied on the statement line.
* auto_reconcile: A flag indicating if the match is enough significant to auto reconcile the candidates.
"""
available_models = self.filtered(
lambda m: m.rule_type != "writeoff_button"
).sorted()
for rec_model in available_models:
if not rec_model._is_applicable_for(st_line, partner):
continue
if rec_model.rule_type == "invoice_matching":
rules_map = rec_model._get_invoice_matching_rules_map()
for rule_index in sorted(rules_map.keys()):
for rule_method in rules_map[rule_index]:
candidate_vals = rule_method(st_line, partner)
if not candidate_vals:
continue
if candidate_vals.get("amls"):
res = rec_model._get_invoice_matching_amls_result(
st_line, partner, candidate_vals
)
if res:
return {
**res,
"model": rec_model,
}
else:
return {
**candidate_vals,
"model": rec_model,
}
elif rec_model.rule_type == "writeoff_suggestion":
return {
"model": rec_model,
"status": "write_off",
"auto_reconcile": rec_model.auto_reconcile,
}
return {}
def _is_applicable_for(self, st_line, partner):
"""Returns true iff this reconciliation model can be used to search for matches
for the provided statement line and partner.
"""
self.ensure_one()
# Filter on journals, amount nature, amount and partners
# All the conditions defined in this block are non-match conditions.
if (
(
self.match_journal_ids
and st_line.move_id.journal_id not in self.match_journal_ids
)
or (self.match_nature == "amount_received" and st_line.amount < 0)
or (self.match_nature == "amount_paid" and st_line.amount > 0)
or (
self.match_amount == "lower"
and abs(st_line.amount) >= self.match_amount_max
)
or (
self.match_amount == "greater"
and abs(st_line.amount) <= self.match_amount_min
)
or (
self.match_amount == "between"
and (
abs(st_line.amount) > self.match_amount_max
or abs(st_line.amount) < self.match_amount_min
)
)
or (self.match_partner and not partner)
or (
self.match_partner
and self.match_partner_ids
and partner not in self.match_partner_ids
)
or (
self.match_partner
and self.match_partner_category_ids
and partner.category_id not in self.match_partner_category_ids
)
):
return False
# Filter on label, note and transaction_type
for record, rule_field, record_field in [
(st_line, "label", "payment_ref"),
(st_line.move_id, "note", "narration"),
(st_line, "transaction_type", "transaction_type"),
]:
rule_term = (self["match_" + rule_field + "_param"] or "").lower()
record_term = (record[record_field] or "").lower()
# This defines non-match conditions
if (
(
self["match_" + rule_field] == "contains"
and rule_term not in record_term
)
or (
self["match_" + rule_field] == "not_contains"
and rule_term in record_term
)
or (
self["match_" + rule_field] == "match_regex"
and not re.match(rule_term, record_term)
)
):
return False
return True
def _get_invoice_matching_amls_domain(self, st_line, partner):
aml_domain = st_line._get_default_amls_matching_domain()
if st_line.amount > 0.0:
aml_domain.append(("balance", ">", 0.0))
else:
aml_domain.append(("balance", "<", 0.0))
currency = st_line.foreign_currency_id or st_line.currency_id
if self.match_same_currency:
aml_domain.append(("currency_id", "=", currency.id))
if partner:
aml_domain.append(("partner_id", "=", partner.id))
if self.past_months_limit:
date_limit = fields.Date.context_today(self) - relativedelta(
months=self.past_months_limit
)
aml_domain.append(("date", ">=", fields.Date.to_string(date_limit)))
return aml_domain
def _get_invoice_matching_st_line_tokens(self, st_line):
"""Parse the textual information from the statement line passed as parameter
in order to extract from it the meaningful information in order to perform the matching.
:param st_line: A statement line.
:return: A list of tokens, each one being a string.
"""
st_line_text_values = st_line._get_st_line_strings_for_matching(
allowed_fields=(
"payment_ref" if self.match_text_location_label else None,
"narration" if self.match_text_location_note else None,
"ref" if self.match_text_location_reference else None,
)
)
significant_token_size = 4
tokens = []
for text_value in st_line_text_values:
for token in (text_value or "").split():
# The token is too short to be significant.
if len(token) < significant_token_size:
continue
formatted_token = "".join(x for x in token if x.isdecimal())
# The token is too short after formatting to be significant.
if len(formatted_token) < significant_token_size:
continue
tokens.append(formatted_token)
return tokens
def _get_invoice_matching_amls_candidates(self, st_line, partner):
"""Returns the match candidates for the 'invoice_matching' rule, with respect to the provided parameters.
:param st_line: A statement line.
:param partner: The partner associated to the statement line.
"""
assert self.rule_type == "invoice_matching"
self.env["account.move"].flush_model()
self.env["account.move.line"].flush_model()
if self.matching_order == "new_first":
order_by = "sub.date_maturity DESC, sub.date DESC, sub.id DESC"
else:
order_by = "sub.date_maturity ASC, sub.date ASC, sub.id ASC"
aml_domain = self._get_invoice_matching_amls_domain(st_line, partner)
query = self.env["account.move.line"]._where_calc(aml_domain)
tables, where_clause, where_params = query.get_sql()
tokens = self._get_invoice_matching_st_line_tokens(st_line)
if tokens:
sub_queries = []
for table_alias, field in (
("account_move_line", "name"),
("account_move_line__move_id", "name"),
("account_move_line__move_id", "ref"),
):
sub_queries.append(
rf"""
SELECT
account_move_line.id,
account_move_line.date,
account_move_line.date_maturity,
UNNEST(
REGEXP_SPLIT_TO_ARRAY(
SUBSTRING(
REGEXP_REPLACE({table_alias}.{field}, '[^0-9\s]', '', 'g'),
'\S(?:.*\S)*'
),
'\s+'
)
) AS token
FROM {tables}
JOIN account_move account_move_line__move_id ON account_move_line__move_id.id = account_move_line.move_id
WHERE {where_clause} AND {table_alias}.{field} IS NOT NULL
"""
)
self._cr.execute(
"""
SELECT
sub.id,
COUNT(*) AS nb_match
FROM ("""
+ " UNION ALL ".join(sub_queries)
+ """) AS sub
WHERE sub.token IN %s
GROUP BY sub.date_maturity, sub.date, sub.id
HAVING COUNT(*) > 0
ORDER BY nb_match DESC, """
+ order_by
+ """
""",
(where_params * 3) + [tuple(tokens)],
)
candidate_ids = [r[0] for r in self._cr.fetchall()]
if candidate_ids:
return {
"allow_auto_reconcile": True,
"amls": self.env["account.move.line"].browse(candidate_ids),
}
# Search without any matching based on textual information.
if partner:
if self.matching_order == "new_first":
order = "date_maturity DESC, date DESC, id DESC"
else:
order = "date_maturity ASC, date ASC, id ASC"
amls = self.env["account.move.line"].search(aml_domain, order=order)
if amls:
return {
"allow_auto_reconcile": False,
"amls": amls,
}
def _get_invoice_matching_rules_map(self):
"""Get a mapping <priority_order, rule> that could be overridden in others modules.
:return: a mapping <priority_order, rule> where:
* priority_order: Defines in which order the rules will be evaluated, the lowest comes first.
This is extremely important since the algorithm stops when a rule returns some candidates.
* rule: Method taking <st_line, partner> as parameters and returning the candidates journal items found.
"""
rules_map = defaultdict(list)
rules_map[10].append(self._get_invoice_matching_amls_candidates)
return rules_map
def _get_partner_from_mapping(self, st_line):
"""Find partner with mapping defined on model.
For invoice matching rules, matches the statement line against each
regex defined in partner mapping, and returns the partner corresponding
to the first one matching.
:param st_line (Model<account.bank.statement.line>):
The statement line that needs a partner to be found
:return Model<res.partner>:
The partner found from the mapping. Can be empty an empty recordset
if there was nothing found from the mapping or if the function is
not applicable.
"""
self.ensure_one()
if self.rule_type not in ("invoice_matching", "writeoff_suggestion"):
return self.env["res.partner"]
for partner_mapping in self.partner_mapping_line_ids:
match_payment_ref = (
re.match(partner_mapping.payment_ref_regex, st_line.payment_ref)
if partner_mapping.payment_ref_regex
else True
)
match_narration = (
re.match(
partner_mapping.narration_regex,
tools.html2plaintext(st_line.narration or "").rstrip(),
)
if partner_mapping.narration_regex
else True
)
if match_payment_ref and match_narration:
return partner_mapping.partner_id
return self.env["res.partner"]
def _get_invoice_matching_amls_result(self, st_line, partner, candidate_vals): # noqa: C901
def _create_result_dict(amls_values_list, status):
if "rejected" in status:
return
result = {"amls": self.env["account.move.line"]}
for aml_values in amls_values_list:
result["amls"] |= aml_values["aml"]
if "allow_write_off" in status and self.line_ids:
result["status"] = "write_off"
if (
"allow_auto_reconcile" in status
and candidate_vals["allow_auto_reconcile"]
and self.auto_reconcile
):
result["auto_reconcile"] = True
return result
st_line_currency = st_line.foreign_currency_id or st_line.currency_id
st_line_amount = st_line._prepare_move_line_default_vals()[1]["amount_currency"]
sign = 1 if st_line_amount > 0.0 else -1
amls = candidate_vals["amls"]
amls_values_list = []
amls_with_epd_values_list = []
same_currency_mode = amls.currency_id == st_line_currency
for aml in amls:
aml_values = {
"aml": aml,
"amount_residual": aml.amount_residual,
"amount_residual_currency": aml.amount_residual_currency,
}
amls_values_list.append(aml_values)
# Manage the early payment discount.
if (
same_currency_mode
and aml.move_id.move_type
in ("out_invoice", "out_receipt", "in_invoice", "in_receipt")
and not aml.matched_debit_ids
and not aml.matched_credit_ids
and aml.discount_date
and st_line.date <= aml.discount_date
):
rate = (
abs(aml.amount_currency) / abs(aml.balance) if aml.balance else 1.0
)
amls_with_epd_values_list.append(
{
**aml_values,
"amount_residual": st_line.company_currency_id.round(
aml.discount_amount_currency / rate
),
"amount_residual_currency": aml.discount_amount_currency,
}
)
else:
amls_with_epd_values_list.append(aml_values)
def match_batch_amls(amls_values_list):
if not same_currency_mode:
return None, []
kepts_amls_values_list = []
sum_amount_residual_currency = 0.0
for aml_values in amls_values_list:
if (
st_line_currency.compare_amounts(
st_line_amount, -aml_values["amount_residual_currency"]
)
== 0
):
# Special case: the amounts are the same, submit the line directly.
return "perfect", [aml_values]
if (
st_line_currency.compare_amounts(
sign * (st_line_amount + sum_amount_residual_currency), 0.0
)
> 0
):
# Here, we still have room for other candidates ; so we add the current one to the list we keep.
# Then, we continue iterating, even if there is no room anymore, just in case one of the following candidates
# is an exact match, which would then be preferred on the current candidates.
kepts_amls_values_list.append(aml_values)
sum_amount_residual_currency += aml_values[
"amount_residual_currency"
]
if st_line_currency.is_zero(
sign * (st_line_amount + sum_amount_residual_currency)
):
return "perfect", kepts_amls_values_list
elif kepts_amls_values_list:
return "partial", kepts_amls_values_list
else:
return None, []
# Try to match a batch with the early payment feature. Only a perfect match is allowed.
match_type, kepts_amls_values_list = match_batch_amls(amls_with_epd_values_list)
if match_type != "perfect":
kepts_amls_values_list = []
# Try to match the amls having the same currency as the statement line.
if not kepts_amls_values_list:
_match_type, kepts_amls_values_list = match_batch_amls(amls_values_list)
# Try to match the whole candidates.
if not kepts_amls_values_list:
kepts_amls_values_list = amls_values_list
# Try to match the amls having the same currency as the statement line.
if kepts_amls_values_list:
status = self._check_rule_propositions(st_line, kepts_amls_values_list)
result = _create_result_dict(kepts_amls_values_list, status)
if result:
return result
def _check_rule_propositions(self, st_line, amls_values_list):
"""Check restrictions that can't be handled for each move.line separately.
Note: Only used by models having a type equals to 'invoice_matching'.
:param st_line: The statement line.
:param amls_values_list: The candidates account.move.line as a list of dict:
* aml: The record.
* amount_residual: The amount residual to consider.
* amount_residual_currency: The amount residual in foreign currency to consider.
:return: A string representing what to do with the candidates:
* rejected: Reject candidates.
* allow_write_off: Allow to generate the write-off from the reconcile model lines if specified.
* allow_auto_reconcile: Allow to automatically reconcile entries if 'auto_validate' is enabled.
"""
self.ensure_one()
if not self.allow_payment_tolerance:
return {"allow_write_off", "allow_auto_reconcile"}
st_line_currency = st_line.foreign_currency_id or st_line.currency_id
st_line_amount_curr = st_line._prepare_move_line_default_vals()[1][
"amount_currency"
]
amls_amount_curr = sum(
st_line._prepare_counterpart_amounts_using_st_line_rate(
aml_values["aml"].currency_id,
aml_values["amount_residual"],
aml_values["amount_residual_currency"],
)["amount_currency"]
for aml_values in amls_values_list
)
sign = 1 if st_line_amount_curr > 0.0 else -1
amount_curr_after_rec = sign * (amls_amount_curr + st_line_amount_curr)
# The statement line will be fully reconciled.
if st_line_currency.is_zero(amount_curr_after_rec):
return {"allow_auto_reconcile"}
# The payment amount is higher than the sum of invoices.
# In that case, don't check the tolerance and don't try to generate any write-off.
if amount_curr_after_rec > 0.0:
return {"allow_auto_reconcile"}
# No tolerance, reject the candidates.
if self.payment_tolerance_param == 0:
return {"rejected"}
# If the tolerance is expressed as a fixed amount, check the residual payment amount doesn't exceed the
# tolerance.
if (
self.payment_tolerance_type == "fixed_amount"
and -amount_curr_after_rec <= self.payment_tolerance_param
):
return {"allow_write_off", "allow_auto_reconcile"}
# The tolerance is expressed as a percentage between 0 and 100.0.
reconciled_percentage_left = (
abs(amount_curr_after_rec / amls_amount_curr)
) * 100.0
if (
self.payment_tolerance_type == "percentage"
and reconciled_percentage_left <= self.payment_tolerance_param
):
return {"allow_write_off", "allow_auto_reconcile"}
return {"rejected"}

View File

@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"

View File

@ -0,0 +1,3 @@
- Dixmit
- Enric Tobella

View File

@ -0,0 +1 @@
This module restores account reconciliation models functions moved from Odoo community to enterpise in V. 17.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1,426 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Account Reconcile Model Oca</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="account-reconcile-model-oca">
<h1 class="title">Account Reconcile Model Oca</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:62683a913039d5afe96202b49fdd537d57edf8ac49edc89bf9c9e4512971888f
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/lgpl-3.0-standalone.html"><img alt="License: LGPL-3" src="https://img.shields.io/badge/licence-LGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/account-reconcile/tree/17.0/account_reconcile_model_oca"><img alt="OCA/account-reconcile" src="https://img.shields.io/badge/github-OCA%2Faccount--reconcile-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/account-reconcile-17-0/account-reconcile-17-0-account_reconcile_model_oca"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/account-reconcile&amp;target_branch=17.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module restores account reconciliation models functions moved from
Odoo community to enterpise in V. 17.0</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-1">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-2">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-3">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-4">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-5">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-1">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/account-reconcile/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/account-reconcile/issues/new?body=module:%20account_reconcile_model_oca%0Aversion:%2017.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-2">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-3">Authors</a></h2>
<ul class="simple">
<li>Dixmit</li>
<li>Odoo</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-4">Contributors</a></h2>
<ul class="simple">
<li>Dixmit<ul>
<li>Enric Tobella</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-5">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/account-reconcile/tree/17.0/account_reconcile_model_oca">OCA/account-reconcile</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1 @@
from . import test_reconciliation_match

View File

@ -0,0 +1,243 @@
import time
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
class TestAccountReconciliationCommon(AccountTestInvoicingCommon):
"""Tests for reconciliation (account.tax)
Test used to check that when doing a sale or purchase invoice in a different currency,
the result will be balanced.
"""
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.company = cls.company_data["company"]
cls.company.currency_id = cls.env.ref("base.EUR")
cls.partner_agrolait = cls.env["res.partner"].create(
{
"name": "Deco Addict",
"is_company": True,
"country_id": cls.env.ref("base.us").id,
}
)
cls.partner_agrolait_id = cls.partner_agrolait.id
cls.currency_swiss_id = cls.env.ref("base.CHF").id
cls.currency_usd_id = cls.env.ref("base.USD").id
cls.currency_euro_id = cls.env.ref("base.EUR").id
cls.account_rcv = cls.company_data["default_account_receivable"]
cls.account_rsa = cls.company_data["default_account_payable"]
cls.product = cls.env["product.product"].create(
{
"name": "Product Product 4",
"standard_price": 500.0,
"list_price": 750.0,
"type": "consu",
"categ_id": cls.env.ref("product.product_category_all").id,
}
)
cls.bank_journal_euro = cls.env["account.journal"].create(
{"name": "Bank", "type": "bank", "code": "BNK67"}
)
cls.account_euro = cls.bank_journal_euro.default_account_id
cls.bank_journal_usd = cls.env["account.journal"].create(
{
"name": "Bank US",
"type": "bank",
"code": "BNK68",
"currency_id": cls.currency_usd_id,
}
)
cls.account_usd = cls.bank_journal_usd.default_account_id
cls.fx_journal = cls.company.currency_exchange_journal_id
cls.diff_income_account = cls.company.income_currency_exchange_account_id
cls.diff_expense_account = cls.company.expense_currency_exchange_account_id
cls.expense_account = cls.company_data["default_account_expense"]
# cash basis intermediary account
cls.tax_waiting_account = cls.env["account.account"].create(
{
"name": "TAX_WAIT",
"code": "TWAIT",
"account_type": "liability_current",
"reconcile": True,
"company_id": cls.company.id,
}
)
# cash basis final account
cls.tax_final_account = cls.env["account.account"].create(
{
"name": "TAX_TO_DEDUCT",
"code": "TDEDUCT",
"account_type": "asset_current",
"company_id": cls.company.id,
}
)
cls.tax_base_amount_account = cls.env["account.account"].create(
{
"name": "TAX_BASE",
"code": "TBASE",
"account_type": "asset_current",
"company_id": cls.company.id,
}
)
cls.company.account_cash_basis_base_account_id = cls.tax_base_amount_account.id
# Journals
cls.purchase_journal = cls.company_data["default_journal_purchase"]
cls.cash_basis_journal = cls.env["account.journal"].create(
{
"name": "Test CABA",
"code": "tCABA",
"type": "general",
}
)
cls.general_journal = cls.company_data["default_journal_misc"]
# Tax Cash Basis
cls.tax_cash_basis = cls.env["account.tax"].create(
{
"name": "cash basis 20%",
"type_tax_use": "purchase",
"company_id": cls.company.id,
"country_id": cls.company.account_fiscal_country_id.id,
"amount": 20,
"tax_exigibility": "on_payment",
"cash_basis_transition_account_id": cls.tax_waiting_account.id,
"invoice_repartition_line_ids": [
(
0,
0,
{
"repartition_type": "base",
},
),
(
0,
0,
{
"repartition_type": "tax",
"account_id": cls.tax_final_account.id,
},
),
],
"refund_repartition_line_ids": [
(
0,
0,
{
"repartition_type": "base",
},
),
(
0,
0,
{
"repartition_type": "tax",
"account_id": cls.tax_final_account.id,
},
),
],
}
)
cls.env["res.currency.rate"].create(
[
{
"currency_id": cls.env.ref("base.EUR").id,
"name": "2010-01-02",
"rate": 1.0,
},
{
"currency_id": cls.env.ref("base.USD").id,
"name": "2010-01-02",
"rate": 1.2834,
},
{
"currency_id": cls.env.ref("base.USD").id,
"name": time.strftime("%Y-06-05"),
"rate": 1.5289,
},
]
)
def _create_invoice(
self,
move_type="out_invoice",
invoice_amount=50,
currency_id=None,
partner_id=None,
date_invoice=None,
payment_term_id=False,
auto_validate=False,
):
date_invoice = date_invoice or time.strftime("%Y") + "-07-01"
invoice_vals = {
"move_type": move_type,
"partner_id": partner_id or self.partner_agrolait_id,
"invoice_date": date_invoice,
"date": date_invoice,
"invoice_line_ids": [
(
0,
0,
{
"name": "product that cost %s" % invoice_amount,
"quantity": 1,
"price_unit": invoice_amount,
"tax_ids": [Command.set([])],
},
)
],
}
if payment_term_id:
invoice_vals["invoice_payment_term_id"] = payment_term_id
if currency_id:
invoice_vals["currency_id"] = currency_id
invoice = (
self.env["account.move"]
.with_context(default_move_type=move_type)
.create(invoice_vals)
)
if auto_validate:
invoice.action_post()
return invoice
def create_invoice(
self, move_type="out_invoice", invoice_amount=50, currency_id=None
):
return self._create_invoice(
move_type=move_type,
invoice_amount=invoice_amount,
currency_id=currency_id,
auto_validate=True,
)
def create_invoice_partner(
self,
move_type="out_invoice",
invoice_amount=50,
currency_id=None,
partner_id=False,
payment_term_id=False,
):
return self._create_invoice(
move_type=move_type,
invoice_amount=invoice_amount,
currency_id=currency_id,
partner_id=partner_id,
payment_term_id=payment_term_id,
auto_validate=True,
)

File diff suppressed because it is too large Load Diff