[IMP] account: Improve reconciliation Models usability

This task aims to both explain supported reconciliation cases and improve the reconciliation model form view.
The form improvement is mainly about label wording and make the options more self speaking.

Also, disable bypass of the matching amount we do in case the statement line's label exactly matches the payment reference.
It can be replaced by just making a final model that is applied whatever the amount and will do the partial reconciliation on everything that wasn't matched by others.
That's cleaner, as users can way more predict what models can do and debug their config.

closes odoo/odoo#73043

Task: 2427089
Related: odoo/upgrade#2729
Signed-off-by: oco-odoo <oco-odoo@users.noreply.github.com>
This commit is contained in:
jbw
2021-09-01 20:57:00 +00:00
committed by Laurent Smet
parent 04f3cac527
commit 5ad660877f
6 changed files with 870 additions and 449 deletions
+54 -19
View File
@@ -1,17 +1,12 @@
# -*- coding: utf-8 -*-
import math
from odoo import api, fields, models, _
from odoo.osv import expression
from odoo.tools import float_is_zero
from odoo.tools import float_compare, float_round, float_repr
from odoo.tools.misc import formatLang, format_date
from odoo.exceptions import UserError, ValidationError
import time
import math
import base64
import re
class AccountCashboxLine(models.Model):
""" Cash Box Details """
@@ -1017,12 +1012,14 @@ class AccountBankStatementLine(models.Model):
# RECONCILIATION METHODS
# -------------------------------------------------------------------------
def _prepare_reconciliation(self, lines_vals_list):
def _prepare_reconciliation(self, lines_vals_list, allow_partial=False):
''' Helper for the "reconcile" method used to get a full preview of the reconciliation result. This method is
quite useful to deal with reconcile models or the reconciliation widget because it ensures the values seen by
the user are exactly the values you get after reconciling.
:param lines_vals_list: See the 'reconcile' method.
:param lines_vals_list: See the 'reconcile' method.
:param allow_partial: In case of matching a line having an higher amount, allow creating a partial instead
an open balance on the statement line.
:return: The diff to be applied on the statement line as a tuple
(
lines_to_create: The values to create the account.move.line on the statement line.
@@ -1031,7 +1028,6 @@ class AccountBankStatementLine(models.Model):
existing_lines: The counterpart lines to which the reconciliation will be done.
)
'''
self.ensure_one()
journal = self.journal_id
company_currency = journal.company_id.currency_id
@@ -1053,6 +1049,7 @@ class AccountBankStatementLine(models.Model):
total_balance = liquidity_lines.balance
total_amount_currency = liquidity_lines.amount_currency
sign = 1 if liquidity_lines.balance > 0.0 else -1
# Step 1: Split 'lines_vals_list' into two batches:
# - The existing account.move.lines that need to be reconciled with the statement line.
@@ -1060,6 +1057,10 @@ class AccountBankStatementLine(models.Model):
# - The account.move.lines to be created from scratch.
# => Will be managed directly.
# In case of the payment is matched directly with an higher amount, don't create an open
# balance but a partial reconciliation.
partial_rec_needed = allow_partial
to_browse_ids = []
to_process_vals = []
for vals in lines_vals_list:
@@ -1070,32 +1071,61 @@ class AccountBankStatementLine(models.Model):
# Existing account.move.line.
to_browse_ids.append(vals.pop('id'))
to_process_vals.append(vals)
if any(x in vals for x in ('balance', 'amount_residual', 'amount_residual_currency')):
partial_rec_needed = False
else:
# Newly created account.move.line from scratch.
line_vals = self._prepare_counterpart_move_line_vals(vals)
total_balance += line_vals['debit'] - line_vals['credit']
total_amount_currency += line_vals['amount_currency']
reconciliation_overview.append({
'line_vals': line_vals,
})
reconciliation_overview.append({'line_vals': line_vals})
partial_rec_needed = False
# Step 2: Browse counterpart lines all in one and process them.
existing_lines = self.env['account.move.line'].browse(to_browse_ids)
i = 0
for line, counterpart_vals in zip(existing_lines, to_process_vals):
line_vals = self._prepare_counterpart_move_line_vals(counterpart_vals, move_line=line)
balance = line_vals['debit'] - line_vals['credit']
amount_currency = line_vals['amount_currency']
i += 1
if i == len(existing_lines):
# Last line.
if partial_rec_needed and sign * (total_amount_currency + amount_currency) < 0.0:
# On the last aml, when the total matched amount becomes higher than the residual amount of the
# statement line, make sure to not create an open balance later.
line_vals = self._prepare_counterpart_move_line_vals(
{
**counterpart_vals,
'amount_residual': -math.copysign(total_balance, balance),
'amount_residual_currency': -math.copysign(total_amount_currency, amount_currency),
'currency_id': foreign_currency.id,
},
move_line=line,
)
balance = line_vals['debit'] - line_vals['credit']
amount_currency = line_vals['amount_currency']
elif sign * total_amount_currency < 0.0:
# The partial reconciliation is no longer an option since the total matched amount is now higher than
# the residual amount of the statement line but this is not the last line to process. Then, since we
# don't want to create zero balance lines, do nothing and let the open-balance be created like it
# should.
partial_rec_needed = False
total_balance += balance
total_amount_currency += amount_currency
reconciliation_overview.append({
'line_vals': line_vals,
'counterpart_line': line,
})
total_balance += balance
total_amount_currency += amount_currency
# Step 3: Fix rounding issue due to currency conversions.
# Add the remaining balance on the first encountered line starting with the custom ones.
@@ -1139,7 +1169,7 @@ class AccountBankStatementLine(models.Model):
return reconciliation_overview, open_balance_vals
def reconcile(self, lines_vals_list, to_check=False):
def reconcile(self, lines_vals_list, to_check=False, allow_partial=False):
''' Perform a reconciliation on the current account.bank.statement.line with some
counterpart account.move.line.
If the statement line entry is not fully balanced after the reconciliation, an open balance will be created
@@ -1154,11 +1184,16 @@ class AccountBankStatementLine(models.Model):
This value must be provided if 'id' is not.
**kwargs: Custom values to be set on the newly created account.move.line.
:param to_check: Mark the current statement line as "to_check" (see field for more details).
:param allow_partial: In case of matching a line having an higher amount, allow creating a partial instead
of an open balance on the statement line.
'''
self.ensure_one()
liquidity_lines, suspense_lines, other_lines = self._seek_for_lines()
reconciliation_overview, open_balance_vals = self._prepare_reconciliation(lines_vals_list)
reconciliation_overview, open_balance_vals = self._prepare_reconciliation(
lines_vals_list,
allow_partial=allow_partial,
)
# ==== Manage res.partner.bank ====
+254 -168
View File
@@ -6,11 +6,11 @@ from odoo.osv.expression import get_unaccent_wrapper
from odoo.exceptions import UserError, ValidationError
import re
from math import copysign
import itertools
from collections import defaultdict
from dateutil.relativedelta import relativedelta
import html2text
class AccountReconcileModelPartnerMapping(models.Model):
_name = 'account.reconcile.model.partner.mapping'
_description = 'Partner mapping for reconciliation models'
@@ -35,6 +35,7 @@ class AccountReconcileModelPartnerMapping(models.Model):
except re.error:
raise ValidationError(_("The following regular expression is invalid to create a partner mapping: %s") % current_regex)
class AccountReconcileModelLine(models.Model):
_name = 'account.reconcile.model.line'
_description = 'Rules for the reconciliation model'
@@ -42,8 +43,8 @@ class AccountReconcileModelLine(models.Model):
_check_company_auto = True
model_id = fields.Many2one('account.reconcile.model', readonly=True, ondelete='cascade')
match_total_amount = fields.Boolean(related='model_id.match_total_amount')
match_total_amount_param = fields.Float(related='model_id.match_total_amount_param')
allow_payment_tolerance = fields.Boolean(related='model_id.allow_payment_tolerance')
payment_tolerance_param = fields.Float(related='model_id.payment_tolerance_param')
rule_type = fields.Selection(related='model_id.rule_type')
company_id = fields.Many2one(related='model_id.company_id', store=True, default=lambda self: self.env.company)
sequence = fields.Integer(required=True, default=10)
@@ -57,6 +58,7 @@ class AccountReconcileModelLine(models.Model):
amount_type = fields.Selection([
('fixed', 'Fixed'),
('percentage', 'Percentage of balance'),
('percentage_st_line', 'Percentage of statement line'),
('regex', 'From label'),
], required=True, default='percentage')
show_force_tax_included = fields.Boolean(compute='_compute_show_force_tax_included', help='Technical field used to show the force tax included button')
@@ -86,7 +88,7 @@ class AccountReconcileModelLine(models.Model):
@api.onchange('amount_type')
def _onchange_amount_type(self):
self.amount_string = ''
if self.amount_type == 'percentage':
if self.amount_type in ('percentage', 'percentage_st_line'):
self.amount_string = '100'
elif self.amount_type == 'regex':
self.amount_string = '([\d,]+)'
@@ -103,9 +105,11 @@ class AccountReconcileModelLine(models.Model):
def _validate_amount(self):
for record in self:
if record.amount_type == 'fixed' and record.amount == 0:
raise UserError(_('The amount is not a number'))
if record.amount_type == 'percentage' and not 0 < record.amount:
raise UserError(_('The amount is not a percentage'))
raise UserError(_("The amount is not a number"))
if record.amount_type == 'percentage_st_line' and record.amount == 0:
raise UserError(_("Balance percentage can't be 0"))
if record.amount_type == 'percentage' and record.amount == 0:
raise UserError(_("Statement line percentage can't be 0"))
if record.amount_type == 'regex':
try:
re.compile(record.amount_string)
@@ -116,6 +120,7 @@ class AccountReconcileModelLine(models.Model):
class AccountReconcileModel(models.Model):
_name = 'account.reconcile.model'
_description = 'Preset to create journal entries during a invoices and payments matching'
_inherit = ['mail.thread']
_order = 'sequence, id'
_check_company_auto = True
@@ -127,13 +132,12 @@ class AccountReconcileModel(models.Model):
comodel_name='res.company',
string='Company', required=True, readonly=True,
default=lambda self: self.env.company)
rule_type = fields.Selection(selection=[
('writeoff_button', 'Manually create a write-off on clicked button'),
('writeoff_suggestion', 'Suggest counterpart values'),
('invoice_matching', 'Match existing invoices/bills'),
], string='Type', default='writeoff_button', required=True)
auto_reconcile = fields.Boolean(string='Auto-validate',
('writeoff_button', 'Button to generate counterpart entry'),
('writeoff_suggestion', 'Rule to suggest counterpart entry'),
('invoice_matching', 'Rule to match invoices/bills'),
], string='Type', default='writeoff_button', required=True, tracking=True)
auto_reconcile = fields.Boolean(string='Auto-validate', tracking=True,
help='Validate the statement line automatically (reconciliation based on your rule).')
to_check = fields.Boolean(string='To Check', default=False, help='This matching rule is used when the user is not certain of all the information of the counterpart.')
matching_order = fields.Selection(
@@ -143,30 +147,34 @@ class AccountReconcileModel(models.Model):
],
required=True,
default='old_first',
tracking=True,
)
# ===== Conditions =====
match_text_location_label = fields.Boolean(
default=True,
help="Search in the Statement's Label to find the Invoice/Payment's reference",
tracking=True,
)
match_text_location_note = fields.Boolean(
default=False,
help="Search in the Statement's Note to find the Invoice/Payment's reference",
tracking=True,
)
match_text_location_reference = fields.Boolean(
default=False,
help="Search in the Statement's Reference to find the Invoice/Payment's reference",
tracking=True,
)
match_journal_ids = fields.Many2many('account.journal', string='Journals',
match_journal_ids = fields.Many2many('account.journal', string='Journals Availability',
domain="[('type', 'in', ('bank', 'cash')), ('company_id', '=', company_id)]",
check_company=True,
help='The reconciliation model will only be available from the selected journals.')
match_nature = fields.Selection(selection=[
('amount_received', 'Amount Received'),
('amount_paid', 'Amount Paid'),
('both', 'Amount Paid/Received')
], string='Amount Nature', required=True, default='both',
('amount_received', 'Received'),
('amount_paid', 'Paid'),
('both', 'Paid/Received')
], string='Amount Type', required=True, default='both', tracking=True,
help='''The reconciliation model will only be applied to the selected transaction type:
* Amount Received: Only applied when receiving an amount.
* Amount Paid: Only applied when paying an amount.
@@ -175,48 +183,65 @@ class AccountReconcileModel(models.Model):
('lower', 'Is Lower Than'),
('greater', 'Is Greater Than'),
('between', 'Is Between'),
], string='Amount',
], string='Amount Condition', tracking=True,
help='The reconciliation model will only be applied when the amount being lower than, greater than or between specified amount(s).')
match_amount_min = fields.Float(string='Amount Min Parameter')
match_amount_max = fields.Float(string='Amount Max Parameter')
match_amount_min = fields.Float(string='Amount Min Parameter', tracking=True)
match_amount_max = fields.Float(string='Amount Max Parameter', tracking=True)
match_label = fields.Selection(selection=[
('contains', 'Contains'),
('not_contains', 'Not Contains'),
('match_regex', 'Match Regex'),
], string='Label', help='''The reconciliation model will only be applied when the label:
], string='Label', tracking=True, help='''The reconciliation model will only be applied when the label:
* Contains: The proposition label must contains this string (case insensitive).
* Not Contains: Negation of "Contains".
* Match Regex: Define your own regular expression.''')
match_label_param = fields.Char(string='Label Parameter')
match_label_param = fields.Char(string='Label Parameter', tracking=True)
match_note = fields.Selection(selection=[
('contains', 'Contains'),
('not_contains', 'Not Contains'),
('match_regex', 'Match Regex'),
], string='Note', help='''The reconciliation model will only be applied when the note:
], string='Note', tracking=True, help='''The reconciliation model will only be applied when the note:
* Contains: The proposition note must contains this string (case insensitive).
* Not Contains: Negation of "Contains".
* Match Regex: Define your own regular expression.''')
match_note_param = fields.Char(string='Note Parameter')
match_note_param = fields.Char(string='Note Parameter', tracking=True)
match_transaction_type = fields.Selection(selection=[
('contains', 'Contains'),
('not_contains', 'Not Contains'),
('match_regex', 'Match Regex'),
], string='Transaction Type', help='''The reconciliation model will only be applied when the transaction type:
], string='Transaction Type', tracking=True, help='''The reconciliation model will only be applied when the transaction type:
* Contains: The proposition transaction type must contains this string (case insensitive).
* Not Contains: Negation of "Contains".
* Match Regex: Define your own regular expression.''')
match_transaction_type_param = fields.Char(string='Transaction Type Parameter')
match_same_currency = fields.Boolean(string='Same Currency Matching', default=True,
match_transaction_type_param = fields.Char(string='Transaction Type Parameter', tracking=True)
match_same_currency = fields.Boolean(string='Same Currency', default=True, tracking=True,
help='Restrict to propositions having the same currency as the statement line.')
match_total_amount = fields.Boolean(string='Amount Matching', default=True,
help='The sum of total residual amount propositions matches the statement line amount.')
match_total_amount_param = fields.Float(string='Amount Matching %', default=100,
help='The sum of total residual amount propositions matches the statement line amount under this percentage.')
match_partner = fields.Boolean(string='Partner Is Set',
allow_payment_tolerance = fields.Boolean(
string="Payment Tolerance",
default=True,
tracking=True,
help="Difference accepted in case of underpayment.",
)
payment_tolerance_param = fields.Float(
string="Gap",
compute='_compute_payment_tolerance_param',
readonly=False,
store=True,
tracking=True,
help="The sum of total residual amount propositions matches the statement line amount under this amount/percentage.",
)
payment_tolerance_type = fields.Selection(
selection=[('percentage', "in percentage"), ('fixed_amount', "in amount")],
default='percentage',
required=True,
tracking=True,
help="The sum of total residual amount propositions and the statement line amount allowed gap type.",
)
match_partner = fields.Boolean(string='Partner should be set', tracking=True,
help='The reconciliation model will only be applied when a customer/vendor is set.')
match_partner_ids = fields.Many2many('res.partner', string='Restrict Partners to',
match_partner_ids = fields.Many2many('res.partner', string='Only Those Partners',
help='The reconciliation model will only be applied to the selected customers/vendors.')
match_partner_category_ids = fields.Many2many('res.partner.category', string='Restrict Partner Categories to',
match_partner_category_ids = fields.Many2many('res.partner.category', string='Only Those Partner Categories',
help='The reconciliation model will only be applied to the selected customer/vendor categories.')
line_ids = fields.One2many('account.reconcile.model.line', 'model_id')
@@ -227,10 +252,17 @@ class AccountReconcileModel(models.Model):
"- To Match the text at the beginning of the line (in label or notes), simply fill in your text.\n"
"- To Match the text anywhere (in label or notes), put your text between .*\n"
" e.g: .*N°48748 abc123.*")
past_months_limit = fields.Integer(string="Past Months Limit", default=18, help="Number of months in the past to consider entries from when applying this model.")
decimal_separator = fields.Char(default=lambda self: self.env['res.lang']._lang_get(self.env.user.lang).decimal_point, help="Every character that is nor a digit nor this separator will be removed from the matching string")
past_months_limit = fields.Integer(
string="Search Months Limit",
default=18,
tracking=True,
help="Number of months in the past to consider entries from when applying this model.",
)
decimal_separator = fields.Char(
default=lambda self: self.env['res.lang']._lang_get(self.env.user.lang).decimal_point,
tracking=True,
help="Every character that is nor a digit nor this separator will be removed from the matching string",
)
show_decimal_separator = fields.Boolean(compute='_compute_show_decimal_separator', help="Technical field to decide if we should show the decimal separator for the regex matching field.")
number_entries = fields.Integer(string='Number of entries related to this model', compute='_compute_number_entries')
@@ -260,10 +292,22 @@ class AccountReconcileModel(models.Model):
for record in self:
record.show_decimal_separator = any(l.amount_type == 'regex' for l in record.line_ids)
@api.onchange('match_total_amount_param')
def _onchange_match_total_amount_param(self):
if self.match_total_amount_param < 0 or self.match_total_amount_param > 100:
self.match_total_amount_param = min(max(0, self.match_total_amount_param), 100)
@api.depends('payment_tolerance_param', 'payment_tolerance_type')
def _compute_payment_tolerance_param(self):
for record in self:
if record.payment_tolerance_type == 'percentage':
record.payment_tolerance_param = min(100.0, max(0.0, record.payment_tolerance_param))
else:
record.payment_tolerance_param = max(0.0, record.payment_tolerance_param)
@api.constrains('allow_payment_tolerance', 'payment_tolerance_param', 'payment_tolerance_type')
def _check_payment_tolerance_param(self):
for record in self:
if record.allow_payment_tolerance:
if record.payment_tolerance_type == 'percentage' and not 0 <= record.payment_tolerance_param <= 100:
raise ValidationError(_("A payment tolerance defined as a percentage should always be between 0 and 100"))
elif record.payment_tolerance_type == 'fixed_amount' and record.payment_tolerance_param < 0:
raise ValidationError(_("A payment tolerance defined as an amount should always be higher than 0"))
####################################################
# RECONCILIATION PROCESS
@@ -321,19 +365,30 @@ class AccountReconcileModel(models.Model):
'''
self.ensure_one()
if self.rule_type == 'invoice_matching' and (not self.match_total_amount or (self.match_total_amount_param == 100)):
if self.rule_type == 'invoice_matching' and (not self.allow_payment_tolerance or self.payment_tolerance_param == 0):
return []
lines_vals_list = []
if st_line:
comp_curr = st_line.company_currency_id
matched_candidates_values = self._process_matched_candidates_data(st_line)
st_line_residual = matched_candidates_values['balance_sign'] * matched_candidates_values['residual_balance']
else:
comp_curr = self.company_id.currency_id
for line in self.line_ids:
currency_id = st_line.currency_id or st_line.journal_id.currency_id or self.company_id.currency_id
if not line.account_id or currency_id.is_zero(residual_balance):
# No statement line
if any(x.amount_type == 'percentage_st_line' for x in self.line_ids):
return []
lines_vals_list = []
for line in self.line_ids:
if line.amount_type == 'percentage':
balance = residual_balance * (line.amount / 100.0)
elif line.amount_type == "regex":
balance = comp_curr.round(residual_balance * (line.amount / 100.0))
elif line.amount_type == 'percentage_st_line':
if st_line:
balance = comp_curr.round(st_line_residual * (line.amount / 100.0))
else:
balance = 0.0
elif line.amount_type == 'regex':
match = re.search(line.amount_string, st_line.payment_ref)
if match:
sign = 1 if residual_balance > 0.0 else -1
@@ -344,8 +399,11 @@ class AccountReconcileModel(models.Model):
balance = 0
else:
balance = 0
else:
balance = line.amount * (1 if residual_balance > 0.0 else -1)
elif line.amount_type == 'fixed':
balance = comp_curr.round(line.amount * (1 if residual_balance > 0.0 else -1))
if comp_curr.is_zero(balance):
continue
writeoff_line = {
'name': line.label or st_line.payment_ref,
@@ -353,7 +411,7 @@ class AccountReconcileModel(models.Model):
'debit': balance > 0 and balance or 0,
'credit': balance < 0 and -balance or 0,
'account_id': line.account_id.id,
'currency_id': False,
'currency_id': comp_curr.id,
'analytic_account_id': line.analytic_account_id.id,
'analytic_tag_ids': [(6, 0, line.analytic_tag_ids.ids)],
'reconcile_model_id': self.id,
@@ -377,75 +435,6 @@ class AccountReconcileModel(models.Model):
return lines_vals_list
def _prepare_reconciliation(self, st_line, aml_ids=[], partner=None):
''' Prepare the reconciliation of the statement line with some counterpart line but
also with some auto-generated write-off lines.
The complexity of this method comes from the fact the reconciliation will be soft meaning
it will be done only if the reconciliation will not trigger an error.
For example, the reconciliation will be skipped if we need to create an open balance but we
don't have a partner to get the receivable/payable account.
This method works in two major steps. First, simulate the reconciliation of the account.move.line.
Then, add some write-off lines depending the rule's fields.
:param st_line: An account.bank.statement.line record.
:param aml_ids: The ids of some account.move.line to reconcile.
:param partner: An optional res.partner record. If not specified, fallback on the statement line's partner.
:return: A list of dictionary to be passed to the account.bank.statement.line's 'reconcile' method.
'''
self.ensure_one()
liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
if st_line.to_check:
st_line_residual = -liquidity_lines.balance
elif suspense_lines.account_id.reconcile:
st_line_residual = sum(suspense_lines.mapped('amount_residual'))
else:
st_line_residual = sum(suspense_lines.mapped('balance'))
partner = partner or st_line.partner_id
has_full_write_off= any(rec_mod_line.amount == 100.0 for rec_mod_line in self.line_ids)
lines_vals_list = []
amls = self.env['account.move.line'].browse(aml_ids)
st_line_residual_before = st_line_residual
aml_total_residual = 0
for aml in amls:
aml_total_residual += aml.amount_residual
if aml.balance * st_line_residual > 0:
# Meaning they have the same signs, so they can't be reconciled together
assigned_balance = -aml.amount_residual
elif has_full_write_off:
assigned_balance = -aml.amount_residual
st_line_residual -= min(-aml.amount_residual, st_line_residual, key=abs)
else:
assigned_balance = min(-aml.amount_residual, st_line_residual, key=abs)
st_line_residual -= assigned_balance
lines_vals_list.append({
'id': aml.id,
'balance': assigned_balance,
'currency_id': st_line.move_id.company_id.currency_id.id,
})
write_off_amount = max(aml_total_residual, -st_line_residual_before, key=abs) + st_line_residual_before + st_line_residual
reconciliation_overview, open_balance_vals = st_line._prepare_reconciliation(lines_vals_list)
writeoff_vals_list = self._get_write_off_move_lines_dict(st_line, write_off_amount)
for line_vals in writeoff_vals_list:
st_line_residual -= st_line.company_currency_id.round(line_vals['balance'])
# Check we have enough information to create an open balance.
if open_balance_vals and not open_balance_vals.get('account_id'):
return []
return lines_vals_list + writeoff_vals_list
####################################################
# RECONCILIATION CRITERIA
####################################################
@@ -822,50 +811,77 @@ class AccountReconcileModel(models.Model):
break
# We check the amount criteria of the reconciliation model, and select the
# candidates if they pass the verification. Candidates from the first priority
# level (even already selected) bypass this check, and are selected anyway.
disable_bypass = self.env['ir.config_parameter'].sudo().get_param('account.disable_rec_models_bypass')
if (not disable_bypass and priorities & {1,2}) or self._check_rule_propositions(st_line, candidates):
# candidates if they pass the verification.
matched_candidates_values = self._process_matched_candidates_data(st_line, candidates)
status = self._check_rule_propositions(matched_candidates_values)
if 'rejected' in status:
rslt = None
else:
rslt = {
'model': self,
'aml_ids': [candidate['aml_id'] for candidate in candidates],
}
new_treated_aml_ids = set(rslt['aml_ids'])
# Create write-off lines.
lines_vals_list = self._prepare_reconciliation(st_line, aml_ids=rslt['aml_ids'], partner=partner)
# Create write-off lines (in company's currency).
if 'allow_write_off' in status:
residual_balance_after_rec = matched_candidates_values['residual_balance'] + matched_candidates_values['candidates_balance']
writeoff_vals_list = self._get_write_off_move_lines_dict(
st_line,
matched_candidates_values['balance_sign'] * residual_balance_after_rec,
)
if writeoff_vals_list:
rslt['status'] = 'write_off'
rslt['write_off_vals'] = writeoff_vals_list
else:
writeoff_vals_list = []
# A write-off must be applied if there are some 'new' lines to propose.
write_off_lines_vals = list(filter(lambda x: 'id' not in x, lines_vals_list))
if not lines_vals_list or write_off_lines_vals:
rslt['status'] = 'write_off'
rslt['write_off_vals'] = write_off_lines_vals
# Reconcile.
if 'allow_auto_reconcile' in status:
# Process auto-reconciliation. We only do that for the first two priorities, if they are not matched elsewhere.
if lines_vals_list and priorities & {1, 3} and self.auto_reconcile:
if not st_line.partner_id and partner:
st_line.partner_id = partner
# Process auto-reconciliation. We only do that for the first two priorities, if they are not matched elsewhere.
aml_ids = [candidate['aml_id'] for candidate in candidates]
lines_vals_list = [{'id': aml_id} for aml_id in aml_ids]
st_line.reconcile(lines_vals_list)
rslt['status'] = 'reconciled'
rslt['reconciled_lines'] = st_line.line_ids
new_reconciled_aml_ids = new_treated_aml_ids
else:
rslt = None
if lines_vals_list and priorities & {1, 3} and self.auto_reconcile:
# Ensure this will not raise an error if case of missing account to create an open balance.
dummy, open_balance_vals = st_line._prepare_reconciliation(lines_vals_list + writeoff_vals_list)
if not open_balance_vals or open_balance_vals.get('account_id'):
if not st_line.partner_id and partner:
st_line.partner_id = partner
st_line.reconcile(lines_vals_list + writeoff_vals_list, allow_partial=True)
rslt['status'] = 'reconciled'
rslt['reconciled_lines'] = st_line.line_ids
new_reconciled_aml_ids = new_treated_aml_ids
return rslt, new_reconciled_aml_ids, new_treated_aml_ids
def _check_rule_propositions(self, statement_line, candidates):
''' Check restrictions that can't be handled for each move.line separately.
/!\ Only used by models having a type equals to 'invoice_matching'.
def _process_matched_candidates_data(self, statement_line, candidates=None):
""" Simulate the reconciliation of the statement line with the candidates and
compute some useful data to perform all the matching rules logic.
:param statement_line: An account.bank.statement.line record.
:param candidates: Fetched account.move.lines from query (dict).
:return: True if the reconciliation propositions are accepted. False otherwise.
'''
if not self.match_total_amount:
return True
if not candidates:
return False
:return: A python dict containing:
* currency: The currency of the transaction.
* statement_line: The statement line matching the candidates.
* candidates: Fetched account.move.lines from query (dict).
* reconciliation_overview: The computed reconciliation from '_prepare_reconciliation'.
* open_balance_vals: The open balance returned by '_prepare_reconciliation'.
* balance_sign: The sign applied to the balance to make amounts always positive.
* residual_balance: The residual balance of the statement line before reconciling anything,
always positive and expressed in company's currency.
* candidates_balance: The balance of candidates lines expressed in company's currency.
* residual_balance_curr: The residual balance of the statement line before reconciling anything,
always positive and expressed in transaction's currency.
* candidates_balance_curr: The balance of candidates lines expressed in transaction's currency.
"""
candidates = candidates or []
reconciliation_overview, open_balance_vals = statement_line._prepare_reconciliation([{
'currency_id': aml['aml_currency_id'],
@@ -873,24 +889,89 @@ class AccountReconcileModel(models.Model):
'amount_residual_currency': aml['aml_amount_residual_currency'],
} for aml in candidates])
# Match total residual amount.
line_currency = statement_line.foreign_currency_id or statement_line.currency_id
line_residual = statement_line.amount_residual
line_residual_after_reconciliation = line_residual
# Compute 'residual_balance', the remaining amount to reconcile of the statement line expressed in the
# transaction currency.
liquidity_lines, suspense_lines, dummy = statement_line._seek_for_lines()
if statement_line.to_check:
stl_residual_balance = -liquidity_lines.balance
stl_residual_balance_curr = -liquidity_lines.amount_currency
elif suspense_lines.account_id.reconcile:
stl_residual_balance = sum(suspense_lines.mapped('amount_residual'))
stl_residual_balance_curr = sum(suspense_lines.mapped('amount_residual_currency'))
else:
stl_residual_balance = sum(suspense_lines.mapped('balance'))
stl_residual_balance_curr = sum(suspense_lines.mapped('amount_currency'))
# Compute 'reconciled_balance', the total reconciled amount to be reconciled by the candidates.
candidates_balance = 0.0
candidates_balance_curr = 0.0
for reconciliation_vals in reconciliation_overview:
line_vals = reconciliation_vals['line_vals']
candidates_balance -= line_vals['debit'] - line_vals['credit']
if line_vals['currency_id']:
line_residual_after_reconciliation -= line_vals['amount_currency']
candidates_balance_curr -= line_vals['amount_currency']
else:
line_residual_after_reconciliation -= line_vals['debit'] - line_vals['credit']
candidates_balance_curr -= line_vals['debit'] - line_vals['credit']
# Statement line amount is equal to the total residual.
if line_currency.is_zero(line_residual_after_reconciliation):
return True
residual_difference = line_residual - line_residual_after_reconciliation
reconciled_percentage = 100 - abs(line_residual_after_reconciliation) / abs(residual_difference) * 100 if (residual_difference != 0) else 0
return reconciled_percentage >= self.match_total_amount_param
# Sign amount to ease computation. Multiplying any amount from the statement line makes it positive.
balance_sign = 1 if stl_residual_balance > 0.0 else -1
return {
'currency': statement_line.foreign_currency_id or statement_line.currency_id,
'statement_line': statement_line,
'candidates': candidates,
'reconciliation_overview': reconciliation_overview,
'open_balance_vals': open_balance_vals,
'balance_sign': balance_sign,
'residual_balance': balance_sign * stl_residual_balance,
'candidates_balance': balance_sign * candidates_balance,
'residual_balance_curr': balance_sign * stl_residual_balance_curr,
'candidates_balance_curr': balance_sign * candidates_balance_curr,
}
def _check_rule_propositions(self, matched_candidates_values):
""" 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 matched_candidates_values: The values computed by '_process_matched_candidates_data'.
: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.
"""
candidates = matched_candidates_values['candidates']
currency = matched_candidates_values['currency']
if not self.allow_payment_tolerance:
return {'allow_write_off', 'allow_auto_reconcile'}
if not candidates:
return {'rejected'}
# The statement line will be fully reconciled.
residual_balance_after_rec = matched_candidates_values['residual_balance_curr'] + matched_candidates_values['candidates_balance_curr']
if currency.is_zero(residual_balance_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 residual_balance_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 -residual_balance_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 = (residual_balance_after_rec / matched_candidates_values['candidates_balance_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'}
def _filter_candidates(self, candidates, aml_ids_to_exclude, reconciled_amls_ids):
""" Sorts reconciliation candidates by priority and filters them so that only
@@ -958,21 +1039,26 @@ class AccountReconcileModel(models.Model):
def _get_writeoff_suggestion_rule_result(self, st_line, partner):
# Create write-off lines.
lines_vals_list = self._prepare_reconciliation(st_line, partner=partner)
matched_candidates_values = self._process_matched_candidates_data(st_line)
residual_balance_after_rec = matched_candidates_values['residual_balance'] + matched_candidates_values['candidates_balance']
writeoff_vals_list = self._get_write_off_move_lines_dict(
st_line,
matched_candidates_values['balance_sign'] * residual_balance_after_rec,
)
rslt = {
'model': self,
'status': 'write_off',
'aml_ids': [],
'write_off_vals': lines_vals_list,
'write_off_vals': writeoff_vals_list,
}
# Process auto-reconciliation.
if lines_vals_list and self.auto_reconcile:
if writeoff_vals_list and self.auto_reconcile:
if not st_line.partner_id and partner:
st_line.partner_id = partner
st_line.reconcile(lines_vals_list)
st_line.reconcile(writeoff_vals_list)
rslt['status'] = 'reconciled'
rslt['reconciled_lines'] = st_line.line_ids
+29 -15
View File
@@ -791,8 +791,9 @@ class AccountChartTemplate(models.Model):
'match_transaction_type': account_reconcile_model.match_transaction_type,
'match_transaction_type_param': account_reconcile_model.match_transaction_type_param,
'match_same_currency': account_reconcile_model.match_same_currency,
'match_total_amount': account_reconcile_model.match_total_amount,
'match_total_amount_param': account_reconcile_model.match_total_amount_param,
'allow_payment_tolerance': account_reconcile_model.allow_payment_tolerance,
'payment_tolerance_type': account_reconcile_model.payment_tolerance_type,
'payment_tolerance_param': account_reconcile_model.payment_tolerance_param,
'match_partner': account_reconcile_model.match_partner,
'match_partner_ids': [(6, None, account_reconcile_model.match_partner_ids.ids)],
'match_partner_category_ids': [(6, None, account_reconcile_model.match_partner_category_ids.ids)],
@@ -830,8 +831,9 @@ class AccountChartTemplate(models.Model):
"auto_reconcile": False,
"match_nature": 'both',
"match_same_currency": True,
"match_total_amount": True,
"match_total_amount_param": 100,
"allow_payment_tolerance": True,
"payment_tolerance_type": 'percentage',
"payment_tolerance_param": 0,
"match_partner": True,
"company_id": company.id,
})
@@ -1195,9 +1197,9 @@ class AccountReconcileModelTemplate(models.Model):
sequence = fields.Integer(required=True, default=10)
rule_type = fields.Selection(selection=[
('writeoff_button', 'Manually create a write-off on clicked button'),
('writeoff_suggestion', 'Suggest a write-off'),
('invoice_matching', 'Match existing invoices/bills')
('writeoff_button', 'Button to generate counterpart entry'),
('writeoff_suggestion', 'Rule to suggest counterpart entry'),
('invoice_matching', 'Rule to match invoices/bills'),
], string='Type', default='writeoff_button', required=True)
auto_reconcile = fields.Boolean(string='Auto-validate',
help='Validate the statement line automatically (reconciliation based on your rule).')
@@ -1222,14 +1224,14 @@ class AccountReconcileModelTemplate(models.Model):
default=False,
help="Search in the Statement's Reference to find the Invoice/Payment's reference",
)
match_journal_ids = fields.Many2many('account.journal', string='Journals',
match_journal_ids = fields.Many2many('account.journal', string='Journals Availability',
domain="[('type', 'in', ('bank', 'cash'))]",
help='The reconciliation model will only be available from the selected journals.')
match_nature = fields.Selection(selection=[
('amount_received', 'Amount Received'),
('amount_paid', 'Amount Paid'),
('both', 'Amount Paid/Received')
], string='Amount Nature', required=True, default='both',
], string='Amount Type', required=True, default='both',
help='''The reconciliation model will only be applied to the selected transaction type:
* Amount Received: Only applied when receiving an amount.
* Amount Paid: Only applied when paying an amount.
@@ -1238,7 +1240,7 @@ class AccountReconcileModelTemplate(models.Model):
('lower', 'Is Lower Than'),
('greater', 'Is Greater Than'),
('between', 'Is Between'),
], string='Amount',
], string='Amount Condition',
help='The reconciliation model will only be applied when the amount being lower than, greater than or between specified amount(s).')
match_amount_min = fields.Float(string='Amount Min Parameter')
match_amount_max = fields.Float(string='Amount Max Parameter')
@@ -1269,12 +1271,24 @@ class AccountReconcileModelTemplate(models.Model):
* Not Contains: Negation of "Contains".
* Match Regex: Define your own regular expression.''')
match_transaction_type_param = fields.Char(string='Transaction Type Parameter')
match_same_currency = fields.Boolean(string='Same Currency Matching', default=True,
match_same_currency = fields.Boolean(string='Same Currency', default=True,
help='Restrict to propositions having the same currency as the statement line.')
match_total_amount = fields.Boolean(string='Amount Matching', default=True,
help='The sum of total residual amount propositions matches the statement line amount.')
match_total_amount_param = fields.Float(string='Amount Matching %', default=100,
help='The sum of total residual amount propositions matches the statement line amount under this percentage.')
allow_payment_tolerance = fields.Boolean(
string="Allow Payment Gap",
default=True,
help="Difference accepted in case of underpayment.",
)
payment_tolerance_param = fields.Float(
string="Gap",
default=0.0,
help="The sum of total residual amount propositions matches the statement line amount under this amount/percentage.",
)
payment_tolerance_type = fields.Selection(
selection=[('percentage', "in percentage"), ('fixed_amount', "in amount")],
required=True,
default='percentage',
help="The sum of total residual amount propositions and the statement line amount allowed gap type.",
)
match_partner = fields.Boolean(string='Partner Is Set',
help='The reconciliation model will only be applied when a customer/vendor is set.')
match_partner_ids = fields.Many2many('res.partner', string='Restrict Partners to',
@@ -4,6 +4,7 @@ from freezegun import freeze_time
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests.common import Form
from odoo.tests import tagged
from odoo import Command
@tagged('post_install', '-at_install')
@@ -59,8 +60,9 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
'auto_reconcile': False,
'match_nature': 'both',
'match_same_currency': True,
'match_total_amount': True,
'match_total_amount_param': 100,
'allow_payment_tolerance': True,
'payment_tolerance_type': 'percentage',
'payment_tolerance_param': 0.0,
'match_partner': True,
'match_partner_ids': [(6, 0, (cls.partner_1 + cls.partner_2 + cls.partner_3).ids)],
'company_id': cls.company.id,
@@ -159,9 +161,9 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
cls._post_statements(cls)
@classmethod
def _create_invoice_line(cls, amount, partner, type, currency=None, pay_reference=None, ref=None, name=None):
def _create_invoice_line(cls, amount, partner, move_type, currency=None, pay_reference=None, ref=None, name=None, inv_date='2019-09-01'):
''' Create an invoice on the fly.'''
invoice_form = Form(cls.env['account.move'].with_context(default_move_type=type, default_invoice_date='2019-09-01', default_date='2019-09-01'))
invoice_form = Form(cls.env['account.move'].with_context(default_move_type=move_type, default_invoice_date=inv_date, default_date=inv_date))
invoice_form.partner_id = partner
if currency:
invoice_form.currency_id = currency
@@ -181,6 +183,43 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
lines = invoice.line_ids
return lines.filtered(lambda l: l.account_id.user_type_id.type in ('receivable', 'payable'))
@classmethod
def _create_st_line(cls, amount=1000.0, date='2019-01-01', payment_ref='turlututu', **kwargs):
st = cls.env['account.bank.statement'].create({
'name': 'test_allow_payment_tolerance_1',
'journal_id': cls.bank_journal.id,
'line_ids': [Command.create({
'amount': amount,
'date': date,
'payment_ref': payment_ref,
'partner_id': cls.partner_a.id,
**kwargs,
})],
})
st.balance_end_real = st.balance_end
st.button_post()
return st.line_ids
@classmethod
def _create_reconcile_model(cls, **kwargs):
return cls.env['account.reconcile.model'].create({
'name': "test",
'rule_type': 'invoice_matching',
'allow_payment_tolerance': True,
'payment_tolerance_type': 'percentage',
'payment_tolerance_param': 0.0,
**kwargs,
'line_ids': [
Command.create({
'account_id': cls.company_data['default_account_revenue'].id,
'amount_type': 'percentage',
'label': f"test {i}",
**line_vals,
})
for i, line_vals in enumerate(kwargs.get('line_ids', []))
],
})
def _post_statements(self):
self.bank_st.balance_end_real = self.bank_st.balance_end
self.bank_st_2.balance_end_real = self.bank_st_2.balance_end
@@ -216,6 +255,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
self.rule_1.match_text_location_label = True
self.rule_1.match_text_location_reference = False
self.rule_1.match_text_location_note = False
self.rule_1.allow_payment_tolerance = False
self._check_statement_matching(self.rule_1, {
self.bank_line_3.id: {'aml_ids': [self.invoice_line_5.id], 'model': self.rule_1, 'partner': self.bank_line_3.partner_id},
self.bank_line_4.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_4.partner_id},
@@ -347,66 +387,230 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
})
self.rule_1.match_label = False
def test_matching_fields_match_total_amount(self):
# Check match_total_amount: line amount >= total residual amount.
self.rule_1.match_total_amount_param = 90.0
self.bank_line_1.amount += 10
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'write_off', 'partner': self.bank_line_1.partner_id},
self.bank_line_2.id: {'aml_ids': [
self.invoice_line_2.id,
self.invoice_line_3.id,
self.invoice_line_1.id,
], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id},
})
self.rule_1.match_total_amount_param = 100.0
self.bank_line_1.amount -= 10
@freeze_time('2019-01-01')
def test_zero_payment_tolerance(self):
rule = self._create_reconcile_model(line_ids=[{}])
# Check match_total_amount: line amount <= total residual amount.
self.rule_1.match_total_amount_param = 90.0
self.bank_line_1.amount -= 10
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'write_off', 'partner': self.bank_line_1.partner_id},
self.bank_line_2.id: {'aml_ids': [
self.invoice_line_2.id,
self.invoice_line_3.id,
self.invoice_line_1.id,
], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id},
})
self.rule_1.match_total_amount_param = 100.0
self.bank_line_1.amount += 10
for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)):
# Check match_total_amount: line amount >= total residual amount, match_total_amount_param just not matched.
self.rule_1.match_total_amount_param = 90.0
self.bank_line_1.amount += 10.01
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': []},
self.bank_line_2.id: {'aml_ids': [
self.invoice_line_1.id,
self.invoice_line_2.id,
self.invoice_line_3.id,
], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id},
})
self.rule_1.match_total_amount_param = 100.0
self.bank_line_1.amount -= 10.01
invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, inv_date='2019-01-01')
# Check match_total_amount: line amount <= total residual amount, match_total_amount_param just not matched.
self.rule_1.match_total_amount_param = 90.0
self.bank_line_1.amount -= 10.01
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': []},
self.bank_line_2.id: {'aml_ids': [
self.invoice_line_1.id,
self.invoice_line_2.id,
self.invoice_line_3.id,
], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id},
})
self.rule_1.match_total_amount_param = 100.0
self.bank_line_1.amount += 10.01
# Exact matching.
st_line = self._create_st_line(amount=bsl_sign * 1000.0)
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id}},
statements=st_line.statement_id,
)
# No matching because there is no tolerance.
st_line = self._create_st_line(amount=bsl_sign * 990.0)
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': []}},
statements=st_line.statement_id,
)
# The payment amount is higher than the invoice one.
st_line = self._create_st_line(amount=bsl_sign * 1010.0)
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id}},
statements=st_line.statement_id,
)
@freeze_time('2019-01-01')
def test_zero_payment_tolerance_auto_reconcile(self):
rule = self._create_reconcile_model(
auto_reconcile=True,
line_ids=[{}],
)
for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)):
invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, pay_reference='123456', inv_date='2019-01-01')
# No matching because there is no tolerance.
st_line = self._create_st_line(amount=bsl_sign * 990.0)
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': []}},
statements=st_line.statement_id,
)
# The payment amount is higher than the invoice one.
st_line = self._create_st_line(amount=bsl_sign * 1010.0, payment_ref='123456')
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id, 'status': 'reconciled'}},
statements=st_line.statement_id,
)
self.assertRecordValues(st_line.line_ids.sorted(lambda x: abs(x.balance)), [
# pylint: disable=bad-whitespace
{'balance': bsl_sign * -10.0, 'account_id': invl.account_id.id, 'reconciled': False},
{'balance': bsl_sign * -1000.0, 'account_id': invl.account_id.id, 'reconciled': True},
{'balance': bsl_sign * 1010.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False},
])
@freeze_time('2019-01-01')
def test_not_enough_payment_tolerance(self):
rule = self._create_reconcile_model(
payment_tolerance_param=0.5,
line_ids=[{}],
)
for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)):
invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, inv_date='2019-01-01')
# No matching because there is no enough tolerance.
st_line = self._create_st_line(amount=bsl_sign * 990.0)
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': []}},
statements=st_line.statement_id,
)
# The payment amount is higher than the invoice one.
# However, since the invoice amount is lower than the payment amount,
# the tolerance is not checked and the invoice line is matched.
st_line = self._create_st_line(amount=bsl_sign * 1010.0)
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id}},
statements=st_line.statement_id,
)
@freeze_time('2019-01-01')
def test_enough_payment_tolerance(self):
rule = self._create_reconcile_model(
payment_tolerance_param=1.0,
line_ids=[{}],
)
for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)):
invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, inv_date='2019-01-01')
# Enough tolerance to match the invoice line.
st_line = self._create_st_line(amount=bsl_sign * 990.0)
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id, 'status': 'write_off'}},
statements=st_line.statement_id,
)
# The payment amount is higher than the invoice one.
# However, since the invoice amount is lower than the payment amount,
# the tolerance is not checked and the invoice line is matched.
st_line = self._create_st_line(amount=bsl_sign * 1010.0)
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id}},
statements=st_line.statement_id,
)
@freeze_time('2019-01-01')
def test_enough_payment_tolerance_auto_reconcile_not_full(self):
rule = self._create_reconcile_model(
payment_tolerance_param=1.0,
auto_reconcile=True,
line_ids=[{'amount_type': 'percentage_st_line', 'amount_string': '200.0'}],
)
for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)):
invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, pay_reference='123456', inv_date='2019-01-01')
# Enough tolerance to match the invoice line.
st_line = self._create_st_line(amount=bsl_sign * 990.0, payment_ref='123456')
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id, 'status': 'reconciled'}},
statements=st_line.statement_id,
)
self.assertRecordValues(st_line.line_ids.sorted(lambda x: abs(x.balance)), [
# pylint: disable=bad-whitespace
{'balance': bsl_sign * 990.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False},
{'balance': bsl_sign * -1000.0, 'account_id': invl.account_id.id, 'reconciled': True},
{'balance': bsl_sign * -1980.0, 'account_id': self.company_data['default_account_revenue'].id, 'reconciled': False},
{'balance': bsl_sign * 1990.0, 'account_id': invl.account_id.id, 'reconciled': False},
])
@freeze_time('2019-01-01')
def test_allow_payment_tolerance_lower_amount(self):
rule = self._create_reconcile_model(line_ids=[{'amount_type': 'percentage_st_line'}])
for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)):
invl = self._create_invoice_line(990.0, self.partner_a, inv_type, inv_date='2019-01-01')
st_line = self._create_st_line(amount=bsl_sign * 1000)
# Partial reconciliation.
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id}},
statements=st_line.statement_id,
)
@freeze_time('2019-01-01')
def test_enough_payment_tolerance_auto_reconcile(self):
rule = self._create_reconcile_model(
payment_tolerance_param=1.0,
auto_reconcile=True,
line_ids=[{}],
)
for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)):
invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, pay_reference='123456', inv_date='2019-01-01')
# Enough tolerance to match the invoice line.
st_line = self._create_st_line(amount=bsl_sign * 990.0, payment_ref='123456')
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id, 'status': 'reconciled'}},
statements=st_line.statement_id,
)
self.assertRecordValues(st_line.line_ids.sorted(lambda x: abs(x.balance)), [
# pylint: disable=bad-whitespace
{'balance': bsl_sign * 10.0, 'account_id': self.company_data['default_account_revenue'].id, 'reconciled': False},
{'balance': bsl_sign * 990.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False},
{'balance': bsl_sign * -1000.0, 'account_id': invl.account_id.id, 'reconciled': True},
])
@freeze_time('2019-01-01')
def test_percentage_st_line_auto_reconcile(self):
rule = self._create_reconcile_model(
payment_tolerance_param=1.0,
rule_type='writeoff_suggestion',
auto_reconcile=True,
line_ids=[
{'amount_type': 'percentage_st_line', 'amount_string': '100.0', 'label': 'A'},
{'amount_type': 'percentage_st_line', 'amount_string': '-100.0', 'label': 'B'},
{'amount_type': 'percentage_st_line', 'amount_string': '100.0', 'label': 'C'},
],
)
for bsl_sign in (1, -1):
st_line = self._create_st_line(amount=bsl_sign * 1000.0)
self._check_statement_matching(
rule,
{st_line.id: {'aml_ids': [], 'model': rule, 'partner': st_line.partner_id, 'status': 'reconciled'}},
statements=st_line.statement_id,
)
self.assertRecordValues(st_line.line_ids.sorted(lambda x: x.account_id), [
# pylint: disable=bad-whitespace
{'balance': bsl_sign * 1000.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False},
{'balance': bsl_sign * -1000.0, 'account_id': self.company_data['default_account_revenue'].id, 'reconciled': False},
{'balance': bsl_sign * 1000.0, 'account_id': self.company_data['default_account_revenue'].id, 'reconciled': False},
{'balance': bsl_sign * -1000.0, 'account_id': self.company_data['default_account_revenue'].id, 'reconciled': False},
])
def test_matching_fields_match_partner_category_ids(self):
test_category = self.env['res.partner.category'].create({'name': 'Consulting Services'})
@@ -460,14 +664,23 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
self.rule_1.sequence = 2
self.rule_1.auto_reconcile = True
self.rule_1.match_total_amount_param = 90
self.rule_1.payment_tolerance_param = 10.0
self.rule_2.sequence = 1
self.rule_2.match_partner_ids |= self.partner_2
self.rule_2.auto_reconcile = True
self._check_statement_matching(self.rule_1 + self.rule_2, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_1.partner_id},
self.bank_line_2.id: {'aml_ids': []},
self.bank_line_1.id: {
'aml_ids': self.invoice_line_1.ids,
'model': self.rule_1,
'status': 'reconciled',
'partner': self.bank_line_1.partner_id,
},
self.bank_line_2.id: {
'aml_ids': (self.invoice_line_2 + self.invoice_line_3).ids,
'model': self.rule_1,
'partner': self.bank_line_2.partner_id,
},
self.cash_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'reconciled', 'partner': self.cash_line_1.partner_id},
})
@@ -491,12 +704,22 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
self.invoice_line_1.move_id.payment_reference = self.bank_line_1.payment_ref
self.rule_1.sequence = 2
self.rule_1.allow_payment_tolerance = False
self.rule_1.auto_reconcile = True
self.rule_1.line_ids = [(5, 0, 0)]
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_1.partner_id},
self.bank_line_2.id: {'aml_ids': []},
self.bank_line_1.id: {
'aml_ids': self.invoice_line_1.ids,
'model': self.rule_1,
'status': 'reconciled',
'partner': self.bank_line_1.partner_id,
},
self.bank_line_2.id: {
'aml_ids': (self.invoice_line_2 + self.invoice_line_3).ids,
'model': self.rule_1,
'partner': self.bank_line_1.partner_id,
},
}, statements=self.bank_st)
# Check first line has been well reconciled.
@@ -644,6 +867,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
right invoice if they have the same payment reference.
"""
self.invoice_line_1.move_id.write({'payment_reference': 'Tournicoti66'})
self.rule_1.allow_payment_tolerance = False
self.bank_line_1.write({
'payment_ref': 'Tournicoti66',
@@ -676,7 +900,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
'match_partner': False,
'match_label': 'contains',
'match_label_param': 'doudlidou', # So that we only match what we want to test
'match_total_amount_param': 90,
'payment_tolerance_param': 10.0,
'auto_reconcile': True,
})
@@ -796,8 +1020,9 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
'rule_type': 'invoice_matching',
'match_partner': True,
'match_partner_ids': [(6, 0, partner.ids)],
'match_total_amount': True,
'match_total_amount_param': 95.0,
'allow_payment_tolerance': True,
'payment_tolerance_type': 'percentage',
'payment_tolerance_param': 5.0,
'match_same_currency': False,
'company_id': self.company_data['company'].id,
'past_months_limit': False,
@@ -865,7 +1090,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
@freeze_time('2020-01-01')
def test_inv_matching_with_write_off(self):
self.rule_1.match_total_amount_param = 90
self.rule_1.payment_tolerance_param = 10.0
self.bank_st.line_ids[1].unlink() # We don't need this one here
statement_line = self.bank_st.line_ids[0]
statement_line.write({
@@ -881,7 +1106,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
# Test the write-off part
expected_write_off = {
'balance': 10,
'currency_id': False,
'currency_id': self.company_data['currency'].id,
'reconcile_model_id': self.rule_1.id,
'account_id': self.current_assets_account.id,
}
@@ -891,10 +1116,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
self.assertEqual(len(matching_result[statement_line.id].get('write_off_vals', [])), 1, "Exactly one write-off line should be proposed.")
full_write_off_dict = matching_result[statement_line.id]['write_off_vals'][0]
to_compare = {
key: full_write_off_dict[key]
for key in expected_write_off.keys()
}
to_compare = {key: full_write_off_dict[key] for key in expected_write_off}
self.assertDictEqual(expected_write_off, to_compare)
@@ -903,54 +1125,32 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
self.rule_1.sequence = 2
self.rule_1.auto_reconcile = True
self.rule_1.match_total_amount_param = 90
self.rule_1.payment_tolerance_param = 10.0
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_1.partner_id},
self.bank_line_2.id: {'aml_ids': []},
self.bank_line_1.id: {
'aml_ids': self.invoice_line_1.ids,
'model': self.rule_1,
'status': 'reconciled',
'partner': self.bank_line_1.partner_id,
},
self.bank_line_2.id: {
'aml_ids': (self.invoice_line_2 + self.invoice_line_3).ids,
'model': self.rule_1,
'partner': self.bank_line_2.partner_id,
},
}, statements=self.bank_st)
# Check first line has been properly reconciled.
self.assertRecordValues(self.bank_line_1.line_ids, [
{'partner_id': self.partner_1.id, 'debit': 95.0, 'credit': 0.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False},
{'partner_id': self.partner_1.id, 'debit': 5.0, 'credit': 0.0, 'account_id': self.current_assets_account.id, 'reconciled': False},
{'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 100.0, 'account_id': self.invoice_line_1.account_id.id, 'reconciled': True},
# pylint: disable=bad-whitespace
{'partner_id': self.partner_1.id, 'debit': 95.0, 'credit': 0.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False},
{'partner_id': self.partner_1.id, 'debit': 5.0, 'credit': 0.0, 'account_id': self.current_assets_account.id, 'reconciled': False},
{'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 100.0, 'account_id': self.invoice_line_1.account_id.id, 'reconciled': True},
])
self.assertEqual(self.invoice_line_1.amount_residual, 0.0, "The invoice should have been fully reconciled")
def test_avoid_amount_matching_bypass(self):
""" By the default, if the label of statement lines exactly matches a payment reference, it bypasses any kind of amount verification.
This is annoying in some setups, so a config parameter was introduced to handle that.
"""
self.env['ir.config_parameter'].set_param('account.disable_rec_models_bypass', '1')
self.rule_1.match_total_amount_param = 90
second_inv_matching_rule = self.env['account.reconcile.model'].create({
'name': 'Invoices Matching Rule',
'sequence': 2,
'rule_type': 'invoice_matching',
'auto_reconcile': False,
'match_nature': 'both',
'match_same_currency': False,
'match_total_amount': False,
'match_partner': True,
'company_id': self.company.id,
})
self.bank_line_1.write({
'payment_ref': self.invoice_line_1.move_id.payment_reference,
'amount': 99,
})
self.bank_line_2.write({
'payment_ref': self.invoice_line_2.move_id.payment_reference,
'amount': 1,
})
self._check_statement_matching(self.rule_1 + second_inv_matching_rule, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'write_off', 'partner': self.bank_line_1.partner_id},
self.bank_line_2.id: {'aml_ids': [self.invoice_line_2.id], 'model': second_inv_matching_rule, 'partner': self.bank_line_2.partner_id}
}, statements=self.bank_st)
def test_payment_similar_communications(self):
def create_payment_line(amount, memo, partner):
payment = self.env['account.payment'].create({
@@ -981,7 +1181,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon):
'partner_id': payment_partner.id,
})
self.bank_line_2.unlink()
self.rule_1.match_total_amount = False
self.rule_1.allow_payment_tolerance = False
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': (pmt_line_1 + pmt_line_2).ids, 'model': self.rule_1, 'partner': payment_partner},
@@ -6,24 +6,30 @@ class AccountingTestTemplConsistency(TransactionCase):
'''Test the templates consistency between some objects like account.account when account.account.template.
'''
def check_fields_consistency(self, model_from, model_to, exceptions=[]):
def get_model_fields(self, model, extra_domain=None):
# Retrieve fields to compare
domain = [
('model', '=', model),
('state', '=', 'base'),
('related', '=', False),
('compute', '=', False),
('store', '=', True),
]
if extra_domain:
domain += extra_domain
return self.env['ir.model.fields'].search(domain)
def check_fields_consistency(self, model_from, model_to, exceptions=None):
'''Check the consistency of fields from one model to another by comparing if all fields
in the model_from are present in the model_to.
:param model_from: The model to compare.
:param model_to: The compared model.
:param exceptions: Not copied model's fields.
'''
extra_domain = [('name', 'not in', exceptions)] if exceptions else []
from_fields = self.get_model_fields(model_from, extra_domain=extra_domain).filtered_domain([('modules', '=', 'account')])
def get_fields(model, extra_domain=None):
# Retrieve fields to compare
domain = [('model', '=', model), ('state', '=', 'base'), ('related', '=', False),
('compute', '=', False), ('store', '=', True)]
if extra_domain:
domain += extra_domain
return self.env['ir.model.fields'].search(domain)
from_fields = get_fields(model_from, extra_domain=[('name', 'not in', exceptions)])
to_fields_set = set([f.name for f in get_fields(model_to)])
to_fields_set = set([f.name for f in self.get_model_fields(model_to)])
for field in from_fields:
assert field.name in to_fields_set,\
'Missing field "%s" from "%s" in model "%s".' % (field.name, model_from, model_to)
@@ -61,7 +67,13 @@ class AccountingTestTemplConsistency(TransactionCase):
'''Test fields consistency for ('account.reconcile.model', 'account.reconcile.model.template')
'''
self.check_fields_consistency('account.reconcile.model.template', 'account.reconcile.model', exceptions=['chart_template_id'])
self.check_fields_consistency('account.reconcile.model', 'account.reconcile.model.template', exceptions=['active', 'company_id', 'past_months_limit', 'partner_mapping_line_ids'])
# exclude fields from inherited 'mail.thread'
mail_thread_fields = [field.name for field in self.get_model_fields('mail.thread')]
self.check_fields_consistency(
'account.reconcile.model',
'account.reconcile.model.template',
exceptions=mail_thread_fields + ['active', 'company_id', 'past_months_limit', 'partner_mapping_line_ids'],
)
# lines
self.check_fields_consistency('account.reconcile.model.line.template', 'account.reconcile.model.line', exceptions=['chart_template_id'])
self.check_fields_consistency('account.reconcile.model.line', 'account.reconcile.model.line.template', exceptions=['company_id', 'journal_id', 'analytic_account_id', 'analytic_tag_ids', 'amount'])
@@ -8,13 +8,13 @@
<field name="arch" type="xml">
<form>
<field name="model_id" invisible="1"/>
<field name="match_total_amount" invisible="1"/>
<field name="match_total_amount_param" invisible="1"/>
<field name="allow_payment_tolerance" invisible="1"/>
<field name="payment_tolerance_param" invisible="1"/>
<field name="rule_type" invisible="1"/>
<group>
<group>
<field name="account_id" options="{'no_create': True}" domain="[('company_id', '=', company_id)]"
attrs="{'required': ['|', ('rule_type', '!=', 'invoice_matching'), '&amp;', '&amp;', ('rule_type', '=', 'invoice_matching'), ('match_total_amount', '=', True), ('match_total_amount_param', '!=', 100.0)]}"/>
attrs="{'required': ['|', ('rule_type', '!=', 'invoice_matching'), '&amp;', '&amp;', ('rule_type', '=', 'invoice_matching'), ('allow_payment_tolerance', '=', True), ('payment_tolerance_param', '!=', 0.0)]}"/>
<field name="amount_type"/>
<field name="tax_ids"
domain="[('company_id', '=', company_id)]"
@@ -60,6 +60,8 @@
<field name="arch" type="xml">
<form string="Operation Templates">
<field name="active" invisible="1"/>
<field name="payment_tolerance_param" invisible="1"/>
<field name="company_id" invisible="1"/>
<sheet>
<div class="oe_button_box" name="button_box">
<button type="object" name="action_reconcile_stat"
@@ -74,7 +76,6 @@
<group>
<group>
<field name="rule_type" widget="radio"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="auto_reconcile" attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}"/>
@@ -83,127 +84,200 @@
<field name="matching_order" attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}"/>
</group>
</group>
<group string="Conditions on Bank Statement Line">
<group>
<field name="match_journal_ids"
options="{'no_create': True}"
widget="many2many_tags"/>
<field name="match_nature" widget="radio"
attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}"/>
</group>
<group attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}">
<label for="match_amount"/>
<div>
<field name="match_amount" style="width: 30% !important" class="oe_inline"/>
<span class="o_form_label" style="width: 2% !important"> </span> <!-- Make a little space. -->
<field name="match_amount_min" style="width: 30% !important;" class="oe_inline"
attrs="{'invisible': [('match_amount', 'in', (False, 'lower'))], 'required': [('match_amount', '!=', False)]}"/>
<span class="o_form_label"
attrs="{'invisible': [('match_amount', '!=', 'between')]}">and</span>
<field name="match_amount_max" style="width: 30% !important;" class="oe_inline"
attrs="{'invisible': [('match_amount', 'in', (False, 'greater'))], 'required': [('match_amount', '=', 'between')]}"/>
</div>
<label for="match_label"/>
<div>
<field name="match_label" style="width: 30% !important" class="oe_inline"/>
<span class="o_form_label" style="width: 2% !important"> </span> <!-- Make a little space. -->
<field name="match_label_param" style="width: 68% !important;" class="oe_inline"
attrs="{'invisible': [('match_label', '=', False)], 'required': [('match_label', '!=', False)]}"/>
</div>
<label for="match_note"/>
<div>
<field name="match_note" style="width: 30% !important" class="oe_inline"/>
<span class="o_form_label" style="width: 2% !important"> </span> <!-- Make a little space. -->
<field name="match_note_param" style="width: 68% !important;" class="oe_inline"
attrs="{'invisible': [('match_note', '=', False)], 'required': [('match_note', '!=', False)]}"/>
</div>
<label for="match_transaction_type"/>
<div>
<field name="match_transaction_type" style="width: 30% !important" class="oe_inline"/>
<span class="o_form_label" style="width: 2% !important"> </span> <!-- Make a little space. -->
<field name="match_transaction_type_param" style="width: 68% !important;" class="oe_inline"
attrs="{'invisible': [('match_transaction_type', '=', False)], 'required': [('match_transaction_type', '!=', False)]}"/>
</div>
<field name="match_same_currency" attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}"/>
<notebook>
<page id="conditions_tab" string="Bank Transactions Conditions">
<group id="conditions_tab_group">
<group style="width:100% !important">
<group id="left_column" class="col">
<label for="match_total_amount" attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}"/>
<div attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}">
<field name="match_total_amount" class="oe_inline"/>
<field name="match_total_amount_param" class="oe_inline"
attrs="{'invisible': [('match_total_amount', '=', False)]}"/>
<span class="o_form_label oe_inline"
attrs="{'invisible': [('match_total_amount', '=', False)]}">%</span>
<div class="text-muted" attrs="{'invisible': [('match_total_amount', '=', False)]}">
This parameter will be bypassed in case of a statement line communication matching exactly existing entries
</div>
</div>
</group>
<group attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}">
<field name="match_partner"
attrs="{'invisible': [('rule_type', '!=', 'writeoff_suggestion')]}"/>
<field name="match_partner"
string="Partner Is Set &amp; Matches"
attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}"/>
<label for="match_partner_ids" class="ml16"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
<field name="match_partner_ids" nolabel="1"
widget="many2many_tags"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
<label for="match_partner_category_ids" class="ml16"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
<field name="match_partner_category_ids" nolabel="1"
widget="many2many_tags"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
</group>
<group attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}">
<label for="match_text_location_label" string="Match Invoice/bill with"/>
<div>
<span class="o_form_label" style="width: 2% !important"> </span>
<label for="match_text_location_label" string="Label"/>
<field name="match_text_location_label"/>
<span class="o_form_label" style="width: 2% !important"> </span>
<label for="match_text_location_note" string="Note"/>
<field name="match_text_location_note"/>
<span class="o_form_label" style="width: 2% !important"> </span>
<label for="match_text_location_reference" string="Reference"/>
<field name="match_text_location_reference"/>
</div>
</group>
</group>
<group string="Counterpart Values"
attrs="{'invisible': [('rule_type', '=', 'invoice_matching'), '|', ('match_total_amount', '=', False), '&amp;', ('match_total_amount', '=', True), ('match_total_amount_param', '=', 100.0)]}">
<group>
<field name="show_decimal_separator" invisible="1"/>
<field name="decimal_separator" attrs="{'invisible': [('show_decimal_separator', '=', False)]}" groups="base.group_no_one"/>
</group>
<field name="line_ids" default="{'default_model_id': self, 'default_company_id': self.company_id}" nolabel="1">
<tree editable="bottom">
<field name="account_id"/>
<field name="amount_type"/>
<field name="journal_id" attrs="{'column_invisible': [('parent.rule_type', '!=', 'writeoff_button')]}"/>
<field name="amount_string"/>
<field name="tax_ids" widget="many2many_tags"/>
<field name="analytic_account_id" groups="analytic.group_analytic_accounting"/>
<field name="analytic_tag_ids" groups="analytic.group_analytic_tags" widget="many2many_tags"/>
<field name="show_force_tax_included" invisible="1"/>
<field name="force_tax_included" attrs="{'invisible': [('show_force_tax_included', '=', False)]}" widget="boolean_toggle"/>
<field name="company_id" invisible="1"/>
<field name="label"/>
</tree>
</field>
</group>
<group style="width:100% !important">
<label for="match_journal_ids"/>
<div>
<field name="match_journal_ids"
widget="many2many_tags"
nolabel="1"
options="{'no_create': True}"/>
</div>
<field name="match_nature"
attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}"/>
</group>
<group string="Partner Mapping" attrs="{'invisible': [('rule_type', 'not in', ('invoice_matching', 'writeoff_suggestion'))]}">
<field name="partner_mapping_line_ids" nolabel="1">
<tree editable="bottom">
<field name="payment_ref_regex" attrs="{'required': [('narration_regex', '=', False)]}"/>
<field name="narration_regex" attrs="{'required': [('payment_ref_regex', '=', False)]}"/>
<field name="partner_id"/>
</tree>
</field>
</group>
<group style="width:100% !important"
attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}">
<label for="match_amount"/>
<div>
<field name="match_amount"
style="width: 30% !important"
class="oe_inline"/>
<span class="o_form_label"
style="width: 2% !important"> </span> <!-- Make a little space. -->
<field name="match_amount_min"
style="width: 30% !important"
class="oe_inline"
attrs="{'invisible': [('match_amount', 'in', (False, 'lower'))], 'required': [('match_amount', '!=', False)]}"/>
<span class="o_form_label"
attrs="{'invisible': [('match_amount', '!=', 'between')]}">and</span>
<field name="match_amount_max"
style="width: 30% !important"
class="oe_inline"
attrs="{'invisible': [('match_amount', 'in', (False, 'greater'))], 'required': [('match_amount', '=', 'between')]}"/>
</div>
<label for="allow_payment_tolerance"
attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}"/>
<div attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}">
<field name="allow_payment_tolerance" class="oe_inline"/>
<span attrs="{'invisible': [('allow_payment_tolerance', '=', False)]}">
<field name="payment_tolerance_param"
style="width: 30% !important"
class="oe_inline"/>
<span class="o_form_label"
style="width: 3% !important"> </span> <!-- Make a little space. -->
<field name="payment_tolerance_type"
style="width: 60% !important"
class="oe_inline"/>
</span>
</div>
<field name="match_same_currency"
attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}"/>
</group>
</group>
<group id="right column" class="col">
<group style="width:100% !important"
attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}">
<label for="match_text_location_label"
string="Match Invoice/bill with"/>
<div>
<span class="o_form_label" style="width: 2% !important"> </span>
<label for="match_text_location_label" string="Label"/>
<field name="match_text_location_label"/>
<span class="o_form_label" style="width: 2% !important"> </span>
<label for="match_text_location_note" string="Note"/>
<field name="match_text_location_note"/>
<span class="o_form_label" style="width: 2% !important"> </span>
<label for="match_text_location_reference" string="Reference"/>
<field name="match_text_location_reference"/>
</div>
</group>
<group style="width:100% !important"
attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}">
<label for="match_label"/>
<div>
<field name="match_label"
style="width: 30% !important" class="oe_inline"/>
<span class="o_form_label"
style="width: 2% !important"> </span> <!-- Make a little space. -->
<field name="match_label_param"
style="width: 68% !important;"
class="oe_inline"
attrs="{'invisible': [('match_label', '=', False)], 'required': [('match_label', '!=', False)]}"/>
</div>
<label for="match_note"/>
<div>
<field name="match_note"
style="width: 30% !important"
class="oe_inline"/>
<span class="o_form_label"
style="width: 2% !important"> </span> <!-- Make a little space. -->
<field name="match_note_param"
style="width: 68% !important;"
class="oe_inline"
attrs="{'invisible': [('match_note', '=', False)], 'required': [('match_note', '!=', False)]}"/>
</div>
<label for="match_transaction_type"/>
<div>
<field name="match_transaction_type"
style="width: 30% !important"
class="oe_inline"/>
<span class="o_form_label"
style="width: 2% !important"> </span> <!-- Make a little space. -->
<field name="match_transaction_type_param"
style="width: 68% !important;"
class="oe_inline"
attrs="{'invisible': [('match_transaction_type', '=', False)], 'required': [('match_transaction_type', '!=', False)]}"/>
</div>
<field name="match_partner"
attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}"/>
<label for="match_partner_ids" class="ml16"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
<field name="match_partner_ids" nolabel="1"
widget="many2many_tags"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
<label for="match_partner_category_ids" class="ml16"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
<field name="match_partner_category_ids" nolabel="1"
widget="many2many_tags"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
</group>
</group>
</group>
<group string="Counterpart Entries"
class="oe_inline"
attrs="{'invisible': [('rule_type', '=', 'invoice_matching'), '|', ('allow_payment_tolerance', '=', False), '&amp;', ('allow_payment_tolerance', '=', True), ('payment_tolerance_param', '=', 0.0)]}">
<group>
<field name="show_decimal_separator" invisible="1"/>
<field name="decimal_separator"
attrs="{'invisible': [('show_decimal_separator', '=', False)]}"
groups="base.group_no_one"/>
</group>
<field name="line_ids"
default="{'default_model_id': self, 'default_company_id': self.company_id}"
nolabel="1">
<tree editable="bottom">
<field name="show_force_tax_included" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="sequence"
widget="handle"/>
<field name="account_id"/>
<field name="amount_type"/>
<field name="journal_id"
attrs="{'column_invisible': [('parent.rule_type', '!=', 'writeoff_button')]}"/>
<field name="amount_string"/>
<field name="tax_ids"
widget="many2many_tags"/>
<field name="analytic_account_id"
groups="analytic.group_analytic_accounting"/>
<field name="analytic_tag_ids"
widget="many2many_tags"
groups="analytic.group_analytic_tags"/>
<field name="force_tax_included"
widget="boolean_toggle"
attrs="{'invisible': [('show_force_tax_included', '=', False)]}"/>
<field name="label"/>
</tree>
</field>
</group>
</group>
</page>
<page id="partner_tab"
string="Partner Mapping"
attrs="{'invisible': [('rule_type', 'not in', ('invoice_matching', 'writeoff_suggestion'))]}">
<field name="partner_mapping_line_ids"
nolabel="1">
<tree editable="bottom">
<field name="payment_ref_regex"
attrs="{'required': [('narration_regex', '=', False)]}"/>
<field name="narration_regex"
attrs="{'required': [('payment_ref_regex', '=', False)]}"/>
<field name="partner_id"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" groups="base.group_user"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>