Browse Source

[IMP] Reconciliation Widget added

pull/164/head
Ajmal Cybro 5 years ago
parent
commit
119ac683ff
  1. 2
      base_accounting_kit/README.rst
  2. 11
      base_accounting_kit/__manifest__.py
  3. 5
      base_accounting_kit/doc/changelog.md
  4. 1
      base_accounting_kit/models/__init__.py
  5. 22
      base_accounting_kit/models/account_journal.py
  6. 22
      base_accounting_kit/models/account_payment.py
  7. 929
      base_accounting_kit/models/payment_matching.py
  8. BIN
      base_accounting_kit/static/description/banner.gif
  9. 15
      base_accounting_kit/static/description/index.html
  10. 505
      base_accounting_kit/static/src/js/payment_matching.js
  11. 1881
      base_accounting_kit/static/src/js/payment_model.js
  12. 929
      base_accounting_kit/static/src/js/payment_render.js
  13. 402
      base_accounting_kit/static/src/xml/payment_matching.xml
  14. 3
      base_accounting_kit/views/assets.xml
  15. 34
      base_accounting_kit/views/payment_matching.xml

2
base_accounting_kit/README.rst

@ -1,5 +1,5 @@
Accounting Kit
=============================
==============
* Full accounting kit for Odoo 14 community editions
Installation

11
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'],

5
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.

1
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

22
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:

22
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() """

929
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

BIN
base_accounting_kit/static/description/banner.gif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 112 KiB

15
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;">
<div class="col-md-7 col-sm-12 col-xs-12" style="padding: 0px">
<h2 style="font-weight: 600;font-size: 1.8rem;margin-top: 15px;"><i>Latest Updates</i></h2>
<ul style=" padding: 0 1px; list-style: none;">
<li style="display: flex;align-items: center;padding: 8px 0;font-size: 19px; list-style: none;">
<i class="fa fa-check-circle-o" style="width:40px; color:#07B700;"></i>
Reconciliation Widget Added
</li>
<li style="display: flex;align-items: center;padding: 8px 0;font-size: 19px; list-style: none;">
<i class="fa fa-check-circle-o" style="width:40px; color:#07B700;"></i>
<i>Payments Matching</i> &nbsp;&nbsp;option in Journal Overview
</li>
<li style="display: flex;align-items: center;padding: 8px 0;font-size: 19px; list-style: none;">
<i class="fa fa-check-circle-o" style="width:40px; color:#07B700;"></i>
<i>Payments Matching</i> &nbsp;&nbsp;button Payment Form
</li>
<li style="display: flex;align-items: center;padding: 8px 0;font-size: 18px; list-style: none;"></li>
</ul>
<div style=" margin: 0 0 0px;padding: 20px 0 10;font-size: 23px;line-height: 35px;font-weight: 400;color: #000;border-top: 1px solid rgba(255,255,255,0.1);border-bottom: 1px solid rgba(255,255,255,0.11);text-align: left;">
<h1 style="font-size: 39px;font-weight: 600;margin: 0px !important;">Odoo 14 Accounting</h1>
<h3 style="font-size: 21px;margin-top: 8px;position: relative;">Dashboard, Asset Management, Accounting

505
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,
};
});

1881
base_accounting_kit/static/src/js/payment_model.js

File diff suppressed because it is too large

929
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<notifications.length; i++) {
var $notification = $(qweb.render("reconciliation.notification", notifications[i])).hide();
$notification.appendTo(this.$(".notification_area")).slideDown(300);
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* Click on close bank statement button, this will
* close and then open form view of bank statement
* @param {MouseEvent} event
*/
_onCloseBankStatement: function (e) {
this.trigger_up('close_statement');
},
/**
* @private
* @param {MouseEvent} event
*/
_onDoAction: function(e) {
e.preventDefault();
var name = e.currentTarget.dataset.action_name;
var model = e.currentTarget.dataset.model;
if (e.currentTarget.dataset.ids) {
var ids = e.currentTarget.dataset.ids.split(",").map(Number);
var domain = [['id', 'in', ids]];
} else {
var domain = e.currentTarget.dataset.domain;
}
var context = e.currentTarget.dataset.context;
var tag = e.currentTarget.dataset.tag;
if (tag) {
this.do_action({
type: 'ir.actions.client',
tag: tag,
context: context,
})
} else {
this.do_action({
name: name,
res_model: model,
domain: domain,
context: context,
views: [[false, 'list'], [false, 'form']],
type: 'ir.actions.act_window',
view_mode: "list"
});
}
},
/**
* Open the list view for account.bank.statement model
* @private
* @param {MouseEvent} event
*/
_onGoToBankStatement: function (e) {
var journalId = $(e.target).attr('data_journal_id');
if (journalId) {
journalId = parseInt(journalId);
}
$('.o_reward').remove();
this.do_action({
name: 'Bank Statements',
res_model: 'account.bank.statement',
views: [[false, 'list'], [false, 'form']],
type: 'ir.actions.act_window',
context: {search_default_journal_id: journalId, 'journal_type':'bank'},
view_mode: 'form',
});
},
/**
* Load more statement lines for reconciliation
* @private
* @param {MouseEvent} event
*/
_onLoadMore: function (e) {
this.trigger_up('load_more');
},
});
/**
* rendering of the bank statement line, contains line data, proposition and
* view for 'match' and 'create' mode
*/
var LineRenderer = Widget.extend(FieldManagerMixin, {
template: "reconciliation.line",
events: {
'click .accounting_view caption .o_buttons button': '_onValidate',
'click .accounting_view tfoot': '_onChangeTab',
'click': '_onTogglePanel',
'click .o_field_widget': '_onStopPropagation',
'keydown .o_input, .edit_amount_input': '_onStopPropagation',
'click .o_notebook li a': '_onChangeTab',
'click .cell': '_onEditAmount',
'change input.filter': '_onFilterChange',
'click .match .load-more a': '_onLoadMore',
'click .match .mv_line td': '_onSelectMoveLine',
'click .accounting_view tbody .mv_line td': '_onSelectProposition',
'click .o_reconcile_models button': '_onQuickCreateProposition',
'click .create .add_line': '_onCreateProposition',
'click .reconcile_model_create': '_onCreateReconcileModel',
'click .reconcile_model_edit': '_onEditReconcileModel',
'keyup input': '_onInputKeyup',
'blur input': '_onInputKeyup',
'keydown': '_onKeydown',
},
custom_events: _.extend({}, FieldManagerMixin.custom_events, {
'field_changed': '_onFieldChanged',
}),
_avoidFieldUpdate: {},
MV_LINE_DEBOUNCE: 200,
_onKeydown: function (ev) {
switch (ev.which) {
case $.ui.keyCode.ENTER:
this.trigger_up('navigation_move', {direction: 'validate', handle: this.handle});
break;
case $.ui.keyCode.UP:
ev.stopPropagation();
ev.preventDefault();
this.trigger_up('navigation_move', {direction: 'up', handle: this.handle});
break;
case $.ui.keyCode.DOWN:
ev.stopPropagation();
ev.preventDefault();
this.trigger_up('navigation_move', {direction: 'down', handle: this.handle});
break;
}
},
/**
* create partner_id field in editable mode
*
* @override
*/
init: function (parent, model, state) {
this._super(parent);
FieldManagerMixin.init.call(this);
this.model = model;
this._initialState = state;
if (this.MV_LINE_DEBOUNCE) {
this._onSelectMoveLine = _.debounce(this._onSelectMoveLine, this.MV_LINE_DEBOUNCE, true);
} else {
this._onSelectMoveLine = this._onSelectMoveLine;
}
},
/**
* @override
*/
start: function () {
var self = this;
var def1 = this._makePartnerRecord(this._initialState.st_line.partner_id, this._initialState.st_line.partner_name).then(function (recordID) {
self.fields = {
partner_id : new relational_fields.FieldMany2One(self,
'partner_id',
self.model.get(recordID), {
mode: 'edit',
attrs: {
placeholder: self._initialState.st_line.communication_partner_name || _t('Select Partner'),
}
}
)
};
self.fields.partner_id.insertAfter(self.$('.accounting_view caption .o_buttons'));
});
var def3 = session.user_has_group('analytic.group_analytic_tags').then(function(has_group) {
self.group_tags = has_group;
});
var def4 = session.user_has_group('analytic.group_analytic_accounting').then(function(has_group) {
self.group_acc = has_group;
});
$('<span class="line_info_button fa fa-info-circle"/>')
.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)) {
$('<span class="line_info_button fa fa-info-circle"/>')
.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)) {
$('<span class="line_info_button fa fa-info-circle"/>')
.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,
};
});

402
base_accounting_kit/static/src/xml/payment_matching.xml

@ -0,0 +1,402 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="reconciliation" class="o_reconciliation">
<div class="o_form_view">
<div class="o_form_sheet_bg">
<div class="o_form_sheet"/>
</div>
</div>
</div>
<t t-name="reconciliation.control.pager">
<div class="progress progress-reconciliation">
<div aria-valuemin="0" t-att-aria-valuenow="widget._initialState.valuenow" t-att-aria-valuemax="widget._initialState.valuemax" class="progress-bar" role="progressbar" style="width: 0%;"><span class="valuenow"><t t-esc="widget._initialState.valuenow"/></span> / <span class="valuemax"><t t-esc="widget._initialState.valuemax"/></span></div>
</div>
</t>
<t t-name="reconciliation.statement">
<div t-if="widget._initialState.valuemax">
<div class="notification_area"/>
<div class="o_reconciliation_lines"/>
<div t-if="widget._initialState.valuemax &gt; widget._initialState.defaultDisplayQty">
<button class="btn btn-secondary js_load_more">Load more</button>
</div>
</div>
<div t-else="" class="o_view_noreconciliation">
<p>Nothing to do!</p>
<p>This page displays all the bank transactions that are to be reconciled and provides with a neat interface to do so.</p>
</div>
</t>
<t t-name="reconciliation.manual.statement" t-extend="reconciliation.statement">
<t t-jquery="div:first" t-operation="attributes">
<attribute name="class" value="o_manual_statement" />
</t>
<t t-jquery=".o_view_noreconciliation p" t-operation="replace"></t>
<!-- <t t-jquery=".o_filter_input_wrapper" t-operation="replace"></t>-->
<t t-jquery=".o_view_noreconciliation" t-operation="append">
<p><b>Good Job!</b> There is nothing to reconcile.</p>
<p>All invoices and payments have been matched, your accounts' balances are clean.</p>
<p>
From now on, you may want to:
<ul>
<li>Check that you have no bank statement lines to <a href="#"
rel="do_action"
data-tag="bank_statement_reconciliation_view">reconcile</a></li>
<li>Verify <a href="#"
rel="do_action"
data-action_name="Unpaid Customer Invoices"
data-model="account.move"
data-domain="[('move_type', 'in', ('out_invoice', 'out_refund'))]"
data-context="{'search_default_unpaid': 1}">unpaid invoices</a> and follow-up customers</li>
<li>Pay your <a href="#"
rel="do_action"
data-action_name="Unpaid Vendor Bills"
data-model="account.move"
data-domain="[('move_type', 'in', ('in_invoice', 'in_refund'))]"
data-context="{'search_default_unpaid': 1}">vendor bills</a></li>
<li>Check all <a href="#"
rel="do_action"
data-action_name="Unreconciled Entries"
data-model="account.move.line"
data-context="{'search_default_unreconciled': 1}">unreconciled entries</a></li>
</ul>
</p>
</t>
</t>
<div t-name="reconciliation.done" class="done_message">
<h2>Congrats, you're all done!</h2>
<p>You reconciled <strong><t t-esc="number"/></strong> transactions in <strong><t t-esc="duration"/></strong>.
<t t-if="number > 1">
<br/>That's on average <t t-esc="timePerTransaction"/> seconds per transaction.
</t>
</p>
<t t-if="context &amp;&amp; context.active_model">
<p t-if="context['active_model'] === 'account.journal' || context['active_model'] === 'account.bank.statement' || context['active_model'] === 'account.bank.statement.import'" class="actions_buttons">
<t t-if="context.journal_id">
<button class="button_back_to_statement btn btn-secondary" t-att-data_journal_id='context.journal_id'>Go to bank statement(s)</button>
</t>
<t t-if="context['active_model'] === 'account.bank.statement'">
<button class="button_close_statement btn btn-primary" style="display: inline-block;">Close statement</button>
</t>
</p>
</t>
</div>
<t t-name="reconciliation.line">
<t t-set="state" t-value="widget._initialState"/>
<div class="o_reconciliation_line" t-att-data-mode="state.mode" tabindex="0">
<table class="accounting_view">
<caption style="caption-side: top;">
<div class="float-right o_buttons">
<button t-attf-class="o_no_valid btn btn-secondary #{state.balance.type &lt; 0 ? '' : 'd-none'}" disabled="disabled" data-toggle="tooltip" title="Select a partner or choose a counterpart" accesskey="">Validate</button>
<button t-attf-class="o_validate btn btn-secondary #{!state.balance.type ? '' : 'd-none'}">Validate</button>
<button t-attf-class="o_reconcile btn btn-primary #{state.balance.type &gt; 0 ? '' : 'd-none'}">Validate</button>
</div>
</caption>
<thead>
<tr>
<td class="cell_account_code"><t t-esc="state.st_line.account_code"/></td>
<td class="cell_due_date"><t t-esc="state.st_line.date"/></td>
<td class="cell_label"><t t-if="state.st_line.name" t-esc="state.st_line.name"/> <t t-if="state.st_line.amount_currency_str"> (<t t-esc="state.st_line.amount_currency_str"/>)</t></td>
<td class="cell_left"><t t-if="state.st_line.amount &gt; 0"><t t-raw="state.st_line.amount_str"/></t></td>
<td class="cell_right"><t t-if="state.st_line.amount &lt; 0"><t t-raw="state.st_line.amount_str"/></t></td>
<td class="cell_info_popover"></td>
</tr>
</thead>
<tbody>
<t t-foreach="state.reconciliation_proposition" t-as="line"><t t-call="reconciliation.line.mv_line"/></t>
</tbody>
<tfoot>
<t t-call="reconciliation.line.balance"/>
</tfoot>
</table>
<div class="o_notebook">
<div class="o_notebook_headers">
<ul class="nav nav-tabs ml-0 mr-0">
<li class="nav-item" t-attf-title="{{'Match statement with existing lines on receivable/payable accounts&lt;br&gt;* Black line: existing journal entry that should be matched&lt;br&gt;* Blue lines: existing payment that should be matched'}}" data-toggle="tooltip"><a data-toggle="tab" disable_anchor="true" t-attf-href="#notebook_page_match_rp_#{state.st_line.id}" class="nav-link active nav-match_rp" role="tab" aria-selected="true">Customer/Vendor Matching</a></li>
<li class="nav-item" title="Match with entries that are not from receivable/payable accounts" data-toggle="tooltip"><a data-toggle="tab" disable_anchor="true" t-attf-href="#notebook_page_match_other_#{state.st_line.id}" class="nav-link nav-match_other" role="tab" aria-selected="false">Miscellaneous Matching</a></li>
<li class="nav-item" title="Create a counterpart" data-toggle="tooltip"><a data-toggle="tab" disable_anchor="true" t-attf-href="#notebook_page_create_#{state.st_line.id}" class="nav-link nav-create" role="tab" aria-selected="false">Manual Operations</a></li>
</ul>
</div>
<div class="tab-content">
<div class="tab-pane active" t-attf-id="notebook_page_match_rp_#{state.st_line.id}">
<div class="match">
<t t-call="reconciliation.line.match"/>
</div>
</div>
<div class="tab-pane" t-attf-id="notebook_page_match_other_#{state.st_line.id}">
<div class="match">
<t t-call="reconciliation.line.match"/>
</div>
</div>
<div class="tab-pane" t-attf-id="notebook_page_create_#{state.st_line.id}">
<div class="create"></div>
</div>
</div>
</div>
</div>
</t>
<t t-name="reconciliation.manual.line" t-extend="reconciliation.line">
<t t-jquery=".o_buttons" t-operation="replace">
<div class="float-right o_buttons">
<button t-attf-class="o_validate btn btn-secondary #{!state.balance.type ? '' : 'd-none'}">Reconcile</button>
<button t-attf-class="o_reconcile btn btn-primary #{state.balance.type &gt; 0 ? '' : 'd-none'}">Reconcile</button>
<button t-attf-class="o_no_valid btn btn-secondary #{state.balance.type &lt; 0 ? '' : 'd-none'}">Skip</button>
</div>
</t>
<t t-jquery=".accounting_view tbody" t-operation="append">
<t t-if='!_.filter(state.reconciliation_proposition, {"display": true}).length'>
<t t-set="line" t-value='{}'/>
<t t-call="reconciliation.line.mv_line"/>
</t>
</t>
<t t-jquery=".accounting_view thead tr" t-operation="replace">
<tr>
<td colspan="3"><span/><span t-if="state.last_time_entries_checked">Last Reconciliation: <t t-esc="state.last_time_entries_checked"/></span></td>
<td colspan="2"><t t-esc="state.st_line.account_code"/></td>
<td class="cell_info_popover"></td>
</tr>
</t>
<t t-jquery='div[t-attf-id*="notebook_page_match_rp"]' t-operation="replace"/>
<t t-jquery='a[t-attf-href*="notebook_page_match_rp"]' t-operation="replace"/>
</t>
<t t-name="reconciliation.line.balance">
<tr t-if="state.balance.show_balance">
<td class="cell_account_code"><t t-esc="state.balance.account_code"/></td>
<td class="cell_due_date"></td>
<td class="cell_label"><t t-if="state.st_line.partner_id">Open balance</t><t t-else="">Choose counterpart or Create Write-off</t></td>
<td class="cell_left"><t t-if="state.balance.amount_currency &lt; 0"><span role="img" t-if="state.balance.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{state.balance.currency_id%8} line_info_button fa fa-money" t-att-data-content="state.balance.amount_currency_str" t-att-aria-label="state.balance.amount_currency_str" t-att-title="state.balance.amount_currency_str"/><t t-raw="state.balance.amount_str"/></t></td>
<td class="cell_right"><t t-if="state.balance.amount_currency &gt; 0"><span role="img" t-if="state.balance.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{state.balance.currency_id%8} line_info_button fa fa-money" t-att-data-content="state.balance.amount_currency_str" t-att-aria-label="state.balance.amount_currency_str" t-att-title="state.balance.amount_currency_str"/><t t-raw="state.balance.amount_str"/></t></td>
<td class="cell_info_popover"></td>
</tr>
</t>
<div t-name="reconciliation.line.match">
<div class="match_controls">
<span><input class="filter o_input" placeholder="Filter on account, label, partner, amount,..." type="text" t-att-value="state['filter_{{state.mode}}']"/></span>
<button class="btn btn-secondary btn-sm fa fa-search" type="button"></button>
</div>
<table>
<tbody>
</tbody>
</table>
<div class="load-more text-center">
<a href="#">Load more... (<span></span> remaining)</a>
</div>
</div>
<div t-name="reconciliation.line.create">
<div class="quick_add">
<div class="btn-group o_reconcile_models" t-if="state.reconcileModels">
<t t-foreach="state.reconcileModels" t-as="reconcileModel">
<button class="btn btn-primary"
t-if="reconcileModel.rule_type === 'writeoff_button' &amp;&amp; (reconcileModel.match_journal_ids.length == 0 || reconcileModel.match_journal_ids.includes(state.st_line.journal_id) || state.st_line.journal_id === undefined)"
t-att-data-reconcile-model-id="reconcileModel.id">
<t t-esc="reconcileModel.name"/>
</button>
</t>
<p t-if="!state.reconcileModels.length" style="color: #bbb;">To speed up reconciliation, define <a style="cursor: pointer;" class="reconcile_model_create">reconciliation models</a>.</p>
</div>
<div class="dropdown float-right">
<a data-toggle="dropdown" href="#"><span class="fa fa-cog" role="img" aria-label="Settings"/></a>
<div class="dropdown-menu dropdown-menu-right" role="menu" aria-label="Presets config">
<a role="menuitem" class="dropdown-item reconcile_model_create" href="#">Create model</a>
<a role="menuitem" class="dropdown-item reconcile_model_edit" href="#">Modify models</a>
</div>
</div>
</div>
<div class="clearfix o_form_sheet">
<div class="o_group">
<table class="o_group o_inner_group o_group_col_6">
<tbody>
<tr class="create_account_id">
<td class="o_td_label"><label class="o_form_label">Account</label></td>
<td class="o_td_field"></td>
</tr>
<tr class="create_tax_id">
<td class="o_td_label"><label class="o_form_label">Taxes</label></td>
<td class="o_td_field"></td>
</tr>
<tr class="create_analytic_account_id" t-if="group_acc">
<td class="o_td_label"><label class="o_form_label">Analytic Acc.</label></td>
<td class="o_td_field"></td>
</tr>
<tr class="create_analytic_tag_ids" t-if="group_tags">
<td class="o_td_label"><label class="o_form_label">Analytic Tags.</label></td>
<td class="o_td_field"></td>
</tr>
</tbody>
</table>
<table class="o_group o_inner_group o_group_col_6">
<tbody>
<tr class="create_journal_id" style="display: none;">
<td class="o_td_label"><label class="o_form_label">Journal</label></td>
<td class="o_td_field"></td>
</tr>
<tr class="create_label">
<td class="o_td_label"><label class="o_form_label">Label</label></td>
<td class="o_td_field"></td>
</tr>
<tr class="create_amount">
<td class="o_td_label"><label class="o_form_label">Amount</label></td>
<td class="o_td_field"></td>
</tr>
<tr class="create_force_tax_included d-none">
<td class="o_td_label"><label class="o_form_label">Tax Included in Price</label></td>
<td class="o_td_field"></td>
</tr>
<tr class="create_date d-none">
<td class="o_td_label"><label class="o_form_label">Writeoff Date</label></td>
<td class="o_td_field"></td>
</tr>
<tr class="create_to_check">
<td class="o_td_label"><label class="o_form_label">To Check</label></td>
<td class="o_td_field"></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="add_line_container">
<a class="add_line" t-att-style="!state.balance.amout ? 'display: none;' : null"><i class="fa fa-plus-circle"/> Save and New</a>
</div>
</div>
<t t-name="reconciliation.line.mv_line.amount">
<span t-att-class="(line.is_move_line &amp;&amp; proposition == true) ? 'cell' : ''">
<span class="line_amount">
<span t-if="line.amount_currency_str"
t-attf-class="o_multi_currency o_multi_currency_color_#{line.currency_id%8} line_info_button fa fa-money"
t-att-data-content="line.amount_currency_str"/>
<span t-if="line.partial_amount &amp;&amp; line.partial_amount != line.amount" class="strike_amount text-muted">
<t t-raw="line.amount_str"/>
<br/>
</span>
</span>
<t t-if="line.is_move_line &amp;&amp; proposition == true">
<i class="fa fa-pencil edit_amount"></i>
<input class="edit_amount_input text-right d-none"/>
</t>
<span class="line_amount">
<t t-if="!line.partial_amount_str" t-raw="line.amount_str"/>
<t t-if="line.partial_amount_str &amp;&amp; line.partial_amount != line.amount" t-raw="line.partial_amount_str"/>
</span>
</span>
</t>
<t t-name="reconciliation.line.mv_line">
<tr t-if="line.display !== false" t-attf-class="mv_line #{line.already_paid ? ' already_reconciled' : ''} #{line.__invalid ? 'invalid' : ''} #{line.is_tax ? 'is_tax' : ''}" t-att-data-line-id="line.id" t-att-data-selected="selected">
<td class="cell_account_code"><t t-esc="line.account_code"/>&#8203;</td> <!-- zero width space to make empty lines the height of the text -->
<td class="cell_due_date">
<t t-if="typeof(line.id) != 'number' &amp;&amp; line.id">
<span class="badge badge-secondary">New</span>
</t>
<t t-else="" t-esc="line.date_maturity || line.date"/>
</td>
<td class="cell_label">
<t t-if="line.partner_id &amp;&amp; line.partner_id !== state.st_line.partner_id">
<t t-if="line.partner_name.length">
<span class="font-weight-bold" t-esc="line.partner_name"/>:
</t>
</t>
<t t-esc="line.label || line.name"/>
<t t-if="line.ref &amp;&amp; line.ref.length"> : </t>
<t t-esc="line.ref"/>
</td>
<td class="cell_left">
<t t-if="line.amount &lt; 0">
<t t-call="reconciliation.line.mv_line.amount"/>
</t>
</td>
<td class="cell_right">
<t t-if="line.amount &gt; 0">
<t t-call="reconciliation.line.mv_line.amount"/>
</t>
</td>
<td class="cell_info_popover"></td>
</tr>
</t>
<t t-name="reconciliation.line.mv_line.details">
<table class='details'>
<tr t-if="line.account_code"><td>Account</td><td><t t-esc="line.account_code"/> <t t-esc="line.account_name"/></td></tr>
<tr><td>Date</td><td><t t-esc="line.date"/></td></tr>
<tr><td>Due Date</td><td><t t-esc="line.date_maturity || line.date"/></td></tr>
<tr><td>Journal</td><td><t t-esc="line.journal_id.display_name"/></td></tr>
<tr t-if="line.partner_id"><td>Partner</td><td><t t-esc="line.partner_name"/></td></tr>
<tr><td>Label</td><td><t t-esc="line.label"/></td></tr>
<tr t-if="line.ref"><td>Ref</td><td><t t-esc="line.ref"/></td></tr>
<tr><td>Amount</td><td><t t-raw="line.total_amount_str"/><t t-if="line.total_amount_currency_str"> (<t t-esc="line.total_amount_currency_str"/>)</t></td></tr>
<tr t-if="line.is_partially_reconciled"><td>Residual</td><td>
<t t-raw="line.amount_str"/><t t-if="line.amount_currency_str"> (<t t-esc="line.amount_currency_str"/>)</t>
</td></tr>
<tr class="one_line_info" t-if='line.already_paid'>
<td colspan="2">This payment is registered but not reconciled.</td>
</tr>
</table>
</t>
<t t-name="reconciliation.line.statement_line.details">
<table class='details'>
<tr><td>Date</td><td><t t-esc="state.st_line.date"/></td></tr>
<tr t-if="state.st_line.partner_name"><td>Partner</td><td><t t-esc="state.st_line.partner_name"/></td></tr>
<tr t-if="state.st_line.ref"><td>Transaction</td><td><t t-esc="state.st_line.ref"/></td></tr>
<tr><td>Description</td><td><t t-esc="state.st_line.name"/></td></tr>
<tr><td>Amount</td><td><t t-raw="state.st_line.amount_str"/><t t-if="state.st_line.amount_currency_str"> (<t t-esc="state.st_line.amount_currency_str"/>)</t></td></tr>
<tr><td>Account</td><td><t t-esc="state.st_line.account_code"/> <t t-esc="state.st_line.account_name"/></td></tr>
<tr t-if="state.st_line.note"><td>Note</td><td style="white-space: pre;"><t t-esc="state.st_line.note"/></td></tr>
</table>
</t>
<t t-name="reconciliation.notification.reconciled">
<t t-if="details !== undefined">
<a rel="do_action" href="#" aria-label="External link" title="External link"
t-att-data-action_name="details.name"
t-att-data-model="details.model"
t-att-data-ids="details.ids">
<t t-esc="nb_reconciled_lines"/>
statement lines
</a>
have been reconciled automatically.
</t>
</t>
<t t-name="reconciliation.notification.default">
<t t-esc="message"/>
<t t-if="details !== undefined">
<a class="fa fa-external-link" rel="do_action" href="#" aria-label="External link" title="External link"
t-att-data-action_name="details.name"
t-att-data-model="details.model"
t-att-data-ids="details.ids">
</a>
</t>
</t>
<t t-name="reconciliation.notification">
<div t-att-class="'notification alert-dismissible alert alert-' + type" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span title="Close" class="fa fa-times"></span></button>
<t t-if="template">
<t t-call="{{template}}"/>
</t>
<t t-else="">
<t t-call="reconciliation.notification.default"/>
</t>
</div>
</t>
</templates>

3
base_accounting_kit/views/assets.xml

@ -5,6 +5,9 @@
<link rel="stylesheet" type="text/scss" href="/base_accounting_kit/static/src/scss/style.scss"/>
<link rel="stylesheet" type="text/scss" href="/base_accounting_kit/static/lib/bootstrap-toggle-master/css/bootstrap-toggle.min.css"/>
<script type="text/javascript" src="/base_accounting_kit/static/src/js/account_dashboard.js"/>
<script type="text/javascript" src="/base_accounting_kit/static/src/js/payment_model.js"/>
<script type="text/javascript" src="/base_accounting_kit/static/src/js/payment_render.js"/>
<script type="text/javascript" src="/base_accounting_kit/static/src/js/payment_matching.js"/>
<script type="text/javascript" src="/base_accounting_kit/static/lib/Chart.bundle.js"/>
<script type="text/javascript" src="/base_accounting_kit/static/lib/Chart.bundle.min.js"/>
<script type="text/javascript" src="/base_accounting_kit/static/lib/Chart.min.js"/>

34
base_accounting_kit/views/payment_matching.xml

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="payment_matching_view" model="ir.ui.view">
<field name="name">account.journal.dashboard.payment</field>
<field name="model">account.journal</field>
<field name="inherit_id" ref="account.account_journal_dashboard_kanban_view"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='sale_purchase_refund']" position="after">
<div>
<a type="object" name="action_open_reconcile" groups="account.group_account_user">Payments Matching</a>
</div>
</xpath>
<xpath expr="//div[@id='dashboard_bank_cash_left']/*[1]" position="before">
<t t-if="dashboard.number_to_reconcile > 0">
<button type="object" name="action_open_reconcile" class="btn btn-primary" groups="account.group_account_user"> Reconcile <t t-esc="dashboard.number_to_reconcile"/> Items</button>
</t>
</xpath>
</field>
</record>
<record id="matching_account_payment" model="ir.ui.view">
<field name="name">account.payment.matching</field>
<field name="model">account.payment</field>
<field name="inherit_id" ref="account.view_account_payment_form" />
<field name="arch" type="xml">
<xpath expr="//button[@name='button_open_statements']" position="after">
<button class="oe_stat_button" name="open_payment_matching_screen"
string="Payment Matching" type="object"
groups="account.group_account_user"
attrs="{'invisible':[('is_reconciled','=',True)]}" icon="fa-dollar"/>
<field name="is_reconciled" invisible="1"/>
</xpath>
</field>
</record>
</odoo>
Loading…
Cancel
Save