389 lines
19 KiB
Python
389 lines
19 KiB
Python
|
# -*- coding: utf-8 -*-
|
||
|
|
||
|
from odoo import api, fields, models, Command, tools, _
|
||
|
from odoo.exceptions import UserError, ValidationError
|
||
|
import re
|
||
|
from math import copysign
|
||
|
from collections import defaultdict
|
||
|
from dateutil.relativedelta import relativedelta
|
||
|
|
||
|
|
||
|
class AccountReconcileModelPartnerMapping(models.Model):
|
||
|
_name = 'account.reconcile.model.partner.mapping'
|
||
|
_description = 'Partner mapping for reconciliation models'
|
||
|
_check_company_auto = True
|
||
|
|
||
|
model_id = fields.Many2one(comodel_name='account.reconcile.model', readonly=True, required=True, ondelete='cascade')
|
||
|
company_id = fields.Many2one(related='model_id.company_id')
|
||
|
partner_id = fields.Many2one(comodel_name='res.partner', string="Partner", required=True, ondelete='cascade', check_company=True)
|
||
|
payment_ref_regex = fields.Char(string="Find Text in Label")
|
||
|
narration_regex = fields.Char(string="Find Text in Notes")
|
||
|
|
||
|
@api.constrains('narration_regex', 'payment_ref_regex')
|
||
|
def validate_regex(self):
|
||
|
for record in self:
|
||
|
if not (record.narration_regex or record.payment_ref_regex):
|
||
|
raise ValidationError(_("Please set at least one of the match texts to create a partner mapping."))
|
||
|
current_regex = None
|
||
|
try:
|
||
|
if record.payment_ref_regex:
|
||
|
current_regex = record.payment_ref_regex
|
||
|
re.compile(record.payment_ref_regex)
|
||
|
if record.narration_regex:
|
||
|
current_regex = record.narration_regex
|
||
|
re.compile(record.narration_regex)
|
||
|
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'
|
||
|
_inherit = 'analytic.mixin'
|
||
|
_description = 'Rules for the reconciliation model'
|
||
|
_order = 'sequence, id'
|
||
|
_check_company_auto = True
|
||
|
|
||
|
model_id = fields.Many2one('account.reconcile.model', readonly=True, ondelete='cascade')
|
||
|
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)
|
||
|
sequence = fields.Integer(required=True, default=10)
|
||
|
account_id = fields.Many2one('account.account', string='Account', ondelete='cascade',
|
||
|
domain="[('deprecated', '=', False), ('account_type', '!=', 'off_balance')]", check_company=True)
|
||
|
|
||
|
# This field is ignored in a bank statement reconciliation.
|
||
|
journal_id = fields.Many2one(
|
||
|
comodel_name='account.journal',
|
||
|
string="Journal",
|
||
|
ondelete='cascade',
|
||
|
check_company=True,
|
||
|
store=True,
|
||
|
readonly=False,
|
||
|
compute='_compute_journal_id',
|
||
|
)
|
||
|
label = fields.Char(string='Journal Item Label', translate=True)
|
||
|
amount_type = fields.Selection(
|
||
|
selection=[
|
||
|
('fixed', 'Fixed'),
|
||
|
('percentage', 'Percentage of balance'),
|
||
|
('percentage_st_line', 'Percentage of statement line'),
|
||
|
('regex', 'From label'),
|
||
|
],
|
||
|
required=True,
|
||
|
store=True,
|
||
|
precompute=True,
|
||
|
compute='_compute_amount_type',
|
||
|
readonly=False,
|
||
|
)
|
||
|
|
||
|
# used to show the force tax included button'
|
||
|
show_force_tax_included = fields.Boolean(compute='_compute_show_force_tax_included')
|
||
|
force_tax_included = fields.Boolean(string='Tax Included in Price', help='Force the tax to be managed as a price included tax.')
|
||
|
# technical shortcut to parse the amount to a float
|
||
|
amount = fields.Float(string="Float Amount", compute='_compute_float_amount', store=True)
|
||
|
amount_string = fields.Char(string="Amount", default='100', required=True, help="""Value for the amount of the writeoff line
|
||
|
* Percentage: Percentage of the balance, between 0 and 100.
|
||
|
* Fixed: The fixed value of the writeoff. The amount will count as a debit if it is negative, as a credit if it is positive.
|
||
|
* From Label: There is no need for regex delimiter, only the regex is needed. For instance if you want to extract the amount from\nR:9672938 10/07 AX 9415126318 T:5L:NA BRT: 3358,07 C:\nYou could enter\nBRT: ([\\d,]+)""")
|
||
|
tax_ids = fields.Many2many(
|
||
|
comodel_name='account.tax',
|
||
|
string="Taxes",
|
||
|
ondelete='restrict',
|
||
|
check_company=True,
|
||
|
compute='_compute_tax_ids',
|
||
|
readonly=False,
|
||
|
store=True,
|
||
|
)
|
||
|
|
||
|
@api.onchange('tax_ids')
|
||
|
def _onchange_tax_ids(self):
|
||
|
# 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 len(self.tax_ids) != 1:
|
||
|
self.force_tax_included = False
|
||
|
|
||
|
@api.depends('tax_ids')
|
||
|
def _compute_show_force_tax_included(self):
|
||
|
for record in self:
|
||
|
record.show_force_tax_included = False if len(record.tax_ids) != 1 else True
|
||
|
|
||
|
@api.onchange('amount_type')
|
||
|
def _onchange_amount_type(self):
|
||
|
self.amount_string = ''
|
||
|
if self.amount_type in ('percentage', 'percentage_st_line'):
|
||
|
self.amount_string = '100'
|
||
|
elif self.amount_type == 'regex':
|
||
|
self.amount_string = r'([\d,]+)'
|
||
|
|
||
|
@api.depends('amount_string')
|
||
|
def _compute_float_amount(self):
|
||
|
for record in self:
|
||
|
try:
|
||
|
record.amount = float(record.amount_string)
|
||
|
except ValueError:
|
||
|
record.amount = 0
|
||
|
|
||
|
@api.depends('rule_type', 'model_id.counterpart_type')
|
||
|
def _compute_amount_type(self):
|
||
|
for line in self:
|
||
|
if line.rule_type == 'writeoff_button' and line.model_id.counterpart_type in ('sale', 'purchase'):
|
||
|
line.amount_type = line.amount_type or 'percentage_st_line'
|
||
|
else:
|
||
|
line.amount_type = line.amount_type or 'percentage'
|
||
|
|
||
|
@api.depends('model_id.counterpart_type')
|
||
|
def _compute_journal_id(self):
|
||
|
for line in self:
|
||
|
if line.journal_id.type != line.model_id.counterpart_type:
|
||
|
line.journal_id = None
|
||
|
else:
|
||
|
line.journal_id = line.journal_id
|
||
|
|
||
|
@api.depends('model_id.counterpart_type', 'rule_type', 'account_id', 'company_id', 'company_id.account_purchase_tax_id')
|
||
|
def _compute_tax_ids(self):
|
||
|
for line in self:
|
||
|
if line.rule_type == 'writeoff_button' and line.model_id.counterpart_type in ('sale', 'purchase'):
|
||
|
line.tax_ids = line.tax_ids.filtered(lambda x: x.type_tax_use == line.model_id.counterpart_type)
|
||
|
if not line.tax_ids:
|
||
|
line.tax_ids = line.account_id.tax_ids.filtered(lambda x: x.type_tax_use == line.model_id.counterpart_type)
|
||
|
if not line.tax_ids:
|
||
|
if line.model_id.counterpart_type == 'purchase' and line.company_id.account_purchase_tax_id:
|
||
|
line.tax_ids = line.company_id.account_purchase_tax_id
|
||
|
elif line.model_id.counterpart_type == 'sale' and line.company_id.account_sale_tax_id:
|
||
|
line.tax_ids = line.company_id.account_sale_tax_id
|
||
|
else:
|
||
|
line.tax_ids = line.tax_ids
|
||
|
|
||
|
@api.constrains('amount_string')
|
||
|
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_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)
|
||
|
except re.error:
|
||
|
raise UserError(_('The regex is not valid'))
|
||
|
|
||
|
|
||
|
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
|
||
|
|
||
|
_sql_constraints = [('name_unique', 'unique(name, company_id)', 'A reconciliation model already bears this name.')]
|
||
|
|
||
|
# Base fields.
|
||
|
active = fields.Boolean(default=True)
|
||
|
name = fields.Char(string='Name', required=True, translate=True)
|
||
|
sequence = fields.Integer(required=True, default=10)
|
||
|
company_id = fields.Many2one(
|
||
|
comodel_name='res.company',
|
||
|
string='Company', required=True, readonly=True,
|
||
|
default=lambda self: self.env.company)
|
||
|
rule_type = fields.Selection(selection=[
|
||
|
('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(
|
||
|
selection=[
|
||
|
('old_first', 'Oldest first'),
|
||
|
('new_first', 'Newest first'),
|
||
|
],
|
||
|
required=True,
|
||
|
default='old_first',
|
||
|
tracking=True,
|
||
|
)
|
||
|
counterpart_type = fields.Selection(
|
||
|
selection=[
|
||
|
('general', 'Journal Entry'),
|
||
|
('sale', 'Customer Invoices'),
|
||
|
('purchase', 'Vendor Bills'),
|
||
|
],
|
||
|
string="Counterpart Type",
|
||
|
default='general',
|
||
|
)
|
||
|
|
||
|
# ===== 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 Availability',
|
||
|
domain="[('type', 'in', ('bank', 'cash', 'credit'))]",
|
||
|
check_company=True,
|
||
|
help='The reconciliation model will only be available from the selected journals.')
|
||
|
match_nature = fields.Selection(selection=[
|
||
|
('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.
|
||
|
* Amount Paid/Received: Applied in both cases.''')
|
||
|
match_amount = fields.Selection(selection=[
|
||
|
('lower', 'Is Lower Than'),
|
||
|
('greater', 'Is Greater Than'),
|
||
|
('between', 'Is Between'),
|
||
|
], 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', 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', 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', tracking=True)
|
||
|
match_note = fields.Selection(selection=[
|
||
|
('contains', 'Contains'),
|
||
|
('not_contains', 'Not Contains'),
|
||
|
('match_regex', 'Match Regex'),
|
||
|
], 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', tracking=True)
|
||
|
match_transaction_type = fields.Selection(selection=[
|
||
|
('contains', 'Contains'),
|
||
|
('not_contains', 'Not Contains'),
|
||
|
('match_regex', 'Match Regex'),
|
||
|
], 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', 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.')
|
||
|
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 is 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='Matching partners',
|
||
|
help='The reconciliation model will only be applied to the selected customers/vendors.')
|
||
|
match_partner_category_ids = fields.Many2many('res.partner.category', string='Matching 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', copy=True)
|
||
|
partner_mapping_line_ids = fields.One2many(string="Partner Mapping Lines",
|
||
|
comodel_name='account.reconcile.model.partner.mapping',
|
||
|
inverse_name='model_id',
|
||
|
help="The mapping uses regular expressions.\n"
|
||
|
"- 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="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']._get_data(code=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",
|
||
|
)
|
||
|
# used to decide if we should show the decimal separator for the regex matching field
|
||
|
show_decimal_separator = fields.Boolean(compute='_compute_show_decimal_separator')
|
||
|
number_entries = fields.Integer(string='Number of entries related to this model', compute='_compute_number_entries')
|
||
|
|
||
|
def action_reconcile_stat(self):
|
||
|
self.ensure_one()
|
||
|
action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
|
||
|
self._cr.execute('''
|
||
|
SELECT ARRAY_AGG(DISTINCT move_id)
|
||
|
FROM account_move_line
|
||
|
WHERE reconcile_model_id = %s
|
||
|
''', [self.id])
|
||
|
action.update({
|
||
|
'context': {},
|
||
|
'domain': [('id', 'in', self._cr.fetchone()[0])],
|
||
|
'help': """<p class="o_view_nocontent_empty_folder">{}</p>""".format(_('This reconciliation model has created no entry so far')),
|
||
|
})
|
||
|
return action
|
||
|
|
||
|
def _compute_number_entries(self):
|
||
|
data = self.env['account.move.line']._read_group([('reconcile_model_id', 'in', self.ids)], ['reconcile_model_id'], ['__count'])
|
||
|
mapped_data = {reconcile_model.id: count for reconcile_model, count in data}
|
||
|
for model in self:
|
||
|
model.number_entries = mapped_data.get(model.id, 0)
|
||
|
|
||
|
@api.depends('line_ids.amount_type')
|
||
|
def _compute_show_decimal_separator(self):
|
||
|
for record in self:
|
||
|
record.show_decimal_separator = any(l.amount_type == 'regex' for l in record.line_ids)
|
||
|
|
||
|
@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"))
|
||
|
|
||
|
def copy_data(self, default=None):
|
||
|
default = dict(default or {})
|
||
|
vals_list = super().copy_data(default)
|
||
|
if default.get('name'):
|
||
|
return vals_list
|
||
|
for model, vals in zip(self, vals_list):
|
||
|
name = _("%s (copy)", model.name)
|
||
|
while self.env['account.reconcile.model'].search_count([('name', '=', name)], limit=1):
|
||
|
name = _("%s (copy)", name)
|
||
|
vals['name'] = name
|
||
|
return vals_list
|