You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

615 lines
31 KiB
Python

# -*- coding: utf-8 -*-
from collections import defaultdict
import re
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, frozendict, get_lang
from odoo.tools import html2plaintext
class AccountReconciliation(models.AbstractModel):
_name = 'account.reconciliation.widget'
_description = 'Account Reconciliation widget'
####################################################
# Public
####################################################
@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 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_reconcile_modelds_for_manual_reconciliation(self, company_ids):
""" Collect the available account.reconcile.model records for the manual reconciliation widget.
:param company_ids: A list of res.company ids.
:return: The result of read for each available record.
"""
reco_models = self.env['account.reconcile.model'] \
.search([
('rule_type', '=', 'writeoff_button'),
('company_id', 'in', company_ids),
]) \
.filtered(lambda x: not set(x.mapped('line_ids.amount_type')).intersection({'percentage_st_line', 'regex'}))
return reco_models.read()
@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)
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:
accounts_data = self.get_data_for_manual_reconciliation('account', account_ids)
return {
'customers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'asset_receivable'),
'suppliers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'liability_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 'liability_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 ('liability_payable', 'asset_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 ' ')
lang = self.env.user.lang or get_lang(self.env).code
query = ("""
SELECT {select} account_id, account_name, account_code, max_date
FROM (
SELECT {inner_select}
a.id AS account_id,
{account_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)
{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 ((a.account_type <> 'liability_payable' AND a.account_type <> 'asset_receivable') OR l.partner_id IS NULL)",
where2=account_type and "AND a.account_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 ' ',
account_name=f"COALESCE(a.name->>'{lang}', a.name->>'en_US')" if self.pool['account.account'].name.translate else 'a.name'
))
self.env['account.move.line'].flush_model()
self.env['account.account'].flush_model()
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 == 'asset_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()
"""
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 = self.env['res.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 _get_search_domain(self, search_str=''):
''' Get the domain to be applied on the account.move.line when the user is typing
something on the search bar.
:param search_str: The search bar content as a string.
:return: A applicable domain on the account.move.line model.
'''
search_str = search_str.strip()
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.account_type', 'in', ('asset_cash', 'liability_credit_card')),
'|', '|', '|', ('debit', '=', amount), ('credit', '=', amount), ('amount_currency', '=', amount), ('amount_currency', '=', -amount),
]
str_domain = expression.OR([str_domain, amount_domain])
except:
pass
return expression.OR([str_domain, [('partner_id.name', 'ilike', search_str)]])
@api.model
def _domain_move_lines_for_manual_reconciliation(self, account_id, partner_id=False, excluded_ids=None, search_str=''):
""" Create domain criteria that are relevant to manual reconciliation. """
domain = ['&', '&', ('reconciled', '=', False), ('account_id', '=', account_id), ('move_id.state', '=', 'posted')]
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._get_search_domain(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
"""
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': line.name and line.name != '/' and line.move_id.name != line.name and ': ' + line.name or '',
'move_name': 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],
'is_liquidity_line': line.account_id.account_type in ('asset_cash', 'liability_credit_card'),
'account_code': line.account_id.code,
'account_name': line.account_id.name,
'account_type': line.account_id.account_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.account_type in ('asset_cash', 'liability_credit_card'):
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['balance'] = amount
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_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
def _prepare_writeoff_move_vals(self, move_lines, vals_list):
aggr = defaultdict(list)
for vals in vals_list:
# detect if it is a tax case (the balance still exists) and manage the 0% tax case
if vals.get('tax_repartition_line_id') and self.env.company.currency_id.is_zero(vals['balance']):
continue
move_vals = self._prepare_writeoff_moves(move_lines, vals)
grouping = frozendict({k: v for k, v in move_vals.items() if k != 'line_ids'})
aggr[grouping].extend(move_vals['line_ids'])
return [{**grouping, 'line_ids': line_ids} for grouping, line_ids in aggr.items()]
@api.model
def _prepare_writeoff_moves(self, move_lines, 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."))
move_fields = {'journal_id', 'date'}
move_vals = {k: v for k, v in vals.items() if k in move_fields}
company_currency = move_lines.company_id.currency_id
currencies = set(line.currency_id for line in move_lines)
currency = list(currencies)[0] if len(currencies) == 1 else company_currency
line_vals = {
**{k: v for k, v in vals.items() if k not in move_fields},
'partner_id': move_lines[0].partner_id.id,
'sequence': 10,
}
if 'debit' not in vals and 'credit' not in vals:
balance = -vals.get('balance', 0.0) or sum(move_lines.mapped('amount_residual'))
else:
balance = vals.get('credit', 0.0) - vals.get('debit', 0.0)
line_vals['balance'] = balance
if currency == company_currency:
line_vals['amount_currency'] = balance
line_vals['currency_id'] = company_currency.id
else:
if 'amount_currency' in vals:
line_vals['amount_currency'] = -vals['amount_currency']
else:
line_vals['amount_currency'] = sum(move_lines.mapped('amount_residual_currency'))
line_vals['currency_id'] = currency.id
move_vals['line_ids'] = [
(0, 0, line_vals),
(0, 0, {
'name': _('Write-Off'),
'balance': -line_vals['balance'],
'amount_currency': -line_vals['amount_currency'],
'currency_id': currency.id,
'account_id': move_lines[0].account_id.id,
'partner_id': move_lines[0].partner_id.id,
'sequence': 20,
}),
]
return move_vals
@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.'))
move_lines = self.env['account.move.line'].browse(move_line_ids)
# Create writeoff move lines
if len(new_mv_line_dicts) > 0:
move_vals_list = self._prepare_writeoff_move_vals(move_lines, new_mv_line_dicts)
moves = self.env['account.move'].create(move_vals_list)
moves.action_post()
account = move_lines[0].account_id
move_lines |= moves.line_ids.filtered(lambda line: line.account_id == account and not line.reconciled)
move_lines.reconcile()
@api.model
def get_reconciliation_dict_from_model(self, model_id, residual_balance, widget_partner_id):
""" Returns the values to be used by the reconciliation widget widget in order
to apply the provided reconciliation model to the provided statement line
(hence the values used to create the lines in the widget).
"""
model = self.env['account.reconcile.model'].browse(model_id)
new_aml_dicts = model._get_write_off_move_lines_dict(residual_balance, widget_partner_id)
self._complete_write_off_vals_for_widget(new_aml_dicts)
return new_aml_dicts
@api.model
def _complete_write_off_vals_for_widget(self, new_aml_dicts):
for line in new_aml_dicts:
balance = line.get('balance', 0.0)
line.update({
'debit': balance if balance > 0.0 else 0.0,
'credit': -balance if balance < 0.0 else 0.0,
})
for m2o_name in ('account_id', 'journal_id', 'partner_id', 'analytic_account_id'):
if line.get(m2o_name) and not isinstance(line[m2o_name], dict):
m2o_record = self.env[self.env['account.move.line']._fields[m2o_name].comodel_name].browse(line[m2o_name])
if m2o_name == 'analytic_account_id':
line['analytic_distribution'] = {m2o_record.id: 100}
else:
line[m2o_name] = {'display_name': m2o_record.display_name, 'id': m2o_record.id}
if m2o_name == 'account_id':
line['account_code'] = m2o_record.code
for x2m_name in ('tax_ids', 'tax_tag_ids'):
if line.get(x2m_name) and not isinstance(line[x2m_name][0], dict):
x2m_records = self.env[self.env['account.move.line']._fields[x2m_name].comodel_name].browse(line[x2m_name][0][2])
line[x2m_name] = [{'display_name': r.display_name, 'id': r.id} for r in x2m_records]
if 'reconcile_model_id' in line:
line['to_check'] = self.env['account.reconcile.model'].browse(line['reconcile_model_id']).to_check
@api.model
def open_rec_model_creation_widget(self, rec_propositions, st_line_amount, to_check):
""" Called by the reconciliation widget in order to open the wizard allowing
automatically creating a reconciliation model from the reconciliation
propositions manually matched with a statement line in the widget.
"""
if st_line_amount:
# No proposition to create. We open an empty wizard.
line_vals = [(0, 0, {
'account_id': proposition['account_id'],
'tax_ids': [(6, 0, proposition['tax_ids'])],
'amount_type': 'percentage',
'amount_string': str(round(100 * proposition['amount'] / st_line_amount, 5)),
'label': proposition.get('label', ''),
}) for proposition in rec_propositions]
else:
line_vals = []
view_id = self.env['ir.model.data']._xmlid_to_res_id('account_accountant.view_account_reconcile_model_widget_wizard')
return {
'type': 'ir.actions.act_window',
'name': _("Create Reconciliation Model"),
'view_mode': 'form',
'res_model': 'account.reconcile.model',
'views': [[view_id, 'form']],
'target': 'new',
'context': {
'default_line_ids': line_vals,
'default_to_check': to_check,
}
}