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

+

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} resolved with an object who contains + * 'handles' key + */ + validate: function (handle) { + var self = this; + this.display_context = 'validate'; + var handles = []; + if (handle) { + handles = [handle]; + } else { + _.each(this.lines, function (line, handle) { + if (!line.reconciled && line.balance && !line.balance.amount && line.reconciliation_proposition.length) { + handles.push(handle); + } + }); + } + var ids = []; + var values = []; + var handlesPromises = []; + _.each(handles, function (handle) { + var line = self.getLine(handle); + var props = _.filter(line.reconciliation_proposition, function (prop) {return !prop.invalid;}); + var computeLinePromise; + if (props.length === 0) { + // Usability: if user has not chosen any lines and click validate, it has the same behavior + // as creating a write-off of the same amount. + props.push(self._formatQuickCreate(line, { + account_id: [line.st_line.open_balance_account_id, self.accounts[line.st_line.open_balance_account_id]], + })); + // update balance of line otherwise it won't be to zero and another line will be added + line.reconciliation_proposition.push(props[0]); + computeLinePromise = self._computeLine(line); + } + ids.push(line.id); + handlesPromises.push(Promise.resolve(computeLinePromise).then(function() { + var values_dict = { + "partner_id": line.st_line.partner_id, + "counterpart_aml_dicts": _.map(_.filter(props, function (prop) { + return !isNaN(prop.id) && !prop.already_paid; + }), self._formatToProcessReconciliation.bind(self, line)), + "payment_aml_ids": _.pluck(_.filter(props, function (prop) { + return !isNaN(prop.id) && prop.already_paid; + }), 'id'), + "new_aml_dicts": _.map(_.filter(props, function (prop) { + return isNaN(prop.id) && prop.display; + }), self._formatToProcessReconciliation.bind(self, line)), + "to_check": line.to_check, + }; + + // If the lines are not fully balanced, create an unreconciled amount. + // line.st_line.currency_id is never false here because its equivalent to + // statement_line.currency_id or statement_line.journal_id.currency_id or statement_line.journal_id.company_id.currency_id (Python-side). + // see: get_statement_line_for_reconciliation_widget method in account/models/account_bank_statement.py for more details + var currency = session.get_currency(line.st_line.currency_id); + var balance = line.balance.amount; + if (!utils.float_is_zero(balance, currency.digits[1])) { + var unreconciled_amount_dict = { + 'account_id': line.st_line.open_balance_account_id, + 'credit': balance > 0 ? balance : 0, + 'debit': balance < 0 ? -balance : 0, + 'name': line.st_line.name + ' : ' + _t("Open balance"), + }; + values_dict['new_aml_dicts'].push(unreconciled_amount_dict); + } + values.push(values_dict); + line.reconciled = true; + })); + + _.each(self.lines, function(other_line) { + if (other_line != line) { + var filtered_prop = other_line.reconciliation_proposition.filter(p => !line.reconciliation_proposition.map(l => l.id).includes(p.id)); + if (filtered_prop.length != other_line.reconciliation_proposition.length) { + other_line.need_update = true; + other_line.reconciliation_proposition = filtered_prop; + } + self._computeLine(line); + } + }) + }); + + return Promise.all(handlesPromises).then(function() { + return self._rpc({ + model: 'account.reconciliation.widget', + method: 'process_bank_statement_line', + args: [ids, values], + context: self.context, + }) + .then(self._validatePostProcess.bind(self)) + .then(function () { + self.valuenow += handles.length; + return {handles: handles}; + }); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * add a line proposition after checking receivable and payable accounts constraint + * + * @private + * @param {Object} line + * @param {Object} prop + */ + _addProposition: function (line, prop) { + line.reconciliation_proposition.push(prop); + }, + /** + * stop the editable proposition line and remove it if it's invalid then + * compute the line + * + * See :func:`_computeLine` + * + * @private + * @param {string} handle + * @returns {Promise} + */ + _blurProposition: function (handle) { + var line = this.getLine(handle); + line.reconciliation_proposition = _.filter(line.reconciliation_proposition, function (l) { + l.__focus = false; + return !l.invalid; + }); + }, + /** + * When changing partner, read property_account_receivable and payable + * of that partner because the counterpart account might cahnge depending + * on the partner + * + * @private + * @param {string} handle + * @param {integer} partner_id + * @returns {Promise} + */ + _changePartner: function (handle, partner_id) { + var self = this; + return this._rpc({ + model: 'res.partner', + method: 'read', + args: [partner_id, ["property_account_receivable_id", "property_account_payable_id"]], + }).then(function (result) { + if (result.length > 0) { + var line = self.getLine(handle); + self.lines[handle].st_line.open_balance_account_id = line.balance.amount < 0 ? result[0]['property_account_payable_id'][0] : result[0]['property_account_receivable_id'][0]; + } + }); + }, + /** + * Calculates the balance; format each proposition amount_str and mark as + * invalid the line with empty account_id, amount or label + * Check the taxes server side for each updated propositions with tax_ids + * extended by ManualModel + * + * @private + * @param {Object} line + * @returns {Promise} + */ + _computeLine: function (line) { + //balance_type + var self = this; + + // compute taxes + var tax_defs = []; + var reconciliation_proposition = []; + var formatOptions = { + currency_id: line.st_line.currency_id, + }; + line.to_check = false; + _.each(line.reconciliation_proposition, function (prop) { + if (prop.to_check) { + // If one of the proposition is to_check, set the global to_check flag to true + line.to_check = true; + } + if (prop.tax_repartition_line_id) { + if (!_.find(line.reconciliation_proposition, {'id': prop.link}).__tax_to_recompute) { + reconciliation_proposition.push(prop); + } + return; + } + if (!prop.already_paid && parseInt(prop.id)) { + prop.is_move_line = true; + } + reconciliation_proposition.push(prop); + + if (prop.tax_ids && prop.tax_ids.length && prop.__tax_to_recompute && prop.base_amount) { + reconciliation_proposition = _.filter(reconciliation_proposition, function (p) { + return !p.tax_repartition_line_id || p.link !== prop.id; + }); + var args = [prop.tax_ids.map(function(el){return el.id;}), prop.base_amount, formatOptions.currency_id]; + var add_context = {'round': true}; + if(prop.tax_ids.length === 1 && line.createForm && line.createForm.force_tax_included) + add_context.force_price_include = true; + tax_defs.push(self._rpc({ + model: 'account.tax', + method: 'json_friendly_compute_all', + args: args, + context: $.extend({}, self.context || {}, add_context), + }) + .then(function (result) { + _.each(result.taxes, function(tax){ + var tax_prop = self._formatQuickCreate(line, { + 'link': prop.id, + 'tax_ids': tax.tax_ids, + 'tax_repartition_line_id': tax.tax_repartition_line_id, + 'tag_ids': tax.tag_ids, + 'amount': tax.amount, + 'label': prop.label ? prop.label + " " + tax.name : tax.name, + 'date': prop.date, + 'account_id': tax.account_id ? [tax.account_id, null] : prop.account_id, + 'analytic': tax.analytic, + '__focus': false + }); + + prop.tax_exigible = tax.tax_exigibility === 'on_payment' ? true : undefined; + prop.amount = tax.base; + prop.amount_str = field_utils.format.monetary(Math.abs(prop.amount), {}, formatOptions); + prop.invalid = !self._isValid(prop); + + tax_prop.amount_str = field_utils.format.monetary(Math.abs(tax_prop.amount), {}, formatOptions); + tax_prop.invalid = prop.invalid; + + reconciliation_proposition.push(tax_prop); + }); + + prop.tag_ids = result.base_tags; + })); + } else { + prop.amount_str = field_utils.format.monetary(Math.abs(prop.amount), {}, formatOptions); + prop.display = self._isDisplayedProposition(prop); + prop.invalid = !self._isValid(prop); + } + }); + + return Promise.all(tax_defs).then(function () { + _.each(reconciliation_proposition, function (prop) { + prop.__tax_to_recompute = false; + }); + line.reconciliation_proposition = reconciliation_proposition; + + var amount_currency = 0; + var total = line.st_line.amount || 0; + var isOtherCurrencyId = _.uniq(_.pluck(_.reject(reconciliation_proposition, 'invalid'), 'currency_id')); + isOtherCurrencyId = isOtherCurrencyId.length === 1 && !total && isOtherCurrencyId[0] !== formatOptions.currency_id ? isOtherCurrencyId[0] : false; + + _.each(reconciliation_proposition, function (prop) { + if (!prop.invalid) { + total -= prop.partial_amount || prop.amount; + if (isOtherCurrencyId) { + amount_currency -= (prop.amount < 0 ? -1 : 1) * Math.abs(prop.amount_currency); + } + } + }); + var company_currency = session.get_currency(line.st_line.currency_id); + var company_precision = company_currency && company_currency.digits[1] || 2; + total = utils.round_decimals(total, company_precision) || 0; + if(isOtherCurrencyId){ + var other_currency = session.get_currency(isOtherCurrencyId); + var other_precision = other_currency && other_currency.digits[1] || 2; + amount_currency = utils.round_decimals(amount_currency, other_precision); + } + line.balance = { + amount: total, + amount_str: field_utils.format.monetary(Math.abs(total), {}, formatOptions), + currency_id: isOtherCurrencyId, + amount_currency: isOtherCurrencyId ? amount_currency : total, + amount_currency_str: isOtherCurrencyId ? field_utils.format.monetary(Math.abs(amount_currency), {}, { + currency_id: isOtherCurrencyId + }) : false, + account_code: self.accounts[line.st_line.open_balance_account_id], + }; + line.balance.show_balance = line.balance.amount_currency != 0; + line.balance.type = line.balance.amount_currency ? (line.st_line.partner_id ? 0 : -1) : 1; + }); + }, + /** + * + * + * @private + * @param {string} handle + * @param {integer} reconcileModelId + */ + _computeReconcileModels: function (handle, reconcileModelId) { + var line = this.getLine(handle); + // if quick create with 2 lines who use 100%, change the both values in same time + var props = _.filter(line.reconciliation_proposition, {'reconcileModelId': reconcileModelId, '__focus': true}); + if (props.length === 2 && props[0].percent && props[1].percent) { + if (props[0].percent + props[1].percent === 100) { + props[0].base_amount = props[0].amount = line.st_line.amount - props[1].base_amount; + props[0].__tax_to_recompute = true; + } + } + }, + /** + * format a name_get into an object {id, display_name}, idempotent + * + * @private + * @param {Object|Array} [value] data or name_get + */ + _formatNameGet: function (value) { + return value ? (value.id ? value : {'id': value[0], 'display_name': value[1]}) : false; + }, + _formatMany2ManyTags: function (value) { + var res = []; + for (var i=0, len=value.length; i 0){ + var hasDifferentPartners = function(prop){ + return !prop.partner_id || prop.partner_id != line.reconciliation_proposition[0].partner_id; + }; + + 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); + } + }else if(!line.st_line.partner_id && line.partner_id && line.partner_name){ + return self.changePartner(line.handle, { + 'id': line.partner_id, + 'display_name': line.partner_name, + }, true); + } + return true; + }) + .then(function(){ + return data.write_off ? self.quickCreateProposition(line.handle, data.model_id) : true; + }) + .then(function() { + // If still no partner set, take the one from context, if it exists + if (!line.st_line.partner_id && self.context.partner_id && self.context.partner_name) { + return self.changePartner(line.handle, { + 'id': self.context.partner_id, + 'display_name': self.context.partner_name, + }, true); + } + return true; + }) + ); + }); + return Promise.all(defs); + }, + /** + * Format the server value then compute the line + * overridden in ManualModel + * + * @see '_computeLine' + * + * @private + * @param {string} handle + * @param {Object[]} mv_lines + * @returns {Promise} + */ + _formatMoveLine: function (handle, mode, mv_lines) { + var self = this; + var line = this.getLine(handle); + line['mv_lines_'+mode] = _.uniq(line['mv_lines_'+mode].concat(mv_lines), l => l.id); + if (mv_lines[0]){ + line['remaining_'+mode] = mv_lines[0].recs_count - mv_lines.length; + } else if (line['mv_lines_'+mode].lenght == 0) { + line['remaining_'+mode] = 0; + } + this._formatLineProposition(line, mv_lines); + + if ((line.mode == 'match_other' || line.mode == "match_rp") && !line['mv_lines_'+mode].length && !line['filter_'+mode].length) { + line.mode = self._getDefaultMode(handle); + if (line.mode !== 'match_rp' && line.mode !== 'match_other' && line.mode !== 'inactive') { + return this._computeLine(line).then(function () { + return self.createProposition(handle); + }); + } + } else { + return this._computeLine(line); + } + }, + /** + * overridden in ManualModel + */ + _getDefaultMode: function(handle) { + var line = this.getLine(handle); + if (line.balance.amount === 0 + && (!line.st_line.mv_lines_match_rp || line.st_line.mv_lines_match_rp.length === 0) + && (!line.st_line.mv_lines_match_other || line.st_line.mv_lines_match_other.length === 0)) { + return 'inactive'; + } + if (line.mv_lines_match_rp && line.mv_lines_match_rp.length) { + return 'match_rp'; + } + if (line.mv_lines_match_other && line.mv_lines_match_other.length) { + return 'match_other'; + } + return 'create'; + }, + _getAvailableModes: function(handle) { + var line = this.getLine(handle); + var modes = [] + if (line.mv_lines_match_rp && line.mv_lines_match_rp.length) { + modes.push('match_rp') + } + if (line.mv_lines_match_other && line.mv_lines_match_other.length) { + modes.push('match_other') + } + modes.push('create') + return modes + }, + /** + * Apply default values for the proposition, format datas and format the + * base_amount with the decimal number from the currency + * extended in ManualModel + * + * @private + * @param {Object} line + * @param {Object} values + * @returns {Object} + */ + _formatQuickCreate: function (line, values) { + values = values || {}; + var today = new moment().utc().format(); + var account = this._formatNameGet(values.account_id); + var formatOptions = { + currency_id: line.st_line.currency_id, + }; + var amount; + switch(values.amount_type) { + case 'percentage': + amount = line.balance.amount * values.amount / 100; + break; + case 'regex': + var matching = line.st_line.name.match(new RegExp(values.amount_from_label_regex)) + amount = 0; + if (matching && matching.length == 2) { + matching = matching[1].replace(new RegExp('\\D' + values.decimal_separator, 'g'), ''); + matching = matching.replace(values.decimal_separator, '.'); + amount = parseFloat(matching) || 0; + amount = line.balance.amount > 0 ? amount : -amount; + } + break; + case 'fixed': + amount = values.amount; + break; + default: + amount = values.amount !== undefined ? values.amount : line.balance.amount; + } + + + var prop = { + 'id': _.uniqueId('createLine'), + 'label': values.label || line.st_line.name, + 'account_id': account, + 'account_code': account ? this.accounts[account.id] : '', + 'analytic_account_id': this._formatNameGet(values.analytic_account_id), + 'analytic_tag_ids': this._formatMany2ManyTags(values.analytic_tag_ids || []), + 'journal_id': this._formatNameGet(values.journal_id), + 'tax_ids': this._formatMany2ManyTagsTax(values.tax_ids || []), + 'tag_ids': values.tag_ids, + 'tax_repartition_line_id': values.tax_repartition_line_id, + 'debit': 0, + 'credit': 0, + 'date': values.date ? values.date : field_utils.parse.date(today, {}, {isUTC: true}), + 'force_tax_included': values.force_tax_included || false, + 'base_amount': amount, + 'percent': values.amount_type === "percentage" ? values.amount : null, + 'link': values.link, + 'display': true, + 'invalid': true, + 'to_check': !!values.to_check, + '__tax_to_recompute': true, + '__focus': '__focus' in values ? values.__focus : true, + }; + if (prop.base_amount) { + // Call to format and parse needed to round the value to the currency precision + var sign = prop.base_amount < 0 ? -1 : 1; + var amount = field_utils.format.monetary(Math.abs(prop.base_amount), {}, formatOptions); + prop.base_amount = sign * field_utils.parse.monetary(amount, {}, formatOptions); + } + + prop.amount = prop.base_amount; + return prop; + }, + /** + * Return list of account_move_line that has been selected and needs to be removed + * from other calls. + * + * @private + * @returns {Array} list of excluded ids + */ + _getExcludedIds: function () { + var excludedIds = []; + _.each(this.lines, function(line) { + if (line.reconciliation_proposition) { + _.each(line.reconciliation_proposition, function(prop) { + if (parseInt(prop['id'])) { + excludedIds.push(prop['id']); + } + }); + } + }); + return excludedIds; + }, + /** + * Defined whether the line is to be displayed or not. Here, we only display + * the line if it comes from the server or if an account is defined when it + * is created + * extended in ManualModel + * + * @private + * @param {object} prop + * @returns {Boolean} + */ + _isDisplayedProposition: function (prop) { + return !isNaN(prop.id) || !!prop.account_id; + }, + /** + * extended in ManualModel + * @private + * @param {object} prop + * @returns {Boolean} + */ + _isValid: function (prop) { + return !isNaN(prop.id) || prop.account_id && prop.amount && prop.label && !!prop.label.length; + }, + /** + * Fetch 'account.reconciliation.widget' propositions. + * overridden in ManualModel + * + * @see '_formatMoveLine' + * + * @private + * @param {string} handle + * @returns {Promise} + */ + _performMoveLine: function (handle, mode, limit) { + limit = limit || this.limitMoveLines; + var line = this.getLine(handle); + var excluded_ids = _.map(_.union(line.reconciliation_proposition, line.mv_lines_match_rp, line.mv_lines_match_other), function (prop) { + return _.isNumber(prop.id) ? prop.id : null; + }).filter(id => id != null); + var filter = line['filter_'+mode] || ""; + return this._rpc({ + model: 'account.reconciliation.widget', + method: 'get_move_lines_for_bank_statement_line', + args: [line.id, line.st_line.partner_id, excluded_ids, filter, 0, limit, mode === 'match_rp' ? 'rp' : 'other'], + context: this.context, + }) + .then(this._formatMoveLine.bind(this, handle, mode)); + }, + /** + * format the proposition to send information server side + * extended in ManualModel + * + * @private + * @param {object} line + * @param {object} prop + * @returns {object} + */ + _formatToProcessReconciliation: function (line, prop) { + var amount = -prop.amount; + if (prop.partial_amount) { + amount = -prop.partial_amount; + } + + var result = { + name : prop.label, + debit : amount > 0 ? amount : 0, + credit : amount < 0 ? -amount : 0, + tax_exigible: prop.tax_exigible, + analytic_tag_ids: [[6, null, _.pluck(prop.analytic_tag_ids, 'id')]] + }; + if (!isNaN(prop.id)) { + result.counterpart_aml_id = prop.id; + } else { + result.account_id = prop.account_id.id; + if (prop.journal_id) { + result.journal_id = prop.journal_id.id; + } + } + if (!isNaN(prop.id)) result.counterpart_aml_id = prop.id; + if (prop.analytic_account_id) result.analytic_account_id = prop.analytic_account_id.id; + if (prop.tax_ids && prop.tax_ids.length) result.tax_ids = [[6, null, _.pluck(prop.tax_ids, 'id')]]; + + if (prop.tag_ids && prop.tag_ids.length) result.tag_ids = [[6, null, prop.tag_ids]]; + if (prop.tax_repartition_line_id) result.tax_repartition_line_id = prop.tax_repartition_line_id; + if (prop.reconcileModelId) result.reconcile_model_id = prop.reconcileModelId + return result; + }, + /** + * Hook to handle return values of the validate's line process. + * + * @private + * @param {Object} data + * @param {Object[]} data.moves list of processed account.move + * @returns {Deferred} + */ + _validatePostProcess: function (data) { + var self = this; + return Promise.resolve(); + }, +}); + + +/** + * Model use to fetch, format and update 'account.move.line' and 'res.partner' + * datas allowing manual reconciliation + */ +var ManualModel = StatementModel.extend({ + quickCreateFields: ['account_id', 'journal_id', 'amount', 'analytic_account_id', 'label', 'tax_ids', 'force_tax_included', 'analytic_tag_ids', 'date', 'to_check'], + + modes: ['create', 'match'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Return a boolean telling if load button needs to be displayed or not + * + * @returns {boolean} true if load more button needs to be displayed + */ + hasMoreLines: function () { + if (this.manualLines.length > this.pagerIndex) { + return true; + } + return false; + }, + /** + * load data from + * - 'account.reconciliation.widget' fetch the lines to reconciliate + * - 'account.account' fetch all account code + * + * @param {Object} context + * @param {string} [context.mode] 'customers', 'suppliers' or 'accounts' + * @param {integer[]} [context.company_ids] + * @param {integer[]} [context.partner_ids] used for 'customers' and + * 'suppliers' mode + * @returns {Promise} + */ + load: function (context) { + var self = this; + this.context = context; + + var domain_account_id = []; + if (context && context.company_ids) { + domain_account_id.push(['company_id', 'in', context.company_ids]); + } + + var def_account = this._rpc({ + model: 'account.account', + method: 'search_read', + domain: domain_account_id, + fields: ['code'], + }) + .then(function (accounts) { + self.account_ids = _.pluck(accounts, 'id'); + self.accounts = _.object(self.account_ids, _.pluck(accounts, 'code')); + }); + + var domainReconcile = []; + var session_allowed_company_ids = session.user_context.allowed_company_ids || [] + var company_ids = context && context.company_ids || session_allowed_company_ids.slice(0, 1); + + if (company_ids) { + domainReconcile.push(['company_id', 'in', company_ids]); + } + var def_reconcileModel = this._loadReconciliationModel({domainReconcile: domainReconcile}); + var def_taxes = this._loadTaxes(); + + return Promise.all([def_reconcileModel, def_account, def_taxes]).then(function () { + switch(context.mode) { + case 'customers': + case 'suppliers': + var mode = context.mode === 'customers' ? 'receivable' : 'payable'; + var args = ['partner', context.partner_ids || null, mode]; + return self._rpc({ + model: 'account.reconciliation.widget', + method: 'get_data_for_manual_reconciliation', + args: args, + context: context, + }) + .then(function (result) { + self.manualLines = result; + self.valuenow = 0; + self.valuemax = Object.keys(self.manualLines).length; + var lines = self.manualLines.slice(0, self.defaultDisplayQty); + self.pagerIndex = lines.length; + return self.loadData(lines); + }); + case 'accounts': + return self._rpc({ + model: 'account.reconciliation.widget', + method: 'get_data_for_manual_reconciliation', + args: ['account', context.account_ids || self.account_ids], + context: context, + }) + .then(function (result) { + self.manualLines = result; + self.valuenow = 0; + self.valuemax = Object.keys(self.manualLines).length; + var lines = self.manualLines.slice(0, self.defaultDisplayQty); + self.pagerIndex = lines.length; + return self.loadData(lines); + }); + default: + var partner_ids = context.partner_ids || null; + var account_ids = context.account_ids || self.account_ids || null; + return self._rpc({ + model: 'account.reconciliation.widget', + method: 'get_all_data_for_manual_reconciliation', + args: [partner_ids, account_ids], + context: context, + }) + .then(function (result) { + // Flatten the result + self.manualLines = [].concat(result.accounts, result.customers, result.suppliers); + self.valuenow = 0; + self.valuemax = Object.keys(self.manualLines).length; + var lines = self.manualLines.slice(0, self.defaultDisplayQty); + self.pagerIndex = lines.length; + return self.loadData(lines); + }); + } + }); + }, + + /** + * Reload data by calling load + * It overrides super.reload() because + * it is not adapted for this model. + * + * Use case: coming back to manual reconcilation + * in breadcrumb + */ + reload: function () { + this.lines = {}; + return this.load(this.context); + }, + + /** + * Load more partners/accounts + * overridden in ManualModel + * + * @param {integer} qty quantity to load + * @returns {Promise} + */ + loadMore: function(qty) { + if (qty === undefined) { + qty = this.defaultDisplayQty; + } + var lines = this.manualLines.slice(this.pagerIndex, this.pagerIndex + qty); + this.pagerIndex += qty; + return this.loadData(lines); + }, + /** + * Method to load informations on lines + * + * @param {Array} lines manualLines to load + * @returns {Promise} + */ + loadData: function(lines) { + var self = this; + var defs = []; + _.each(lines, function (l) { + defs.push(self._formatLine(l.mode, l)); + }); + return Promise.all(defs); + + }, + /** + * Mark the account or the partner as reconciled + * + * @param {(string|string[])} handle + * @returns {Promise} resolved with the handle array + */ + validate: function (handle) { + var self = this; + var handles = []; + if (handle) { + handles = [handle]; + } else { + _.each(this.lines, function (line, handle) { + if (!line.reconciled && !line.balance.amount && line.reconciliation_proposition.length) { + handles.push(handle); + } + }); + } + + var def = Promise.resolve(); + var process_reconciliations = []; + var reconciled = []; + _.each(handles, function (handle) { + var line = self.getLine(handle); + if(line.reconciled) { + return; + } + var props = line.reconciliation_proposition; + if (!props.length) { + self.valuenow++; + reconciled.push(handle); + line.reconciled = true; + process_reconciliations.push({ + id: line.type === 'accounts' ? line.account_id : line.partner_id, + type: line.type, + mv_line_ids: [], + new_mv_line_dicts: [], + }); + } else { + var mv_line_ids = _.pluck(_.filter(props, function (prop) {return !isNaN(prop.id);}), 'id'); + var new_mv_line_dicts = _.map(_.filter(props, function (prop) {return isNaN(prop.id) && prop.display;}), self._formatToProcessReconciliation.bind(self, line)); + process_reconciliations.push({ + id: null, + type: null, + mv_line_ids: mv_line_ids, + new_mv_line_dicts: new_mv_line_dicts + }); + } + line.reconciliation_proposition = []; + }); + if (process_reconciliations.length) { + def = self._rpc({ + model: 'account.reconciliation.widget', + method: 'process_move_lines', + args: [process_reconciliations], + }); + } + + return def.then(function() { + var defs = []; + var account_ids = []; + var partner_ids = []; + _.each(handles, function (handle) { + var line = self.getLine(handle); + if (line.reconciled) { + return; + } + line.filter_match = ""; + defs.push(self._performMoveLine(handle, 'match').then(function () { + if(!line.mv_lines_match.length) { + self.valuenow++; + reconciled.push(handle); + line.reconciled = true; + if (line.type === 'accounts') { + account_ids.push(line.account_id.id); + } else { + partner_ids.push(line.partner_id); + } + } + })); + }); + return Promise.all(defs).then(function () { + if (partner_ids.length) { + self._rpc({ + model: 'res.partner', + method: 'mark_as_reconciled', + args: [partner_ids], + }); + } + return {reconciled: reconciled, updated: _.difference(handles, reconciled)}; + }); + }); + }, + removeProposition: function (handle, id) { + var self = this; + var line = this.getLine(handle); + var defs = []; + var prop = _.find(line.reconciliation_proposition, {'id' : id}); + if (prop) { + line.reconciliation_proposition = _.filter(line.reconciliation_proposition, function (p) { + return p.id !== prop.id && p.id !== prop.link && p.link !== prop.id && (!p.link || p.link !== prop.link); + }); + line.mv_lines_match = line.mv_lines_match || []; + line.mv_lines_match.unshift(prop); + + // No proposition left and then, reset the st_line partner. + if(line.reconciliation_proposition.length == 0 && line.st_line.has_no_partner) + defs.push(self.changePartner(line.handle)); + } + line.mode = (id || line.mode !== "create") && isNaN(id) ? 'create' : 'match'; + defs.push(this._computeLine(line)); + return Promise.all(defs).then(function() { + return self.changeMode(handle, line.mode, true); + }) + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * override change the balance type to display or not the reconcile button + * + * @override + * @private + * @param {Object} line + * @returns {Promise} + */ + _computeLine: function (line) { + return this._super(line).then(function () { + var props = _.reject(line.reconciliation_proposition, 'invalid'); + _.each(line.reconciliation_proposition, function(p) { + delete p.is_move_line; + }); + line.balance.type = -1; + if (!line.balance.amount_currency && props.length) { + line.balance.type = 1; + } else if(_.any(props, function (prop) {return prop.amount > 0;}) && + _.any(props, function (prop) {return prop.amount < 0;})) { + line.balance.type = 0; + } + }); + }, + /** + * Format each server lines and propositions and compute all lines + * + * @see '_computeLine' + * + * @private + * @param {'customers' | 'suppliers' | 'accounts'} type + * @param {Object} data + * @returns {Promise} + */ + _formatLine: function (type, data) { + var line = this.lines[_.uniqueId('rline')] = _.extend(data, { + type: type, + reconciled: false, + mode: 'inactive', + limitMoveLines: this.limitMoveLines, + filter_match: "", + reconcileModels: this.reconcileModels, + account_id: this._formatNameGet([data.account_id, data.account_name]), + st_line: data, + visible: true + }); + this._formatLineProposition(line, line.reconciliation_proposition); + if (!line.reconciliation_proposition.length) { + delete line.reconciliation_proposition; + } + return this._computeLine(line); + }, + /** + * override to add journal_id + * + * @override + * @private + * @param {Object} line + * @param {Object} props + */ + _formatLineProposition: function (line, props) { + var self = this; + this._super(line, props); + if (props.length) { + _.each(props, function (prop) { + var tmp_value = prop.debit || prop.credit; + prop.credit = prop.credit !== 0 ? 0 : tmp_value; + prop.debit = prop.debit !== 0 ? 0 : tmp_value; + prop.amount = -prop.amount; + prop.journal_id = self._formatNameGet(prop.journal_id || line.journal_id); + prop.to_check = !!prop.to_check; + }); + } + }, + /** + * override to add journal_id on tax_created_line + * + * @private + * @param {Object} line + * @param {Object} values + * @returns {Object} + */ + _formatQuickCreate: function (line, values) { + // Add journal to created line + if (values && values.journal_id === undefined && line && line.createForm && line.createForm.journal_id) { + values.journal_id = line.createForm.journal_id; + } + return this._super(line, values); + }, + /** + * @override + * @param {object} prop + * @returns {Boolean} + */ + _isDisplayedProposition: function (prop) { + return !!prop.journal_id && this._super(prop); + }, + /** + * @override + * @param {object} prop + * @returns {Boolean} + */ + _isValid: function (prop) { + return prop.journal_id && this._super(prop); + }, + /** + * Fetch 'account.move.line' propositions. + * + * @see '_formatMoveLine' + * + * @override + * @private + * @param {string} handle + * @returns {Promise} + */ + _performMoveLine: function (handle, mode, limit) { + limit = limit || this.limitMoveLines; + var line = this.getLine(handle); + var excluded_ids = _.map(_.union(line.reconciliation_proposition, line.mv_lines_match), function (prop) { + return _.isNumber(prop.id) ? prop.id : null; + }).filter(id => id != null); + var filter = line.filter_match || ""; + var args = [line.account_id.id, line.partner_id, excluded_ids, filter, 0, limit]; + return this._rpc({ + model: 'account.reconciliation.widget', + method: 'get_move_lines_for_manual_reconciliation', + args: args, + context: this.context, + }) + .then(this._formatMoveLine.bind(this, handle, '')); + }, + + _formatToProcessReconciliation: function (line, prop) { + var result = this._super(line, prop); + result['date'] = prop.date; + return result; + }, + _getDefaultMode: function(handle) { + var line = this.getLine(handle); + if (line.balance.amount === 0 && (!line.st_line.mv_lines_match || line.st_line.mv_lines_match.length === 0)) { + return 'inactive'; + } + return line.mv_lines_match.length > 0 ? 'match' : 'create'; + }, + _formatMoveLine: function (handle, mode, mv_lines) { + var self = this; + var line = this.getLine(handle); + line.mv_lines_match = _.uniq((line.mv_lines_match || []).concat(mv_lines), l => l.id); + this._formatLineProposition(line, mv_lines); + + if (line.mode !== 'create' && !line.mv_lines_match.length && !line.filter_match.length) { + line.mode = this.avoidCreate || !line.balance.amount ? 'inactive' : 'create'; + if (line.mode === 'create') { + return this._computeLine(line).then(function () { + return self.createProposition(handle); + }); + } + } else { + return this._computeLine(line); + } + }, +}); + +return { + StatementModel: StatementModel, + ManualModel: ManualModel, +}; +}); diff --git a/base_accounting_kit/static/src/js/payment_render.js b/base_accounting_kit/static/src/js/payment_render.js new file mode 100644 index 000000000..96266ce9b --- /dev/null +++ b/base_accounting_kit/static/src/js/payment_render.js @@ -0,0 +1,929 @@ +odoo.define('base_accounting_kit.ReconciliationRenderer', function (require) { +"use strict"; + +var Widget = require('web.Widget'); +var FieldManagerMixin = require('web.FieldManagerMixin'); +var relational_fields = require('web.relational_fields'); +var basic_fields = require('web.basic_fields'); +var core = require('web.core'); +var time = require('web.time'); +var session = require('web.session'); +var qweb = core.qweb; +var _t = core._t; + + +/** + * rendering of the bank statement action contains progress bar, title and + * auto reconciliation button + */ +var StatementRenderer = Widget.extend(FieldManagerMixin, { + template: 'reconciliation.statement', + events: { + 'click *[rel="do_action"]': '_onDoAction', + 'click button.js_load_more': '_onLoadMore', + }, + /** + * @override + */ + init: function (parent, model, state) { + this._super(parent); + this.model = model; + this._initialState = state; + }, + /** + * display iniial state and create the name statement field + * + * @override + */ + start: function () { + var self = this; + var defs = [this._super.apply(this, arguments)]; + this.time = Date.now(); + this.$progress = $(''); + + return Promise.all(defs); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /* + * hide the button to load more statement line + */ + hideLoadMoreButton: function (show) { + if (!show) { + this.$('.js_load_more').show(); + } + else { + this.$('.js_load_more').hide(); + } + }, + showRainbowMan: function (state) { + if (this.model.display_context !== 'validate') { + return + } + 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('*').addClass('o_reward_subcontent'); + $done.find('.button_close_statement').click(this._onCloseBankStatement.bind(this)); + $done.find('.button_back_to_statement').click(this._onGoToBankStatement.bind(this)); + // 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 + * + * @param {object} state - statement data + * @param {integer} state.valuenow - for the progress bar + * @param {integer} state.valuemax - for the progress bar + * @param {string} state.title - for the progress bar + * @param {[object]} [state.notifications] + */ + update: function (state) { + var self = this; + this._updateProgressBar(state); + + if (state.valuenow === state.valuemax && !this.$('.done_message').length) { + this.showRainbowMan(state); + } + + if (state.notifications) { + this._renderNotifications(state.notifications); + } + }, + _updateProgressBar: function(state) { + this.$progress.find('.valuenow').text(state.valuenow); + this.$progress.find('.valuemax').text(state.valuemax); + this.$progress.find('.progress-bar') + .attr('aria-valuenow', state.valuenow) + .attr('aria-valuemax', state.valuemax) + .css('width', (state.valuenow/state.valuemax*100) + '%'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** + * render the notifications + * + * @param {[object]} notifications + */ + _renderNotifications: function(notifications) { + this.$(".notification_area").empty(); + for (var i=0; i') + .appendTo(this.$('thead .cell_info_popover')) + .attr("data-content", qweb.render('reconciliation.line.statement_line.details', {'state': this._initialState})); + this.$el.popover({ + 'selector': '.line_info_button', + 'placement': 'left', + 'container': this.$el, + 'html': true, + // disable bootstrap sanitizer because we use a table that has been + // rendered using qweb.render so it is safe and also because sanitizer escape table by default. + 'sanitize': false, + 'trigger': 'hover', + 'animation': false, + 'toggle': 'popover' + }); + var def2 = this._super.apply(this, arguments); + return Promise.all([def1, def2, def3, def4]); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * update the statement line rendering + * + * @param {object} state - statement line + */ + update: function (state) { + var self = this; + // isValid + var to_check_checked = !!(state.to_check); + this.$('caption .o_buttons button.o_validate').toggleClass('d-none', !!state.balance.type && !to_check_checked); + this.$('caption .o_buttons button.o_reconcile').toggleClass('d-none', state.balance.type <= 0 || to_check_checked); + this.$('caption .o_buttons .o_no_valid').toggleClass('d-none', state.balance.type >= 0 || to_check_checked); + self.$('caption .o_buttons button.o_validate').toggleClass('text-warning', to_check_checked); + + // partner_id + this._makePartnerRecord(state.st_line.partner_id, state.st_line.partner_name).then(function (recordID) { + self.fields.partner_id.reset(self.model.get(recordID)); + self.$el.attr('data-partner', state.st_line.partner_id); + }); + + // mode + this.$el.data('mode', state.mode).attr('data-mode', state.mode); + this.$('.o_notebook li a').attr('aria-selected', false); + this.$('.o_notebook li a').removeClass('active'); + this.$('.o_notebook .tab-content .tab-pane').removeClass('active'); + this.$('.o_notebook li a[href*="notebook_page_' + state.mode + '"]').attr('aria-selected', true); + this.$('.o_notebook li a[href*="notebook_page_' + state.mode + '"]').addClass('active'); + this.$('.o_notebook .tab-content .tab-pane[id*="notebook_page_' + state.mode + '"]').addClass('active'); + this.$('.create, .match').each(function () { + $(this).removeAttr('style'); + }); + + // reconciliation_proposition + var $props = this.$('.accounting_view tbody').empty(); + + // Search propositions that could be a partial credit/debit. + var props = []; + var balance = state.balance.amount_currency; + _.each(state.reconciliation_proposition, function (prop) { + if (prop.display) { + props.push(prop); + } + }); + + _.each(props, function (line) { + var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state, 'proposition': true})); + if (!isNaN(line.id)) { + $('') + .appendTo($line.find('.cell_info_popover')) + .attr("data-content", qweb.render('reconciliation.line.mv_line.details', {'line': line})); + } + $props.append($line); + }); + + // mv_lines + var matching_modes = self.model.modes.filter(x => x.startsWith('match')); + for (let i = 0; i < matching_modes.length; i++) { + var stateMvLines = state['mv_lines_'+matching_modes[i]] || []; + var recs_count = stateMvLines.length > 0 ? stateMvLines[0].recs_count : 0; + var remaining = state['remaining_' + matching_modes[i]]; + var $mv_lines = this.$('div[id*="notebook_page_' + matching_modes[i] + '"] .match table tbody').empty(); + this.$('.o_notebook li a[href*="notebook_page_' + matching_modes[i] + '"]').parent().toggleClass('d-none', stateMvLines.length === 0 && !state['filter_'+matching_modes[i]]); + + _.each(stateMvLines, function (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})); + } + $mv_lines.append($line); + }); + this.$('div[id*="notebook_page_' + matching_modes[i] + '"] .match div.load-more').toggle(remaining > 0); + this.$('div[id*="notebook_page_' + matching_modes[i] + '"] .match div.load-more span').text(remaining); + } + + // balance + this.$('.popover').remove(); + this.$('table tfoot').html(qweb.render("reconciliation.line.balance", {'state': state})); + + // create form + if (state.createForm) { + var createPromise; + if (!this.fields.account_id) { + createPromise = this._renderCreate(state); + } + Promise.resolve(createPromise).then(function(){ + var data = self.model.get(self.handleCreateRecord).data; + return self.model.notifyChanges(self.handleCreateRecord, state.createForm) + .then(function () { + // FIXME can't it directly written REPLACE_WITH ids=state.createForm.analytic_tag_ids + return self.model.notifyChanges(self.handleCreateRecord, {analytic_tag_ids: {operation: 'REPLACE_WITH', ids: []}}) + }) + .then(function (){ + var defs = []; + _.each(state.createForm.analytic_tag_ids, function (tag) { + defs.push(self.model.notifyChanges(self.handleCreateRecord, {analytic_tag_ids: {operation: 'ADD_M2M', ids: tag}})); + }); + return Promise.all(defs); + }) + .then(function () { + return self.model.notifyChanges(self.handleCreateRecord, {tax_ids: {operation: 'REPLACE_WITH', ids: []}}) + }) + .then(function (){ + var defs = []; + _.each(state.createForm.tax_ids, function (tag) { + defs.push(self.model.notifyChanges(self.handleCreateRecord, {tax_ids: {operation: 'ADD_M2M', ids: tag}})); + }); + return Promise.all(defs); + }) + .then(function () { + var record = self.model.get(self.handleCreateRecord); + _.each(self.fields, function (field, fieldName) { + if (self._avoidFieldUpdate[fieldName]) return; + if (fieldName === "partner_id") return; + if ((data[fieldName] || state.createForm[fieldName]) && !_.isEqual(state.createForm[fieldName], data[fieldName])) { + field.reset(record); + } + if (fieldName === 'tax_ids') { + if (!state.createForm[fieldName].length || state.createForm[fieldName].length > 1) { + $('.create_force_tax_included').addClass('d-none'); + } + else { + $('.create_force_tax_included').removeClass('d-none'); + var price_include = state.createForm[fieldName][0].price_include; + var force_tax_included = state.createForm[fieldName][0].force_tax_included; + self.$('.create_force_tax_included input').prop('checked', force_tax_included); + self.$('.create_force_tax_included input').prop('disabled', price_include); + } + } + }); + if (state.to_check) { + // Set the to_check field to true if global to_check is set + self.$('.create_to_check input').prop('checked', state.to_check).change(); + } + return true; + }); + }); + } + this.$('.create .add_line').toggle(!!state.balance.amount_currency); + }, + + updatePartialAmount: function(line_id, amount) { + var $line = this.$('.mv_line[data-line-id='+line_id+']'); + $line.find('.edit_amount').addClass('d-none'); + $line.find('.edit_amount_input').removeClass('d-none'); + $line.find('.edit_amount_input').focus(); + $line.find('.edit_amount_input').val(amount); + $line.find('.line_amount').addClass('d-none'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQueryElement} $el + */ + _destroyPopover: function ($el) { + var popover = $el.data('bs.popover'); + if (popover) { + popover.dispose(); + } + }, + /** + * @private + * @param {integer} partnerID + * @param {string} partnerName + * @returns {string} local id of the dataPoint + */ + _makePartnerRecord: function (partnerID, partnerName) { + var field = { + relation: 'res.partner', + type: 'many2one', + name: 'partner_id', + }; + if (partnerID) { + field.value = [partnerID, partnerName]; + } + return this.model.makeRecord('account.bank.statement.line', [field], { + partner_id: { + domain: ["|", ["is_company", "=", true], ["parent_id", "=", false]], + options: { + no_open: true + } + } + }); + }, + + /** + * create account_id, tax_ids, analytic_account_id, analytic_tag_ids, label and amount fields + * + * @private + * @param {object} state - statement line + * @returns {Promise} + */ + _renderCreate: function (state) { + var self = this; + return this.model.makeRecord('account.bank.statement.line', [{ + relation: 'account.account', + type: 'many2one', + name: 'account_id', + domain: [['company_id', '=', state.st_line.company_id], ['deprecated', '=', false]], + }, { + relation: 'account.journal', + type: 'many2one', + name: 'journal_id', + domain: [['company_id', '=', state.st_line.company_id]], + }, { + relation: 'account.tax', + type: 'many2many', + name: 'tax_ids', + domain: [['company_id', '=', state.st_line.company_id]], + }, { + relation: 'account.analytic.account', + type: 'many2one', + name: 'analytic_account_id', + }, { + relation: 'account.analytic.tag', + type: 'many2many', + name: 'analytic_tag_ids', + }, { + type: 'boolean', + name: 'force_tax_included', + }, { + type: 'char', + name: 'label', + }, { + type: 'float', + name: 'amount', + }, { + type: 'char', //TODO is it a bug or a feature when type date exists ? + name: 'date', + }, { + type: 'boolean', + name: 'to_check', + }], { + account_id: { + string: _t("Account"), + }, + label: {string: _t("Label")}, + amount: {string: _t("Account")}, + }).then(function (recordID) { + self.handleCreateRecord = recordID; + var record = self.model.get(self.handleCreateRecord); + + self.fields.account_id = new relational_fields.FieldMany2One(self, + 'account_id', record, {mode: 'edit', attrs: {can_create:false}}); + + self.fields.journal_id = new relational_fields.FieldMany2One(self, + 'journal_id', record, {mode: 'edit'}); + + self.fields.tax_ids = new relational_fields.FieldMany2ManyTags(self, + 'tax_ids', record, {mode: 'edit', additionalContext: {append_type_to_tax_name: true}}); + + self.fields.analytic_account_id = new relational_fields.FieldMany2One(self, + 'analytic_account_id', record, {mode: 'edit'}); + + self.fields.analytic_tag_ids = new relational_fields.FieldMany2ManyTags(self, + 'analytic_tag_ids', record, {mode: 'edit'}); + + self.fields.force_tax_included = new basic_fields.FieldBoolean(self, + 'force_tax_included', record, {mode: 'edit'}); + + self.fields.label = new basic_fields.FieldChar(self, + 'label', record, {mode: 'edit'}); + + self.fields.amount = new basic_fields.FieldFloat(self, + 'amount', record, {mode: 'edit'}); + + self.fields.date = new basic_fields.FieldDate(self, + 'date', record, {mode: 'edit'}); + + self.fields.to_check = new basic_fields.FieldBoolean(self, + 'to_check', record, {mode: 'edit'}); + + var $create = $(qweb.render("reconciliation.line.create", {'state': state, 'group_tags': self.group_tags, 'group_acc': self.group_acc})); + self.fields.account_id.appendTo($create.find('.create_account_id .o_td_field')) + .then(addRequiredStyle.bind(self, self.fields.account_id)); + self.fields.journal_id.appendTo($create.find('.create_journal_id .o_td_field')); + self.fields.tax_ids.appendTo($create.find('.create_tax_id .o_td_field')); + self.fields.analytic_account_id.appendTo($create.find('.create_analytic_account_id .o_td_field')); + self.fields.analytic_tag_ids.appendTo($create.find('.create_analytic_tag_ids .o_td_field')); + self.fields.force_tax_included.appendTo($create.find('.create_force_tax_included .o_td_field')); + self.fields.label.appendTo($create.find('.create_label .o_td_field')) + .then(addRequiredStyle.bind(self, self.fields.label)); + self.fields.amount.appendTo($create.find('.create_amount .o_td_field')) + .then(addRequiredStyle.bind(self, self.fields.amount)); + self.fields.date.appendTo($create.find('.create_date .o_td_field')); + self.fields.to_check.appendTo($create.find('.create_to_check .o_td_field')); + self.$('.create').append($create); + + function addRequiredStyle(widget) { + widget.$el.addClass('o_required_modifier'); + } + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * The event on the partner m2o widget was propagated to the bank statement + * line widget, causing it to expand and the others to collapse. This caused + * the dropdown to be poorly placed and an unwanted update of this widget. + * + * @private + */ + _onStopPropagation: function(ev) { + ev.stopPropagation(); + }, + + /** + * @private + * @param {MouseEvent} event + */ + _onCreateReconcileModel: function (event) { + event.preventDefault(); + var self = this; + this.do_action({ + type: 'ir.actions.act_window', + res_model: 'account.reconcile.model', + views: [[false, 'form']], + target: 'current' + }, + { + on_reverse_breadcrumb: function() {self.trigger_up('reload');}, + }); + }, + _editAmount: function (event) { + event.stopPropagation(); + var $line = $(event.target); + var moveLineId = $line.closest('.mv_line').data('line-id'); + this.trigger_up('partial_reconcile', {'data': {mvLineId: moveLineId, 'amount': $line.val()}}); + }, + _onEditAmount: function (event) { + event.preventDefault(); + event.stopPropagation(); + // Don't call when clicking inside the input field + if (! $(event.target).hasClass('edit_amount_input')){ + var $line = $(event.target); + this.trigger_up('getPartialAmount', {'data': $line.closest('.mv_line').data('line-id')}); + } + }, + /** + * @private + * @param {MouseEvent} event + */ + _onEditReconcileModel: function (event) { + event.preventDefault(); + var self = this; + this.do_action({ + type: 'ir.actions.act_window', + res_model: 'account.reconcile.model', + views: [[false, 'list'], [false, 'form']], + view_mode: "list", + target: 'current' + }, + { + on_reverse_breadcrumb: function() {self.trigger_up('reload');}, + }); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + event.stopPropagation(); + var fieldName = event.target.name; + if (fieldName === 'partner_id') { + var partner_id = event.data.changes.partner_id; + this.trigger_up('change_partner', {'data': partner_id}); + } else { + if (event.data.changes.amount && isNaN(event.data.changes.amount)) { + return; + } + this.trigger_up('update_proposition', {'data': event.data.changes}); + } + }, + /** + * @private + */ + _onTogglePanel: function () { + if (this.$el[0].getAttribute('data-mode') == 'inactive') + this.trigger_up('change_mode', {'data': 'default'}); + }, + /** + * @private + */ + _onChangeTab: function(event) { + if (event.currentTarget.nodeName === 'TFOOT') { + this.trigger_up('change_mode', {'data': 'next'}); + } else { + var modes = this.model.modes; + var selected_mode = modes.find(function(e) {return event.target.getAttribute('href').includes(e)}); + if (selected_mode) { + this.trigger_up('change_mode', {'data': selected_mode}); + } + } + }, + /** + * @private + * @param {input event} event + */ + _onFilterChange: function (event) { + this.trigger_up('change_filter', {'data': _.str.strip($(event.target).val())}); + }, + /** + * @private + * @param {keyup event} event + */ + _onInputKeyup: function (event) { + var target_partner_id = $(event.target).parents('[name="partner_id"]'); + if (target_partner_id.length === 1) { + return; + } + if(event.keyCode === 13) { + if ($(event.target).hasClass('edit_amount_input')) { + $(event.target).blur(); + return; + } + var created_lines = _.findWhere(this.model.lines, {mode: 'create'}); + if (created_lines && created_lines.balance.amount) { + this._onCreateProposition(); + } + return; + } + if ($(event.target).hasClass('edit_amount_input')) { + if (event.type === 'keyup') { + return; + } + else { + return this._editAmount(event); + } + } + + var self = this; + for (var fieldName in this.fields) { + var field = this.fields[fieldName]; + if (!field.$el.is(event.target)) { + continue; + } + this._avoidFieldUpdate[field.name] = event.type !== 'focusout'; + field.value = false; + field._setValue($(event.target).val()).then(function () { + self._avoidFieldUpdate[field.name] = false; + }); + break; + } + }, + /** + * @private + */ + _onLoadMore: function (ev) { + ev.preventDefault(); + this.trigger_up('change_offset'); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onSelectMoveLine: function (event) { + var $el = $(event.target); + $el.prop('disabled', true); + this._destroyPopover($el); + var moveLineId = $el.closest('.mv_line').data('line-id'); + this.trigger_up('add_proposition', {'data': moveLineId}); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onSelectProposition: function (event) { + var $el = $(event.target); + this._destroyPopover($el); + var moveLineId = $el.closest('.mv_line').data('line-id'); + this.trigger_up('remove_proposition', {'data': moveLineId}); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onQuickCreateProposition: function (event) { + document.activeElement && document.activeElement.blur(); + this.trigger_up('quick_create_proposition', {'data': $(event.target).data('reconcile-model-id')}); + }, + /** + * @private + */ + _onCreateProposition: function () { + document.activeElement && document.activeElement.blur(); + var invalid = []; + _.each(this.fields, function (field) { + if (!field.isValid()) { + invalid.push(field.string); + } + }); + if (invalid.length) { + this.do_warn(_t("Some fields are undefined"), invalid.join(', ')); + return; + } + this.trigger_up('create_proposition'); + }, + /** + * @private + */ + _onValidate: function () { + this.trigger_up('validate'); + } +}); + + +/** + * rendering of the manual reconciliation action contains progress bar, title + * and auto reconciliation button + */ +var ManualRenderer = StatementRenderer.extend({ + template: "reconciliation.manual.statement", + +}); + + +/** + * rendering of the manual reconciliation, contains line data, proposition and + * view for 'match' mode + */ +var ManualLineRenderer = LineRenderer.extend({ + template: "reconciliation.manual.line", + /** + * @override + * @param {string} handle + * @param {number} proposition id (move line id) + * @returns {Promise} + */ + removeProposition: function (handle, id) { + if (!id) { + return Promise.resolve(); + } + return this._super(handle, id); + }, + /** + * move the partner field + * + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + return self.model.makeRecord('account.move.line', [{ + relation: 'account.account', + type: 'many2one', + name: 'account_id', + value: [self._initialState.account_id.id, self._initialState.account_id.display_name], + }]).then(function (recordID) { + self.fields.title_account_id = new relational_fields.FieldMany2One(self, + 'account_id', + self.model.get(recordID), + {mode: 'readonly'} + ); + }).then(function () { + return self.fields.title_account_id.appendTo(self.$('.accounting_view thead td:eq(0) span:first')); + }); + }); + }, + /** + * @override + */ + update: function (state) { + this._super(state); + var props = _.filter(state.reconciliation_proposition, {'display': true}); + if (!props.length) { + var $line = $(qweb.render("reconciliation.line.mv_line", {'line': {}, 'state': state})); + this.$('.accounting_view tbody').append($line); + } + }, + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * display journal_id field + * + * @override + */ + _renderCreate: function (state) { + var self = this; + var parentPromise = this._super(state).then(function() { + self.$('.create .create_journal_id').show(); + self.$('.create .create_date').removeClass('d-none'); + self.$('.create .create_journal_id .o_input').addClass('o_required_modifier'); + }); + return parentPromise; + }, + +}); + + +return { + StatementRenderer: StatementRenderer, + ManualRenderer: ManualRenderer, + LineRenderer: LineRenderer, + ManualLineRenderer: ManualLineRenderer, +}; +}); diff --git a/base_accounting_kit/static/src/xml/payment_matching.xml b/base_accounting_kit/static/src/xml/payment_matching.xml new file mode 100644 index 000000000..e96b3b941 --- /dev/null +++ b/base_accounting_kit/static/src/xml/payment_matching.xml @@ -0,0 +1,402 @@ + + + + +
+
+
+
+
+
+
+ + +
+
/
+
+
+ + +
+
+
+
+ +
+
+
+

Nothing to do!

+

This page displays all the bank transactions that are to be reconciled and provides with a neat interface to do so.

+
+ + + + + + + + + +

Good Job! There is nothing to reconcile.

+

All invoices and payments have been matched, your accounts' balances are clean.

+

+ From now on, you may want to: +

+

+
+
+ +
+

Congrats, you're all done!

+

You reconciled transactions in . + +
That's on average seconds per transaction. + +

+ +

+ + + + + + +

+
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+
+ + + +
+
()
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + + +
+ + + +
+
+ + + + + + + + + Last Reconciliation: + + + + + + + + + + + + + Open balanceChoose counterpart or Create Write-off + + + + + + + +
+
+ + +
+ + + +
+ +
+ + +
+
+
+ + + +

To speed up reconciliation, define reconciliation models.

+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ + + + + + + + +
+
+
+ + + + + + + + +
+
+ + + + + + + + New + + + + + + + : + + + + : + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account
Date
Due Date
Journal
Partner
Label
Ref
Amount ()
Residual + () +
This payment is registered but not reconciled.
+
+ + + + + + + + + + + +
Date
Partner
Transaction
Description
Amount ()
Account
Note
+
+ + + + + + + statement lines + + have been reconciled automatically. + + + + + + + + + + + + + + +
+ + + + + + + +
+
+ + diff --git a/base_accounting_kit/views/assets.xml b/base_accounting_kit/views/assets.xml index 2d0aa3adc..5e2cdc776 100644 --- a/base_accounting_kit/views/assets.xml +++ b/base_accounting_kit/views/assets.xml @@ -5,6 +5,9 @@