[ADD] account_reconcile_model_oca
parent
7419bc5b56
commit
835e0c9b16
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
|
@ -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": [],
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
from . import account_reconcile_model
|
||||
from . import account_bank_statement_line
|
|
@ -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
|
|
@ -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"}
|
|
@ -0,0 +1,3 @@
|
|||
[build-system]
|
||||
requires = ["whool"]
|
||||
build-backend = "whool.buildapi"
|
|
@ -0,0 +1,3 @@
|
|||
- Dixmit
|
||||
|
||||
- Enric Tobella
|
|
@ -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 |
|
@ -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&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>
|
|
@ -0,0 +1 @@
|
|||
from . import test_reconciliation_match
|
|
@ -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
Loading…
Reference in New Issue