diff --git a/addons/account/data/account_data.xml b/addons/account/data/account_data.xml
index bd2c6a8d683..4381b3b14f9 100644
--- a/addons/account/data/account_data.xml
+++ b/addons/account/data/account_data.xml
@@ -182,5 +182,19 @@
codeaction = records.action_share()
+
+
+
+ Invoices Matching Rule
+ 1
+ invoice_matching
+
+ both
+
+
+
+
diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py
index 3cab85d1a0e..ecdc1f061d6 100644
--- a/addons/account/models/account_reconcile_model.py
+++ b/addons/account/models/account_reconcile_model.py
@@ -1,18 +1,72 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
+from odoo.tools import float_compare, float_is_zero
+from odoo.exceptions import UserError
class AccountReconcileModel(models.Model):
- _name = "account.reconcile.model"
- _description = "Preset to create journal entries during a invoices and payments matching"
+ _name = 'account.reconcile.model'
+ _description = 'Preset to create journal entries during a invoices and payments matching'
+ _order = 'sequence, id'
# Base fields.
- name = fields.Char(string='Button Label', required=True)
+ name = fields.Char(string='Name', required=True)
sequence = fields.Integer(required=True, default=10)
- has_second_line = fields.Boolean(string='Add a second line', default=False)
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id)
+ 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.'))
+ ], 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).')
+
+ # ===== Conditions =====
+ match_journal_ids = fields.Many2many('account.journal', string='Journals',
+ 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',
+ 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',
+ 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_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:
+ * 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_same_currency = fields.Boolean(string='Same Currency Matching', 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.')
+ 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',
+ 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',
+ help='The reconciliation model will only be applied to the selected customer/vendor categories.')
+
+ # ===== Write-Off =====
# First part fields.
account_id = fields.Many2one('account.account', string='Account', ondelete='cascade', domain=[('deprecated', '=', False)])
journal_id = fields.Many2one('account.journal', string='Journal', ondelete='cascade', help="This field is ignored in a bank statement reconciliation.")
@@ -27,12 +81,13 @@ class AccountReconcileModel(models.Model):
help='Technical field used inside the view to make the force_tax_included field invisible if the tax is a group.')
force_tax_included = fields.Boolean(string='Tax Included in Price',
help='Force the tax to be managed as a price included tax.')
- amount = fields.Float(digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
+ amount = fields.Float(string='Write-off Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
tax_id = fields.Many2one('account.tax', string='Tax', ondelete='restrict')
analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', ondelete='set null')
analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags')
# Second part fields.
+ has_second_line = fields.Boolean(string='Add a second line', default=False)
second_account_id = fields.Many2one('account.account', string='Second Account', ondelete='cascade', domain=[('deprecated', '=', False)])
second_journal_id = fields.Many2one('account.journal', string='Second Journal', ondelete='cascade', help="This field is ignored in a bank statement reconciliation.")
second_label = fields.Char(string='Second Journal Item Label')
@@ -46,7 +101,7 @@ class AccountReconcileModel(models.Model):
help='Technical field used inside the view to make the force_second_tax_included field invisible if the tax is a group.')
force_second_tax_included = fields.Boolean(string='Second Tax Included in Price',
help='Force the second tax to be managed as a price included tax.')
- second_amount = fields.Float(string='Second Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
+ second_amount = fields.Float(string='Second Write-off Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
second_tax_id = fields.Many2one('account.tax', string='Second Tax', ondelete='restrict', domain=[('type_tax_use', '=', 'purchase')])
second_analytic_account_id = fields.Many2one('account.analytic.account', string='Second Analytic Account', ondelete='set null')
second_analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Second Analytic Tags')
@@ -64,3 +119,600 @@ class AccountReconcileModel(models.Model):
def _onchange_second_tax_id(self):
if self.second_tax_id:
self.force_second_tax_included = self.second_tax_id.price_include
+
+ @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)
+
+ ####################################################
+ # RECONCILIATION PROCESS
+ ####################################################
+
+ @api.model
+ def _get_taxes_move_lines_dict(self, tax, base_line_dict):
+ ''' Get move.lines dict (to be passed to the create()) corresponding to a tax.
+ :param tax: An account.tax record.
+ :param base_line_dict: A dict representing the move.line containing the base amount.
+ :return: A list of dict representing move.lines to be created corresponding to the tax.
+ '''
+ balance = base_line_dict['debit'] - base_line_dict['credit']
+ currency = base_line_dict.get('currency_id') and self.env['res.currency'].browse(base_line_dict['currency_id'])
+
+ res = tax.compute_all(balance, currency=currency)
+
+ new_aml_dicts = []
+ for tax_res in res['taxes']:
+ tax = self.env['account.tax'].browse(tax_res['id'])
+
+ new_aml_dicts.append({
+ 'account_id': tax.account_id and tax.account_id.id or base_line_dict['account_id'],
+ 'name': tax.name,
+ 'partner_id': base_line_dict.get('partner_id'),
+ 'debit': tax_res['amount'] > 0 and tax_res['amount'] or 0,
+ 'credit': tax_res['amount'] < 0 and -tax_res['amount'] or 0,
+ 'analytic_account_id': tax.analytic and base_line_dict['analytic_account_id'],
+ 'analytic_tag_ids': tax.analytic and base_line_dict['analytic_tag_ids'],
+ 'tax_exigible': tax.tax_exigibility == 'on_payment',
+ })
+
+ # Handle price included taxes.
+ base_line_dict['debit'] = tax_res['base'] > 0 and tax_res['base'] or base_line_dict['debit']
+ base_line_dict['credit'] = tax_res['base'] < 0 and -tax_res['base'] or base_line_dict['credit']
+ return new_aml_dicts
+
+ @api.multi
+ def _get_write_off_move_lines_dict(self, st_line, move_lines=None):
+ ''' Get move.lines dict (to be passed to the create()) corresponding to the reconciliation model's write-off lines.
+ :param st_line: An account.bank.statement.line record.
+ :param move_lines: An account.move.line recordset.
+ :return: A list of dict representing move.lines to be created corresponding to the write-off lines.
+ '''
+ self.ensure_one()
+
+ if self.rule_type == 'invoice_matching' and (not self.match_total_amount or (self.match_total_amount_param == 100)):
+ return []
+
+ line_residual = st_line.currency_id and st_line.amount_currency or st_line.amount
+ line_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.company_id.currency_id
+ total_residual = move_lines and sum(aml.currency_id and aml.amount_residual_currency or aml.amount_residual for aml in move_lines) or 0.0
+ balance = line_residual - total_residual
+
+ if not self.account_id or float_is_zero(balance, precision_rounding=line_currency.rounding) or balance < 0:
+ return []
+
+ line_balance = self.amount_type == 'percentage' and balance * (self.amount / 100.0) or self.amount
+
+ new_aml_dicts = []
+
+ # First write-off line.
+ writeoff_line = {
+ 'name': self.label,
+ 'account_id': self.account_id.id,
+ 'analytic_account_id': self.analytic_account_id.id,
+ 'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)],
+ 'debit': line_balance < 0 and -line_balance or 0,
+ 'credit': line_balance > 0 and line_balance or 0,
+ }
+ new_aml_dicts.append(writeoff_line)
+
+ if self.tax_id:
+ writeoff_line['tax_ids'] = [(6, None, [self.tax_id.id])]
+ tax = self.tax_id
+ if self.force_tax_included:
+ tax = tax.with_context(force_price_include=True)
+ new_aml_dicts += self._get_taxes_move_lines_dict(tax, writeoff_line)
+
+ # Second write-off line.
+ if self.has_second_line and self.second_account_id:
+ line_balance = balance - sum(aml['debit'] - aml['credit'] for aml in new_aml_dicts)
+ second_writeoff_line = {
+ 'name': self.second_label,
+ 'account_id': self.second_account_id.id,
+ 'analytic_account_id': self.second_analytic_account_id.id,
+ 'analytic_tag_ids': [(6, 0, self.second_analytic_tag_ids.ids)],
+ 'debit': line_balance < 0 and -line_balance or 0,
+ 'credit': line_balance > 0 and line_balance or 0,
+ }
+ new_aml_dicts.append(second_writeoff_line)
+
+ if self.second_tax_id:
+ second_writeoff_line['tax_ids'] = [(6, None, [self.second_tax_id.id])]
+ tax = self.second_tax_id
+ if self.force_second_tax_included:
+ tax = tax.with_context(force_price_include=True)
+ new_aml_dicts += self._get_taxes_move_lines_dict(tax, second_writeoff_line)
+
+ return new_aml_dicts
+
+ @api.multi
+ def _prepare_reconciliation(self, st_line, move_lines=None, partner=None):
+ ''' Reconcile the statement line with some move lines using this reconciliation model.
+ :param st_line: An account.bank.statement.line record.
+ :param move_lines: An account.move.line recordset.
+ :param partner_id: An optional res.partner record. If not set, st_line.partner_id will be used.
+ :return: Counterpart account.moves.
+ '''
+ self.ensure_one()
+
+ # Create counterpart_aml_dicts + payment_aml_rec.
+ counterpart_aml_dicts = []
+ payment_aml_rec = self.env['account.move.line']
+ if move_lines:
+ for aml in move_lines:
+ if aml.account_id.internal_type == 'liquidity':
+ payment_aml_rec |= aml
+ else:
+ amount = aml.currency_id and aml.amount_residual_currency or aml.amount_residual
+ counterpart_aml_dicts.append({
+ 'name': aml.name if aml.name != '/' else aml.move_id.name,
+ 'debit': amount < 0 and -amount or 0,
+ 'credit': amount > 0 and amount or 0,
+ 'move_line': aml,
+ })
+
+ # Create new_aml_dicts.
+ new_aml_dicts = self._get_write_off_move_lines_dict(st_line, move_lines=move_lines)
+
+ line_residual = st_line.currency_id and st_line.amount_currency or st_line.amount
+ line_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.company_id.currency_id
+ total_residual = move_lines and sum(aml.currency_id and aml.amount_residual_currency or aml.amount_residual for aml in move_lines) or 0.0
+ total_residual -= sum(aml['debit'] - aml['credit'] for aml in new_aml_dicts)
+
+ # Create open_balance_dict
+ open_balance_dict = None
+ if float_compare(line_residual, total_residual, precision_rounding=line_currency.rounding) != 0:
+ if not partner and not st_line.partner_id:
+ open_balance_dict = False
+ else:
+ balance = total_residual - line_residual
+ partner = partner or st_line.partner_id
+ open_balance_dict = {
+ 'name': '%s : %s' % (st_line.name, _('Open Balance')),
+ 'account_id': balance < 0 and partner.property_account_payable_id.id or partner.property_account_receivable_id.id,
+ 'debit': balance > 0 and balance or 0,
+ 'credit': balance < 0 and -balance or 0,
+ }
+ return {
+ 'counterpart_aml_dicts': counterpart_aml_dicts,
+ 'payment_aml_rec': payment_aml_rec,
+ 'new_aml_dicts': new_aml_dicts,
+ 'open_balance_dict': open_balance_dict
+ }
+
+ ####################################################
+ # RECONCILIATION CRITERIA
+ ####################################################
+
+ @api.multi
+ def _apply_conditions(self, query, params):
+ self.ensure_one()
+ rule = self
+ # Filter on journals.
+ if rule.match_journal_ids:
+ query += ' AND st_line.journal_id IN %s'
+ params += [tuple(rule.match_journal_ids.ids)]
+
+ # Filter on amount nature.
+ if rule.match_nature == 'amount_received':
+ query += ' AND st_line.amount >= 0.0'
+ elif rule.match_nature == 'amount_paid':
+ query += ' AND st_line.amount <= 0.0'
+
+ # Filter on amount.
+ if rule.match_amount:
+ query += ' AND ROUND(ABS(st_line.amount), jnl_precision.dp) '
+ if rule.match_amount == 'lower':
+ query += '< %s'
+ params += [self.match_amount_max]
+ elif rule.match_amount == 'greater':
+ query += '> %s'
+ params += [self.match_amount_min]
+ else:
+ # if self.match_amount == 'between'
+ query += 'BETWEEN %s AND %s'
+ params += [rule.match_amount_min, rule.match_amount_max]
+
+ # Filter on label.
+ if rule.match_label == 'contains':
+ query += ' AND st_line.name ILIKE %s'
+ params += ['%%%s%%' % rule.match_label_param]
+ elif rule.match_label == 'not_contains':
+ query += ' AND st_line.name NOT ILIKE %s'
+ params += ['%%%s%%' % rule.match_label_param]
+ elif rule.match_label == 'match_regex':
+ query += ' AND st_line.name ~ %s'
+ params += [rule.match_label_param]
+
+ # Filter on partners.
+ if rule.match_partner:
+ query += ' AND line_partner.partner_id != 0'
+
+ if rule.match_partner_ids:
+ query += ' AND line_partner.partner_id IN %s'
+ params += [tuple(rule.match_partner_ids.ids)]
+
+ if rule.match_partner_category_ids:
+ query += '''
+ AND line_partner.partner_id IN (
+ SELECT DISTINCT categ.partner_id FROM res_partner_res_partner_category_rel categ WHERE categ.category_id IN %s
+ )
+ '''
+ params += [tuple(rule.match_partner_category_ids.ids)]
+
+ return query, params
+
+ @api.multi
+ def _get_with_tables(self, st_lines, partner_map=None):
+ with_tables = '''
+ WITH jnl_precision AS (
+ SELECT
+ j.id AS journal_id, currency.decimal_places AS dp
+ FROM account_journal j
+ LEFT JOIN res_company c ON j.company_id = c.id
+ LEFT JOIN res_currency currency ON COALESCE(j.currency_id, c.currency_id) = currency.id
+ WHERE j.type IN ('bank', 'cash')
+ )'''
+ # Compute partners values table.
+ # This is required since some statement line's partners could be shadowed in the reconciliation widget.
+ partners_list = []
+ for line in st_lines:
+ partner_id = partner_map and partner_map.get(line.id) or line.partner_id.id or 0
+ partners_list.append('(%d, %d)' % (line.id, partner_id))
+ partners_table = 'SELECT * FROM (VALUES %s) AS line_partner (line_id, partner_id)' % ','.join(partners_list)
+ with_tables += ', partners_table AS (' + partners_table + ')'
+ return with_tables
+
+ @api.multi
+ def _get_invoice_matching_query(self, st_lines, excluded_ids=None, partner_map=None):
+ ''' Get the query applying all rules trying to match existing entries with the given statement lines.
+ :param st_lines: Account.bank.statement.lines recordset.
+ :param excluded_ids: Account.move.lines to exclude.
+ :param partner_map: Dict mapping each line with new partner eventually.
+ :return: (query, params)
+ '''
+ if any(m.rule_type != 'invoice_matching' for m in self):
+ raise UserError(_('Programmation Error: Can\'t call _get_invoice_matching_query() for different rules than \'invoice_matching\''))
+
+ queries = []
+ all_params = []
+ for rule in self:
+ # N.B: 'communication_flag' is there to distinguish invoice matching through the number/reference
+ # (higher priority) from invoice matching using the partner (lower priority).
+ query = '''
+ SELECT
+ %s AS sequence,
+ %s AS model_id,
+ st_line.id AS id,
+ aml.id AS aml_id,
+ aml.currency_id AS aml_currency_id,
+ aml.date_maturity AS aml_date_maturity,
+ aml.amount_residual AS aml_amount_residual,
+ aml.amount_residual_currency AS aml_amount_residual_currency,
+ CASE WHEN
+ REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(move.name, '[^0-9]', '', 'g')
+ OR (
+ move.ref IS NOT NULL
+ AND
+ REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(move.ref, '[^0-9]', '', 'g')
+ )
+ THEN TRUE ELSE FALSE END AS communication_flag
+ FROM account_bank_statement_line st_line
+ LEFT JOIN account_journal journal ON journal.id = st_line.journal_id
+ LEFT JOIN jnl_precision ON jnl_precision.journal_id = journal.id
+ LEFT JOIN res_company company ON company.id = st_line.company_id
+ LEFT JOIN partners_table line_partner ON line_partner.line_id = st_line.id
+ , account_move_line aml
+ LEFT JOIN account_move move ON move.id = aml.move_id
+ LEFT JOIN account_account account ON account.id = aml.account_id
+ WHERE st_line.id IN %s
+ AND aml.company_id = st_line.company_id
+ AND (
+ -- the field match_partner of the rule might enforce the second part of
+ -- the OR condition, later in _apply_conditions()
+ line_partner.partner_id = 0
+ OR
+ aml.partner_id = line_partner.partner_id
+ )
+ AND CASE WHEN st_line.amount > 0.0
+ THEN aml.balance > 0
+ ELSE aml.balance < 0
+ END
+
+ -- if there is a partner, propose all aml of the partner, otherwise propose only the ones
+ -- matching the statement line communication
+ AND CASE WHEN line_partner.partner_id != 0 THEN
+ aml.partner_id = line_partner.partner_id
+ ELSE
+ (
+ REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(move.name, '[^0-9]', '', 'g')
+ OR (
+ move.ref IS NOT NULL
+ AND
+ REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(move.ref, '[^0-9]', '', 'g')
+ )
+ )
+ END
+
+ AND
+ (
+ (
+ -- blue lines appearance conditions
+ aml.account_id IN (journal.default_credit_account_id, journal.default_debit_account_id)
+ AND aml.statement_id IS NULL
+ AND (
+ company.account_bank_reconciliation_start IS NULL
+ OR
+ aml.date > company.account_bank_reconciliation_start
+ )
+ )
+ OR
+ (
+ -- black lines appearance conditions
+ account.reconcile IS TRUE
+ AND aml.reconciled IS FALSE
+ )
+ )
+ '''
+ # Filter on the same currency.
+ if rule.match_same_currency:
+ query += '''
+ AND COALESCE(st_line.currency_id, journal.currency_id, company.currency_id) = COALESCE(aml.currency_id, company.currency_id)
+ '''
+
+ params = [rule.sequence, rule.id, tuple(st_lines.ids)]
+ # Filter out excluded account.move.line.
+ if excluded_ids:
+ query += 'AND aml.id NOT IN %s'
+ params += [tuple(excluded_ids)]
+ query, params = rule._apply_conditions(query, params)
+ queries.append(query)
+ all_params += params
+ full_query = self._get_with_tables(st_lines, partner_map=partner_map)
+ full_query += ' UNION ALL '.join(queries)
+ # Oldest due dates come first.
+ full_query += ' ORDER BY aml_date_maturity, aml_id'
+ return full_query, all_params
+
+ @api.multi
+ def _get_writeoff_suggestion_query(self, st_lines, excluded_ids=None, partner_map=None):
+ ''' Get the query applying all reconciliation rules.
+ :param st_lines: Account.bank.statement.lines recordset.
+ :param excluded_ids: Account.move.lines to exclude.
+ :param partner_map: Dict mapping each line with new partner eventually.
+ :return: (query, params)
+ '''
+ if any(m.rule_type != 'writeoff_suggestion' for m in self):
+ raise UserError(_('Programmation Error: Can\'t call _get_wo_suggestion_query() for different rules than \'writeoff_suggestion\''))
+
+ queries = []
+ all_params = []
+ for rule in self:
+ query = '''
+ SELECT
+ %s AS sequence,
+ %s AS model_id,
+ st_line.id AS id
+ FROM account_bank_statement_line st_line
+ LEFT JOIN account_journal journal ON journal.id = st_line.journal_id
+ LEFT JOIN jnl_precision ON jnl_precision.journal_id = journal.id
+ LEFT JOIN res_company company ON company.id = st_line.company_id
+ LEFT JOIN partners_table line_partner ON line_partner.line_id = st_line.id
+ WHERE st_line.id IN %s
+ '''
+ params = [rule.sequence, rule.id, tuple(st_lines.ids)]
+
+ query, params = rule._apply_conditions(query, params)
+ queries.append(query)
+ all_params += params
+
+ full_query = self._get_with_tables(st_lines, partner_map=partner_map)
+ full_query += ' UNION ALL '.join(queries)
+ return full_query, all_params
+
+ @api.multi
+ 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'.
+ :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
+
+ # Match total residual amount.
+ total_residual = sum(
+ aml['aml_currency_id'] and aml['aml_amount_residual_currency'] or aml['aml_amount_residual'] for aml in
+ candidates)
+ line_residual = statement_line.currency_id and statement_line.amount_currency or statement_line.amount
+ line_currency = statement_line.currency_id or statement_line.journal_id.currency_id or statement_line.company_id.currency_id
+
+ # Statement line amount must not be lower than the total residual.
+ if float_compare(line_residual, total_residual, precision_rounding=line_currency.rounding) < 0:
+ return False
+
+ # Statement line amount is equal to the total residual.
+ if float_is_zero(total_residual - line_residual, precision_rounding=line_currency.rounding):
+ return True
+
+ amount_percentage = (total_residual / line_residual) * 100
+ return amount_percentage >= self.match_total_amount_param
+
+ @api.multi
+ def _apply_rules(self, st_lines, excluded_ids=None, partner_map=None):
+ ''' Apply criteria to get candidates for all reconciliation models.
+ :param st_lines: Account.bank.statement.lines recordset.
+ :param excluded_ids: Account.move.lines to exclude.
+ :param partner_map: Dict mapping each line with new partner eventually.
+ :return: A dict mapping each statement line id with:
+ * aml_ids: A list of account.move.line ids.
+ * model: An account.reconcile.model record (optional).
+ * status: 'reconciled' if the lines has been already reconciled, 'write_off' if the write-off must be
+ applied on the statement line.
+ '''
+ available_models = self.filtered(lambda m: m.rule_type != 'writeoff_button')
+
+ results = dict((r.id, {'aml_ids': []}) for r in st_lines)
+
+ if not available_models:
+ return results
+
+ ordered_models = available_models.sorted(key=lambda m: (m.sequence, m.id))
+
+ grouped_candidates = {}
+
+ # Type == 'invoice_matching'.
+ # Map each (st_line.id, model_id) with matching amls.
+ invoices_models = ordered_models.filtered(lambda m: m.rule_type == 'invoice_matching')
+ if invoices_models:
+ query, params = invoices_models._get_invoice_matching_query(st_lines, excluded_ids=excluded_ids, partner_map=partner_map)
+ self._cr.execute(query, params)
+ query_res = self._cr.dictfetchall()
+
+ for res in query_res:
+ grouped_candidates.setdefault(res['id'], {})
+ grouped_candidates[res['id']].setdefault(res['model_id'], [])
+ grouped_candidates[res['id']][res['model_id']].append(res)
+
+ # Type == 'writeoff_suggestion'.
+ # Map each (st_line.id, model_id) with a flag indicating the st_line matches the criteria.
+ write_off_models = ordered_models.filtered(lambda m: m.rule_type == 'writeoff_suggestion')
+ if write_off_models:
+ query, params = write_off_models._get_writeoff_suggestion_query(st_lines, excluded_ids=excluded_ids, partner_map=partner_map)
+ self._cr.execute(query, params)
+ query_res = self._cr.dictfetchall()
+
+ for res in query_res:
+ grouped_candidates.setdefault(res['id'], {})
+ grouped_candidates[res['id']].setdefault(res['model_id'], True)
+
+ # Keep track of already processed amls.
+ amls_ids_to_exclude = set()
+
+ # Keep track of already reconciled amls.
+ reconciled_amls_ids = set()
+
+ # Iterate all and create results.
+ sorted_st_lines = sorted(st_lines, key=lambda line: (line.statement_id.id, line.date, -line.sequence, line.id), reverse=True)
+ for line in sorted_st_lines:
+ line_currency = line.currency_id or line.journal_id.currency_id or line.company_id.currency_id
+ line_residual = line.currency_id and line.amount_currency or line.amount
+
+ # Search for applicable rule.
+ # /!\ BREAK are very important here to avoid applying multiple rules on the same line.
+ for model in ordered_models:
+ # No result found.
+ if not grouped_candidates.get(line.id) or not grouped_candidates[line.id].get(model.id):
+ continue
+
+ excluded_lines_found = False
+
+ if model.rule_type == 'invoice_matching':
+ candidates = grouped_candidates[line.id][model.id]
+
+ # If some invoices match on the communication, suggest them.
+ # Otherwise, suggest all invoices having the same partner.
+ # N.B: The only way to match a line without a partner is through the communication.
+ first_batch_candidates = []
+ second_batch_candidates = []
+ for c in candidates:
+ # Don't take into account already reconciled lines.
+ if c['aml_id'] in reconciled_amls_ids:
+ continue
+
+ # Dispatch candidates between lines matching invoices with the communication or only the partner.
+ if c['communication_flag']:
+ first_batch_candidates.append(c)
+ elif not first_batch_candidates:
+ second_batch_candidates.append(c)
+ available_candidates = first_batch_candidates or second_batch_candidates
+
+ # Special case: the amount are the same, submit the line directly.
+ for c in available_candidates:
+ residual_amount = c['aml_currency_id'] and c['aml_amount_residual_currency'] or c['aml_amount_residual']
+
+ if float_is_zero(residual_amount - line_residual, precision_rounding=line_currency.rounding):
+ available_candidates = [c]
+ break
+
+ # Needed to handle check on total residual amounts.
+ if first_batch_candidates or model._check_rule_propositions(line, available_candidates):
+ results[line.id]['model'] = model
+
+ # Add candidates to the result.
+ for candidate in available_candidates:
+
+ # Special case: the propositions match the rule but some of them are already consumed by
+ # another one. Then, suggest the remaining propositions to the user but don't make any
+ # automatic reconciliation.
+ if candidate['aml_id'] in amls_ids_to_exclude:
+ excluded_lines_found = True
+ continue
+
+ results[line.id]['aml_ids'].append(candidate['aml_id'])
+ amls_ids_to_exclude.add(candidate['aml_id'])
+
+ if excluded_lines_found:
+ break
+
+ # Create write-off lines.
+ move_lines = self.env['account.move.line'].browse(results[line.id]['aml_ids'])
+ partner = partner_map and partner_map.get(line.id) and self.env['res.partner'].browse(partner_map[line.id])
+ reconciliation_results = model._prepare_reconciliation(line, move_lines, partner=partner)
+
+ # A write-off must be applied.
+ if reconciliation_results['new_aml_dicts']:
+ results[line.id]['status'] = 'write_off'
+
+ # Process auto-reconciliation.
+ if model.auto_reconcile:
+ # An open balance is needed but no partner has been found.
+ if reconciliation_results['open_balance_dict'] is False:
+ break
+
+ new_aml_dicts = reconciliation_results['new_aml_dicts']
+ if reconciliation_results['open_balance_dict']:
+ new_aml_dicts.append(reconciliation_results['open_balance_dict'])
+ counterpart_moves = line.process_reconciliation(
+ counterpart_aml_dicts=reconciliation_results['counterpart_aml_dicts'],
+ payment_aml_rec=reconciliation_results['payment_aml_rec'],
+ new_aml_dicts=new_aml_dicts,
+ )
+ results[line.id]['status'] = 'reconciled'
+ results[line.id]['reconciled_lines'] = counterpart_moves.mapped('line_ids')
+
+ # The reconciled move lines are no longer candidates for another rule.
+ reconciled_amls_ids.update(move_lines.ids)
+
+ # Break models loop.
+ break
+
+ elif model.rule_type == 'writeoff_suggestion' and grouped_candidates[line.id][model.id]:
+ results[line.id]['model'] = model
+ results[line.id]['status'] = 'write_off'
+
+ # Create write-off lines.
+ partner = partner_map and partner_map.get(line.id) and self.env['res.partner'].browse(partner_map[line.id])
+ reconciliation_results = model._prepare_reconciliation(line, partner=partner)
+
+ # An open balance is needed but no partner has been found.
+ if reconciliation_results['open_balance_dict'] is False:
+ break
+
+ # Process auto-reconciliation.
+ if model.auto_reconcile:
+ new_aml_dicts = reconciliation_results['new_aml_dicts']
+ if reconciliation_results['open_balance_dict']:
+ new_aml_dicts.append(reconciliation_results['open_balance_dict'])
+ counterpart_moves = line.process_reconciliation(
+ counterpart_aml_dicts=reconciliation_results['counterpart_aml_dicts'],
+ payment_aml_rec=reconciliation_results['payment_aml_rec'],
+ new_aml_dicts=new_aml_dicts,
+ )
+ results[line.id]['status'] = 'reconciled'
+ results[line.id]['reconciled_lines'] = counterpart_moves.mapped('line_ids')
+
+ # Break models loop.
+ break
+ return results
diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py
index 881c76c7d8d..256fe79feb6 100644
--- a/addons/account/models/chart_template.py
+++ b/addons/account/models/chart_template.py
@@ -669,6 +669,21 @@ class AccountChartTemplate(models.Model):
'force_second_tax_included': account_reconcile_model.force_second_tax_included,
'second_amount': account_reconcile_model.second_amount,
'second_tax_id': account_reconcile_model.second_tax_id and tax_template_ref[account_reconcile_model.second_tax_id.id] or False,
+ 'rule_type': account_reconcile_model.rule_type,
+ 'auto_reconcile': account_reconcile_model.auto_reconcile,
+ 'match_journal_ids': [(6, None, account_reconcile_model.match_journal_ids.ids)],
+ 'match_nature': account_reconcile_model.match_nature,
+ 'match_amount': account_reconcile_model.match_amount,
+ 'match_amount_min': account_reconcile_model.match_amount_min,
+ 'match_amount_max': account_reconcile_model.match_amount_max,
+ 'match_label': account_reconcile_model.match_label,
+ 'match_label_param': account_reconcile_model.match_label_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,
+ '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)],
}
@api.multi
@@ -907,27 +922,83 @@ class AccountFiscalPositionAccountTemplate(models.Model):
class AccountReconcileModelTemplate(models.Model):
_name = "account.reconcile.model.template"
+ # Base fields.
chart_template_id = fields.Many2one('account.chart.template', string='Chart Template', required=True)
name = fields.Char(string='Button Label', required=True)
sequence = fields.Integer(required=True, default=10)
- has_second_line = fields.Boolean(string='Add a second line', default=False)
+
+ 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.'))
+ ], 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).')
+
+ # ===== Conditions =====
+ match_journal_ids = fields.Many2many('account.journal', string='Journals',
+ 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',
+ 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',
+ 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_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:
+ * 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_same_currency = fields.Boolean(string='Same Currency Matching', 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.')
+ 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',
+ 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',
+ help='The reconciliation model will only be applied to the selected customer/vendor categories.')
+
+ # First part fields.
account_id = fields.Many2one('account.account.template', string='Account', ondelete='cascade', domain=[('deprecated', '=', False)])
label = fields.Char(string='Journal Item Label')
amount_type = fields.Selection([
('fixed', 'Fixed'),
('percentage', 'Percentage of balance')
], required=True, default='percentage')
- amount = fields.Float(digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
+ amount = fields.Float(string='Write-off Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
force_tax_included = fields.Boolean(string='Tax Included in Price',
help='Force the tax to be managed as a price included tax.')
tax_id = fields.Many2one('account.tax.template', string='Tax', ondelete='restrict', domain=[('type_tax_use', '=', 'purchase')])
+
+ # Second part fields.
+ has_second_line = fields.Boolean(string='Add a second line', default=False)
second_account_id = fields.Many2one('account.account.template', string='Second Account', ondelete='cascade', domain=[('deprecated', '=', False)])
second_label = fields.Char(string='Second Journal Item Label')
second_amount_type = fields.Selection([
('fixed', 'Fixed'),
('percentage', 'Percentage of amount')
], string="Second Amount type",required=True, default='percentage')
- second_amount = fields.Float(string='Second Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
+ second_amount = fields.Float(string='Second Write-off Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
force_second_tax_included = fields.Boolean(string='Second Tax Included in Price',
help='Force the second tax to be managed as a price included tax.')
second_tax_id = fields.Many2one('account.tax.template', string='Second Tax', ondelete='restrict', domain=[('type_tax_use', '=', 'purchase')])
diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py
index 2b1ee4733d3..5d742281464 100644
--- a/addons/account/models/reconciliation_widget.py
+++ b/addons/account/models/reconciliation_widget.py
@@ -3,241 +3,13 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.osv import expression
-from odoo.tools import pycompat, float_is_zero
+from odoo.tools import pycompat
from odoo.tools.misc import formatLang
class AccountReconciliation(models.AbstractModel):
_name = 'account.reconciliation.widget'
- ####################################################
- # Search propositions
- ####################################################
-
- @api.model
- def _get_matching_amls_query(self, st_lines, excluded_ids=None):
- ''' Base query used by the matching rules.
- Main things about this huge query:
- - Try to join the res.partner table:
- 1) On st_line.partner_id.
- 2) On partner_id found on a res.partner.bank sharing the same account number.
- 3) On a partner having the same case insensitive name.
- - Basic filters on account_move_line.
-
- :param st_lines: Account.bank.statement.lines recordset.
- :param excluded_ids: Account.move.lines to exclude.
- :return: (query, params)
- '''
- params = [tuple(st_lines.ids)]
-
- # N.B: The first part of the CASE is about 'blue lines' while the second part is about 'black lines'.
- query = '''
- SELECT
- st_line.id AS id,
- aml.id AS aml_id,
- aml.currency_id AS aml_currency_id,
- aml.amount_residual AS aml_amount_residual,
- aml.amount_residual_currency AS aml_amount_residual_currency
- FROM account_bank_statement_line st_line
- LEFT JOIN account_journal journal ON journal.id = st_line.journal_id
- LEFT JOIN res_company company ON company.id = st_line.company_id
- LEFT JOIN res_partner_bank bank ON bank.id = st_line.bank_account_id OR bank.acc_number = st_line.account_number
- LEFT JOIN res_partner partner ON (
- CASE WHEN st_line.partner_id IS NOT NULL THEN
- partner.id = st_line.partner_id
- WHEN bank.partner_id IS NOT NULL THEN
- partner.id = bank.partner_id
- ELSE
- partner.name ILIKE st_line.partner_name
- END
- )
- , account_move_line aml
- LEFT JOIN res_company aml_company ON aml_company.id = aml.company_id
- LEFT JOIN account_account aml_account ON aml_account.id = aml.account_id
- WHERE st_line.id IN %s
- AND aml.company_id = st_line.company_id
- AND aml.statement_id IS NULL
-
- AND (
- company.account_bank_reconciliation_start IS NULL
- OR
- aml.date > company.account_bank_reconciliation_start
- )
-
- AND CASE WHEN journal.default_credit_account_id IS NOT NULL
- AND journal.default_debit_account_id IS NOT NULL
- THEN
- (
- aml.account_id IN (journal.default_credit_account_id, journal.default_debit_account_id)
- AND aml.payment_id IS NOT NULL
- )
- OR
- (
- aml_account.reconcile IS TRUE
- AND aml.reconciled IS FALSE
- )
- END
- '''
-
- if excluded_ids:
- query += 'AND aml.id NOT IN %s'
- params.append(tuple(excluded_ids))
- return query, params
-
- @api.model
- def _get_matching_amls_invoice_rule(self, st_lines, excluded_ids=None):
- ''' RULE 1: Match an account.move.line automatically if linked to an invoice having a number or reference quite
- similar.
-
- This rule automatically match when invoice_reference match when:
- - matching only one invoice.
- - matching multiple invoices but having the same total amount residual.
-
- :param st_lines: Account.bank.statement.lines recordset.
- :param excluded_ids: Account.move.lines to exclude.
- :return: (query, params, automatic_match_func)
- '''
-
- def automatic_match_func(st_line, fetched_amls):
- # Match only one invoice.
- if len(fetched_amls) == 1:
- return True
-
- # Match multiple invoices but having the same total amount residual.
- total_residual = sum(aml['aml_currency_id'] and aml['aml_amount_residual_currency'] or aml['aml_amount_residual'] for aml in fetched_amls)
- line_residual = st_line.currency_id and st_line.amount_currency or st_line.amount
- line_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.company_id.currency_id
- return float_is_zero(total_residual - line_residual, precision_rounding=line_currency.rounding)
-
- query, params = self._get_matching_amls_query(st_lines, excluded_ids=excluded_ids)
-
- # Join the account_invoice table.
- query = query.replace(
- 'account_move_line aml',
- '''
- account_move_line aml
- LEFT JOIN account_move move ON move.id = aml.move_id
- LEFT JOIN account_invoice invoice ON invoice.move_name = move.name
- '''
- )
-
- # Add where clause.
- # N.B: invoice_reference could be a list of invoice reference/number (e.g. 'INV/2018/0001,INV/2018/0002').
- query += '''
- AND invoice.state = 'open'
- AND CASE WHEN st_line.amount >= 0.0 THEN
- invoice.type IN ('out_invoice', 'in_refund')
- ELSE
- invoice.type IN ('in_invoice', 'out_refund')
- END
- AND (
- REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(invoice.number, '[^0-9]', '', 'g')
- OR (
- invoice.reference IS NOT NULL
- AND
- REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(invoice.reference, '[^0-9]', '', 'g')
- )
- )
- AND CASE WHEN partner.id IS NOT NULL THEN
- invoice.partner_id = partner.id
- ELSE
- TRUE
- END
- '''
- return query, params, automatic_match_func
-
- @api.model
- def _get_matching_amls_amount_rule(self, st_lines, excluded_ids=None):
- ''' RULE 2: Match one or more account.move.lines automatically if either the statement line has exactly the same amount
- or either the statement line matchs a single line having a greater or equals amount.
-
- This only work if a partner has been found on the statement line.
-
- :param st_lines: Account.bank.statement.lines recordset.
- :param excluded_ids: Account.move.lines to exclude.
- :return: (query, params, automatic_match_func)
- '''
-
- def automatic_match_func(st_line, fetched_amls):
- total_residual = sum(aml['aml_currency_id'] and aml['aml_amount_residual_currency'] or aml['aml_amount_residual'] for aml in fetched_amls)
- line_residual = st_line.currency_id and st_line.amount_currency or st_line.amount
- line_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.company_id.currency_id
-
- # Match the total residual amount.
- if float_is_zero(total_residual - line_residual, precision_rounding=line_currency.rounding):
- return True
-
- # Match only one line having a greater residual amount.
- return len(fetched_amls) == 1 and line_residual < total_residual
-
- query, params = self._get_matching_amls_query(st_lines, excluded_ids=excluded_ids)
-
- # Add where clause.
- # N.B: move.line currency_id is either set or either got from the company.
- # N.B2: statement.line currency_id is either set, either got from the journal or either got from the company.
- query += '''
- AND partner.id IS NOT NULL
- AND aml.partner_id = partner.id
- AND (
- CASE WHEN st_line.currency_id IS NOT NULL AND st_line.currency_id != aml_company.currency_id THEN
- aml.currency_id = st_line.currency_id
- WHEN journal.currency_id IS NOT NULL AND journal.currency_id != aml_company.currency_id THEN
- aml.currency_id = journal.currency_id
- ELSE
- aml.currency_id IS NULL
- END
- )
- '''
- return query, params, automatic_match_func
-
- @api.model
- def _get_matching_amls(self, st_lines, excluded_ids=None):
- ''' Apply reconciliation matching rules in order to find matching account.move.lines.
-
- :param st_lines: Account.bank.statement.lines recordset.
- :param excluded_ids: Account.move.lines ids to exclude.
- :return: A dictionnary mapping each id with:
- * line: The account.bank.statement.line record.
- * aml_ids: The matching account.move.line ids.
- '''
- results = dict((r.id, {'line': r, 'aml_ids': []}) for r in st_lines)
- excluded_ids = excluded_ids or []
-
- rules = [
- self._get_matching_amls_invoice_rule,
- self._get_matching_amls_amount_rule,
- ]
- for rule in rules:
- query, params, automatic_match_func = rule(st_lines, excluded_ids=excluded_ids)
- self._cr.execute(query, params)
- query_res = self._cr.dictfetchall()
-
- # Map statement line with candidates.
- candidates_map = {}
- for res in query_res:
- candidates_map.setdefault(res['id'], [])
- candidates_map[res['id']].append(res)
-
- for line_id, fetched_amls in candidates_map.items():
- st_line = results[line_id]['line']
-
- candidate_amls = []
- candidate_amls_ids = []
- for aml in fetched_amls:
- if aml['aml_id'] not in excluded_ids:
- candidate_amls.append(aml)
- candidate_amls_ids.append(aml['aml_id'])
-
- if automatic_match_func(results[line_id]['line'], candidate_amls):
- results[line_id]['aml_ids'] = candidate_amls_ids
-
- # Mark statement line as already processed.
- st_lines -= st_line
-
- # Exclude move lines.
- excluded_ids += candidate_amls_ids
- return results
-
####################################################
# Public
####################################################
@@ -304,6 +76,33 @@ class AccountReconciliation(models.AbstractModel):
target_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.journal_id.company_id.currency_id
return self._prepare_move_lines(aml_recs, target_currency=target_currency, target_date=st_line.date, recs_count=recs_count)
+ @api.model
+ def _get_bank_statement_line_partners(self, st_lines):
+ query = '''
+ SELECT
+ st_line.id AS id,
+ partner.id AS partner_id
+ FROM account_bank_statement_line st_line
+ LEFT JOIN res_partner_bank bank ON bank.id = st_line.bank_account_id OR bank.acc_number = st_line.account_number
+ LEFT JOIN res_partner partner ON (
+ CASE WHEN st_line.partner_id IS NOT NULL THEN
+ partner.id = st_line.partner_id
+ WHEN bank.partner_id IS NOT NULL THEN
+ partner.id = bank.partner_id
+ ELSE
+ partner.name ILIKE st_line.partner_name
+ END
+ )
+ WHERE st_line.id IN %s
+ '''
+ params = [tuple(st_lines.ids)]
+ self._cr.execute(query, params)
+
+ result = {}
+ for res in self._cr.dictfetchall():
+ result[res['id']] = res['partner_id']
+ return result
+
@api.model
def get_bank_statement_line_data(self, st_line_ids, excluded_ids=None):
""" Returns the data required to display a reconciliation widget, for
@@ -314,19 +113,43 @@ class AccountReconciliation(models.AbstractModel):
result
"""
excluded_ids = excluded_ids or []
- ret = []
- st_lines = self.env['account.bank.statement.line'].browse(st_line_ids)
- matching_amls = self._get_matching_amls(st_lines, excluded_ids=excluded_ids)
+
+ bank_statement_lines = self.env['account.bank.statement.line'].browse(st_line_ids)
+ sorted_st_lines = sorted(bank_statement_lines, key=lambda line: (line.statement_id.id, line.date, -line.sequence, line.id), reverse=True)
+ reconcile_model = self.env['account.reconcile.model'].search([('rule_type', '!=', 'writeoff_button')])
+
+ # Search for missing partners when opening the reconciliation widget.
+ partner_map = self._get_bank_statement_line_partners(bank_statement_lines)
+
+ matching_amls = reconcile_model._apply_rules(bank_statement_lines, excluded_ids=excluded_ids, partner_map=partner_map)
+
+ results = {
+ 'lines': [],
+ 'value_min': 0,
+ 'value_max': len(bank_statement_lines),
+ 'reconciled_aml_ids': [],
+ }
# Iterate on st_lines to keep the same order in the results list.
- for line in st_lines:
- aml_ids = matching_amls[line.id]['aml_ids']
- amls = aml_ids and self.env['account.move.line'].browse(aml_ids)
- ret.append({
- 'st_line': self._get_statement_line(line),
- 'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls) or [],
- })
- return ret
+ bank_statements_left = self.env['account.bank.statement']
+ for line in sorted_st_lines:
+ if matching_amls[line.id].get('status') == 'reconciled':
+ reconciled_move_lines = matching_amls[line.id].get('reconciled_lines')
+ results['value_min'] += 1
+ results['reconciled_aml_ids'] += reconciled_move_lines and reconciled_move_lines.ids or []
+ else:
+ aml_ids = matching_amls[line.id]['aml_ids']
+ bank_statements_left += line.statement_id
+
+ amls = aml_ids and self.env['account.move.line'].browse(aml_ids)
+ results['lines'].append({
+ 'st_line': self._get_statement_line(line),
+ 'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls) or [],
+ 'model_id': matching_amls[line.id].get('model') and matching_amls[line.id]['model'].id,
+ 'write_off': matching_amls[line.id].get('status') == 'write_off',
+ })
+
+ return results
@api.model
def get_bank_statement_data(self, bank_statement_ids):
@@ -339,62 +162,43 @@ class AccountReconciliation(models.AbstractModel):
:param st_line_id: ids of the bank statement
"""
bank_statements = self.env['account.bank.statement'].browse(bank_statement_ids)
- Bank_statement_line = self.env['account.bank.statement.line']
- # NB : The field account_id can be used at the statement line creation/import to avoid the reconciliation process on it later on,
- # this is why we filter out statements lines where account_id is set
+ query = '''
+ SELECT line.id
+ FROM account_bank_statement_line line
+ WHERE account_id IS NULL
+ AND line.amount != 0.0
+ AND line.statement_id IN %s
+ AND NOT EXISTS (SELECT 1 from account_move_line aml WHERE aml.statement_line_id = line.id)
+ '''
+ self.env.cr.execute(query, [tuple(bank_statements.ids)])
- sql_query = """SELECT stl.id
- FROM account_bank_statement_line stl
- WHERE account_id IS NULL AND stl.amount != 0.0 AND not exists (select 1 from account_move_line aml where aml.statement_line_id = stl.id)
- """
- params = []
- if bank_statements:
- sql_query += ' AND stl.statement_id IN %s'
- params += (tuple(bank_statements.ids),)
- else:
- sql_query += ' AND stl.company_id = %s'
- params += [self.env.user.company_id.id]
- sql_query += ' ORDER BY stl.id'
- self.env.cr.execute(sql_query, params)
- st_lines_left = Bank_statement_line.browse([line.get('id') for line in self.env.cr.dictfetchall()])
+ bank_statement_lines = self.env['account.bank.statement.line'].browse([line.get('id') for line in self.env.cr.dictfetchall()])
- #try to assign partner to bank_statement_line
- stl_to_assign = st_lines_left.filtered(lambda stl: not stl.partner_id)
- refs = set(stl_to_assign.mapped('name'))
- if stl_to_assign and refs\
- and st_lines_left[0].journal_id.default_credit_account_id\
- and st_lines_left[0].journal_id.default_debit_account_id:
+ results = self.get_bank_statement_line_data(bank_statement_lines.ids)
+ bank_statement_lines_left = self.env['account.bank.statement.line'].browse([line['st_line']['id'] for line in results['lines']])
+ bank_statements_left = bank_statement_lines_left.mapped('statement_id')
- sql_query = """SELECT aml.partner_id, aml.ref, stl.id
- FROM account_move_line aml
- JOIN account_account acc ON acc.id = aml.account_id
- JOIN account_bank_statement_line stl ON aml.ref = stl.name
- WHERE (aml.company_id = stl.company_id
- AND aml.partner_id IS NOT NULL)
- AND (
- (aml.statement_id IS NULL AND aml.account_id IN %s)
- OR
- (acc.internal_type IN ('payable', 'receivable') AND aml.reconciled = false)
- )
- AND aml.ref IN %s
- """
- params = ((st_lines_left[0].journal_id.default_credit_account_id.id, st_lines_left[0].journal_id.default_debit_account_id.id), tuple(refs))
- if bank_statements:
- sql_query += 'AND stl.id IN %s'
- params += (tuple(stl_to_assign.ids),)
- self.env.cr.execute(sql_query, params)
- results = self.env.cr.dictfetchall()
- for line in results:
- Bank_statement_line.browse(line.get('id')).write({'partner_id': line.get('partner_id')})
+ results.update({
+ 'statement_name': len(bank_statements_left) == 1 and bank_statements_left.name or False,
+ 'journal_id': bank_statements_left and bank_statements_left[0].journal_id.id or False,
+ 'notifications': []
+ })
- return {
- 'st_lines_ids': st_lines_left.ids,
- 'notifications': [],
- 'statement_name': len(bank_statements) == 1 and bank_statements[0].name or False,
- 'journal_id': bank_statements and bank_statements[0].journal_id.id or False,
- 'num_already_reconciled_lines': 0,
- }
+ if len(results['lines']) < len(bank_statement_lines):
+ results['notifications'].append({
+ 'type': 'info',
+ 'template': 'reconciliation.notification.reconciled',
+ 'reconciled_aml_ids': results['reconciled_aml_ids'],
+ 'nb_reconciled_lines': results['value_min'],
+ 'details': {
+ 'name': _('Journal Items'),
+ 'model': 'account.move.line',
+ 'ids': results['reconciled_aml_ids'],
+ }
+ })
+
+ return results
@api.model
def get_move_lines_for_manual_reconciliation(self, account_id, partner_id=False, excluded_ids=None, search_str=False, offset=0, limit=None, target_currency_id=False):
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_action.js b/addons/account/static/src/js/reconciliation/reconciliation_action.js
index 88668a2da97..bdca26fa15f 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_action.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_action.js
@@ -7,6 +7,7 @@ var ReconciliationRenderer = require('account.ReconciliationRenderer');
var ControlPanelMixin = require('web.ControlPanelMixin');
var Widget = require('web.Widget');
var core = require('web.core');
+var _t = core._t;
/**
@@ -109,7 +110,18 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, {
this.renderer.prependTo(self.$('.o_form_sheet'));
this._renderLines();
- this._openFirstLine();
+
+ // No more lines to reconcile, trigger the rainbowman.
+ var initialState = this.renderer._initialState;
+ if(initialState.valuenow === initialState.valuemax){
+ initialState.context = this.model.getContext();
+ this.renderer.showRainbowMan(initialState);
+ }else{
+ // Create a notification if some lines has been reconciled automatically.
+ if(initialState.valuenow > 0)
+ this.renderer._renderNotifications(this.model.statement.notifications);
+ this._openFirstLine();
+ }
},
/**
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js
index e8a6838a139..dcc93343189 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_model.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js
@@ -358,13 +358,13 @@ var StatementModel = BasicModel.extend({
.then(function (statement) {
self.statement = statement;
self.bank_statement_id = statement_ids.length === 1 ? {id: statement_ids[0], display_name: statement.statement_name} : false;
- self.valuenow = 0;
- self.valuemax = statement.st_lines_ids.length;
+ self.valuenow = statement.value_min;
+ self.valuemax = statement.value_max;
self.context.journal_id = statement.journal_id;
- _.each(statement.st_lines_ids, function (id) {
+ _.each(statement.lines, function (res) {
var handle = _.uniqueId('rline');
self.lines[handle] = {
- id: id,
+ id: res.st_line.id,
handle: handle,
reconciled: false,
mode: 'inactive',
@@ -412,7 +412,7 @@ var StatementModel = BasicModel.extend({
var ids = _.pluck(self.lines, 'id');
ids = ids.splice(0, self.defaultDisplayQty);
self.pagerIndex = ids.length;
- return self.loadData(ids, []);
+ return self._formatLine(self.statement.lines);
});
},
/**
@@ -445,7 +445,9 @@ var StatementModel = BasicModel.extend({
args: [ids, excluded_ids],
context: self.context,
})
- .then(self._formatLine.bind(self));
+ .then(function(res){
+ return self._formatLine(res['lines']);
+ })
},
/**
* Add lines into the propositions from the reconcile model
@@ -545,24 +547,14 @@ var StatementModel = BasicModel.extend({
var line = this.getLine(handle);
// Retrieve the toggle proposition
- var selected;
- var targetLineAmount = line.st_line.amount;
- line.reconciliation_proposition.every(function (prop) {
- if (!prop.invalid) {
- if (((line.balance.amount < 0 || !line.partial_reconcile) && prop.amount > 0 && targetLineAmount > 0 && targetLineAmount < prop.amount) ||
- ((line.balance.amount > 0 || !line.partial_reconcile) && prop.amount < 0 && targetLineAmount < 0 && targetLineAmount > prop.amount)) {
- selected = prop;
- return false;
- }
- targetLineAmount -= prop.amount;
- }
- return true;
- });
+ var selected = _.filter(line.reconciliation_proposition, function(prop){return prop.display_triangle});
// If no toggled proposition found, reject it
- if (selected == null)
+ if(selected.length != 1)
return $.Deferred().reject();
+ selected = selected[0];
+
// Inverse partial_reconcile value
selected.partial_reconcile = !selected.partial_reconcile;
if (!selected.partial_reconcile) {
@@ -985,41 +977,32 @@ var StatementModel = BasicModel.extend({
self._formatLineProposition(line, line.reconciliation_proposition);
if (!line.reconciliation_proposition.length) {
delete line.reconciliation_proposition;
- }else{
- // loop state propositions
- var debit_props = _.filter(line.reconciliation_proposition, function(p){
- return p.amount > 0 && line.st_line.amount;
- });
- var credit_props = _.filter(line.reconciliation_proposition, function(p){
- return p.amount < 0 && line.st_line.amount < 0;
- });
- var sum_debit_props = debit_props.length > 0 ? debit_props.map(function(p){return p.amount}).reduce(function(p1, p2){return p1 + p2}) : 0;
- var sum_credit_props = credit_props.length > 0 ? credit_props.map(function(p){return p.amount}).reduce(function(p1, p2){return p1 + p2}) : 0;
-
- // Want to display the triangle for partial reconciliation or not.
- if(line.st_line.amount_currency > 0 && line.st_line.amount_currency < sum_debit_props && debit_props.length == 1)
- debit_props[0].display_triangle = true;
- else if(line.st_line.amount_currency < 0 && line.st_line.amount_currency > sum_credit_props && credit_props.length == 1)
- credit_props[0].display_triangle = true;
}
// No partner set on st_line and all matching amls have the same one: set it on the st_line.
- if(!line.st_line.partner_id && line.reconciliation_proposition){
- var hasDifferentPartners = function(prop){
- return !prop.partner_id || prop.partner_id != line.reconciliation_proposition[0].partner_id;
- }
+ defs.push(
+ self._computeLine(line)
+ .then(function(){
+ if(!line.st_line.partner_id && line.reconciliation_proposition.length > 0){
+ var hasDifferentPartners = function(prop){
+ return !prop.partner_id || prop.partner_id != line.reconciliation_proposition[0].partner_id;
+ }
- if(!_.any(line.reconciliation_proposition, hasDifferentPartners)){
- defs.push(self.changePartner(line.handle, {
- 'id': line.reconciliation_proposition[0].partner_id,
- 'display_name': line.reconciliation_proposition[0].partner_name,
- }, true));
- }
- }
-
- defs.push(self._computeLine(line));
+ if(!_.any(line.reconciliation_proposition, hasDifferentPartners)){
+ return self.changePartner(line.handle, {
+ 'id': line.reconciliation_proposition[0].partner_id,
+ 'display_name': line.reconciliation_proposition[0].partner_name,
+ }, true);
+ }
+ }
+ return true;
+ })
+ .then(function(){
+ return data.status === 'write_off'? self.quickCreateProposition(line.handle, data.model_id) : true;
+ })
+ );
});
- return $.when.apply($, defs);
+ return $.when(defs);
},
/**
* Format the server value then compute the line
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
index e7b9067ca3d..e59934610b6 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
@@ -84,6 +84,29 @@ var StatementRenderer = Widget.extend(FieldManagerMixin, {
hideLoadMoreButton: function () {
this.$('.js_load_more').hide();
},
+ showRainbowMan(state){
+ var dt = Date.now()-this.time;
+ var $done = $(qweb.render("reconciliation.done", {
+ 'duration': moment(dt).utc().format(time.getLangTimeFormat()),
+ 'number': state.valuenow,
+ 'timePerTransaction': Math.round(dt/1000/state.valuemax),
+ 'context': state.context,
+ }));
+ $done.find('.button_close_statement').click(this._onCloseBankStatement.bind(this));
+ $done.find('.button_back_to_statement').click(this._onGoToBankStatement.bind(this));
+ this.$el.children().hide();
+ // display rainbowman after full reconciliation
+ if (session.show_effect) {
+ this.trigger_up('show_effect', {
+ type: 'rainbow_man',
+ fadeout: 'no',
+ message: $done,
+ });
+ this.$el.css('min-height', '450px');
+ } else {
+ $done.appendTo(this.$el);
+ }
+ },
/**
* update the statement rendering
*
@@ -103,27 +126,7 @@ var StatementRenderer = Widget.extend(FieldManagerMixin, {
.css('width', (state.valuenow/state.valuemax*100) + '%');
if (state.valuenow === state.valuemax && !this.$('.done_message').length) {
- var dt = Date.now()-this.time;
- var $done = $(qweb.render("reconciliation.done", {
- 'duration': moment(dt).utc().format(time.getLangTimeFormat()),
- 'number': state.valuenow,
- 'timePerTransaction': Math.round(dt/1000/state.valuemax),
- 'context': state.context,
- }));
- $done.find('.button_close_statement').click(this._onCloseBankStatement.bind(this));
- $done.find('.button_back_to_statement').click(this._onGoToBankStatement.bind(this));
- this.$el.children().hide();
- // display rainbowman after full reconciliation
- if (session.show_effect) {
- this.trigger_up('show_effect', {
- type: 'rainbow_man',
- fadeout: 'no',
- message: $done,
- });
- this.$el.css('min-height', '450px');
- } else {
- $done.appendTo(this.$el);
- }
+ this.showRainbowMan(state);
}
if (state.notifications) {
@@ -145,11 +148,7 @@ var StatementRenderer = Widget.extend(FieldManagerMixin, {
_renderNotifications: function(notifications) {
this.$(".notification_area").empty();
for (var i=0; i 0 && prop.amount > state.st_line.amount) {
- partialDebitProps++;
- } else if (prop.amount < 0 && prop.amount < state.st_line.amount) {
- partialCreditProps++;
- }
+ /*
+ Examples:
+ statement line | 100 | |
+ move line 1 | | 200 | <- can be a partial of 100
+ balance: -100
+
+ statement line | 500 | |
+ move line 1 | | 300 | <- is not a eligible to be a partial due to the second line.
+ move line 2 | | 300 | <- can be a partial of 200
+ balance: -100
+
+ statement line | 500 | |
+ move line 1 | | 700 | <- must not be a partial (debit = 800 > 700 = credit).
+ move line 2 | 300 | |
+ balance: 100
+ */
+ if(!prop.display_new && balance < 0 && prop.amount > 0 && balance + prop.amount > 0)
+ partialDebitProp = prop;
+ else if(!prop.display_new && balance > 0 && prop.amount < 0 && balance + prop.amount < 0)
+ partialCreditProp = prop;
}
});
- var targetLineAmount = state.st_line.amount;
-
_.each(props, function (line) {
- var display_triangle = (line.already_paid === false &&
- ((state.balance.amount_currency < 0 || line.partial_reconcile)
- && line.amount > 0 && state.st_line.amount > 0 && targetLineAmount < line.amount && partialDebitProps <= 1) ||
- ((state.balance.amount_currency > 0 || line.partial_reconcile)
- && line.amount < 0 && state.st_line.amount < 0 && targetLineAmount > line.amount && partialCreditProps <= 1));
- var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state, 'display_triangle': display_triangle}));
+ line.display_triangle = (line.already_paid === false &&
+ ((state.balance.amount_currency < 0 || line.partial_reconcile) && partialDebitProp && partialDebitProp === line) ||
+ ((state.balance.amount_currency > 0 || line.partial_reconcile) && partialCreditProp && partialCreditProp === line));
+ var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state}));
if (!isNaN(line.id)) {
$('')
.appendTo($line.find('.cell_info_popover'))
.attr("data-content", qweb.render('reconciliation.line.mv_line.details', {'line': line}));
}
- targetLineAmount -= line.amount;
-
$props.append($line);
});
diff --git a/addons/account/static/src/js/reconciliation/tour_reconciliation.js b/addons/account/static/src/js/reconciliation/tour_reconciliation.js
index ab6efaef113..533b68274ba 100644
--- a/addons/account/static/src/js/reconciliation/tour_reconciliation.js
+++ b/addons/account/static/src/js/reconciliation/tour_reconciliation.js
@@ -9,24 +9,15 @@ Tour.register('bank_statement_reconciliation', {
test: true,
// Go to the reconciliation page of the statement: "BNK/2014/001"
}, [
- // Reconciliation of 'SAJ/2018/002'
- // Select the 'INV/2018/0002' line and click on reconcile.
+ // Reconciliation of 'INV/2018/0002'
+ // Click on reconcile (matching done automatically by the reconciliation rule).
- {
- content: "open the last line in match mode to test the reconcile button",
- trigger: '.toggle_match:last',
- extra_trigger: '.o_reconciliation',
- },
- {
- content: "select the 'INV/2018/0002' line",
- trigger: '.o_reconciliation_line:last .match .cell_label:contains("INV/2018/0002")'
- },
{
content: "reconcile the line",
- trigger: '.o_reconciliation_line:last .o_reconcile:visible',
+ trigger: '.o_reconciliation_line:nth-child(1) .o_reconcile:visible',
},
- // Reconciliation of 'First 2000 € of SAJ/2014/0001'
+ // Reconciliation of 'First 2000 $ of INV/2018/0001'
// Make a partial reconciliation
{
@@ -34,10 +25,6 @@ Tour.register('bank_statement_reconciliation', {
extra_trigger: '.o_reconciliation_line:first[data-mode="match"]',
trigger: '.o_reconciliation_line:last .cell_label:contains("First")'
},
- {
- content: "select a line with with a higher amount",
- trigger: '.o_reconciliation_line:last .match .cell_label:contains("INV/2018/0001")'
- },
{
content: "click on partial reconcile",
trigger: '.o_reconciliation_line:last .accounting_view .do_partial_reconcile_true'
@@ -67,7 +54,6 @@ Tour.register('bank_statement_reconciliation', {
},
{
content: "select a line linked to Deco Addict ",
- extra_trigger: '.o_reconciliation_line:nth-child(2) .match:not(:has(tr:eq(1)))',
trigger: ".o_reconciliation_line:nth-child(2) .match .line_info_button[data-content*='Deco Addict']"
},
{
diff --git a/addons/account/static/src/xml/account_reconciliation.xml b/addons/account/static/src/xml/account_reconciliation.xml
index 3d5cc28ec3d..39cff76a6a6 100644
--- a/addons/account/static/src/xml/account_reconciliation.xml
+++ b/addons/account/static/src/xml/account_reconciliation.xml
@@ -32,7 +32,6 @@
-
Tip: Hit CTRL-Enter to reconcile all the balanced items in the sheet.