diff --git a/base_accounting_kit/README.rst b/base_accounting_kit/README.rst
index 8616c21b9..e78857144 100644
--- a/base_accounting_kit/README.rst
+++ b/base_accounting_kit/README.rst
@@ -1,5 +1,5 @@
Accounting Kit
-=============================
+==============
* Full accounting kit for Odoo 14 community editions
Installation
diff --git a/base_accounting_kit/__manifest__.py b/base_accounting_kit/__manifest__.py
index 7276aa020..ee0ebfb4c 100644
--- a/base_accounting_kit/__manifest__.py
+++ b/base_accounting_kit/__manifest__.py
@@ -22,7 +22,7 @@
{
'name': 'Odoo 14 Full Accounting Kit',
- 'version': '14.0.1.4.4',
+ 'version': '14.0.2.4.4',
'category': 'Accounting',
'live_test_url': 'https://www.youtube.com/watch?v=peAp2Tx_XIs',
'summary': """ Asset and Budget Management,
@@ -39,7 +39,10 @@
Full Accounting, Complete Accounting,
Odoo Community Accounting, Accounting for odoo 14,
Full Accounting Package,
- Financial Reports, Financial Report for Odoo 14
+ Financial Reports, Financial Report for Odoo 14,
+ Reconciliation Widget,
+ Reconciliation Widget For Odoo14,
+ Payments Matching
""",
'author': ' Odoo SA,Cybrosys Techno Solutions',
'website': "https://www.cybrosys.com",
@@ -72,6 +75,7 @@
'views/account_move_views.xml',
'views/account_asset_templates.xml',
'views/product_template_views.xml',
+ 'views/payment_matching.xml',
'wizard/financial_report.xml',
'wizard/general_ledger.xml',
'wizard/partner_ledger.xml',
@@ -99,7 +103,8 @@
'report/report.xml',
],
'qweb': [
- 'static/src/xml/template.xml'
+ 'static/src/xml/template.xml',
+ 'static/src/xml/payment_matching.xml'
],
'license': 'LGPL-3',
'images': ['static/description/banner.gif'],
diff --git a/base_accounting_kit/doc/changelog.md b/base_accounting_kit/doc/changelog.md
index 378ac2104..bf26547f9 100644
--- a/base_accounting_kit/doc/changelog.md
+++ b/base_accounting_kit/doc/changelog.md
@@ -25,4 +25,9 @@
#### UPDT
- Removed warnings, Updated access rules.
+#### 18.01.2021
+#### Version 14.0.2.4.4
+#### UPDT
+- Reconciliation Widget Added.
+
diff --git a/base_accounting_kit/models/__init__.py b/base_accounting_kit/models/__init__.py
index 9562f352a..646509e4b 100644
--- a/base_accounting_kit/models/__init__.py
+++ b/base_accounting_kit/models/__init__.py
@@ -33,3 +33,4 @@ from . import recurring_payments
from . import res_config_settings
from . import res_partner
from . import account_dashboard
+from . import payment_matching
diff --git a/base_accounting_kit/models/account_journal.py b/base_accounting_kit/models/account_journal.py
index 698a590a1..4a13eaefa 100755
--- a/base_accounting_kit/models/account_journal.py
+++ b/base_accounting_kit/models/account_journal.py
@@ -26,6 +26,28 @@ from odoo import models, api
class AccountJournal(models.Model):
_inherit = "account.journal"
+ def action_open_reconcile(self):
+ if self.type in ['bank', 'cash']:
+ # Open reconciliation view for bank statements belonging to this journal
+ bank_stmt = self.env['account.bank.statement'].search([('journal_id', 'in', self.ids)]).mapped('line_ids')
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'bank_statement_reconciliation_view',
+ 'context': {'statement_line_ids': bank_stmt.ids, 'company_ids': self.mapped('company_id').ids},
+ }
+ else:
+ # Open reconciliation view for customers/suppliers
+ action_context = {'show_mode_selector': False, 'company_ids': self.mapped('company_id').ids}
+ if self.type == 'sale':
+ action_context.update({'mode': 'customers'})
+ elif self.type == 'purchase':
+ action_context.update({'mode': 'suppliers'})
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'manual_reconciliation_view',
+ 'context': action_context,
+ }
+
@api.depends('outbound_payment_method_ids')
def _compute_check_printing_payment_method_selected(self):
for journal in self:
diff --git a/base_accounting_kit/models/account_payment.py b/base_accounting_kit/models/account_payment.py
index 5a3e2f9e7..cb0b9e9e7 100755
--- a/base_accounting_kit/models/account_payment.py
+++ b/base_accounting_kit/models/account_payment.py
@@ -59,6 +59,28 @@ class AccountPayment(models.Model):
help='Effective date of PDC', copy=False,
default=False)
+ def open_payment_matching_screen(self):
+ # Open reconciliation view for customers/suppliers
+ move_line_id = False
+ for move_line in self.line_ids:
+ if move_line.account_id.reconcile:
+ move_line_id = move_line.id
+ break
+ if not self.partner_id:
+ raise UserError(_("Payments without a customer can't be matched"))
+ action_context = {'company_ids': [self.company_id.id], 'partner_ids': [self.partner_id.commercial_partner_id.id]}
+ if self.partner_type == 'customer':
+ action_context.update({'mode': 'customers'})
+ elif self.partner_type == 'supplier':
+ action_context.update({'mode': 'suppliers'})
+ if move_line_id:
+ action_context.update({'move_line_id': move_line_id})
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'manual_reconciliation_view',
+ 'context': action_context,
+ }
+
def print_checks(self):
""" Check that the recordset is valid, set the payments state to
sent and call print_checks() """
diff --git a/base_accounting_kit/models/payment_matching.py b/base_accounting_kit/models/payment_matching.py
new file mode 100644
index 000000000..7f184da35
--- /dev/null
+++ b/base_accounting_kit/models/payment_matching.py
@@ -0,0 +1,929 @@
+# -*- coding: utf-8 -*-
+
+import copy
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+from odoo.osv import expression
+from odoo.tools.misc import formatLang, format_date, parse_date
+
+
+class AccountReconciliation(models.AbstractModel):
+ _name = 'account.reconciliation.widget'
+ _description = 'Account Reconciliation widget'
+
+ ####################################################
+ # Public
+ ####################################################
+
+ @api.model
+ def process_bank_statement_line(self, st_line_ids, data):
+ """ Handles data sent from the bank statement reconciliation widget
+ (and can otherwise serve as an old-API bridge)
+
+ :param st_line_ids
+ :param list of dicts data: must contains the keys
+ 'counterpart_aml_dicts', 'payment_aml_ids' and 'new_aml_dicts',
+ whose value is the same as described in process_reconciliation
+ except that ids are used instead of recordsets.
+ :returns dict: used as a hook to add additional keys.
+ """
+ st_lines = self.env['account.bank.statement.line'].browse(st_line_ids)
+ AccountMoveLine = self.env['account.move.line']
+ ctx = dict(self._context, force_price_include=False)
+
+ processed_moves = self.env['account.move']
+ for st_line, datum in zip(st_lines, copy.deepcopy(data)):
+ payment_aml_rec = AccountMoveLine.browse(datum.get('payment_aml_ids', []))
+
+ for aml_dict in datum.get('counterpart_aml_dicts', []):
+ aml_dict['move_line'] = AccountMoveLine.browse(aml_dict['counterpart_aml_id'])
+ del aml_dict['counterpart_aml_id']
+
+ if datum.get('partner_id') is not None:
+ st_line.write({'partner_id': datum['partner_id']})
+
+ ctx['default_to_check'] = datum.get('to_check')
+ moves = st_line.with_context(ctx).reconcile(datum.get('lines_vals_list', []), to_check=datum.get('to_check', False))
+
+ return {'moves': processed_moves.ids, 'statement_line_ids': processed_moves.mapped('line_ids.statement_line_id').ids}
+
+ @api.model
+ def get_move_lines_for_bank_statement_line(self, st_line_id, partner_id=None, excluded_ids=None, search_str=False, offset=0, limit=None, mode=None):
+ """ Returns move lines for the bank statement reconciliation widget,
+ formatted as a list of dicts
+
+ :param st_line_id: ids of the statement lines
+ :param partner_id: optional partner id to select only the moves
+ line corresponding to the partner
+ :param excluded_ids: optional move lines ids excluded from the
+ result
+ :param search_str: optional search (can be the amout, display_name,
+ partner name, move line name)
+ :param offset: useless but kept in stable to preserve api
+ :param limit: number of the result to search
+ :param mode: 'rp' for receivable/payable or 'other'
+ """
+ st_line = self.env['account.bank.statement.line'].browse(st_line_id)
+
+ # Blue lines = payment on bank account not assigned to a statement yet
+ aml_accounts = [
+ st_line.journal_id.default_account_id.id
+ ]
+
+ if partner_id is None:
+ partner_id = st_line.partner_id.id
+
+ domain = self._domain_move_lines_for_reconciliation(st_line, aml_accounts, partner_id, excluded_ids=excluded_ids, search_str=search_str, mode=mode)
+ recs_count = self.env['account.move.line'].search_count(domain)
+
+ from_clause, where_clause, where_clause_params = self.env['account.move.line']._where_calc(domain).get_sql()
+ query_str = '''
+ SELECT "account_move_line".id FROM {from_clause}
+ {where_str}
+ ORDER BY ("account_move_line".debit - "account_move_line".credit) = {amount} DESC,
+ "account_move_line".date_maturity ASC,
+ "account_move_line".id ASC
+ {limit_str}
+ '''.format(
+ from_clause=from_clause,
+ where_str=where_clause and (" WHERE %s" % where_clause) or '',
+ amount=st_line.amount,
+ limit_str=limit and ' LIMIT %s' or '',
+ )
+ params = where_clause_params + (limit and [limit] or [])
+ self.env['account.move'].flush()
+ self.env['account.move.line'].flush()
+ self.env['account.bank.statement'].flush()
+ self._cr.execute(query_str, params)
+ res = self._cr.fetchall()
+
+ aml_recs = self.env['account.move.line'].browse([i[0] for i in res])
+ 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):
+ params = []
+
+ # Add the res.partner.ban's IR rules. In case partners are not shared between companies,
+ # identical bank accounts may exist in a company we don't have access to.
+ ir_rules_query = self.env['res.partner.bank']._where_calc([])
+ self.env['res.partner.bank']._apply_ir_rules(ir_rules_query, 'read')
+ from_clause, where_clause, where_clause_params = ir_rules_query.get_sql()
+ if where_clause:
+ where_bank = ('AND %s' % where_clause).replace('res_partner_bank', 'bank')
+ params += where_clause_params
+ else:
+ where_bank = ''
+
+ # Add the res.partner's IR rules. In case partners are not shared between companies,
+ # identical partners may exist in a company we don't have access to.
+ ir_rules_query = self.env['res.partner']._where_calc([])
+ self.env['res.partner']._apply_ir_rules(ir_rules_query, 'read')
+ from_clause, where_clause, where_clause_params = ir_rules_query.get_sql()
+ if where_clause:
+ where_partner = ('AND %s' % where_clause).replace('res_partner', 'p3')
+ params += where_clause_params
+ else:
+ where_partner = ''
+
+ query = '''
+ SELECT
+ st_line.id AS id,
+ COALESCE(p1.id,p2.id,p3.id) AS partner_id
+ FROM account_bank_statement_line st_line
+ JOIN account_move move ON move.id = st_line.move_id
+ '''
+ query += "LEFT JOIN res_partner_bank bank ON bank.id = move.partner_bank_id OR bank.sanitized_acc_number ILIKE regexp_replace(st_line.account_number, '\W+', '', 'g') %s\n" % (where_bank)
+ query += 'LEFT JOIN res_partner p1 ON st_line.partner_id=p1.id \n'
+ query += 'LEFT JOIN res_partner p2 ON bank.partner_id=p2.id \n'
+ # By definition the commercial partner_id doesn't have a parent_id set
+ query += 'LEFT JOIN res_partner p3 ON p3.name ILIKE st_line.partner_name %s AND p3.parent_id is NULL \n' % (where_partner)
+ query += '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
+ each statement line in self
+
+ :param st_line_id: ids of the statement lines
+ :param excluded_ids: optional move lines ids excluded from the
+ result
+ """
+ results = {
+ 'lines': [],
+ 'value_min': 0,
+ 'value_max': 0,
+ 'reconciled_aml_ids': [],
+ }
+
+ if not st_line_ids:
+ return results
+
+ excluded_ids = excluded_ids or []
+
+ # Make a search to preserve the table's order.
+ bank_statement_lines = self.env['account.bank.statement.line'].search([('id', 'in', st_line_ids)])
+ results['value_max'] = len(bank_statement_lines)
+ reconcile_model = self.env['account.reconcile.model'].search([('rule_type', '!=', 'writeoff_button')])
+
+ # Search for missing partners when opening the reconciliation widget.
+ if bank_statement_lines:
+ 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)
+
+ # Iterate on st_lines to keep the same order in the results list.
+ bank_statements_left = self.env['account.bank.statement']
+ for line in bank_statement_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
+ target_currency = line.currency_id or line.journal_id.currency_id or line.journal_id.company_id.currency_id
+
+ amls = aml_ids and self.env['account.move.line'].browse(aml_ids)
+ line_vals = {
+ 'st_line': self._get_statement_line(line),
+ 'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls, target_currency=target_currency, target_date=line.date) 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',
+ }
+ if not line.partner_id and partner_map.get(line.id):
+ partner = self.env['res.partner'].browse(partner_map[line.id])
+ line_vals.update({
+ 'partner_id': partner.id,
+ 'partner_name': partner.name,
+ })
+ results['lines'].append(line_vals)
+
+ return results
+
+ @api.model
+ def get_bank_statement_data(self, bank_statement_line_ids, srch_domain=[]):
+ """ Get statement lines of the specified statements or all unreconciled
+ statement lines and try to automatically reconcile them / find them
+ a partner.
+ Return ids of statement lines left to reconcile and other data for
+ the reconciliation widget.
+
+ :param bank_statement_line_ids: ids of the bank statement lines
+ """
+ if not bank_statement_line_ids:
+ return {}
+ domain = [['id', 'in', tuple(bank_statement_line_ids)], ('is_reconciled', '=', False)] + srch_domain
+ bank_statement_lines = self.env['account.bank.statement.line'].search(domain)
+ bank_statements = bank_statement_lines.mapped('statement_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')
+
+ results.update({
+ 'statement_name': len(bank_statements_left) == 1 and bank_statements_left.name or False,
+ 'journal_id': bank_statements and bank_statements[0].journal_id.id or False,
+ 'notifications': []
+ })
+
+ 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):
+ """ Returns unreconciled move lines for an account or a partner+account, formatted for the manual reconciliation widget """
+
+ Account_move_line = self.env['account.move.line']
+ Account = self.env['account.account']
+ Currency = self.env['res.currency']
+
+ domain = self._domain_move_lines_for_manual_reconciliation(account_id, partner_id, excluded_ids, search_str)
+ recs_count = Account_move_line.search_count(domain)
+ lines = Account_move_line.search(domain, limit=limit, order="date_maturity desc, id desc")
+ if target_currency_id:
+ target_currency = Currency.browse(target_currency_id)
+ else:
+ account = Account.browse(account_id)
+ target_currency = account.currency_id or account.company_id.currency_id
+ return self._prepare_move_lines(lines, target_currency=target_currency,recs_count=recs_count)
+
+ @api.model
+ def get_all_data_for_manual_reconciliation(self, partner_ids, account_ids):
+ """ Returns the data required for the invoices & payments matching of partners/accounts.
+ If an argument is None, fetch all related reconciliations. Use [] to fetch nothing.
+ """
+ MoveLine = self.env['account.move.line']
+ aml_ids = self._context.get('active_ids') and self._context.get('active_model') == 'account.move.line' and tuple(self._context.get('active_ids'))
+ if aml_ids:
+ aml = MoveLine.browse(aml_ids)
+ aml._check_reconcile_validity()
+ account = aml[0].account_id
+ currency = account.currency_id or account.company_id.currency_id
+ return {
+ 'accounts': [{
+ 'reconciliation_proposition': self._prepare_move_lines(aml, target_currency=currency),
+ 'company_id': account.company_id.id,
+ 'currency_id': currency.id,
+ 'mode': 'accounts',
+ 'account_id': account.id,
+ 'account_name': account.name,
+ 'account_code': account.code,
+ }],
+ 'customers': [],
+ 'suppliers': [],
+ }
+ # If we have specified partner_ids, don't return the list of reconciliation for specific accounts as it will
+ # show entries that are not reconciled with other partner. Asking for a specific partner on a specific account
+ # is never done.
+ accounts_data = []
+ if not partner_ids or not any(partner_ids):
+ accounts_data = self.get_data_for_manual_reconciliation('account', account_ids)
+ return {
+ 'customers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'receivable'),
+ 'suppliers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'payable'),
+ 'accounts': accounts_data,
+ }
+
+ @api.model
+ def get_data_for_manual_reconciliation(self, res_type, res_ids=None, account_type=None):
+ """ Returns the data required for the invoices & payments matching of partners/accounts (list of dicts).
+ If no res_ids is passed, returns data for all partners/accounts that can be reconciled.
+
+ :param res_type: either 'partner' or 'account'
+ :param res_ids: ids of the partners/accounts to reconcile, use None to fetch data indiscriminately
+ of the id, use [] to prevent from fetching any data at all.
+ :param account_type: if a partner is both customer and vendor, you can use 'payable' to reconcile
+ the vendor-related journal entries and 'receivable' for the customer-related entries.
+ """
+
+ Account = self.env['account.account']
+ Partner = self.env['res.partner']
+
+ if res_ids is not None and len(res_ids) == 0:
+ # Note : this short-circuiting is better for performances, but also required
+ # since postgresql doesn't implement empty list (so 'AND id in ()' is useless)
+ return []
+ res_ids = res_ids and tuple(res_ids)
+
+ assert res_type in ('partner', 'account')
+ assert account_type in ('payable', 'receivable', None)
+ is_partner = res_type == 'partner'
+ res_alias = is_partner and 'p' or 'a'
+ aml_ids = self._context.get('active_ids') and self._context.get('active_model') == 'account.move.line' and tuple(self._context.get('active_ids'))
+ all_entries = self._context.get('all_entries', False)
+ all_entries_query = """
+ AND EXISTS (
+ SELECT NULL
+ FROM account_move_line l
+ JOIN account_move move ON l.move_id = move.id
+ JOIN account_journal journal ON l.journal_id = journal.id
+ WHERE l.account_id = a.id
+ {inner_where}
+ AND l.amount_residual != 0
+ AND move.state = 'posted'
+ )
+ """.format(inner_where=is_partner and 'AND l.partner_id = p.id' or ' ')
+ only_dual_entries_query = """
+ AND EXISTS (
+ SELECT NULL
+ FROM account_move_line l
+ JOIN account_move move ON l.move_id = move.id
+ JOIN account_journal journal ON l.journal_id = journal.id
+ WHERE l.account_id = a.id
+ {inner_where}
+ AND l.amount_residual > 0
+ AND move.state = 'posted'
+ )
+ AND EXISTS (
+ SELECT NULL
+ FROM account_move_line l
+ JOIN account_move move ON l.move_id = move.id
+ JOIN account_journal journal ON l.journal_id = journal.id
+ WHERE l.account_id = a.id
+ {inner_where}
+ AND l.amount_residual < 0
+ AND move.state = 'posted'
+ )
+ """.format(inner_where=is_partner and 'AND l.partner_id = p.id' or ' ')
+ query = ("""
+ SELECT {select} account_id, account_name, account_code, max_date
+ FROM (
+ SELECT {inner_select}
+ a.id AS account_id,
+ a.name AS account_name,
+ a.code AS account_code,
+ MAX(l.write_date) AS max_date
+ FROM
+ account_move_line l
+ RIGHT JOIN account_account a ON (a.id = l.account_id)
+ RIGHT JOIN account_account_type at ON (at.id = a.user_type_id)
+ {inner_from}
+ WHERE
+ a.reconcile IS TRUE
+ AND l.full_reconcile_id is NULL
+ {where1}
+ {where2}
+ {where3}
+ AND l.company_id = {company_id}
+ {where4}
+ {where5}
+ GROUP BY {group_by1} a.id, a.name, a.code {group_by2}
+ {order_by}
+ ) as s
+ {outer_where}
+ """.format(
+ select=is_partner and "partner_id, partner_name, to_char(last_time_entries_checked, 'YYYY-MM-DD') AS last_time_entries_checked," or ' ',
+ inner_select=is_partner and 'p.id AS partner_id, p.name AS partner_name, p.last_time_entries_checked AS last_time_entries_checked,' or ' ',
+ inner_from=is_partner and 'RIGHT JOIN res_partner p ON (l.partner_id = p.id)' or ' ',
+ where1=is_partner and ' ' or "AND ((at.type <> 'payable' AND at.type <> 'receivable') OR l.partner_id IS NULL)",
+ where2=account_type and "AND at.type = %(account_type)s" or '',
+ where3=res_ids and 'AND ' + res_alias + '.id in %(res_ids)s' or '',
+ company_id=self.env.company.id,
+ where4=aml_ids and 'AND l.id IN %(aml_ids)s' or ' ',
+ where5=all_entries and all_entries_query or only_dual_entries_query,
+ group_by1=is_partner and 'l.partner_id, p.id,' or ' ',
+ group_by2=is_partner and ', p.last_time_entries_checked' or ' ',
+ order_by=is_partner and 'ORDER BY p.last_time_entries_checked' or 'ORDER BY a.code',
+ outer_where=is_partner and 'WHERE (last_time_entries_checked IS NULL OR max_date > last_time_entries_checked)' or ' ',
+ ))
+ self.env['account.move.line'].flush()
+ self.env['account.account'].flush()
+ self.env.cr.execute(query, locals())
+
+ # Apply ir_rules by filtering out
+ rows = self.env.cr.dictfetchall()
+ ids = [x['account_id'] for x in rows]
+ allowed_ids = set(Account.browse(ids).ids)
+ rows = [row for row in rows if row['account_id'] in allowed_ids]
+ if is_partner:
+ ids = [x['partner_id'] for x in rows]
+ allowed_ids = set(Partner.browse(ids).ids)
+ rows = [row for row in rows if row['partner_id'] in allowed_ids]
+
+ # Keep mode for future use in JS
+ if res_type == 'account':
+ mode = 'accounts'
+ else:
+ mode = 'customers' if account_type == 'receivable' else 'suppliers'
+
+ # Fetch other data
+ for row in rows:
+ account = Account.browse(row['account_id'])
+ currency = account.currency_id or account.company_id.currency_id
+ row['currency_id'] = currency.id
+ partner_id = is_partner and row['partner_id'] or None
+ rec_prop = aml_ids and self.env['account.move.line'].browse(aml_ids) or self._get_move_line_reconciliation_proposition(account.id, partner_id)
+ row['reconciliation_proposition'] = self._prepare_move_lines(rec_prop, target_currency=currency)
+ row['mode'] = mode
+ row['company_id'] = account.company_id.id
+
+ # Return the partners with a reconciliation proposition first, since they are most likely to
+ # be reconciled.
+ return [r for r in rows if r['reconciliation_proposition']] + [r for r in rows if not r['reconciliation_proposition']]
+
+ @api.model
+ def process_move_lines(self, data):
+ """ Used to validate a batch of reconciliations in a single call
+ :param data: list of dicts containing:
+ - 'type': either 'partner' or 'account'
+ - 'id': id of the affected res.partner or account.account
+ - 'mv_line_ids': ids of existing account.move.line to reconcile
+ - 'new_mv_line_dicts': list of dicts containing values suitable for account_move_line.create()
+ """
+
+ Partner = self.env['res.partner']
+ Account = self.env['account.account']
+
+ for datum in data:
+ if len(datum['mv_line_ids']) >= 1 or len(datum['mv_line_ids']) + len(datum['new_mv_line_dicts']) >= 2:
+ self._process_move_lines(datum['mv_line_ids'], datum['new_mv_line_dicts'])
+
+ if datum['type'] == 'partner':
+ partners = Partner.browse(datum['id'])
+ partners.mark_as_reconciled()
+
+ ####################################################
+ # Private
+ ####################################################
+
+ def _str_domain_for_mv_line(self, search_str):
+ return [
+ '|', ('account_id.code', 'ilike', search_str),
+ '|', ('move_id.name', 'ilike', search_str),
+ '|', ('move_id.ref', 'ilike', search_str),
+ '|', ('date_maturity', 'like', parse_date(self.env, search_str)),
+ '&', ('name', '!=', '/'), ('name', 'ilike', search_str)
+ ]
+
+ @api.model
+ def _domain_move_lines(self, search_str):
+ """ Returns the domain from the search_str search
+ :param search_str: search string
+ """
+ if not search_str:
+ return []
+ str_domain = self._str_domain_for_mv_line(search_str)
+ if search_str[0] in ['-', '+']:
+ try:
+ amounts_str = search_str.split('|')
+ for amount_str in amounts_str:
+ amount = amount_str[0] == '-' and float(amount_str) or float(amount_str[1:])
+ amount_domain = [
+ '|', ('amount_residual', '=', amount),
+ '|', ('amount_residual_currency', '=', amount),
+ '|', (amount_str[0] == '-' and 'credit' or 'debit', '=', float(amount_str[1:])),
+ ('amount_currency', '=', amount),
+ ]
+ str_domain = expression.OR([str_domain, amount_domain])
+ except:
+ pass
+ else:
+ try:
+ amount = float(search_str)
+ amount_domain = [
+ '|', ('amount_residual', '=', amount),
+ '|', ('amount_residual_currency', '=', amount),
+ '|', ('amount_residual', '=', -amount),
+ '|', ('amount_residual_currency', '=', -amount),
+ '&', ('account_id.internal_type', '=', 'liquidity'),
+ '|', '|', '|', ('debit', '=', amount), ('credit', '=', amount), ('amount_currency', '=', amount), ('amount_currency', '=', -amount),
+ ]
+ str_domain = expression.OR([str_domain, amount_domain])
+ except:
+ pass
+ return str_domain
+
+ @api.model
+ def _domain_move_lines_for_reconciliation(self, st_line, aml_accounts, partner_id, excluded_ids=[], search_str=False, mode='rp'):
+ """ Return the domain for account.move.line records which can be used for bank statement reconciliation.
+
+ :param aml_accounts:
+ :param partner_id:
+ :param excluded_ids:
+ :param search_str:
+ :param mode: 'rp' for receivable/payable or 'other'
+ """
+ AccountMoveLine = self.env['account.move.line']
+
+ #Always exclude the journal items that have been marked as 'to be checked' in a former bank statement reconciliation
+ to_check_excluded = AccountMoveLine.search(AccountMoveLine._get_suspense_moves_domain()).ids
+ excluded_ids.extend(to_check_excluded)
+
+ domain_reconciliation = [
+ '&', '&', '&',
+ ('statement_line_id', '=', False),
+ ('account_id', 'in', aml_accounts),
+ ('payment_id', '<>', False),
+ ('balance', '!=', 0.0),
+ ]
+
+ # default domain matching
+ domain_matching = [
+ '&', '&',
+ ('reconciled', '=', False),
+ ('account_id.reconcile', '=', True),
+ ('balance', '!=', 0.0),
+ ]
+
+ domain = expression.OR([domain_reconciliation, domain_matching])
+ if partner_id:
+ domain = expression.AND([domain, [('partner_id', '=', partner_id)]])
+ if mode == 'rp':
+ domain = expression.AND([domain,
+ [('account_id.internal_type', 'in', ['receivable', 'payable', 'liquidity'])]
+ ])
+ else:
+ domain = expression.AND([domain,
+ [('account_id.internal_type', 'not in', ['receivable', 'payable', 'liquidity'])]
+ ])
+
+ # Domain factorized for all reconciliation use cases
+ if search_str:
+ str_domain = self._domain_move_lines(search_str=search_str)
+ str_domain = expression.OR([
+ str_domain,
+ [('partner_id.name', 'ilike', search_str)]
+ ])
+ domain = expression.AND([
+ domain,
+ str_domain
+ ])
+
+ if excluded_ids:
+ domain = expression.AND([
+ [('id', 'not in', excluded_ids)],
+ domain
+ ])
+ # filter on account.move.line having the same company as the statement line
+ domain = expression.AND([domain, [('company_id', '=', st_line.company_id.id)]])
+
+ # take only moves in valid state. Draft is accepted only when "Post At" is set
+ # to "Bank Reconciliation" in the associated journal
+ domain_post_at = [
+
+ ('move_id.state', 'not in', ['draft', 'cancel']),
+ ]
+ domain = expression.AND([domain, domain_post_at])
+
+ # if st_line.company_id.account_bank_reconciliation_start:
+ # domain = expression.AND([domain, [('date', '>=', st_line.company_id.account_bank_reconciliation_start)]])
+ return domain
+
+ @api.model
+ def _domain_move_lines_for_manual_reconciliation(self, account_id, partner_id=False, excluded_ids=None, search_str=False):
+ """ Create domain criteria that are relevant to manual reconciliation. """
+ domain = [
+ ('reconciled', '=', False),
+ ('account_id', '=', account_id),
+ ('move_id.state', '=', 'posted')
+ ]
+ domain = expression.AND([domain, [('balance', '!=', 0.0)]])
+ if partner_id:
+ domain = expression.AND([domain, [('partner_id', '=', partner_id)]])
+ if excluded_ids:
+ domain = expression.AND([[('id', 'not in', excluded_ids)], domain])
+ if search_str:
+ str_domain = self._domain_move_lines(search_str=search_str)
+ domain = expression.AND([domain, str_domain])
+ # filter on account.move.line having the same company as the given account
+ account = self.env['account.account'].browse(account_id)
+ domain = expression.AND([domain, [('company_id', '=', account.company_id.id)]])
+ return domain
+
+ @api.model
+ def _prepare_move_lines(self, move_lines, target_currency=False, target_date=False, recs_count=0):
+ """ Returns move lines formatted for the manual/bank reconciliation widget
+
+ :param move_line_ids:
+ :param target_currency: currency (browse) you want the move line debit/credit converted into
+ :param target_date: date to use for the monetary conversion
+ """
+ context = dict(self._context or {})
+ ret = []
+
+ for line in move_lines:
+ company_currency = line.company_id.currency_id
+ line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency
+ ret_line = {
+ 'id': line.id,
+ 'name': line.name and line.name != '/' and line.move_id.name != line.name and line.move_id.name + ': ' + line.name or line.move_id.name,
+ 'ref': line.move_id.ref or '',
+ # For reconciliation between statement transactions and already registered payments (eg. checks)
+ # NB : we don't use the 'reconciled' field because the line we're selecting is not the one that gets reconciled
+ 'account_id': [line.account_id.id, line.account_id.display_name],
+ 'already_paid': line.account_id.internal_type == 'liquidity',
+ 'account_code': line.account_id.code,
+ 'account_name': line.account_id.name,
+ 'account_type': line.account_id.internal_type,
+ 'date_maturity': format_date(self.env, line.date_maturity),
+ 'date': format_date(self.env, line.date),
+ 'journal_id': [line.journal_id.id, line.journal_id.display_name],
+ 'partner_id': line.partner_id.id,
+ 'partner_name': line.partner_id.name,
+ 'currency_id': line_currency.id,
+ }
+
+ debit = line.debit
+ credit = line.credit
+ amount = line.amount_residual
+ amount_currency = line.amount_residual_currency
+
+ # For already reconciled lines, don't use amount_residual(_currency)
+ if line.account_id.internal_type == 'liquidity':
+ amount = debit - credit
+ amount_currency = line.amount_currency
+
+ target_currency = target_currency or company_currency
+
+ # Use case:
+ # Let's assume that company currency is in USD and that we have the 3 following move lines
+ # Debit Credit Amount currency Currency
+ # 1) 25 0 0 NULL
+ # 2) 17 0 25 EUR
+ # 3) 33 0 25 YEN
+ #
+ # If we ask to see the information in the reconciliation widget in company currency, we want to see
+ # The following information
+ # 1) 25 USD (no currency information)
+ # 2) 17 USD [25 EUR] (show 25 euro in currency information, in the little bill)
+ # 3) 33 USD [25 YEN] (show 25 yen in currency information)
+ #
+ # If we ask to see the information in another currency than the company let's say EUR
+ # 1) 35 EUR [25 USD]
+ # 2) 25 EUR (no currency information)
+ # 3) 50 EUR [25 YEN]
+ # In that case, we have to convert the debit-credit to the currency we want and we show next to it
+ # the value of the amount_currency or the debit-credit if no amount currency
+ if target_currency == company_currency:
+ if line_currency == target_currency:
+ amount = amount
+ amount_currency = ""
+ total_amount = debit - credit
+ total_amount_currency = ""
+ else:
+ amount = amount
+ amount_currency = amount_currency
+ total_amount = debit - credit
+ total_amount_currency = line.amount_currency
+
+ if target_currency != company_currency:
+ if line_currency == target_currency:
+ amount = amount_currency
+ amount_currency = ""
+ total_amount = line.amount_currency
+ total_amount_currency = ""
+ else:
+ amount_currency = line.currency_id and amount_currency or amount
+ company = line.account_id.company_id
+ date = target_date or line.date
+ amount = company_currency._convert(amount, target_currency, company, date)
+ total_amount = company_currency._convert((line.debit - line.credit), target_currency, company, date)
+ total_amount_currency = line.currency_id and line.amount_currency or (line.debit - line.credit)
+
+ ret_line['recs_count'] = recs_count
+ ret_line['debit'] = amount > 0 and amount or 0
+ ret_line['credit'] = amount < 0 and -amount or 0
+ ret_line['amount_currency'] = amount_currency
+ ret_line['amount_str'] = formatLang(self.env, abs(amount), currency_obj=target_currency)
+ ret_line['total_amount_str'] = formatLang(self.env, abs(total_amount), currency_obj=target_currency)
+ ret_line['amount_currency_str'] = amount_currency and formatLang(self.env, abs(amount_currency), currency_obj=line_currency) or ""
+ ret_line['total_amount_currency_str'] = total_amount_currency and formatLang(self.env, abs(total_amount_currency), currency_obj=line_currency) or ""
+ ret.append(ret_line)
+ return ret
+
+ @api.model
+ def _get_statement_line(self, st_line):
+ """ Returns the data required by the bank statement reconciliation widget to display a statement line """
+
+ statement_currency = st_line.journal_id.currency_id or st_line.journal_id.company_id.currency_id
+ if st_line.amount_currency and st_line.currency_id:
+ amount = st_line.amount_currency
+ amount_currency = st_line.amount
+ amount_currency_str = formatLang(self.env, abs(amount_currency), currency_obj=statement_currency)
+ else:
+ amount = st_line.amount
+ amount_currency = amount
+ amount_currency_str = ""
+ amount_str = formatLang(self.env, abs(amount), currency_obj=st_line.currency_id or statement_currency)
+
+ data = {
+ 'id': st_line.id,
+ 'ref': st_line.ref,
+ 'note': st_line.narration or "",
+ 'name': st_line.name,
+ 'date': format_date(self.env, st_line.date),
+ 'amount': amount,
+ 'amount_str': amount_str, # Amount in the statement line currency
+ 'currency_id': st_line.currency_id.id or statement_currency.id,
+ 'partner_id': st_line.partner_id.id,
+ 'journal_id': st_line.journal_id.id,
+ 'statement_id': st_line.statement_id.id,
+ 'account_id': [st_line.journal_id.default_account_id.id, st_line.journal_id.default_account_id.display_name],
+ 'account_code': st_line.journal_id.default_account_id.code,
+ 'account_name': st_line.journal_id.default_account_id.name,
+ 'partner_name': st_line.partner_id.name,
+ 'communication_partner_name': st_line.partner_name,
+ 'amount_currency_str': amount_currency_str, # Amount in the statement currency
+ 'amount_currency': amount_currency, # Amount in the statement currency
+ 'has_no_partner': not st_line.partner_id.id,
+ 'company_id': st_line.company_id.id,
+ }
+ if st_line.partner_id:
+ data['open_balance_account_id'] = amount > 0 and st_line.partner_id.property_account_receivable_id.id or st_line.partner_id.property_account_payable_id.id
+
+ return data
+
+ @api.model
+ def _get_move_line_reconciliation_proposition(self, account_id, partner_id=None):
+ """ Returns two lines whose amount are opposite """
+
+ Account_move_line = self.env['account.move.line']
+
+ ir_rules_query = Account_move_line._where_calc([])
+ Account_move_line._apply_ir_rules(ir_rules_query, 'read')
+ from_clause, where_clause, where_clause_params = ir_rules_query.get_sql()
+ where_str = where_clause and (" WHERE %s" % where_clause) or ''
+
+ # Get pairs
+ query = """
+ SELECT a.id, b.id
+ FROM account_move_line a, account_move_line b,
+ account_move move_a, account_move move_b,
+ account_journal journal_a, account_journal journal_b
+ WHERE a.id != b.id
+ AND move_a.id = a.move_id
+ AND move_a.state = 'posted'
+ AND move_a.journal_id = journal_a.id
+ AND move_b.id = b.move_id
+ AND move_b.journal_id = journal_b.id
+ AND move_b.state = 'posted'
+ AND a.amount_residual = -b.amount_residual
+ AND a.balance != 0.0
+ AND b.balance != 0.0
+ AND NOT a.reconciled
+ AND a.account_id = %s
+ AND (%s IS NULL AND b.account_id = %s)
+ AND (%s IS NULL AND NOT b.reconciled OR b.id = %s)
+ AND (%s is NULL OR (a.partner_id = %s AND b.partner_id = %s))
+ AND a.id IN (SELECT "account_move_line".id FROM {0})
+ AND b.id IN (SELECT "account_move_line".id FROM {0})
+ ORDER BY a.date desc
+ LIMIT 1
+ """.format(from_clause + where_str)
+ move_line_id = self.env.context.get('move_line_id') or None
+ params = [
+ account_id,
+ move_line_id, account_id,
+ move_line_id, move_line_id,
+ partner_id, partner_id, partner_id,
+ ] + where_clause_params + where_clause_params
+ self.env.cr.execute(query, params)
+
+ pairs = self.env.cr.fetchall()
+
+ if pairs:
+ return Account_move_line.browse(pairs[0])
+ return Account_move_line
+
+ @api.model
+ def _process_move_lines(self, move_line_ids, new_mv_line_dicts):
+ """ Create new move lines from new_mv_line_dicts (if not empty) then call reconcile_partial on self and new move lines
+
+ :param new_mv_line_dicts: list of dicts containing values suitable for account_move_line.create()
+ """
+ if len(move_line_ids) < 1 or len(move_line_ids) + len(new_mv_line_dicts) < 2:
+ raise UserError(_('A reconciliation must involve at least 2 move lines.'))
+
+ account_move_line = self.env['account.move.line'].browse(move_line_ids)
+ writeoff_lines = self.env['account.move.line']
+
+ # Create writeoff move lines
+ if len(new_mv_line_dicts) > 0:
+ company_currency = account_move_line[0].account_id.company_id.currency_id
+ same_currency = False
+ currencies = list(set([aml.currency_id or company_currency for aml in account_move_line]))
+ if len(currencies) == 1 and currencies[0] != company_currency:
+ same_currency = True
+ # We don't have to convert debit/credit to currency as all values in the reconciliation widget are displayed in company currency
+ # If all the lines are in the same currency, create writeoff entry with same currency also
+ for mv_line_dict in new_mv_line_dicts:
+ if not same_currency:
+ mv_line_dict['amount_currency'] = False
+ writeoff_lines += account_move_line._create_writeoff([mv_line_dict])
+
+ (account_move_line + writeoff_lines).reconcile()
+ else:
+ account_move_line.reconcile()
+
+
+class AccountInvoiceLine(models.Model):
+ _inherit = 'account.move.line'
+
+ def _create_writeoff(self, writeoff_vals):
+ def compute_writeoff_counterpart_vals(values):
+ line_values = values.copy()
+ line_values['debit'], line_values['credit'] = line_values['credit'], line_values['debit']
+ if 'amount_currency' in values:
+ line_values['amount_currency'] = -line_values['amount_currency']
+ return line_values
+ # Group writeoff_vals by journals
+ writeoff_dict = {}
+ for val in writeoff_vals:
+ journal_id = val.get('journal_id', False)
+ if not writeoff_dict.get(journal_id, False):
+ writeoff_dict[journal_id] = [val]
+ else:
+ writeoff_dict[journal_id].append(val)
+
+ partner_id = self.env['res.partner']._find_accounting_partner(self[0].partner_id).id
+ company_currency = self[0].account_id.company_id.currency_id
+ writeoff_currency = self[0].account_id.currency_id or company_currency
+ line_to_reconcile = self.env['account.move.line']
+ # Iterate and create one writeoff by journal
+ writeoff_moves = self.env['account.move']
+ for journal_id, lines in writeoff_dict.items():
+ total = 0
+ total_currency = 0
+ writeoff_lines = []
+ date = fields.Date.today()
+ for vals in lines:
+ # Check and complete vals
+ if 'account_id' not in vals or 'journal_id' not in vals:
+ raise UserError(_("It is mandatory to specify an account and a journal to create a write-off."))
+ if ('debit' in vals) ^ ('credit' in vals):
+ raise UserError(_("Either pass both debit and credit or none."))
+ if 'date' not in vals:
+ vals['date'] = self._context.get('date_p') or fields.Date.today()
+ vals['date'] = fields.Date.to_date(vals['date'])
+ if vals['date'] and vals['date'] < date:
+ date = vals['date']
+ if 'name' not in vals:
+ vals['name'] = self._context.get('comment') or _('Write-Off')
+ if 'analytic_account_id' not in vals:
+ vals['analytic_account_id'] = self.env.context.get('analytic_id', False)
+ #compute the writeoff amount if not given
+ if 'credit' not in vals and 'debit' not in vals:
+ amount = sum([r.amount_residual for r in self])
+ vals['credit'] = amount > 0 and amount or 0.0
+ vals['debit'] = amount < 0 and abs(amount) or 0.0
+ vals['partner_id'] = partner_id
+ total += vals['debit']-vals['credit']
+ if 'amount_currency' not in vals and writeoff_currency != company_currency:
+ vals['currency_id'] = writeoff_currency.id
+ sign = 1 if vals['debit'] > 0 else -1
+ vals['amount_currency'] = sign * abs(sum([r.amount_residual_currency for r in self]))
+ total_currency += vals['amount_currency']
+
+ writeoff_lines.append(compute_writeoff_counterpart_vals(vals))
+
+ # Create balance line
+ writeoff_lines.append({
+ 'name': _('Write-Off'),
+ 'debit': total > 0 and total or 0.0,
+ 'credit': total < 0 and -total or 0.0,
+ 'amount_currency': total_currency,
+ 'currency_id': total_currency and writeoff_currency.id or False,
+ 'journal_id': journal_id,
+ 'account_id': self[0].account_id.id,
+ 'partner_id': partner_id
+ })
+
+ # Create the move
+ writeoff_move = self.env['account.move'].create({
+ 'journal_id': journal_id,
+ 'date': date,
+ 'state': 'draft',
+ 'line_ids': [(0, 0, line) for line in writeoff_lines],
+ })
+ writeoff_moves += writeoff_move
+ line_to_reconcile += writeoff_move.line_ids.filtered(lambda r: r.account_id == self[0].account_id).sorted(key='id')[-1:]
+
+ #post all the writeoff moves at once
+ if writeoff_moves:
+ writeoff_moves.action_post()
+
+ # Return the writeoff move.line which is to be reconciled
+ return line_to_reconcile
+
diff --git a/base_accounting_kit/static/description/banner.gif b/base_accounting_kit/static/description/banner.gif
index c1fc69581..58e686ce8 100644
Binary files a/base_accounting_kit/static/description/banner.gif and b/base_accounting_kit/static/description/banner.gif differ
diff --git a/base_accounting_kit/static/description/index.html b/base_accounting_kit/static/description/index.html
index dce19d6c2..501809481 100644
--- a/base_accounting_kit/static/description/index.html
+++ b/base_accounting_kit/static/description/index.html
@@ -9,6 +9,21 @@
style="margin:75px 0;position: relative;color: #000;background-position: center;background: #ffffff;border-bottom: 1px solid #e4e4e4; padding-bottom: 30px;">
Latest Updates
+
+ -
+
+ Reconciliation Widget Added
+
+ -
+
+ Payments Matching option in Journal Overview
+
+ -
+
+ Payments Matching button Payment Form
+
+
+
Odoo 14 Accounting
Dashboard, Asset Management, Accounting
diff --git a/base_accounting_kit/static/src/js/payment_matching.js b/base_accounting_kit/static/src/js/payment_matching.js
new file mode 100644
index 000000000..80a173298
--- /dev/null
+++ b/base_accounting_kit/static/src/js/payment_matching.js
@@ -0,0 +1,505 @@
+odoo.define('base_accounting_kit.ReconciliationClientAction', function (require) {
+"use strict";
+
+var AbstractAction = require('web.AbstractAction');
+var ReconciliationModel = require('base_accounting_kit.ReconciliationModel');
+var ReconciliationRenderer = require('base_accounting_kit.ReconciliationRenderer');
+var core = require('web.core');
+var QWeb = core.qweb;
+
+
+/**
+ * Widget used as action for 'account.bank.statement' reconciliation
+ */
+var StatementAction = AbstractAction.extend({
+ hasControlPanel: true,
+ withSearchBar: true,
+ loadControlPanel: true,
+ title: core._t('Bank Reconciliation'),
+ contentTemplate: 'reconciliation',
+ custom_events: {
+ change_mode: '_onAction',
+ change_filter: '_onAction',
+ change_offset: '_onAction',
+ change_partner: '_onAction',
+ add_proposition: '_onAction',
+ remove_proposition: '_onAction',
+ update_proposition: '_onAction',
+ create_proposition: '_onAction',
+ getPartialAmount: '_onActionPartialAmount',
+ quick_create_proposition: '_onAction',
+ partial_reconcile: '_onAction',
+ validate: '_onValidate',
+ close_statement: '_onCloseStatement',
+ load_more: '_onLoadMore',
+ reload: 'reload',
+ search: '_onSearch',
+ navigation_move:'_onNavigationMove',
+ },
+ config: _.extend({}, AbstractAction.prototype.config, {
+ // used to instantiate the model
+ Model: ReconciliationModel.StatementModel,
+ // used to instantiate the action interface
+ ActionRenderer: ReconciliationRenderer.StatementRenderer,
+ // used to instantiate each widget line
+ LineRenderer: ReconciliationRenderer.LineRenderer,
+ // used context params
+ params: ['statement_line_ids'],
+ // number of statements/partners/accounts to display
+ defaultDisplayQty: 10,
+ // number of moves lines displayed in 'match' mode
+ limitMoveLines: 15,
+ }),
+
+ _onNavigationMove: function (ev) {
+ var non_reconciled_keys = _.keys(_.pick(this.model.lines, function(value, key, object) {return !value.reconciled}));
+ var currentIndex = _.indexOf(non_reconciled_keys, ev.data.handle);
+ var widget = false;
+ switch (ev.data.direction) {
+ case 'up':
+ ev.stopPropagation();
+ widget = this._getWidget(non_reconciled_keys[currentIndex-1]);
+ break;
+ case 'down':
+ ev.stopPropagation();
+ widget = this._getWidget(non_reconciled_keys[currentIndex+1]);
+ break;
+ case 'validate':
+ ev.stopPropagation();
+ widget = this._getWidget(non_reconciled_keys[currentIndex]);
+ widget.$('caption .o_buttons button:visible').click();
+ break;
+ }
+ if (widget) widget.$el.focus();
+ },
+
+ /**
+ * @override
+ * @param {Object} params
+ * @param {Object} params.context
+ *
+ */
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+ this.action_manager = parent;
+ this.params = params;
+ this.searchModelConfig.modelName = 'account.bank.statement.line';
+ this.controlPanelProps.cp_content = {};
+// this.controlPanelParams.modelName = 'account.bank.statement.line';
+ this.model = new this.config.Model(this, {
+ modelName: "account.reconciliation.widget",
+ defaultDisplayQty: params.params && params.params.defaultDisplayQty || this.config.defaultDisplayQty,
+ limitMoveLines: params.params && params.params.limitMoveLines || this.config.limitMoveLines,
+ });
+ this.widgets = [];
+ // Adding values from the context is necessary to put this information in the url via the action manager so that
+ // you can retrieve it if the person shares his url or presses f5
+ _.each(params.params, function (value, name) {
+ params.context[name] = name.indexOf('_ids') !== -1 ? _.map((value+'').split(','), parseFloat) : value;
+ });
+ params.params = {};
+ _.each(this.config.params, function (name) {
+ if (params.context[name]) {
+ params.params[name] = params.context[name];
+ }
+ });
+ },
+
+ /**
+ * instantiate the action renderer
+ *
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ var def = this.model.load(this.params.context).then(this._super.bind(this));
+ return def.then(function () {
+ if (!self.model.context || !self.model.context.active_id) {
+ self.model.context = {'active_id': self.params.context.active_id,
+ 'active_model': self.params.context.active_model};
+ }
+ var journal_id = self.params.context.journal_id;
+ if (self.model.context.active_id && self.model.context.active_model === 'account.journal') {
+ journal_id = journal_id || self.model.context.active_id;
+ }
+ if (journal_id) {
+ var promise = self._rpc({
+ model: 'account.journal',
+ method: 'read',
+ args: [journal_id, ['display_name']],
+ });
+ } else {
+ var promise = Promise.resolve();
+ }
+ return promise.then(function (result) {
+ var title = result && result[0] ? result[0]['display_name'] : self.params.display_name || ''
+ self._setTitle(title);
+ self.renderer = new self.config.ActionRenderer(self, self.model, {
+ 'bank_statement_line_id': self.model.bank_statement_line_id,
+ 'valuenow': self.model.valuenow,
+ 'valuemax': self.model.valuemax,
+ 'defaultDisplayQty': self.model.defaultDisplayQty,
+ 'title': title,
+ });
+ });
+ });
+ },
+
+ reload: function() {
+ // On reload destroy all rendered line widget, reload data and then rerender widget
+ var self = this;
+
+ self.$('.o_reconciliation_lines').addClass('d-none'); // prevent the browser from recomputing css after each destroy for HUGE perf improvement on a lot of lines
+ _.each(this.widgets, function(widget) {
+ widget.destroy();
+ });
+ this.widgets = [];
+ self.$('.o_reconciliation_lines').removeClass('d-none');
+ return this.model.reload().then(function() {
+ return self._renderLinesOrRainbow();
+ });
+ },
+
+ _renderLinesOrRainbow: function() {
+ var self = this;
+ return self._renderLines().then(function() {
+ var initialState = self.renderer._initialState;
+ var valuenow = self.model.statement ? self.model.statement.value_min : initialState.valuenow;
+ var valuemax = self.model.statement ? self.model.statement.value_max : initialState.valuemax;
+ // No more lines to reconcile, trigger the rainbowman.
+ if(valuenow === valuemax){
+ initialState.valuenow = valuenow;
+ initialState.context = self.model.getContext();
+ self.renderer.showRainbowMan(initialState);
+ self.remove_cp();
+ }else{
+ // Create a notification if some lines have been reconciled automatically.
+ if(initialState.valuenow > 0)
+ self.renderer._renderNotifications(self.model.statement.notifications);
+ self._openFirstLine();
+ self.renderer.$('[data-toggle="tooltip"]').tooltip();
+ self.do_show();
+ }
+ });
+ },
+
+ /**
+ * append the renderer and instantiate the line renderers
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var args = arguments;
+ var sup = this._super;
+
+ return this.renderer.prependTo(self.$('.o_form_sheet')).then(function() {
+ return self._renderLinesOrRainbow().then(function() {
+ self.do_show();
+ return sup.apply(self, args);
+ });
+ });
+ },
+
+ /**
+ * update the control panel and breadcrumbs
+ *
+ * @override
+ */
+ do_show: function () {
+ this._super.apply(this, arguments);
+ if (this.action_manager) {
+ this.$pager = $(QWeb.render('reconciliation.control.pager', {widget: this.renderer}));
+ this.controlPanelProps.cp_content = {$pager: this.$pager};
+// this.updateControlPanel({
+// clear: true,
+// cp_content: {
+// $pager: this.$pager,
+// },
+// });
+ this.renderer.$progress = this.$pager;
+ $(this.renderer.$progress).parent().css('width', '100%').css('padding-left', '0');
+ }
+ },
+
+ remove_cp: function() {
+ this.controlPanelProps.cp_content = {};
+// this.updateControlPanel({
+// clear: true,
+// });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} handle
+ * @returns {Widget} widget line
+ */
+ _getWidget: function (handle) {
+ return _.find(this.widgets, function (widget) {return widget.handle===handle;});
+ },
+
+ /**
+ *
+ */
+ _loadMore: function(qty) {
+ var self = this;
+ return this.model.loadMore(qty).then(function () {
+ return self._renderLines();
+ });
+ },
+ /**
+ * sitch to 'match' the first available line
+ *
+ * @private
+ */
+ _openFirstLine: function (previous_handle) {
+ var self = this;
+ previous_handle = previous_handle || 'rline0';
+ var handle = _.compact(_.map(this.model.lines, function (line, handle) {
+ return (line.reconciled || (parseInt(handle.substr(5)) < parseInt(previous_handle.substr(5)))) ? null : handle;
+ }))[0];
+ if (handle) {
+ var line = this.model.getLine(handle);
+ this.model.changeMode(handle, 'default').then(function () {
+ self._getWidget(handle).update(line);
+ }).guardedCatch(function(){
+ self._getWidget(handle).update(line);
+ }).then(function() {
+ self._getWidget(handle).$el.focus();
+ }
+ );
+ }
+ return handle;
+ },
+
+ _forceUpdate: function() {
+ var self = this;
+ _.each(this.model.lines, function(handle) {
+ var widget = self._getWidget(handle['handle']);
+ if (widget && handle.need_update) {
+ widget.update(handle);
+ widget.need_update = false;
+ }
+ })
+ },
+ /**
+ * render line widget and append to view
+ *
+ * @private
+ */
+ _renderLines: function () {
+ var self = this;
+ var linesToDisplay = this.model.getStatementLines();
+ var linePromises = [];
+ _.each(linesToDisplay, function (line, handle) {
+ var widget = new self.config.LineRenderer(self, self.model, line);
+ widget.handle = handle;
+ self.widgets.push(widget);
+ linePromises.push(widget.appendTo(self.$('.o_reconciliation_lines')));
+ });
+ if (this.model.hasMoreLines() === false) {
+ this.renderer.hideLoadMoreButton(true);
+ }
+ else {
+ this.renderer.hideLoadMoreButton(false);
+ }
+ return Promise.all(linePromises);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * dispatch on the camelcased event name to model method then update the
+ * line renderer with the new state. If the mode was switched from 'inactive'
+ * to 'create' or 'match_rp' or 'match_other', the other lines switch to
+ * 'inactive' mode
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onAction: function (event) {
+ var self = this;
+ var handle = event.target.handle;
+ var current_line = this.model.getLine(handle);
+ this.model[_.str.camelize(event.name)](handle, event.data.data).then(function () {
+ var widget = self._getWidget(handle);
+ if (widget) {
+ widget.update(current_line);
+ }
+ if (current_line.mode !== 'inactive') {
+ _.each(self.model.lines, function (line, _handle) {
+ if (line.mode !== 'inactive' && _handle !== handle) {
+ self.model.changeMode(_handle, 'inactive');
+ var widget = self._getWidget(_handle);
+ if (widget) {
+ widget.update(line);
+ }
+ }
+ });
+ }
+ });
+ },
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSearch: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ this.model.domain = ev.data.domain;
+ this.model.display_context = 'search';
+ self.reload().then(function() {
+ self.renderer._updateProgressBar({
+ 'valuenow': self.model.valuenow,
+ 'valuemax': self.model.valuemax,
+ });
+ });
+ },
+
+ _onActionPartialAmount: function(event) {
+ var self = this;
+ var handle = event.target.handle;
+ var line = this.model.getLine(handle);
+ var amount = this.model.getPartialReconcileAmount(handle, event.data);
+ self._getWidget(handle).updatePartialAmount(event.data.data, amount);
+ },
+
+ /**
+ * call 'closeStatement' model method
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onCloseStatement: function (event) {
+ var self = this;
+ return this.model.closeStatement().then(function (result) {
+ self.do_action({
+ name: 'Bank Statements',
+ res_model: 'account.bank.statement.line',
+ res_id: result,
+ views: [[false, 'form']],
+ type: 'ir.actions.act_window',
+ view_mode: 'form',
+ });
+ $('.o_reward').remove();
+ });
+ },
+ /**
+ * Load more statement and render them
+ *
+ * @param {OdooEvent} event
+ */
+ _onLoadMore: function (event) {
+ return this._loadMore(this.model.defaultDisplayQty);
+ },
+ /**
+ * call 'validate' model method then destroy the
+ * validated lines and update the action renderer with the new status bar
+ * values and notifications then open the first available line
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onValidate: function (event) {
+ var self = this;
+ var handle = event.target.handle;
+ this.model.validate(handle).then(function (result) {
+ self.renderer.update({
+ 'valuenow': self.model.valuenow,
+ 'valuemax': self.model.valuemax,
+ 'title': self.title,
+ 'time': Date.now()-self.time,
+ 'notifications': result.notifications,
+ 'context': self.model.getContext(),
+ });
+ self._forceUpdate();
+ _.each(result.handles, function (handle) {
+ var widget = self._getWidget(handle);
+ if (widget) {
+ widget.destroy();
+ var index = _.findIndex(self.widgets, function (widget) {return widget.handle===handle;});
+ self.widgets.splice(index, 1);
+ }
+ });
+ // Get number of widget and if less than constant and if there are more to laod, load until constant
+ if (self.widgets.length < self.model.defaultDisplayQty
+ && self.model.valuemax - self.model.valuenow >= self.model.defaultDisplayQty) {
+ var toLoad = self.model.defaultDisplayQty - self.widgets.length;
+ self._loadMore(toLoad);
+ }
+ self._openFirstLine(handle);
+ });
+ },
+});
+
+
+/**
+ * Widget used as action for 'account.move.line' and 'res.partner' for the
+ * manual reconciliation and mark data as reconciliate
+ */
+var ManualAction = StatementAction.extend({
+ title: core._t('Journal Items to Reconcile'),
+ withSearchBar: false,
+ config: _.extend({}, StatementAction.prototype.config, {
+ Model: ReconciliationModel.ManualModel,
+ ActionRenderer: ReconciliationRenderer.ManualRenderer,
+ LineRenderer: ReconciliationRenderer.ManualLineRenderer,
+ params: ['company_ids', 'mode', 'partner_ids', 'account_ids'],
+ defaultDisplayQty: 30,
+ limitMoveLines: 15,
+ }),
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * call 'validate' model method then destroy the
+ * reconcilied lines, update the not reconcilied and update the action
+ * renderer with the new status bar values and notifications then open the
+ * first available line
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onValidate: function (event) {
+ var self = this;
+ var handle = event.target.handle;
+ var method = 'validate';
+ this.model[method](handle).then(function (result) {
+ _.each(result.reconciled, function (handle) {
+ self._getWidget(handle).destroy();
+ });
+ _.each(result.updated, function (handle) {
+ self._getWidget(handle).update(self.model.getLine(handle));
+ });
+ self.renderer.update({
+ valuenow: _.compact(_.invoke(self.widgets, 'isDestroyed')).length,
+ valuemax: self.widgets.length,
+ title: self.title,
+ time: Date.now()-self.time,
+ });
+ if(!_.any(result.updated, function (handle) {
+ return self.model.getLine(handle).mode !== 'inactive';
+ })) {
+ self._openFirstLine(handle);
+ }
+ });
+ },
+});
+
+core.action_registry.add('bank_statement_reconciliation_view', StatementAction);
+core.action_registry.add('manual_reconciliation_view', ManualAction);
+
+return {
+ StatementAction: StatementAction,
+ ManualAction: ManualAction,
+};
+});
diff --git a/base_accounting_kit/static/src/js/payment_model.js b/base_accounting_kit/static/src/js/payment_model.js
new file mode 100644
index 000000000..07785c4b2
--- /dev/null
+++ b/base_accounting_kit/static/src/js/payment_model.js
@@ -0,0 +1,1881 @@
+odoo.define('base_accounting_kit.ReconciliationModel', function (require) {
+"use strict";
+
+var BasicModel = require('web.BasicModel');
+var field_utils = require('web.field_utils');
+var utils = require('web.utils');
+var session = require('web.session');
+var WarningDialog = require('web.CrashManager').WarningDialog;
+var core = require('web.core');
+var _t = core._t;
+
+
+/**
+ * Model use to fetch, format and update 'account.reconciliation.widget',
+ * datas allowing reconciliation
+ *
+ * The statement internal structure::
+ *
+ * {
+ * valuenow: integer
+ * valuenow: valuemax
+ * [bank_statement_line_id]: {
+ * id: integer
+ * display_name: string
+ * }
+ * reconcileModels: [object]
+ * accounts: {id: code}
+ * }
+ *
+ * The internal structure of each line is::
+ *
+ * {
+ * balance: {
+ * type: number - show/hide action button
+ * amount: number - real amount
+ * amount_str: string - formated amount
+ * account_code: string
+ * },
+ * st_line: {
+ * partner_id: integer
+ * partner_name: string
+ * }
+ * mode: string ('inactive', 'match_rp', 'match_other', 'create')
+ * reconciliation_proposition: {
+ * id: number|string
+ * partial_amount: number
+ * invalid: boolean - through the invalid line (without account, label...)
+ * account_code: string
+ * date: string
+ * date_maturity: string
+ * label: string
+ * amount: number - real amount
+ * amount_str: string - formated amount
+ * [already_paid]: boolean
+ * [partner_id]: integer
+ * [partner_name]: string
+ * [account_code]: string
+ * [journal_id]: {
+ * id: integer
+ * display_name: string
+ * }
+ * [ref]: string
+ * [is_partially_reconciled]: boolean
+ * [to_check]: boolean
+ * [amount_currency_str]: string|false (amount in record currency)
+ * }
+ * mv_lines_match_rp: object - idem than reconciliation_proposition
+ * mv_lines_match_other: object - idem than reconciliation_proposition
+ * limitMoveLines: integer
+ * filter: string
+ * [createForm]: {
+ * account_id: {
+ * id: integer
+ * display_name: string
+ * }
+ * tax_ids: {
+ * id: integer
+ * display_name: string
+ * }
+ * analytic_account_id: {
+ * id: integer
+ * display_name: string
+ * }
+ * analytic_tag_ids: {
+ * }
+ * label: string
+ * amount: number,
+ * [journal_id]: {
+ * id: integer
+ * display_name: string
+ * }
+ * }
+ * }
+ */
+var StatementModel = BasicModel.extend({
+ avoidCreate: false,
+ quickCreateFields: ['account_id', 'amount', 'analytic_account_id', 'label', 'tax_ids', 'force_tax_included', 'analytic_tag_ids', 'to_check'],
+
+ // overridden in ManualModel
+ modes: ['create', 'match_rp', 'match_other'],
+
+ /**
+ * @override
+ *
+ * @param {Widget} parent
+ * @param {object} options
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.reconcileModels = [];
+ this.lines = {};
+ this.valuenow = 0;
+ this.valuemax = 0;
+ this.alreadyDisplayed = [];
+ this.domain = [];
+ this.defaultDisplayQty = options && options.defaultDisplayQty || 10;
+ this.limitMoveLines = options && options.limitMoveLines || 15;
+ this.display_context = 'init';
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * add a reconciliation proposition from the matched lines
+ * We also display a warning if the user tries to add 2 line with different
+ * account type
+ *
+ * @param {string} handle
+ * @param {number} mv_line_id
+ * @returns {Promise}
+ */
+ addProposition: function (handle, mv_line_id) {
+ var self = this;
+ var line = this.getLine(handle);
+ var prop = _.clone(_.find(line['mv_lines_'+line.mode], {'id': mv_line_id}));
+ this._addProposition(line, prop);
+ line['mv_lines_'+line.mode] = _.filter(line['mv_lines_'+line.mode], l => l['id'] != mv_line_id);
+
+ // remove all non valid lines
+ line.reconciliation_proposition = _.filter(line.reconciliation_proposition, function (prop) {return prop && !prop.invalid;});
+
+ // Onchange the partner if not already set on the statement line.
+ if(!line.st_line.partner_id && line.reconciliation_proposition
+ && line.reconciliation_proposition.length == 1 && prop.partner_id && line.type === undefined){
+ return this.changePartner(handle, {'id': prop.partner_id, 'display_name': prop.partner_name}, true);
+ }
+
+ return Promise.all([
+ this._computeLine(line),
+ this._performMoveLine(handle, 'match_rp', line.mode == 'match_rp'? 1 : 0),
+ this._performMoveLine(handle, 'match_other', line.mode == 'match_other'? 1 : 0)
+ ]);
+ },
+ /**
+ * change the filter for the target line and fetch the new matched lines
+ *
+ * @param {string} handle
+ * @param {string} filter
+ * @returns {Promise}
+ */
+ changeFilter: function (handle, filter) {
+ var line = this.getLine(handle);
+ line['filter_'+line.mode] = filter;
+ line['mv_lines_'+line.mode] = [];
+ return this._performMoveLine(handle, line.mode);
+ },
+ /**
+ * change the mode line ('inactive', 'match_rp', 'match_other', 'create'),
+ * and fetch the new matched lines or prepare to create a new line
+ *
+ * ``match_rp``
+ * display the matched lines from receivable/payable accounts, the user
+ * can select the lines to apply there as proposition
+ * ``match_other``
+ * display the other matched lines, the user can select the lines to apply
+ * there as proposition
+ * ``create``
+ * display fields and quick create button to create a new proposition
+ * for the reconciliation
+ *
+ * @param {string} handle
+ * @param {'inactive' | 'match_rp' | 'create'} mode
+ * @returns {Promise}
+ */
+ changeMode: function (handle, mode) {
+ var self = this;
+ var line = this.getLine(handle);
+ if (mode === 'default') {
+ var match_requests = self.modes.filter(x => x.startsWith('match')).map(x => this._performMoveLine(handle, x))
+ return Promise.all(match_requests).then(function() {
+ return self.changeMode(handle, self._getDefaultMode(handle));
+ });
+ }
+ if (mode === 'next') {
+ var available_modes = self._getAvailableModes(handle)
+ mode = available_modes[(available_modes.indexOf(line.mode) + 1) % available_modes.length];
+ }
+ line.mode = mode;
+ if (['match_rp', 'match_other'].includes(line.mode)) {
+ if (!(line['mv_lines_' + line.mode] && line['mv_lines_' + line.mode].length)) {
+ return this._performMoveLine(handle, line.mode);
+ } else {
+ return this._formatMoveLine(handle, line.mode, []);
+ }
+ }
+ if (line.mode === 'create') {
+ return this.createProposition(handle);
+ }
+ return Promise.resolve();
+ },
+ /**
+ * fetch the more matched lines
+ *
+ * @param {string} handle
+ * @returns {Promise}
+ */
+ changeOffset: function (handle) {
+ var line = this.getLine(handle);
+ return this._performMoveLine(handle, line.mode);
+ },
+ /**
+ * change the partner on the line and fetch the new matched lines
+ *
+ * @param {string} handle
+ * @param {bool} preserveMode
+ * @param {Object} partner
+ * @param {string} partner.display_name
+ * @param {number} partner.id
+ * @returns {Promise}
+ */
+ changePartner: function (handle, partner, preserveMode) {
+ var self = this;
+ var line = this.getLine(handle);
+ line.st_line.partner_id = partner && partner.id;
+ line.st_line.partner_name = partner && partner.display_name || '';
+ line.mv_lines_match_rp = [];
+ line.mv_lines_match_other = [];
+ return Promise.resolve(partner && this._changePartner(handle, partner.id))
+ .then(function() {
+ if(line.st_line.partner_id){
+ _.each(line.reconciliation_proposition, function(prop){
+ if(prop.partner_id != line.st_line.partner_id){
+ line.reconciliation_proposition = [];
+ return false;
+ }
+ });
+ }
+ return self._computeLine(line);
+ })
+ .then(function () {
+ return self.changeMode(handle, preserveMode ? line.mode : 'default', true);
+ })
+
+ },
+ /**
+ * close the statement
+ * @returns {Promise} resolves to the res_id of the closed statements
+ */
+ closeStatement: function () {
+ var self = this;
+ return this._rpc({
+ model: 'account.bank.statement.line',
+ method: 'button_confirm_bank',
+ args: [self.bank_statement_line_id.id],
+ })
+ .then(function () {
+ return self.bank_statement_line_id.id;
+ });
+ },
+ /**
+ *
+ * then open the first available line
+ *
+ * @param {string} handle
+ * @returns {Promise}
+ */
+ createProposition: function (handle) {
+ var line = this.getLine(handle);
+ var prop = _.filter(line.reconciliation_proposition, '__focus');
+ prop = this._formatQuickCreate(line);
+ line.reconciliation_proposition.push(prop);
+ line.createForm = _.pick(prop, this.quickCreateFields);
+ return this._computeLine(line);
+ },
+ /**
+ * Return context information and journal_id
+ * @returns {Object} context
+ */
+ getContext: function () {
+ return this.context;
+ },
+ /**
+ * Return the lines that needs to be displayed by the widget
+ *
+ * @returns {Object} lines that are loaded and not yet displayed
+ */
+ getStatementLines: function () {
+ var self = this;
+ var linesToDisplay = _.pick(this.lines, function(value, key, object) {
+ if (value.visible === true && self.alreadyDisplayed.indexOf(key) === -1) {
+ self.alreadyDisplayed.push(key);
+ return object;
+ }
+ });
+ return linesToDisplay;
+ },
+ /**
+ * Return a boolean telling if load button needs to be displayed or not
+ * overridden in ManualModel
+ *
+ * @returns {boolean} true if load more button needs to be displayed
+ */
+ hasMoreLines: function () {
+ var notDisplayed = _.filter(this.lines, function(line) { return !line.visible; });
+ if (notDisplayed.length > 0) {
+ return true;
+ }
+ return false;
+ },
+ /**
+ * get the line data for this handle
+ *
+ * @param {Object} handle
+ * @returns {Object}
+ */
+ getLine: function (handle) {
+ return this.lines[handle];
+ },
+ /**
+ * load data from
+ *
+ * - 'account.bank.statement' fetch the line id and bank_statement_id info
+ * - 'account.reconcile.model' fetch all reconcile model (for quick add)
+ * - 'account.account' fetch all account code
+ * - 'account.reconciliation.widget' fetch each line data
+ *
+ * overridden in ManualModel
+ * @param {Object} context
+ * @param {number[]} context.statement_line_ids
+ * @returns {Promise}
+ */
+ load: function (context) {
+ var self = this;
+ this.context = context;
+ this.statement_line_ids = context.statement_line_ids;
+ if (this.statement_line_ids === undefined) {
+ // This could be undefined if the user pressed F5, take everything as fallback instead of rainbowman
+ return self._rpc({
+ model: 'account.bank.statement.line',
+ method: 'search_read',
+ fields: ['id'],
+ domain: [['journal_id', '=?', context.active_id]],
+ }).then(function (result) {
+ self.statement_line_ids = result.map(r => r.id);
+ return self.reload()
+ })
+ } else {
+ return self.reload();
+ }
+
+ },
+ /**
+ * Load more bank statement line
+ *
+ * @param {integer} qty quantity to load
+ * @returns {Promise}
+ */
+ loadMore: function(qty) {
+ if (qty === undefined) {
+ qty = this.defaultDisplayQty;
+ }
+ var ids = _.pluck(this.lines, 'id');
+ ids = ids.splice(this.pagerIndex, qty);
+ this.pagerIndex += qty;
+ return this.loadData(ids, this._getExcludedIds());
+ },
+ /**
+ * RPC method to load informations on lines
+ * overridden in ManualModel
+ *
+ * @param {Array} ids ids of bank statement line passed to rpc call
+ * @param {Array} excluded_ids list of move_line ids that needs to be excluded from search
+ * @returns {Promise}
+ */
+ loadData: function(ids) {
+ var self = this;
+ var excluded_ids = this._getExcludedIds();
+ return self._rpc({
+ model: 'account.reconciliation.widget',
+ method: 'get_bank_statement_line_data',
+ args: [ids, excluded_ids],
+ context: self.context,
+ })
+ .then(function(res){
+ return self._formatLine(res['lines']);
+ })
+ },
+ /**
+ * Reload all data
+ */
+ reload: function() {
+ var self = this;
+ self.alreadyDisplayed = [];
+ self.lines = {};
+ self.pagerIndex = 0;
+ var def_statement = this._rpc({
+ model: 'account.reconciliation.widget',
+ method: 'get_bank_statement_data',
+ kwargs: {"bank_statement_line_ids":self.statement_line_ids, "srch_domain":self.domain},
+ context: self.context,
+ })
+ .then(function (statement) {
+ self.statement = statement;
+ self.bank_statement_line_id = self.statement_line_ids.length === 1 ? {id: self.statement_line_ids[0], display_name: statement.statement_name} : false;
+ self.valuenow = self.valuenow || statement.value_min;
+ self.valuemax = self.valuemax || statement.value_max;
+ self.context.journal_id = statement.journal_id;
+ _.each(statement.lines, function (res) {
+ var handle = _.uniqueId('rline');
+ self.lines[handle] = {
+ id: res.st_line.id,
+ partner_id: res.st_line.partner_id,
+ handle: handle,
+ reconciled: false,
+ mode: 'inactive',
+ mv_lines_match_rp: [],
+ mv_lines_match_other: [],
+ filter_match_rp: "",
+ filter_match_other: "",
+ reconciliation_proposition: [],
+ reconcileModels: [],
+ };
+ });
+ });
+ var domainReconcile = [];
+ if (self.context && self.context.company_ids) {
+ domainReconcile.push(['company_id', 'in', self.context.company_ids]);
+ }
+ if (self.context && self.context.active_model === 'account.journal' && self.context.active_ids) {
+ domainReconcile.push('|');
+ domainReconcile.push(['match_journal_ids', '=', false]);
+ domainReconcile.push(['match_journal_ids', 'in', self.context.active_ids]);
+ }
+ var def_reconcileModel = this._loadReconciliationModel({domainReconcile: domainReconcile});
+ var def_account = this._rpc({
+ model: 'account.account',
+ method: 'search_read',
+ fields: ['code'],
+ })
+ .then(function (accounts) {
+ self.accounts = _.object(_.pluck(accounts, 'id'), _.pluck(accounts, 'code'));
+ });
+ var def_taxes = self._loadTaxes();
+ return Promise.all([def_statement, def_reconcileModel, def_account, def_taxes]).then(function () {
+ _.each(self.lines, function (line) {
+ line.reconcileModels = self.reconcileModels;
+ });
+ var ids = _.pluck(self.lines, 'id');
+ ids = ids.splice(0, self.defaultDisplayQty);
+ self.pagerIndex = ids.length;
+ return self._formatLine(self.statement.lines);
+ });
+ },
+ _readAnalyticTags: function (params) {
+ var self = this;
+ this.analyticTags = {};
+ if (!params || !params.res_ids || !params.res_ids.length) {
+ return $.when();
+ }
+ var fields = (params && params.fields || []).concat(['id', 'display_name']);
+ return this._rpc({
+ model: 'account.analytic.tag',
+ method: 'read',
+ args: [
+ params.res_ids,
+ fields,
+ ],
+ }).then(function (tags) {
+ for (var i=0; i= Math.abs(amount)) {
+ amount = Math.abs(amount);
+ } else if (partial <= Math.abs(prop.amount) && partial >= 0) {
+ amount = partial;
+ } else {
+ amount = Math.abs(amount);
+ }
+ return field_utils.format.monetary(amount, {}, formatOptions);
+ }
+ },
+ /**
+ * Force the partial reconciliation to display the reconciliate button.
+ *
+ * @param {string} handle
+ * @returns {Promise}
+ */
+ partialReconcile: function(handle, data) {
+ var line = this.getLine(handle);
+ var prop = _.find(line.reconciliation_proposition, {'id' : data.mvLineId});
+ if (prop) {
+ var amount = data.amount;
+ try {
+ amount = field_utils.parse.float(data.amount);
+ }
+ catch (err) {
+ amount = NaN;
+ }
+ // Amount can't be greater than line.amount and can not be negative and must be a number
+ // the amount we receive will be a string, so take sign of previous line amount in consideration in order to put
+ // the amount in the correct left or right column
+ if (amount >= Math.abs(prop.amount) || amount <= 0 || isNaN(amount)) {
+ delete prop.partial_amount_str;
+ delete prop.partial_amount;
+ if (isNaN(amount) || amount < 0) {
+ this.do_warn(_.str.sprintf(_t('The amount %s is not a valid partial amount'), data.amount));
+ }
+ return this._computeLine(line);
+ }
+ else {
+ var format_options = { currency_id: line.st_line.currency_id };
+ prop.partial_amount = (prop.amount > 0 ? 1 : -1)*amount;
+ prop.partial_amount_str = field_utils.format.monetary(Math.abs(prop.partial_amount), {}, format_options);
+ }
+ }
+ return this._computeLine(line);
+ },
+ /**
+ * Change the value of the editable proposition line or create a new one.
+ *
+ * If the editable line comes from a reconcile model with 2 lines
+ * and their 'amount_type' is "percent"
+ * and their total equals 100% (this doesn't take into account the taxes
+ * who can be included or not)
+ * Then the total is recomputed to have 100%.
+ *
+ * @param {string} handle
+ * @param {*} values
+ * @returns {Promise}
+ */
+ updateProposition: function (handle, values) {
+ var self = this;
+ var line = this.getLine(handle);
+ var prop = _.last(_.filter(line.reconciliation_proposition, '__focus'));
+ if ('to_check' in values && values.to_check === false) {
+ // check if we have another line with to_check and if yes don't change value of this proposition
+ prop.to_check = line.reconciliation_proposition.some(function(rec_prop, index) {
+ return rec_prop.id !== prop.id && rec_prop.to_check;
+ });
+ }
+ if (!prop) {
+ prop = this._formatQuickCreate(line);
+ line.reconciliation_proposition.push(prop);
+ }
+ _.each(values, function (value, fieldName) {
+ if (fieldName === 'analytic_tag_ids') {
+ switch (value.operation) {
+ case "ADD_M2M":
+ // handle analytic_tag selection via drop down (single dict) and
+ // full widget (array of dict)
+ var vids = _.isArray(value.ids) ? value.ids : [value.ids];
+ _.each(vids, function (val) {
+ if (!_.findWhere(prop.analytic_tag_ids, {id: val.id})) {
+ prop.analytic_tag_ids.push(val);
+ }
+ });
+ break;
+ case "FORGET":
+ var id = self.localData[value.ids[0]].ref;
+ prop.analytic_tag_ids = _.filter(prop.analytic_tag_ids, function (val) {
+ return val.id !== id;
+ });
+ break;
+ }
+ }
+ else if (fieldName === 'tax_ids') {
+ switch(value.operation) {
+ case "ADD_M2M":
+ prop.__tax_to_recompute = true;
+ var vids = _.isArray(value.ids) ? value.ids : [value.ids];
+ _.each(vids, function(val){
+ if (!_.findWhere(prop.tax_ids, {id: val.id})) {
+ value.ids.price_include = self.taxes[val.id] ? self.taxes[val.id].price_include : false;
+ prop.tax_ids.push(val);
+ }
+ });
+ break;
+ case "FORGET":
+ prop.__tax_to_recompute = true;
+ var id = self.localData[value.ids[0]].ref;
+ prop.tax_ids = _.filter(prop.tax_ids, function (val) {
+ return val.id !== id;
+ });
+ break;
+ }
+ }
+ else {
+ prop[fieldName] = values[fieldName];
+ }
+ });
+ if ('account_id' in values) {
+ prop.account_code = prop.account_id ? this.accounts[prop.account_id.id] : '';
+ }
+ if ('amount' in values) {
+ prop.base_amount = values.amount;
+ if (prop.reconcileModelId) {
+ this._computeReconcileModels(handle, prop.reconcileModelId);
+ }
+ }
+ if ('force_tax_included' in values || 'amount' in values || 'account_id' in values) {
+ prop.__tax_to_recompute = true;
+ }
+ line.createForm = _.pick(prop, this.quickCreateFields);
+ // If you check/uncheck the force_tax_included box, reset the createForm amount.
+ if(prop.base_amount)
+ line.createForm.amount = prop.base_amount;
+ if (prop.tax_ids.length !== 1 ) {
+ // When we have 0 or more than 1 taxes, reset the base_amount and force_tax_included, otherwise weird behavior can happen
+ prop.amount = prop.base_amount;
+ line.createForm.force_tax_included = false;
+ }
+ return this._computeLine(line);
+ },
+ /**
+ * Format the value and send it to 'account.reconciliation.widget' model
+ * Update the number of validated lines
+ * overridden in ManualModel
+ *
+ * @param {(string|string[])} handle
+ * @returns {Promise