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 @@ code action = 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.

Nothing to do!

@@ -157,7 +156,11 @@
- +

To speed up reconciliation, define reconciliation models.

@@ -214,7 +217,7 @@ - + @@ -231,7 +234,12 @@ - + + + New + + + @@ -297,16 +305,41 @@ + + + + + + statement lines + + have been reconciled automatically. + + + + + + + + + + + + +
- - - - + + + + +
diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index 2e554a05776..6feac45cf0a 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -159,6 +159,7 @@ var db = { fields: { id: {string: "ID", type: 'integer'}, name: {string: "Button Label", type: 'char'}, + rule_type: {string: "Type", type: 'selection', selection: [['writeoff_button', 'Create a Button'], ['writeoff_suggestion', 'Write off Suggestion'], ['invoice_matching', 'Invoice matching']], default:'writeoff_button'}, has_second_line: {string: "Add a second line", type: 'boolean'}, account_id: {string: "Account", type: 'many2one', relation:'account.account'}, journal_id: {string: "Journal", type: 'many2one', relation:'account.journal'}, @@ -174,14 +175,15 @@ var db = { second_amount: {string: "Second Amount", type: 'float', digits:0, help:"Fixed amount will count as a debit if it is negative, as a credit if it is positive.", default:100.0}, second_tax_id: {string: "Second Tax", type: 'many2one', relation:'account.tax', domain:[('type_tax_use', '=', 'purchase')]}, second_analytic_account_id: {string: "Second Analytic Account", type: 'many2one', relation:'account.analytic.account'}, + match_journal_ids: {string: "Journal Ids", type: 'many2many', relation: 'account.journal'} }, records: [ - {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 4, 'analytic_account_id': false, 'display_name': "Int\u00e9rrets", 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 282, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "fixed", 'name': "Int\u00e9rrets", 'amount': 0.0, 'second_amount': 100.0}, - {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 2, 'analytic_account_id': false, 'display_name': "Perte et Profit", 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 283, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Perte et Profit", 'amount': 100.0, 'second_amount': 100.0}, - {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 5, 'analytic_account_id': false, 'display_name': "Fs bank", 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 284, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Fs bank", 'amount': 100.0, 'second_amount': 100.0}, - {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 8, 'analytic_account_id': false, 'display_name': "Caisse Sand.", 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': "Caisse Sand.", 'second_label': false, 'second_account_id': false, 'account_id': 308, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Caisse Sand.", 'amount': 100.0, 'second_amount': 100.0}, - {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 3, 'analytic_account_id': false, 'display_name': "ATOS", 'second_tax_id': 7, 'has_second_line': true, 'journal_id': false, 'label': "ATOS Banque", 'second_label': "ATOS Frais", 'second_account_id': 286, 'account_id': 285, 'company_id': [1, "Demo SPRL"], 'tax_id': 6, 'amount_type': "percentage", 'name': "ATOS", 'amount': 97.5, 'second_amount': 2.5}, - {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 10, 'analytic_account_id': false, 'display_name': "Double", 'second_tax_id': false, 'has_second_line': true, 'journal_id': false, 'label': "Double Banque", 'second_label': "Double Frais", 'second_account_id': 286, 'account_id': 285, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Double", 'amount': 97.5, 'second_amount': 2.5}, + {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 4, 'analytic_account_id': false, 'display_name': "Int\u00e9rrets", 'rule_type': 'writeoff_button', 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 282, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "fixed", 'name': "Int\u00e9rrets", 'amount': 0.0, 'second_amount': 100.0, 'match_journal_ids': []}, + {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 2, 'analytic_account_id': false, 'display_name': "Perte et Profit", 'rule_type': 'writeoff_button', 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 283, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Perte et Profit", 'amount': 100.0, 'second_amount': 100.0, 'match_journal_ids': []}, + {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 5, 'analytic_account_id': false, 'display_name': "Fs bank", 'rule_type': 'writeoff_button', 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 284, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Fs bank", 'amount': 100.0, 'second_amount': 100.0}, + {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 8, 'analytic_account_id': false, 'display_name': "Caisse Sand.", 'rule_type': 'writeoff_button', 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': "Caisse Sand.", 'second_label': false, 'second_account_id': false, 'account_id': 308, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Caisse Sand.", 'amount': 100.0, 'second_amount': 100.0, 'match_journal_ids': []}, + {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 3, 'analytic_account_id': false, 'display_name': "ATOS", 'rule_type': 'writeoff_button', 'second_tax_id': 7, 'has_second_line': true, 'journal_id': false, 'label': "ATOS Banque", 'second_label': "ATOS Frais", 'second_account_id': 286, 'account_id': 285, 'company_id': [1, "Demo SPRL"], 'tax_id': 6, 'amount_type': "percentage", 'name': "ATOS", 'amount': 97.5, 'second_amount': 2.5}, + {'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 10, 'analytic_account_id': false, 'display_name': "Double", 'rule_type': 'writeoff_button', 'second_tax_id': false, 'has_second_line': true, 'journal_id': false, 'label': "Double Banque", 'second_label': "Double Frais", 'second_account_id': 286, 'account_id': 285, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Double", 'amount': 97.5, 'second_amount': 2.5, 'match_journal_ids': []}, ] }, 'account.reconciliation.widget': { @@ -222,10 +224,17 @@ var db = { }, get_bank_statement_line_data: function (args) { var ids = args[0]; - return $.when(_.filter(Datas.used.data_widget, function (w) {return _.contains(ids, w.st_line.id);})); + var results = { + value_min: 0, + value_max: ids.length, + lines: _.filter(Datas.used.data_widget, function (w) {return _.contains(ids, w.st_line.id);}) + }; + return $.when(results); }, get_bank_statement_data: function () { - return $.when(Datas.used.data_preprocess); + var results = Datas.used.data_preprocess; + results.lines = _.filter(Datas.used.data_widget, function (w) {return _.contains(results.st_lines_ids, w.st_line.id);}); + return $.when(results); }, get_move_lines_for_manual_reconciliation: function (args) { var excluded_ids = args.splice(2, 1)[0]; @@ -267,6 +276,8 @@ var db = { }; var data_preprocess = { + value_min: 0, + value_max: 4, notifications: [], num_already_reconciled_lines: 0, st_lines_ids: [5, 6, 7, 8], @@ -865,7 +876,7 @@ QUnit.module('account', { }, }); - clientAction.appendTo($('body')); + clientAction.appendTo($('#qunit-fixture')); var widget = clientAction.widgets[0]; @@ -1048,7 +1059,7 @@ QUnit.module('account', { assert.notOk(widget.$('.accounting_view tfoot .cell_label').text(), "should not display 'Open Balance' line because the rest to reconcile is null"); assert.strictEqual(widget.$('.accounting_view tbody tr').length, 1, "should have only the created reconcile line"); - assert.strictEqual(widget.$('.accounting_view tbody tr').text().replace(/[\n\r\s$,]+/g, ' '), " 101200 SAJ/2014/002 and SAJ/2014/003 1175.00 ", + assert.strictEqual(widget.$('.accounting_view tbody tr').text().replace(/[\n\r\s$,]+/g, ' '), " 101200 New SAJ/2014/002 and SAJ/2014/003 1175.00 ", "the new line should have the selected account, name and amout"); assert.ok(widget.$('caption button.btn-primary:visible').length, "should display the 'Reconcile' button"); @@ -1071,7 +1082,7 @@ QUnit.module('account', { assert.strictEqual(widget.$('.accounting_view tbody .cell_right').text().trim().replace(/[\n\r\s\u00a0]+/g, ' '), "$ 1100.00", "should display the value 1100.00 in right column"); assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "$ 75.00", "should display 'Open Balance' line because the rest to reconcile is 75.00"); assert.strictEqual(widget.$('.accounting_view tbody tr').length, 1, "should have ever only the created reconcile line"); - assert.strictEqual(widget.$('.accounting_view tbody tr').text().replace(/[\n\r\s$,]+/g, ' '), " 101200 SAJ/2014/002 and SAJ/2014/003 1100.00 ", + assert.strictEqual(widget.$('.accounting_view tbody tr').text().replace(/[\n\r\s$,]+/g, ' '), " 101200 New SAJ/2014/002 and SAJ/2014/003 1100.00 ", "the new line should be update the amout"); assert.ok(widget.$('caption button.btn-secondary:visible').length, "should display the 'validate' button"); @@ -1085,7 +1096,7 @@ QUnit.module('account', { assert.strictEqual(widget.$('.accounting_view tfoot .cell_label').text(), "Open balance", "should display 'Open Balance'"); assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "$ 175.00", "should display 'Open Balance' line because the rest to reconcile is 175.00"); assert.strictEqual(widget.$('.accounting_view tbody tr').length, 2, "should have 2 created reconcile lines"); - assert.strictEqual(widget.$('.accounting_view tbody tr:eq(1)').text().replace(/[\n\r\s$,]+/g, ' '), " 101000 test0 100.00 ", + assert.strictEqual(widget.$('.accounting_view tbody tr:eq(1)').text().replace(/[\n\r\s$,]+/g, ' '), " 101000 New test0 100.00 ", "the new line should have the selected account, name and amout"); widget.$('.accounting_view tfoot td:first').trigger('click'); @@ -1236,7 +1247,7 @@ QUnit.module('account', { widget.$('.create .create_amount input').val('100').trigger('input'); assert.strictEqual(widget.$('.accounting_view tbody').text().replace(/[\n\r\s$,]+/g, ' '), - " 101120 ATOS Banque 1075.00 101120 Tax 20.00% 215.00 101130 ATOS Frais 90.91 101300 Tax 10.00% include 9.09 ", + " 101120 New ATOS Banque 1075.00 101120 New Tax 20.00% 215.00 101130 New ATOS Frais 90.91 101300 New Tax 10.00% include 9.09 ", "should update the value of the 4 lines (because the line must have 100% of the value)"); assert.strictEqual(widget.$('.accounting_view tfoot .cell_label, .accounting_view tfoot .cell_left').text().replace(/[\n\r\s$,]+/g, ' '), "Open balance215.00", "should change the 'Open balance' line because the 20.00% tax is not an include tax"); @@ -1249,7 +1260,7 @@ QUnit.module('account', { widget.$('.create .quick_add button:contains(Double)').trigger('click'); assert.strictEqual(widget.$('.accounting_view tbody').text().replace(/[\n\r\s$,]+/g, ' '), - " 101120 Double Banque 1145.62 101130 Double Frais 29.38 ", + " 101120 New Double Banque 1145.62 101130 New Double Frais 29.38 ", "should have a sum of reconciliation proposition amounts equal to the line amount"); clientAction.destroy(); @@ -1306,240 +1317,6 @@ QUnit.module('account', { clientAction.destroy(); }); - QUnit.test('Reconciliation pager', function (assert) { - assert.expect(6); - - this.params.data['account.bank.statement.line'].records.push( - {id: 9, display_name: "add 9"}, - {id: 10, display_name: "add 10"}, - {id: 11, display_name: "add 11"}, - {id: 12, display_name: "add 12"}, - {id: 13, display_name: "add 13"}, - {id: 14, display_name: "add 14"}, - {id: 15, display_name: "add 15"}, - {id: 16, display_name: "add 16"} - ); - - var data_preprocess = this.params.data_preprocess; - data_preprocess.st_lines_ids.push(9, 10, 11, 12, 13, 14, 15, 16); - - this.params.data_widget.push( - { - 'st_line': { - 'currency_id': 3, - 'communication_partner_name': false, - 'open_balance_account_id': 287, - 'name': "add 9", - 'partner_name': "Camptocamp", - 'partner_id': 12, - 'has_no_partner': false, - 'journal_id': 84, - 'account_name': "Bank", - 'note': "", - 'amount': 650.0, - 'amount_str': "$ 650.00", - 'amount_currency_str': "", - 'date': "2017-01-01", - 'account_code': "101401", - 'ref': "", - 'id': 9, - 'statement_id': 2 - }, - 'reconciliation_proposition': [] - }, - { - 'st_line': { - 'currency_id': 3, - 'communication_partner_name': false, - 'open_balance_account_id': 287, - 'name': "add 10", - 'partner_name': "Camptocamp", - 'partner_id': 12, - 'has_no_partner': false, - 'journal_id': 84, - 'account_name': "Bank", - 'note': "", - 'amount': 650.0, - 'amount_str': "$ 650.00", - 'amount_currency_str': "", - 'date': "2017-01-01", - 'account_code': "101401", - 'ref': "", - 'id': 10, - 'statement_id': 2 - }, - 'reconciliation_proposition': [] - }, - { - 'st_line': { - 'currency_id': 3, - 'communication_partner_name': false, - 'open_balance_account_id': 287, - 'name': "add 11", - 'partner_name': "Camptocamp", - 'partner_id': 12, - 'has_no_partner': false, - 'journal_id': 84, - 'account_name': "Bank", - 'note': "", - 'amount': 650.0, - 'amount_str': "$ 650.00", - 'amount_currency_str': "", - 'date': "2017-01-01", - 'account_code': "101401", - 'ref': "", - 'id': 11, - 'statement_id': 2 - }, - 'reconciliation_proposition': [] - }, - { - 'st_line': { - 'currency_id': 3, - 'communication_partner_name': false, - 'open_balance_account_id': 287, - 'name': "add 12", - 'partner_name': "Camptocamp", - 'partner_id': 12, - 'has_no_partner': false, - 'journal_id': 84, - 'account_name': "Bank", - 'note': "", - 'amount': 650.0, - 'amount_str': "$ 650.00", - 'amount_currency_str': "", - 'date': "2017-01-01", - 'account_code': "101401", - 'ref': "", - 'id': 12, - 'statement_id': 2 - }, - 'reconciliation_proposition': [] - }, - { - 'st_line': { - 'currency_id': 3, - 'communication_partner_name': false, - 'open_balance_account_id': 287, - 'name': "add 13", - 'partner_name': "Camptocamp", - 'partner_id': 12, - 'has_no_partner': false, - 'journal_id': 84, - 'account_name': "Bank", - 'note': "", - 'amount': 650.0, - 'amount_str': "$ 650.00", - 'amount_currency_str': "", - 'date': "2017-01-01", - 'account_code': "101401", - 'ref': "", - 'id': 13, - 'statement_id': 2 - }, - 'reconciliation_proposition': [] - }, - { - 'st_line': { - 'currency_id': 3, - 'communication_partner_name': false, - 'open_balance_account_id': 287, - 'name': "add 14", - 'partner_name': "Camptocamp", - 'partner_id': 12, - 'has_no_partner': false, - 'journal_id': 84, - 'account_name': "Bank", - 'note': "", - 'amount': 650.0, - 'amount_str': "$ 650.00", - 'amount_currency_str': "", - 'date': "2017-01-01", - 'account_code': "101401", - 'ref': "", - 'id': 14, - 'statement_id': 2 - }, - 'reconciliation_proposition': [] - }, - { - 'st_line': { - 'currency_id': 3, - 'communication_partner_name': false, - 'open_balance_account_id': 287, - 'name': "add 15", - 'partner_name': "Camptocamp", - 'partner_id': 12, - 'has_no_partner': false, - 'journal_id': 84, - 'account_name': "Bank", - 'note': "", - 'amount': 650.0, - 'amount_str': "$ 650.00", - 'amount_currency_str': "", - 'date': "2017-01-01", - 'account_code': "101401", - 'ref': "", - 'id': 15, - 'statement_id': 2 - }, - 'reconciliation_proposition': [] - }, - { - 'st_line': { - 'currency_id': 3, - 'communication_partner_name': false, - 'open_balance_account_id': 287, - 'name': "add 16", - 'partner_name': "Camptocamp", - 'partner_id': 12, - 'has_no_partner': false, - 'journal_id': 84, - 'account_name': "Bank", - 'note': "", - 'amount': 650.0, - 'amount_str': "$ 650.00", - 'amount_currency_str': "", - 'date': "2017-01-01", - 'account_code': "101401", - 'ref': "", - 'id': 16, - 'statement_id': 2 - }, - 'reconciliation_proposition': [] - } - ); - - var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options); - testUtils.addMockEnvironment(clientAction, { - data: this.params.data, - session: { - currencies: { - 3: { - digits: [69, 2], - position: "before", - symbol: "$" - } - } - }, - }); - - clientAction.appendTo($('#qunit-fixture')); - - assert.strictEqual(clientAction.$('.o_reconciliation_line').length, 10, "should display 10 lines"); - assert.strictEqual(clientAction.$('.js_load_more:visible').length, 1, "should display the load more button"); - clientAction.$('.accounting_view:has(.o_reconcile:visible) thead .cell_action').click(); - clientAction.$('.o_reconcile:visible').click(); - assert.strictEqual(clientAction.$('.o_reconcile:visible').length, 0, "should remove the reconciled line"); - assert.strictEqual(clientAction.$('.o_reconciliation_line').length, 10, "should load one line to complete the 10"); - clientAction.$('thead .cell_action:eq(1)').click(); - clientAction.$('.js_load_more').click(); - assert.strictEqual(clientAction.$('.o_reconciliation_line').length, 11, "should load the last record"); - assert.strictEqual(clientAction.$('.js_load_more:visible').length, 0, "should hide the load more button"); - - clientAction.destroy(); - }); - QUnit.test('Reconciliation: Payment < inv1 + inv2(partial)', function (assert) { assert.expect(4); @@ -1780,8 +1557,7 @@ QUnit.module('account', { // Add first prop widget.$('.match .cell_account_code:first').trigger('click'); - assert.notOk( widget.$('.cell_right .line_info_button').length, - "should not display the partial reconciliation alert"); + assert.equal( widget.$('.cell_right .line_info_button').length, 1, "should display the partial reconciliation alert"); // Add second prop widget.$('.match .cell_account_code:first').trigger('click'); diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index 1d6d1a3c4d5..33408802fd7 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -18,3 +18,4 @@ from . import test_account_move_taxes_edition from . import test_templates_consistency from . import test_account_fiscal_year from . import test_account_all_l10n +from . import test_reconciliation_matching_rules diff --git a/addons/account/tests/test_bank_statement_reconciliation.py b/addons/account/tests/test_bank_statement_reconciliation.py index 118127ee222..49d5d74d71d 100644 --- a/addons/account/tests/test_bank_statement_reconciliation.py +++ b/addons/account/tests/test_bank_statement_reconciliation.py @@ -19,7 +19,7 @@ class TestBankStatementReconciliation(AccountingTestCase): st_line = self.create_statement_line(100) # exact amount match - rec_prop = self.reconciliation_widget.get_bank_statement_line_data(st_line.ids) + rec_prop = self.reconciliation_widget.get_bank_statement_line_data(st_line.ids)['lines'] prop = rec_prop[0]['reconciliation_proposition'] self.assertEqual(len(prop), 1) diff --git a/addons/account/tests/test_reconciliation_matching_rules.py b/addons/account/tests/test_reconciliation_matching_rules.py new file mode 100644 index 00000000000..bfe0d6ef5e1 --- /dev/null +++ b/addons/account/tests/test_reconciliation_matching_rules.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.account_test_classes import AccountingTestCase +from odoo.tests.common import Form +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestReconciliationMatchingRules(AccountingTestCase): + def _create_invoice_line(self, amount, partner, type): + ''' Create an invoice on the fly.''' + self_ctx = self.env['account.invoice'].with_context(type=type) + journal_id = self_ctx._default_journal().id + self_ctx = self_ctx.with_context(journal_id=journal_id) + view = type in ('in_invoice', 'in_refund') and 'account.invoice_supplier_form' or 'account.invoice_form' + with Form(self_ctx, view=view) as invoice_form: + invoice_form.partner_id = partner + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.name = 'xxxx' + invoice_line_form.quantity = 1 + invoice_line_form.price_unit = amount + invoice_line_form.invoice_line_tax_ids.clear() + invoice = invoice_form.save() + invoice.action_invoice_open() + lines = invoice.move_id.line_ids + return lines.filtered(lambda l: l.account_id == invoice.account_id) + + def _check_statement_matching(self, rules, expected_values): + statement_lines = (self.bank_st + self.cash_st).mapped('line_ids') + matching_values = rules._apply_rules(statement_lines) + for st_line_id, values in matching_values.items(): + values.pop('reconciled_lines', None) + self.assertDictEqual(values, expected_values[st_line_id]) + + def setUp(self): + super(AccountingTestCase, self).setUp() + + self.partner_1 = self.env['res.partner'].create({'name': 'partner_1'}) + self.partner_2 = self.env['res.partner'].create({'name': 'partner_2'}) + + self.invoice_line_1 = self._create_invoice_line(100, self.partner_1, 'out_invoice') + self.invoice_line_2 = self._create_invoice_line(200, self.partner_1, 'out_invoice') + self.invoice_line_3 = self._create_invoice_line(300, self.partner_1, 'in_refund') + self.invoice_line_4 = self._create_invoice_line(1000, self.partner_2, 'in_invoice') + + current_assets_account = self.env['account.account'].search( + [('user_type_id', '=', self.env.ref('account.data_account_type_current_assets').id)], limit=1) + + self.rule_1 = self.env.ref('account.reconciliation_model_default_rule') + self.rule_1.account_id = current_assets_account + self.rule_1.match_partner = True + self.rule_1.match_partner_ids |= self.partner_1 + self.partner_2 + self.rule_2 = self.env['account.reconcile.model'].create({ + 'name': 'write-off model', + 'rule_type': 'writeoff_suggestion', + 'match_partner': True, + 'match_partner_ids': [6, 0, (self.partner_1 + self.partner_2).ids], + 'account_id': current_assets_account.id, + }) + + invoice_number = self.invoice_line_1.move_id.name + + bank_journal = self.env['account.journal'].search([('type', '=', 'bank')], limit=1) + + self.bank_st = self.env['account.bank.statement'].create({ + 'name': 'test bank journal', 'journal_id': bank_journal.id, + }) + self.bank_line_1 = self.env['account.bank.statement.line'].create({ + 'statement_id': self.bank_st.id, + 'name': 'invoice %s-%s' % (invoice_number.split('/')[1], invoice_number.split('/')[2]), + 'partner_id': self.partner_1.id, + 'amount': 100, + 'sequence': 1, + }) + self.bank_line_2 = self.env['account.bank.statement.line'].create({ + 'statement_id': self.bank_st.id, + 'name': 'xxxxx', + 'partner_id': self.partner_1.id, + 'amount': 600, + 'sequence': 2, + }) + + cash_journal = self.env['account.journal'].search([('type', '=', 'cash')], limit=1) + self.cash_st = self.env['account.bank.statement'].create({ + 'name': 'test cash journal', 'journal_id': cash_journal.id, + }) + self.cash_line_1 = self.env['account.bank.statement.line'].create({ + 'statement_id': self.cash_st.id, + 'name': 'yyyyy', + 'partner_id': self.partner_2.id, + 'amount': -1000, + 'sequence': 1, + }) + + def test_matching_fields(self): + ''' Test all fields used to restrict the rules's applicability.''' + + # Check without restriction. + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_2.id, + self.invoice_line_3.id, + ], 'model': self.rule_1}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1}, + }) + + # Check match_journal_ids. + self.rule_1.match_journal_ids |= self.cash_st.journal_id + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1}, + }) + self.rule_1.match_journal_ids |= self.bank_st.journal_id + self.cash_st.journal_id + + # Check match_nature. + self.rule_1.match_nature = 'amount_received' + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_2.id, + self.invoice_line_3.id, + ], 'model': self.rule_1}, + self.cash_line_1.id: {'aml_ids': []}, + }) + self.rule_1.match_nature = 'amount_paid' + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1}, + }) + self.rule_1.match_nature = 'both' + + # Check match_amount. + self.rule_1.match_amount = 'lower' + self.rule_1.match_amount_max = 150 + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': []}, + }) + self.rule_1.match_amount = 'greater' + self.rule_1.match_amount_min = 200 + 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}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1}, + }) + self.rule_1.match_amount = 'between' + self.rule_1.match_amount_min = 200 + self.rule_1.match_amount_max = 800 + 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}, + self.cash_line_1.id: {'aml_ids': []}, + }) + self.rule_1.match_amount = False + + # Check match_label. + self.rule_1.match_label = 'contains' + self.rule_1.match_label_param = 'yyyyy' + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1}, + }) + self.rule_1.match_label = 'not_contains' + self.rule_1.match_label_param = 'xxxxx' + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1}, + }) + self.rule_1.match_label = 'match_regex' + self.rule_1.match_label_param = 'xxxxx|yyyyy' + 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}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1}, + }) + self.rule_1.match_label = False + + # Check match_total_amount. + self.rule_1.match_total_amount_param = 90.0 + self.bank_line_1.amount += 5 + 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'}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_2.id, + self.invoice_line_3.id, + ], 'model': self.rule_1}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1}, + }) + self.rule_1.match_total_amount_param = 100.0 + self.bank_line_1.amount -= 5 + + # Check match_partner_category_ids. + test_category = self.env.ref('base.res_partner_category_8') + self.partner_2.category_id = test_category + self.rule_1.match_partner_category_ids |= test_category + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1}, + }) + self.rule_1.match_partner_category_ids = False + + def test_mixin_rules(self): + ''' Test usage of rules together.''' + + # rule_1 is used before rule_2. + self.rule_1.sequence = 1 + self.rule_2.sequence = 2 + + 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}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_2.id, + self.invoice_line_3.id, + ], 'model': self.rule_1}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1}, + }) + + # rule_2 is used before rule_1. + self.rule_1.sequence = 2 + self.rule_2.sequence = 1 + + self._check_statement_matching(self.rule_1 + self.rule_2, { + self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off'}, + self.bank_line_2.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off'}, + self.cash_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off'}, + }) + + # rule_2 is used before rule_1 but only on partner_1. + self.rule_2.match_partner_ids |= self.partner_1 + + self._check_statement_matching(self.rule_1 + self.rule_2, { + self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off'}, + self.bank_line_2.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off'}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1}, + }) + + def test_auto_reconcile(self): + ''' Test auto reconciliation.''' + + self.rule_1.sequence = 2 + self.rule_1.auto_reconcile = True + self.rule_1.match_total_amount_param = 90 + self.rule_2.sequence = 1 + self.rule_2.match_partner_ids |= self.partner_2 + self.rule_2.auto_reconcile = True + + self.bank_line_1.amount += 5 + + 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'}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'reconciled'}, + }) + + # Check first line has been well reconciled. + self.assertRecordValues(self.bank_line_1.journal_entry_ids, [ + {'partner_id': self.partner_1.id, 'debit': 105.0, 'credit': 0.0}, + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 100.0}, + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 5.0}, + ]) + + # Check second line has been well reconciled. + self.assertRecordValues(self.cash_line_1.journal_entry_ids, [ + {'partner_id': self.partner_2.id, 'debit': 0.0, 'credit': 1000.0}, + {'partner_id': self.partner_2.id, 'debit': 1000.0, 'credit': 0.0}, + ]) diff --git a/addons/account/tests/test_reconciliation_widget.py b/addons/account/tests/test_reconciliation_widget.py index 07700717439..5d2dc637ed7 100644 --- a/addons/account/tests/test_reconciliation_widget.py +++ b/addons/account/tests/test_reconciliation_widget.py @@ -1,5 +1,6 @@ import logging import odoo.tests +import time _logger = logging.getLogger(__name__) @@ -10,9 +11,10 @@ _logger = logging.getLogger(__name__) class TestUi(odoo.tests.HttpCase): def test_01_admin_bank_statement_reconciliation(self): - bank_stmt = self.env['account.bank.statement'].search([('name', '=', 'BNK/2014/001')]) + bank_stmt_name = 'BNK/%s/0001' % time.strftime('%Y') + bank_stmt = self.env['account.bank.statement'].search([('name', '=', bank_stmt_name)]) if not bank_stmt: - _logger.exception('Could not find bank statement BNK/2014/001') + _logger.exception('Could not find bank statement %s' % bank_stmt_name) # To be able to test reconciliation, admin user must have access to accounting features, so we give him the right group for that self.env.ref('base.user_admin').write({'groups_id': [(4, self.env.ref('account.group_account_user').id)]}) diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index f1ca4d470f7..807275ed73a 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -867,7 +867,75 @@
- + + + + + + + + + + + + + + + + + + + + + %
- + -
+
- + - + %
- + diff --git a/addons/l10n_generic_coa/data/account_bank_statement_demo.xml b/addons/l10n_generic_coa/data/account_bank_statement_demo.xml index 6166636bf20..88294df47bd 100644 --- a/addons/l10n_generic_coa/data/account_bank_statement_demo.xml +++ b/addons/l10n_generic_coa/data/account_bank_statement_demo.xml @@ -6,7 +6,7 @@ ('type', '=', 'bank'), ('company_id', '=', obj().env['res.company']._company_default_get('account.journal').id)]"/> - BNK/2014/001 + 8998.2 5103.0 @@ -15,11 +15,11 @@ 1 - SAJ/2014/002 and SAJ/2014/003 + - 1175.0 + 1275.0 @@ -53,7 +53,7 @@ 4 - First 2000 € of SAJ/2014/001 + @@ -78,7 +78,7 @@ 1 - + diff --git a/odoo/addons/base/models/res_currency.py b/odoo/addons/base/models/res_currency.py index 36aef2ba234..89175be9f87 100644 --- a/odoo/addons/base/models/res_currency.py +++ b/odoo/addons/base/models/res_currency.py @@ -32,7 +32,7 @@ class Currency(models.Model): help='The rate of the currency to the currency of rate 1.') rate_ids = fields.One2many('res.currency.rate', 'currency_id', string='Rates') rounding = fields.Float(string='Rounding Factor', digits=(12, 6), default=0.01) - decimal_places = fields.Integer(compute='_compute_decimal_places') + decimal_places = fields.Integer(compute='_compute_decimal_places', store=True) active = fields.Boolean(default=True) position = fields.Selection([('after', 'After Amount'), ('before', 'Before Amount')], default='after', string='Symbol Position', help="Determines where the currency symbol should be placed after or before the amount.")