[IMP] account: management of reconciliation rules

Bank statement lines need to be matched with entries; We have to automate this operation as much as possible.
At the same time, create an algorithm that fit everyone needs is quite impossible.
For that reason we will let users choose the rules they want with the improvement of reconciliation models.

Note that we did it only for bank statement reconliation, not for manual reconciliations.

Was task: 1877375
Was PR #26669
This commit is contained in:
Laurent Smet
2018-09-20 17:11:52 +02:00
committed by qdp-odoo
parent e19ecefa8e
commit 7053ae7828
17 changed files with 1388 additions and 689 deletions
+14
View File
@@ -182,5 +182,19 @@
<field name="state">code</field>
<field name="code">action = records.action_share()</field>
</record>
<!-- Account Reconciliation Models
Create a default rule for the reconciliation widget matching invoices automatically.
-->
<record id="reconciliation_model_default_rule" model="account.reconcile.model">
<field name="name">Invoices Matching Rule</field>
<field name="sequence">1</field>
<field name="rule_type">invoice_matching</field>
<field name="auto_reconcile" eval="False"/>
<field name="match_nature">both</field>
<field name="match_same_currency" eval="True"/>
<field name="match_total_amount" eval="True"/>
<field name="match_total_amount_param" eval="100"/>
</record>
</data>
</odoo>
@@ -1,18 +1,72 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.tools import float_compare, float_is_zero
from odoo.exceptions import UserError
class AccountReconcileModel(models.Model):
_name = "account.reconcile.model"
_description = "Preset to create journal entries during a invoices and payments matching"
_name = 'account.reconcile.model'
_description = 'Preset to create journal entries during a invoices and payments matching'
_order = 'sequence, id'
# Base fields.
name = fields.Char(string='Button Label', required=True)
name = fields.Char(string='Name', required=True)
sequence = fields.Integer(required=True, default=10)
has_second_line = fields.Boolean(string='Add a second line', default=False)
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id)
rule_type = fields.Selection(selection=[
('writeoff_button', _('Manually create a write-off on clicked button.')),
('writeoff_suggestion', _('Suggest a write-off.')),
('invoice_matching', _('Match existing invoices/bills.'))
], string='Type', default='writeoff_button', required=True)
auto_reconcile = fields.Boolean(string='Auto-validate',
help='Validate the statement line automatically (reconciliation based on your rule).')
# ===== Conditions =====
match_journal_ids = fields.Many2many('account.journal', string='Journals',
domain="[('type', 'in', ('bank', 'cash'))]",
help='The reconciliation model will only be available from the selected journals.')
match_nature = fields.Selection(selection=[
('amount_received', 'Amount Received'),
('amount_paid', 'Amount Paid'),
('both', 'Amount Paid/Received')
], string='Amount Nature', required=True, default='both',
help='''The reconciliation model will only be applied to the selected transaction type:
* Amount Received: Only applied when receiving an amount.
* Amount Paid: Only applied when paying an amount.
* Amount Paid/Received: Applied in both cases.''')
match_amount = fields.Selection(selection=[
('lower', 'Is Lower Than'),
('greater', 'Is Greater Than'),
('between', 'Is Between'),
], string='Amount',
help='The reconciliation model will only be applied when the amount being lower than, greater than or between specified amount(s).')
match_amount_min = fields.Float(string='Amount Min Parameter')
match_amount_max = fields.Float(string='Amount Max Parameter')
match_label = fields.Selection(selection=[
('contains', 'Contains'),
('not_contains', 'Not Contains'),
('match_regex', 'Match Regex'),
], string='Label', help='''The reconciliation model will only be applied when the label:
* Contains: The proposition label must contains this string (case insensitive).
* Not Contains: Negation of "Contains".
* Match Regex: Define your own regular expression.''')
match_label_param = fields.Char(string='Label Parameter')
match_same_currency = fields.Boolean(string='Same Currency Matching', default=True,
help='Restrict to propositions having the same currency as the statement line.')
match_total_amount = fields.Boolean(string='Amount Matching', default=True,
help='The sum of total residual amount propositions matches the statement line amount.')
match_total_amount_param = fields.Float(string='Amount Matching %', default=100,
help='The sum of total residual amount propositions matches the statement line amount under this percentage.')
match_partner = fields.Boolean(string='Partner Is Set',
help='The reconciliation model will only be applied when a customer/vendor is set.')
match_partner_ids = fields.Many2many('res.partner', string='Restrict Partners to',
help='The reconciliation model will only be applied to the selected customers/vendors.')
match_partner_category_ids = fields.Many2many('res.partner.category', string='Restrict Partner Categories to',
help='The reconciliation model will only be applied to the selected customer/vendor categories.')
# ===== Write-Off =====
# First part fields.
account_id = fields.Many2one('account.account', string='Account', ondelete='cascade', domain=[('deprecated', '=', False)])
journal_id = fields.Many2one('account.journal', string='Journal', ondelete='cascade', help="This field is ignored in a bank statement reconciliation.")
@@ -27,12 +81,13 @@ class AccountReconcileModel(models.Model):
help='Technical field used inside the view to make the force_tax_included field invisible if the tax is a group.')
force_tax_included = fields.Boolean(string='Tax Included in Price',
help='Force the tax to be managed as a price included tax.')
amount = fields.Float(digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
amount = fields.Float(string='Write-off Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
tax_id = fields.Many2one('account.tax', string='Tax', ondelete='restrict')
analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', ondelete='set null')
analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags')
# Second part fields.
has_second_line = fields.Boolean(string='Add a second line', default=False)
second_account_id = fields.Many2one('account.account', string='Second Account', ondelete='cascade', domain=[('deprecated', '=', False)])
second_journal_id = fields.Many2one('account.journal', string='Second Journal', ondelete='cascade', help="This field is ignored in a bank statement reconciliation.")
second_label = fields.Char(string='Second Journal Item Label')
@@ -46,7 +101,7 @@ class AccountReconcileModel(models.Model):
help='Technical field used inside the view to make the force_second_tax_included field invisible if the tax is a group.')
force_second_tax_included = fields.Boolean(string='Second Tax Included in Price',
help='Force the second tax to be managed as a price included tax.')
second_amount = fields.Float(string='Second Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
second_amount = fields.Float(string='Second Write-off Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
second_tax_id = fields.Many2one('account.tax', string='Second Tax', ondelete='restrict', domain=[('type_tax_use', '=', 'purchase')])
second_analytic_account_id = fields.Many2one('account.analytic.account', string='Second Analytic Account', ondelete='set null')
second_analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Second Analytic Tags')
@@ -64,3 +119,600 @@ class AccountReconcileModel(models.Model):
def _onchange_second_tax_id(self):
if self.second_tax_id:
self.force_second_tax_included = self.second_tax_id.price_include
@api.onchange('match_total_amount_param')
def _onchange_match_total_amount_param(self):
if self.match_total_amount_param < 0 or self.match_total_amount_param > 100:
self.match_total_amount_param = min(max(0, self.match_total_amount_param), 100)
####################################################
# RECONCILIATION PROCESS
####################################################
@api.model
def _get_taxes_move_lines_dict(self, tax, base_line_dict):
''' Get move.lines dict (to be passed to the create()) corresponding to a tax.
:param tax: An account.tax record.
:param base_line_dict: A dict representing the move.line containing the base amount.
:return: A list of dict representing move.lines to be created corresponding to the tax.
'''
balance = base_line_dict['debit'] - base_line_dict['credit']
currency = base_line_dict.get('currency_id') and self.env['res.currency'].browse(base_line_dict['currency_id'])
res = tax.compute_all(balance, currency=currency)
new_aml_dicts = []
for tax_res in res['taxes']:
tax = self.env['account.tax'].browse(tax_res['id'])
new_aml_dicts.append({
'account_id': tax.account_id and tax.account_id.id or base_line_dict['account_id'],
'name': tax.name,
'partner_id': base_line_dict.get('partner_id'),
'debit': tax_res['amount'] > 0 and tax_res['amount'] or 0,
'credit': tax_res['amount'] < 0 and -tax_res['amount'] or 0,
'analytic_account_id': tax.analytic and base_line_dict['analytic_account_id'],
'analytic_tag_ids': tax.analytic and base_line_dict['analytic_tag_ids'],
'tax_exigible': tax.tax_exigibility == 'on_payment',
})
# Handle price included taxes.
base_line_dict['debit'] = tax_res['base'] > 0 and tax_res['base'] or base_line_dict['debit']
base_line_dict['credit'] = tax_res['base'] < 0 and -tax_res['base'] or base_line_dict['credit']
return new_aml_dicts
@api.multi
def _get_write_off_move_lines_dict(self, st_line, move_lines=None):
''' Get move.lines dict (to be passed to the create()) corresponding to the reconciliation model's write-off lines.
:param st_line: An account.bank.statement.line record.
:param move_lines: An account.move.line recordset.
:return: A list of dict representing move.lines to be created corresponding to the write-off lines.
'''
self.ensure_one()
if self.rule_type == 'invoice_matching' and (not self.match_total_amount or (self.match_total_amount_param == 100)):
return []
line_residual = st_line.currency_id and st_line.amount_currency or st_line.amount
line_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.company_id.currency_id
total_residual = move_lines and sum(aml.currency_id and aml.amount_residual_currency or aml.amount_residual for aml in move_lines) or 0.0
balance = line_residual - total_residual
if not self.account_id or float_is_zero(balance, precision_rounding=line_currency.rounding) or balance < 0:
return []
line_balance = self.amount_type == 'percentage' and balance * (self.amount / 100.0) or self.amount
new_aml_dicts = []
# First write-off line.
writeoff_line = {
'name': self.label,
'account_id': self.account_id.id,
'analytic_account_id': self.analytic_account_id.id,
'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)],
'debit': line_balance < 0 and -line_balance or 0,
'credit': line_balance > 0 and line_balance or 0,
}
new_aml_dicts.append(writeoff_line)
if self.tax_id:
writeoff_line['tax_ids'] = [(6, None, [self.tax_id.id])]
tax = self.tax_id
if self.force_tax_included:
tax = tax.with_context(force_price_include=True)
new_aml_dicts += self._get_taxes_move_lines_dict(tax, writeoff_line)
# Second write-off line.
if self.has_second_line and self.second_account_id:
line_balance = balance - sum(aml['debit'] - aml['credit'] for aml in new_aml_dicts)
second_writeoff_line = {
'name': self.second_label,
'account_id': self.second_account_id.id,
'analytic_account_id': self.second_analytic_account_id.id,
'analytic_tag_ids': [(6, 0, self.second_analytic_tag_ids.ids)],
'debit': line_balance < 0 and -line_balance or 0,
'credit': line_balance > 0 and line_balance or 0,
}
new_aml_dicts.append(second_writeoff_line)
if self.second_tax_id:
second_writeoff_line['tax_ids'] = [(6, None, [self.second_tax_id.id])]
tax = self.second_tax_id
if self.force_second_tax_included:
tax = tax.with_context(force_price_include=True)
new_aml_dicts += self._get_taxes_move_lines_dict(tax, second_writeoff_line)
return new_aml_dicts
@api.multi
def _prepare_reconciliation(self, st_line, move_lines=None, partner=None):
''' Reconcile the statement line with some move lines using this reconciliation model.
:param st_line: An account.bank.statement.line record.
:param move_lines: An account.move.line recordset.
:param partner_id: An optional res.partner record. If not set, st_line.partner_id will be used.
:return: Counterpart account.moves.
'''
self.ensure_one()
# Create counterpart_aml_dicts + payment_aml_rec.
counterpart_aml_dicts = []
payment_aml_rec = self.env['account.move.line']
if move_lines:
for aml in move_lines:
if aml.account_id.internal_type == 'liquidity':
payment_aml_rec |= aml
else:
amount = aml.currency_id and aml.amount_residual_currency or aml.amount_residual
counterpart_aml_dicts.append({
'name': aml.name if aml.name != '/' else aml.move_id.name,
'debit': amount < 0 and -amount or 0,
'credit': amount > 0 and amount or 0,
'move_line': aml,
})
# Create new_aml_dicts.
new_aml_dicts = self._get_write_off_move_lines_dict(st_line, move_lines=move_lines)
line_residual = st_line.currency_id and st_line.amount_currency or st_line.amount
line_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.company_id.currency_id
total_residual = move_lines and sum(aml.currency_id and aml.amount_residual_currency or aml.amount_residual for aml in move_lines) or 0.0
total_residual -= sum(aml['debit'] - aml['credit'] for aml in new_aml_dicts)
# Create open_balance_dict
open_balance_dict = None
if float_compare(line_residual, total_residual, precision_rounding=line_currency.rounding) != 0:
if not partner and not st_line.partner_id:
open_balance_dict = False
else:
balance = total_residual - line_residual
partner = partner or st_line.partner_id
open_balance_dict = {
'name': '%s : %s' % (st_line.name, _('Open Balance')),
'account_id': balance < 0 and partner.property_account_payable_id.id or partner.property_account_receivable_id.id,
'debit': balance > 0 and balance or 0,
'credit': balance < 0 and -balance or 0,
}
return {
'counterpart_aml_dicts': counterpart_aml_dicts,
'payment_aml_rec': payment_aml_rec,
'new_aml_dicts': new_aml_dicts,
'open_balance_dict': open_balance_dict
}
####################################################
# RECONCILIATION CRITERIA
####################################################
@api.multi
def _apply_conditions(self, query, params):
self.ensure_one()
rule = self
# Filter on journals.
if rule.match_journal_ids:
query += ' AND st_line.journal_id IN %s'
params += [tuple(rule.match_journal_ids.ids)]
# Filter on amount nature.
if rule.match_nature == 'amount_received':
query += ' AND st_line.amount >= 0.0'
elif rule.match_nature == 'amount_paid':
query += ' AND st_line.amount <= 0.0'
# Filter on amount.
if rule.match_amount:
query += ' AND ROUND(ABS(st_line.amount), jnl_precision.dp) '
if rule.match_amount == 'lower':
query += '< %s'
params += [self.match_amount_max]
elif rule.match_amount == 'greater':
query += '> %s'
params += [self.match_amount_min]
else:
# if self.match_amount == 'between'
query += 'BETWEEN %s AND %s'
params += [rule.match_amount_min, rule.match_amount_max]
# Filter on label.
if rule.match_label == 'contains':
query += ' AND st_line.name ILIKE %s'
params += ['%%%s%%' % rule.match_label_param]
elif rule.match_label == 'not_contains':
query += ' AND st_line.name NOT ILIKE %s'
params += ['%%%s%%' % rule.match_label_param]
elif rule.match_label == 'match_regex':
query += ' AND st_line.name ~ %s'
params += [rule.match_label_param]
# Filter on partners.
if rule.match_partner:
query += ' AND line_partner.partner_id != 0'
if rule.match_partner_ids:
query += ' AND line_partner.partner_id IN %s'
params += [tuple(rule.match_partner_ids.ids)]
if rule.match_partner_category_ids:
query += '''
AND line_partner.partner_id IN (
SELECT DISTINCT categ.partner_id FROM res_partner_res_partner_category_rel categ WHERE categ.category_id IN %s
)
'''
params += [tuple(rule.match_partner_category_ids.ids)]
return query, params
@api.multi
def _get_with_tables(self, st_lines, partner_map=None):
with_tables = '''
WITH jnl_precision AS (
SELECT
j.id AS journal_id, currency.decimal_places AS dp
FROM account_journal j
LEFT JOIN res_company c ON j.company_id = c.id
LEFT JOIN res_currency currency ON COALESCE(j.currency_id, c.currency_id) = currency.id
WHERE j.type IN ('bank', 'cash')
)'''
# Compute partners values table.
# This is required since some statement line's partners could be shadowed in the reconciliation widget.
partners_list = []
for line in st_lines:
partner_id = partner_map and partner_map.get(line.id) or line.partner_id.id or 0
partners_list.append('(%d, %d)' % (line.id, partner_id))
partners_table = 'SELECT * FROM (VALUES %s) AS line_partner (line_id, partner_id)' % ','.join(partners_list)
with_tables += ', partners_table AS (' + partners_table + ')'
return with_tables
@api.multi
def _get_invoice_matching_query(self, st_lines, excluded_ids=None, partner_map=None):
''' Get the query applying all rules trying to match existing entries with the given statement lines.
:param st_lines: Account.bank.statement.lines recordset.
:param excluded_ids: Account.move.lines to exclude.
:param partner_map: Dict mapping each line with new partner eventually.
:return: (query, params)
'''
if any(m.rule_type != 'invoice_matching' for m in self):
raise UserError(_('Programmation Error: Can\'t call _get_invoice_matching_query() for different rules than \'invoice_matching\''))
queries = []
all_params = []
for rule in self:
# N.B: 'communication_flag' is there to distinguish invoice matching through the number/reference
# (higher priority) from invoice matching using the partner (lower priority).
query = '''
SELECT
%s AS sequence,
%s AS model_id,
st_line.id AS id,
aml.id AS aml_id,
aml.currency_id AS aml_currency_id,
aml.date_maturity AS aml_date_maturity,
aml.amount_residual AS aml_amount_residual,
aml.amount_residual_currency AS aml_amount_residual_currency,
CASE WHEN
REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(move.name, '[^0-9]', '', 'g')
OR (
move.ref IS NOT NULL
AND
REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(move.ref, '[^0-9]', '', 'g')
)
THEN TRUE ELSE FALSE END AS communication_flag
FROM account_bank_statement_line st_line
LEFT JOIN account_journal journal ON journal.id = st_line.journal_id
LEFT JOIN jnl_precision ON jnl_precision.journal_id = journal.id
LEFT JOIN res_company company ON company.id = st_line.company_id
LEFT JOIN partners_table line_partner ON line_partner.line_id = st_line.id
, account_move_line aml
LEFT JOIN account_move move ON move.id = aml.move_id
LEFT JOIN account_account account ON account.id = aml.account_id
WHERE st_line.id IN %s
AND aml.company_id = st_line.company_id
AND (
-- the field match_partner of the rule might enforce the second part of
-- the OR condition, later in _apply_conditions()
line_partner.partner_id = 0
OR
aml.partner_id = line_partner.partner_id
)
AND CASE WHEN st_line.amount > 0.0
THEN aml.balance > 0
ELSE aml.balance < 0
END
-- if there is a partner, propose all aml of the partner, otherwise propose only the ones
-- matching the statement line communication
AND CASE WHEN line_partner.partner_id != 0 THEN
aml.partner_id = line_partner.partner_id
ELSE
(
REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(move.name, '[^0-9]', '', 'g')
OR (
move.ref IS NOT NULL
AND
REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(move.ref, '[^0-9]', '', 'g')
)
)
END
AND
(
(
-- blue lines appearance conditions
aml.account_id IN (journal.default_credit_account_id, journal.default_debit_account_id)
AND aml.statement_id IS NULL
AND (
company.account_bank_reconciliation_start IS NULL
OR
aml.date > company.account_bank_reconciliation_start
)
)
OR
(
-- black lines appearance conditions
account.reconcile IS TRUE
AND aml.reconciled IS FALSE
)
)
'''
# Filter on the same currency.
if rule.match_same_currency:
query += '''
AND COALESCE(st_line.currency_id, journal.currency_id, company.currency_id) = COALESCE(aml.currency_id, company.currency_id)
'''
params = [rule.sequence, rule.id, tuple(st_lines.ids)]
# Filter out excluded account.move.line.
if excluded_ids:
query += 'AND aml.id NOT IN %s'
params += [tuple(excluded_ids)]
query, params = rule._apply_conditions(query, params)
queries.append(query)
all_params += params
full_query = self._get_with_tables(st_lines, partner_map=partner_map)
full_query += ' UNION ALL '.join(queries)
# Oldest due dates come first.
full_query += ' ORDER BY aml_date_maturity, aml_id'
return full_query, all_params
@api.multi
def _get_writeoff_suggestion_query(self, st_lines, excluded_ids=None, partner_map=None):
''' Get the query applying all reconciliation rules.
:param st_lines: Account.bank.statement.lines recordset.
:param excluded_ids: Account.move.lines to exclude.
:param partner_map: Dict mapping each line with new partner eventually.
:return: (query, params)
'''
if any(m.rule_type != 'writeoff_suggestion' for m in self):
raise UserError(_('Programmation Error: Can\'t call _get_wo_suggestion_query() for different rules than \'writeoff_suggestion\''))
queries = []
all_params = []
for rule in self:
query = '''
SELECT
%s AS sequence,
%s AS model_id,
st_line.id AS id
FROM account_bank_statement_line st_line
LEFT JOIN account_journal journal ON journal.id = st_line.journal_id
LEFT JOIN jnl_precision ON jnl_precision.journal_id = journal.id
LEFT JOIN res_company company ON company.id = st_line.company_id
LEFT JOIN partners_table line_partner ON line_partner.line_id = st_line.id
WHERE st_line.id IN %s
'''
params = [rule.sequence, rule.id, tuple(st_lines.ids)]
query, params = rule._apply_conditions(query, params)
queries.append(query)
all_params += params
full_query = self._get_with_tables(st_lines, partner_map=partner_map)
full_query += ' UNION ALL '.join(queries)
return full_query, all_params
@api.multi
def _check_rule_propositions(self, statement_line, candidates):
''' Check restrictions that can't be handled for each move.line separately.
/!\ Only used by models having a type equals to 'invoice_matching'.
:param statement_line: An account.bank.statement.line record.
:param candidates: Fetched account.move.lines from query (dict).
:return: True if the reconciliation propositions are accepted. False otherwise.
'''
if not self.match_total_amount:
return True
# Match total residual amount.
total_residual = sum(
aml['aml_currency_id'] and aml['aml_amount_residual_currency'] or aml['aml_amount_residual'] for aml in
candidates)
line_residual = statement_line.currency_id and statement_line.amount_currency or statement_line.amount
line_currency = statement_line.currency_id or statement_line.journal_id.currency_id or statement_line.company_id.currency_id
# Statement line amount must not be lower than the total residual.
if float_compare(line_residual, total_residual, precision_rounding=line_currency.rounding) < 0:
return False
# Statement line amount is equal to the total residual.
if float_is_zero(total_residual - line_residual, precision_rounding=line_currency.rounding):
return True
amount_percentage = (total_residual / line_residual) * 100
return amount_percentage >= self.match_total_amount_param
@api.multi
def _apply_rules(self, st_lines, excluded_ids=None, partner_map=None):
''' Apply criteria to get candidates for all reconciliation models.
:param st_lines: Account.bank.statement.lines recordset.
:param excluded_ids: Account.move.lines to exclude.
:param partner_map: Dict mapping each line with new partner eventually.
:return: A dict mapping each statement line id with:
* aml_ids: A list of account.move.line ids.
* model: An account.reconcile.model record (optional).
* status: 'reconciled' if the lines has been already reconciled, 'write_off' if the write-off must be
applied on the statement line.
'''
available_models = self.filtered(lambda m: m.rule_type != 'writeoff_button')
results = dict((r.id, {'aml_ids': []}) for r in st_lines)
if not available_models:
return results
ordered_models = available_models.sorted(key=lambda m: (m.sequence, m.id))
grouped_candidates = {}
# Type == 'invoice_matching'.
# Map each (st_line.id, model_id) with matching amls.
invoices_models = ordered_models.filtered(lambda m: m.rule_type == 'invoice_matching')
if invoices_models:
query, params = invoices_models._get_invoice_matching_query(st_lines, excluded_ids=excluded_ids, partner_map=partner_map)
self._cr.execute(query, params)
query_res = self._cr.dictfetchall()
for res in query_res:
grouped_candidates.setdefault(res['id'], {})
grouped_candidates[res['id']].setdefault(res['model_id'], [])
grouped_candidates[res['id']][res['model_id']].append(res)
# Type == 'writeoff_suggestion'.
# Map each (st_line.id, model_id) with a flag indicating the st_line matches the criteria.
write_off_models = ordered_models.filtered(lambda m: m.rule_type == 'writeoff_suggestion')
if write_off_models:
query, params = write_off_models._get_writeoff_suggestion_query(st_lines, excluded_ids=excluded_ids, partner_map=partner_map)
self._cr.execute(query, params)
query_res = self._cr.dictfetchall()
for res in query_res:
grouped_candidates.setdefault(res['id'], {})
grouped_candidates[res['id']].setdefault(res['model_id'], True)
# Keep track of already processed amls.
amls_ids_to_exclude = set()
# Keep track of already reconciled amls.
reconciled_amls_ids = set()
# Iterate all and create results.
sorted_st_lines = sorted(st_lines, key=lambda line: (line.statement_id.id, line.date, -line.sequence, line.id), reverse=True)
for line in sorted_st_lines:
line_currency = line.currency_id or line.journal_id.currency_id or line.company_id.currency_id
line_residual = line.currency_id and line.amount_currency or line.amount
# Search for applicable rule.
# /!\ BREAK are very important here to avoid applying multiple rules on the same line.
for model in ordered_models:
# No result found.
if not grouped_candidates.get(line.id) or not grouped_candidates[line.id].get(model.id):
continue
excluded_lines_found = False
if model.rule_type == 'invoice_matching':
candidates = grouped_candidates[line.id][model.id]
# If some invoices match on the communication, suggest them.
# Otherwise, suggest all invoices having the same partner.
# N.B: The only way to match a line without a partner is through the communication.
first_batch_candidates = []
second_batch_candidates = []
for c in candidates:
# Don't take into account already reconciled lines.
if c['aml_id'] in reconciled_amls_ids:
continue
# Dispatch candidates between lines matching invoices with the communication or only the partner.
if c['communication_flag']:
first_batch_candidates.append(c)
elif not first_batch_candidates:
second_batch_candidates.append(c)
available_candidates = first_batch_candidates or second_batch_candidates
# Special case: the amount are the same, submit the line directly.
for c in available_candidates:
residual_amount = c['aml_currency_id'] and c['aml_amount_residual_currency'] or c['aml_amount_residual']
if float_is_zero(residual_amount - line_residual, precision_rounding=line_currency.rounding):
available_candidates = [c]
break
# Needed to handle check on total residual amounts.
if first_batch_candidates or model._check_rule_propositions(line, available_candidates):
results[line.id]['model'] = model
# Add candidates to the result.
for candidate in available_candidates:
# Special case: the propositions match the rule but some of them are already consumed by
# another one. Then, suggest the remaining propositions to the user but don't make any
# automatic reconciliation.
if candidate['aml_id'] in amls_ids_to_exclude:
excluded_lines_found = True
continue
results[line.id]['aml_ids'].append(candidate['aml_id'])
amls_ids_to_exclude.add(candidate['aml_id'])
if excluded_lines_found:
break
# Create write-off lines.
move_lines = self.env['account.move.line'].browse(results[line.id]['aml_ids'])
partner = partner_map and partner_map.get(line.id) and self.env['res.partner'].browse(partner_map[line.id])
reconciliation_results = model._prepare_reconciliation(line, move_lines, partner=partner)
# A write-off must be applied.
if reconciliation_results['new_aml_dicts']:
results[line.id]['status'] = 'write_off'
# Process auto-reconciliation.
if model.auto_reconcile:
# An open balance is needed but no partner has been found.
if reconciliation_results['open_balance_dict'] is False:
break
new_aml_dicts = reconciliation_results['new_aml_dicts']
if reconciliation_results['open_balance_dict']:
new_aml_dicts.append(reconciliation_results['open_balance_dict'])
counterpart_moves = line.process_reconciliation(
counterpart_aml_dicts=reconciliation_results['counterpart_aml_dicts'],
payment_aml_rec=reconciliation_results['payment_aml_rec'],
new_aml_dicts=new_aml_dicts,
)
results[line.id]['status'] = 'reconciled'
results[line.id]['reconciled_lines'] = counterpart_moves.mapped('line_ids')
# The reconciled move lines are no longer candidates for another rule.
reconciled_amls_ids.update(move_lines.ids)
# Break models loop.
break
elif model.rule_type == 'writeoff_suggestion' and grouped_candidates[line.id][model.id]:
results[line.id]['model'] = model
results[line.id]['status'] = 'write_off'
# Create write-off lines.
partner = partner_map and partner_map.get(line.id) and self.env['res.partner'].browse(partner_map[line.id])
reconciliation_results = model._prepare_reconciliation(line, partner=partner)
# An open balance is needed but no partner has been found.
if reconciliation_results['open_balance_dict'] is False:
break
# Process auto-reconciliation.
if model.auto_reconcile:
new_aml_dicts = reconciliation_results['new_aml_dicts']
if reconciliation_results['open_balance_dict']:
new_aml_dicts.append(reconciliation_results['open_balance_dict'])
counterpart_moves = line.process_reconciliation(
counterpart_aml_dicts=reconciliation_results['counterpart_aml_dicts'],
payment_aml_rec=reconciliation_results['payment_aml_rec'],
new_aml_dicts=new_aml_dicts,
)
results[line.id]['status'] = 'reconciled'
results[line.id]['reconciled_lines'] = counterpart_moves.mapped('line_ids')
# Break models loop.
break
return results
+74 -3
View File
@@ -669,6 +669,21 @@ class AccountChartTemplate(models.Model):
'force_second_tax_included': account_reconcile_model.force_second_tax_included,
'second_amount': account_reconcile_model.second_amount,
'second_tax_id': account_reconcile_model.second_tax_id and tax_template_ref[account_reconcile_model.second_tax_id.id] or False,
'rule_type': account_reconcile_model.rule_type,
'auto_reconcile': account_reconcile_model.auto_reconcile,
'match_journal_ids': [(6, None, account_reconcile_model.match_journal_ids.ids)],
'match_nature': account_reconcile_model.match_nature,
'match_amount': account_reconcile_model.match_amount,
'match_amount_min': account_reconcile_model.match_amount_min,
'match_amount_max': account_reconcile_model.match_amount_max,
'match_label': account_reconcile_model.match_label,
'match_label_param': account_reconcile_model.match_label_param,
'match_same_currency': account_reconcile_model.match_same_currency,
'match_total_amount': account_reconcile_model.match_total_amount,
'match_total_amount_param': account_reconcile_model.match_total_amount_param,
'match_partner': account_reconcile_model.match_partner,
'match_partner_ids': [(6, None, account_reconcile_model.match_partner_ids.ids)],
'match_partner_category_ids': [(6, None, account_reconcile_model.match_partner_category_ids.ids)],
}
@api.multi
@@ -907,27 +922,83 @@ class AccountFiscalPositionAccountTemplate(models.Model):
class AccountReconcileModelTemplate(models.Model):
_name = "account.reconcile.model.template"
# Base fields.
chart_template_id = fields.Many2one('account.chart.template', string='Chart Template', required=True)
name = fields.Char(string='Button Label', required=True)
sequence = fields.Integer(required=True, default=10)
has_second_line = fields.Boolean(string='Add a second line', default=False)
rule_type = fields.Selection(selection=[
('writeoff_button', _('Manually create a write-off on clicked button.')),
('writeoff_suggestion', _('Suggest a write-off.')),
('invoice_matching', _('Match existing invoices/bills.'))
], string='Type', default='writeoff_button', required=True)
auto_reconcile = fields.Boolean(string='Auto-validate',
help='Validate the statement line automatically (reconciliation based on your rule).')
# ===== Conditions =====
match_journal_ids = fields.Many2many('account.journal', string='Journals',
domain="[('type', 'in', ('bank', 'cash'))]",
help='The reconciliation model will only be available from the selected journals.')
match_nature = fields.Selection(selection=[
('amount_received', 'Amount Received'),
('amount_paid', 'Amount Paid'),
('both', 'Amount Paid/Received')
], string='Amount Nature', required=True, default='both',
help='''The reconciliation model will only be applied to the selected transaction type:
* Amount Received: Only applied when receiving an amount.
* Amount Paid: Only applied when paying an amount.
* Amount Paid/Received: Applied in both cases.''')
match_amount = fields.Selection(selection=[
('lower', 'Is Lower Than'),
('greater', 'Is Greater Than'),
('between', 'Is Between'),
], string='Amount',
help='The reconciliation model will only be applied when the amount being lower than, greater than or between specified amount(s).')
match_amount_min = fields.Float(string='Amount Min Parameter')
match_amount_max = fields.Float(string='Amount Max Parameter')
match_label = fields.Selection(selection=[
('contains', 'Contains'),
('not_contains', 'Not Contains'),
('match_regex', 'Match Regex'),
], string='Label', help='''The reconciliation model will only be applied when the label:
* Contains: The proposition label must contains this string (case insensitive).
* Not Contains: Negation of "Contains".
* Match Regex: Define your own regular expression.''')
match_label_param = fields.Char(string='Label Parameter')
match_same_currency = fields.Boolean(string='Same Currency Matching', default=True,
help='Restrict to propositions having the same currency as the statement line.')
match_total_amount = fields.Boolean(string='Amount Matching', default=True,
help='The sum of total residual amount propositions matches the statement line amount.')
match_total_amount_param = fields.Float(string='Amount Matching %', default=100,
help='The sum of total residual amount propositions matches the statement line amount under this percentage.')
match_partner = fields.Boolean(string='Partner Is Set',
help='The reconciliation model will only be applied when a customer/vendor is set.')
match_partner_ids = fields.Many2many('res.partner', string='Restrict Partners to',
help='The reconciliation model will only be applied to the selected customers/vendors.')
match_partner_category_ids = fields.Many2many('res.partner.category', string='Restrict Partner Categories to',
help='The reconciliation model will only be applied to the selected customer/vendor categories.')
# First part fields.
account_id = fields.Many2one('account.account.template', string='Account', ondelete='cascade', domain=[('deprecated', '=', False)])
label = fields.Char(string='Journal Item Label')
amount_type = fields.Selection([
('fixed', 'Fixed'),
('percentage', 'Percentage of balance')
], required=True, default='percentage')
amount = fields.Float(digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
amount = fields.Float(string='Write-off Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
force_tax_included = fields.Boolean(string='Tax Included in Price',
help='Force the tax to be managed as a price included tax.')
tax_id = fields.Many2one('account.tax.template', string='Tax', ondelete='restrict', domain=[('type_tax_use', '=', 'purchase')])
# Second part fields.
has_second_line = fields.Boolean(string='Add a second line', default=False)
second_account_id = fields.Many2one('account.account.template', string='Second Account', ondelete='cascade', domain=[('deprecated', '=', False)])
second_label = fields.Char(string='Second Journal Item Label')
second_amount_type = fields.Selection([
('fixed', 'Fixed'),
('percentage', 'Percentage of amount')
], string="Second Amount type",required=True, default='percentage')
second_amount = fields.Float(string='Second Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
second_amount = fields.Float(string='Second Write-off Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.")
force_second_tax_included = fields.Boolean(string='Second Tax Included in Price',
help='Force the second tax to be managed as a price included tax.')
second_tax_id = fields.Many2one('account.tax.template', string='Second Tax', ondelete='restrict', domain=[('type_tax_use', '=', 'purchase')])
+95 -291
View File
@@ -3,241 +3,13 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.tools import pycompat, float_is_zero
from odoo.tools import pycompat
from odoo.tools.misc import formatLang
class AccountReconciliation(models.AbstractModel):
_name = 'account.reconciliation.widget'
####################################################
# Search propositions
####################################################
@api.model
def _get_matching_amls_query(self, st_lines, excluded_ids=None):
''' Base query used by the matching rules.
Main things about this huge query:
- Try to join the res.partner table:
1) On st_line.partner_id.
2) On partner_id found on a res.partner.bank sharing the same account number.
3) On a partner having the same case insensitive name.
- Basic filters on account_move_line.
:param st_lines: Account.bank.statement.lines recordset.
:param excluded_ids: Account.move.lines to exclude.
:return: (query, params)
'''
params = [tuple(st_lines.ids)]
# N.B: The first part of the CASE is about 'blue lines' while the second part is about 'black lines'.
query = '''
SELECT
st_line.id AS id,
aml.id AS aml_id,
aml.currency_id AS aml_currency_id,
aml.amount_residual AS aml_amount_residual,
aml.amount_residual_currency AS aml_amount_residual_currency
FROM account_bank_statement_line st_line
LEFT JOIN account_journal journal ON journal.id = st_line.journal_id
LEFT JOIN res_company company ON company.id = st_line.company_id
LEFT JOIN res_partner_bank bank ON bank.id = st_line.bank_account_id OR bank.acc_number = st_line.account_number
LEFT JOIN res_partner partner ON (
CASE WHEN st_line.partner_id IS NOT NULL THEN
partner.id = st_line.partner_id
WHEN bank.partner_id IS NOT NULL THEN
partner.id = bank.partner_id
ELSE
partner.name ILIKE st_line.partner_name
END
)
, account_move_line aml
LEFT JOIN res_company aml_company ON aml_company.id = aml.company_id
LEFT JOIN account_account aml_account ON aml_account.id = aml.account_id
WHERE st_line.id IN %s
AND aml.company_id = st_line.company_id
AND aml.statement_id IS NULL
AND (
company.account_bank_reconciliation_start IS NULL
OR
aml.date > company.account_bank_reconciliation_start
)
AND CASE WHEN journal.default_credit_account_id IS NOT NULL
AND journal.default_debit_account_id IS NOT NULL
THEN
(
aml.account_id IN (journal.default_credit_account_id, journal.default_debit_account_id)
AND aml.payment_id IS NOT NULL
)
OR
(
aml_account.reconcile IS TRUE
AND aml.reconciled IS FALSE
)
END
'''
if excluded_ids:
query += 'AND aml.id NOT IN %s'
params.append(tuple(excluded_ids))
return query, params
@api.model
def _get_matching_amls_invoice_rule(self, st_lines, excluded_ids=None):
''' RULE 1: Match an account.move.line automatically if linked to an invoice having a number or reference quite
similar.
This rule automatically match when invoice_reference match when:
- matching only one invoice.
- matching multiple invoices but having the same total amount residual.
:param st_lines: Account.bank.statement.lines recordset.
:param excluded_ids: Account.move.lines to exclude.
:return: (query, params, automatic_match_func)
'''
def automatic_match_func(st_line, fetched_amls):
# Match only one invoice.
if len(fetched_amls) == 1:
return True
# Match multiple invoices but having the same total amount residual.
total_residual = sum(aml['aml_currency_id'] and aml['aml_amount_residual_currency'] or aml['aml_amount_residual'] for aml in fetched_amls)
line_residual = st_line.currency_id and st_line.amount_currency or st_line.amount
line_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.company_id.currency_id
return float_is_zero(total_residual - line_residual, precision_rounding=line_currency.rounding)
query, params = self._get_matching_amls_query(st_lines, excluded_ids=excluded_ids)
# Join the account_invoice table.
query = query.replace(
'account_move_line aml',
'''
account_move_line aml
LEFT JOIN account_move move ON move.id = aml.move_id
LEFT JOIN account_invoice invoice ON invoice.move_name = move.name
'''
)
# Add where clause.
# N.B: invoice_reference could be a list of invoice reference/number (e.g. 'INV/2018/0001,INV/2018/0002').
query += '''
AND invoice.state = 'open'
AND CASE WHEN st_line.amount >= 0.0 THEN
invoice.type IN ('out_invoice', 'in_refund')
ELSE
invoice.type IN ('in_invoice', 'out_refund')
END
AND (
REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(invoice.number, '[^0-9]', '', 'g')
OR (
invoice.reference IS NOT NULL
AND
REGEXP_REPLACE(st_line.name, '[^0-9]', '', 'g') ~ REGEXP_REPLACE(invoice.reference, '[^0-9]', '', 'g')
)
)
AND CASE WHEN partner.id IS NOT NULL THEN
invoice.partner_id = partner.id
ELSE
TRUE
END
'''
return query, params, automatic_match_func
@api.model
def _get_matching_amls_amount_rule(self, st_lines, excluded_ids=None):
''' RULE 2: Match one or more account.move.lines automatically if either the statement line has exactly the same amount
or either the statement line matchs a single line having a greater or equals amount.
This only work if a partner has been found on the statement line.
:param st_lines: Account.bank.statement.lines recordset.
:param excluded_ids: Account.move.lines to exclude.
:return: (query, params, automatic_match_func)
'''
def automatic_match_func(st_line, fetched_amls):
total_residual = sum(aml['aml_currency_id'] and aml['aml_amount_residual_currency'] or aml['aml_amount_residual'] for aml in fetched_amls)
line_residual = st_line.currency_id and st_line.amount_currency or st_line.amount
line_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.company_id.currency_id
# Match the total residual amount.
if float_is_zero(total_residual - line_residual, precision_rounding=line_currency.rounding):
return True
# Match only one line having a greater residual amount.
return len(fetched_amls) == 1 and line_residual < total_residual
query, params = self._get_matching_amls_query(st_lines, excluded_ids=excluded_ids)
# Add where clause.
# N.B: move.line currency_id is either set or either got from the company.
# N.B2: statement.line currency_id is either set, either got from the journal or either got from the company.
query += '''
AND partner.id IS NOT NULL
AND aml.partner_id = partner.id
AND (
CASE WHEN st_line.currency_id IS NOT NULL AND st_line.currency_id != aml_company.currency_id THEN
aml.currency_id = st_line.currency_id
WHEN journal.currency_id IS NOT NULL AND journal.currency_id != aml_company.currency_id THEN
aml.currency_id = journal.currency_id
ELSE
aml.currency_id IS NULL
END
)
'''
return query, params, automatic_match_func
@api.model
def _get_matching_amls(self, st_lines, excluded_ids=None):
''' Apply reconciliation matching rules in order to find matching account.move.lines.
:param st_lines: Account.bank.statement.lines recordset.
:param excluded_ids: Account.move.lines ids to exclude.
:return: A dictionnary mapping each id with:
* line: The account.bank.statement.line record.
* aml_ids: The matching account.move.line ids.
'''
results = dict((r.id, {'line': r, 'aml_ids': []}) for r in st_lines)
excluded_ids = excluded_ids or []
rules = [
self._get_matching_amls_invoice_rule,
self._get_matching_amls_amount_rule,
]
for rule in rules:
query, params, automatic_match_func = rule(st_lines, excluded_ids=excluded_ids)
self._cr.execute(query, params)
query_res = self._cr.dictfetchall()
# Map statement line with candidates.
candidates_map = {}
for res in query_res:
candidates_map.setdefault(res['id'], [])
candidates_map[res['id']].append(res)
for line_id, fetched_amls in candidates_map.items():
st_line = results[line_id]['line']
candidate_amls = []
candidate_amls_ids = []
for aml in fetched_amls:
if aml['aml_id'] not in excluded_ids:
candidate_amls.append(aml)
candidate_amls_ids.append(aml['aml_id'])
if automatic_match_func(results[line_id]['line'], candidate_amls):
results[line_id]['aml_ids'] = candidate_amls_ids
# Mark statement line as already processed.
st_lines -= st_line
# Exclude move lines.
excluded_ids += candidate_amls_ids
return results
####################################################
# Public
####################################################
@@ -304,6 +76,33 @@ class AccountReconciliation(models.AbstractModel):
target_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.journal_id.company_id.currency_id
return self._prepare_move_lines(aml_recs, target_currency=target_currency, target_date=st_line.date, recs_count=recs_count)
@api.model
def _get_bank_statement_line_partners(self, st_lines):
query = '''
SELECT
st_line.id AS id,
partner.id AS partner_id
FROM account_bank_statement_line st_line
LEFT JOIN res_partner_bank bank ON bank.id = st_line.bank_account_id OR bank.acc_number = st_line.account_number
LEFT JOIN res_partner partner ON (
CASE WHEN st_line.partner_id IS NOT NULL THEN
partner.id = st_line.partner_id
WHEN bank.partner_id IS NOT NULL THEN
partner.id = bank.partner_id
ELSE
partner.name ILIKE st_line.partner_name
END
)
WHERE st_line.id IN %s
'''
params = [tuple(st_lines.ids)]
self._cr.execute(query, params)
result = {}
for res in self._cr.dictfetchall():
result[res['id']] = res['partner_id']
return result
@api.model
def get_bank_statement_line_data(self, st_line_ids, excluded_ids=None):
""" Returns the data required to display a reconciliation widget, for
@@ -314,19 +113,43 @@ class AccountReconciliation(models.AbstractModel):
result
"""
excluded_ids = excluded_ids or []
ret = []
st_lines = self.env['account.bank.statement.line'].browse(st_line_ids)
matching_amls = self._get_matching_amls(st_lines, excluded_ids=excluded_ids)
bank_statement_lines = self.env['account.bank.statement.line'].browse(st_line_ids)
sorted_st_lines = sorted(bank_statement_lines, key=lambda line: (line.statement_id.id, line.date, -line.sequence, line.id), reverse=True)
reconcile_model = self.env['account.reconcile.model'].search([('rule_type', '!=', 'writeoff_button')])
# Search for missing partners when opening the reconciliation widget.
partner_map = self._get_bank_statement_line_partners(bank_statement_lines)
matching_amls = reconcile_model._apply_rules(bank_statement_lines, excluded_ids=excluded_ids, partner_map=partner_map)
results = {
'lines': [],
'value_min': 0,
'value_max': len(bank_statement_lines),
'reconciled_aml_ids': [],
}
# Iterate on st_lines to keep the same order in the results list.
for line in st_lines:
aml_ids = matching_amls[line.id]['aml_ids']
amls = aml_ids and self.env['account.move.line'].browse(aml_ids)
ret.append({
'st_line': self._get_statement_line(line),
'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls) or [],
})
return ret
bank_statements_left = self.env['account.bank.statement']
for line in sorted_st_lines:
if matching_amls[line.id].get('status') == 'reconciled':
reconciled_move_lines = matching_amls[line.id].get('reconciled_lines')
results['value_min'] += 1
results['reconciled_aml_ids'] += reconciled_move_lines and reconciled_move_lines.ids or []
else:
aml_ids = matching_amls[line.id]['aml_ids']
bank_statements_left += line.statement_id
amls = aml_ids and self.env['account.move.line'].browse(aml_ids)
results['lines'].append({
'st_line': self._get_statement_line(line),
'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls) or [],
'model_id': matching_amls[line.id].get('model') and matching_amls[line.id]['model'].id,
'write_off': matching_amls[line.id].get('status') == 'write_off',
})
return results
@api.model
def get_bank_statement_data(self, bank_statement_ids):
@@ -339,62 +162,43 @@ class AccountReconciliation(models.AbstractModel):
:param st_line_id: ids of the bank statement
"""
bank_statements = self.env['account.bank.statement'].browse(bank_statement_ids)
Bank_statement_line = self.env['account.bank.statement.line']
# NB : The field account_id can be used at the statement line creation/import to avoid the reconciliation process on it later on,
# this is why we filter out statements lines where account_id is set
query = '''
SELECT line.id
FROM account_bank_statement_line line
WHERE account_id IS NULL
AND line.amount != 0.0
AND line.statement_id IN %s
AND NOT EXISTS (SELECT 1 from account_move_line aml WHERE aml.statement_line_id = line.id)
'''
self.env.cr.execute(query, [tuple(bank_statements.ids)])
sql_query = """SELECT stl.id
FROM account_bank_statement_line stl
WHERE account_id IS NULL AND stl.amount != 0.0 AND not exists (select 1 from account_move_line aml where aml.statement_line_id = stl.id)
"""
params = []
if bank_statements:
sql_query += ' AND stl.statement_id IN %s'
params += (tuple(bank_statements.ids),)
else:
sql_query += ' AND stl.company_id = %s'
params += [self.env.user.company_id.id]
sql_query += ' ORDER BY stl.id'
self.env.cr.execute(sql_query, params)
st_lines_left = Bank_statement_line.browse([line.get('id') for line in self.env.cr.dictfetchall()])
bank_statement_lines = self.env['account.bank.statement.line'].browse([line.get('id') for line in self.env.cr.dictfetchall()])
#try to assign partner to bank_statement_line
stl_to_assign = st_lines_left.filtered(lambda stl: not stl.partner_id)
refs = set(stl_to_assign.mapped('name'))
if stl_to_assign and refs\
and st_lines_left[0].journal_id.default_credit_account_id\
and st_lines_left[0].journal_id.default_debit_account_id:
results = self.get_bank_statement_line_data(bank_statement_lines.ids)
bank_statement_lines_left = self.env['account.bank.statement.line'].browse([line['st_line']['id'] for line in results['lines']])
bank_statements_left = bank_statement_lines_left.mapped('statement_id')
sql_query = """SELECT aml.partner_id, aml.ref, stl.id
FROM account_move_line aml
JOIN account_account acc ON acc.id = aml.account_id
JOIN account_bank_statement_line stl ON aml.ref = stl.name
WHERE (aml.company_id = stl.company_id
AND aml.partner_id IS NOT NULL)
AND (
(aml.statement_id IS NULL AND aml.account_id IN %s)
OR
(acc.internal_type IN ('payable', 'receivable') AND aml.reconciled = false)
)
AND aml.ref IN %s
"""
params = ((st_lines_left[0].journal_id.default_credit_account_id.id, st_lines_left[0].journal_id.default_debit_account_id.id), tuple(refs))
if bank_statements:
sql_query += 'AND stl.id IN %s'
params += (tuple(stl_to_assign.ids),)
self.env.cr.execute(sql_query, params)
results = self.env.cr.dictfetchall()
for line in results:
Bank_statement_line.browse(line.get('id')).write({'partner_id': line.get('partner_id')})
results.update({
'statement_name': len(bank_statements_left) == 1 and bank_statements_left.name or False,
'journal_id': bank_statements_left and bank_statements_left[0].journal_id.id or False,
'notifications': []
})
return {
'st_lines_ids': st_lines_left.ids,
'notifications': [],
'statement_name': len(bank_statements) == 1 and bank_statements[0].name or False,
'journal_id': bank_statements and bank_statements[0].journal_id.id or False,
'num_already_reconciled_lines': 0,
}
if len(results['lines']) < len(bank_statement_lines):
results['notifications'].append({
'type': 'info',
'template': 'reconciliation.notification.reconciled',
'reconciled_aml_ids': results['reconciled_aml_ids'],
'nb_reconciled_lines': results['value_min'],
'details': {
'name': _('Journal Items'),
'model': 'account.move.line',
'ids': results['reconciled_aml_ids'],
}
})
return results
@api.model
def get_move_lines_for_manual_reconciliation(self, account_id, partner_id=False, excluded_ids=None, search_str=False, offset=0, limit=None, target_currency_id=False):
@@ -7,6 +7,7 @@ var ReconciliationRenderer = require('account.ReconciliationRenderer');
var ControlPanelMixin = require('web.ControlPanelMixin');
var Widget = require('web.Widget');
var core = require('web.core');
var _t = core._t;
/**
@@ -109,7 +110,18 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, {
this.renderer.prependTo(self.$('.o_form_sheet'));
this._renderLines();
this._openFirstLine();
// No more lines to reconcile, trigger the rainbowman.
var initialState = this.renderer._initialState;
if(initialState.valuenow === initialState.valuemax){
initialState.context = this.model.getContext();
this.renderer.showRainbowMan(initialState);
}else{
// Create a notification if some lines has been reconciled automatically.
if(initialState.valuenow > 0)
this.renderer._renderNotifications(this.model.statement.notifications);
this._openFirstLine();
}
},
/**
@@ -358,13 +358,13 @@ var StatementModel = BasicModel.extend({
.then(function (statement) {
self.statement = statement;
self.bank_statement_id = statement_ids.length === 1 ? {id: statement_ids[0], display_name: statement.statement_name} : false;
self.valuenow = 0;
self.valuemax = statement.st_lines_ids.length;
self.valuenow = statement.value_min;
self.valuemax = statement.value_max;
self.context.journal_id = statement.journal_id;
_.each(statement.st_lines_ids, function (id) {
_.each(statement.lines, function (res) {
var handle = _.uniqueId('rline');
self.lines[handle] = {
id: id,
id: res.st_line.id,
handle: handle,
reconciled: false,
mode: 'inactive',
@@ -412,7 +412,7 @@ var StatementModel = BasicModel.extend({
var ids = _.pluck(self.lines, 'id');
ids = ids.splice(0, self.defaultDisplayQty);
self.pagerIndex = ids.length;
return self.loadData(ids, []);
return self._formatLine(self.statement.lines);
});
},
/**
@@ -445,7 +445,9 @@ var StatementModel = BasicModel.extend({
args: [ids, excluded_ids],
context: self.context,
})
.then(self._formatLine.bind(self));
.then(function(res){
return self._formatLine(res['lines']);
})
},
/**
* Add lines into the propositions from the reconcile model
@@ -545,24 +547,14 @@ var StatementModel = BasicModel.extend({
var line = this.getLine(handle);
// Retrieve the toggle proposition
var selected;
var targetLineAmount = line.st_line.amount;
line.reconciliation_proposition.every(function (prop) {
if (!prop.invalid) {
if (((line.balance.amount < 0 || !line.partial_reconcile) && prop.amount > 0 && targetLineAmount > 0 && targetLineAmount < prop.amount) ||
((line.balance.amount > 0 || !line.partial_reconcile) && prop.amount < 0 && targetLineAmount < 0 && targetLineAmount > prop.amount)) {
selected = prop;
return false;
}
targetLineAmount -= prop.amount;
}
return true;
});
var selected = _.filter(line.reconciliation_proposition, function(prop){return prop.display_triangle});
// If no toggled proposition found, reject it
if (selected == null)
if(selected.length != 1)
return $.Deferred().reject();
selected = selected[0];
// Inverse partial_reconcile value
selected.partial_reconcile = !selected.partial_reconcile;
if (!selected.partial_reconcile) {
@@ -985,41 +977,32 @@ var StatementModel = BasicModel.extend({
self._formatLineProposition(line, line.reconciliation_proposition);
if (!line.reconciliation_proposition.length) {
delete line.reconciliation_proposition;
}else{
// loop state propositions
var debit_props = _.filter(line.reconciliation_proposition, function(p){
return p.amount > 0 && line.st_line.amount;
});
var credit_props = _.filter(line.reconciliation_proposition, function(p){
return p.amount < 0 && line.st_line.amount < 0;
});
var sum_debit_props = debit_props.length > 0 ? debit_props.map(function(p){return p.amount}).reduce(function(p1, p2){return p1 + p2}) : 0;
var sum_credit_props = credit_props.length > 0 ? credit_props.map(function(p){return p.amount}).reduce(function(p1, p2){return p1 + p2}) : 0;
// Want to display the triangle for partial reconciliation or not.
if(line.st_line.amount_currency > 0 && line.st_line.amount_currency < sum_debit_props && debit_props.length == 1)
debit_props[0].display_triangle = true;
else if(line.st_line.amount_currency < 0 && line.st_line.amount_currency > sum_credit_props && credit_props.length == 1)
credit_props[0].display_triangle = true;
}
// No partner set on st_line and all matching amls have the same one: set it on the st_line.
if(!line.st_line.partner_id && line.reconciliation_proposition){
var hasDifferentPartners = function(prop){
return !prop.partner_id || prop.partner_id != line.reconciliation_proposition[0].partner_id;
}
defs.push(
self._computeLine(line)
.then(function(){
if(!line.st_line.partner_id && line.reconciliation_proposition.length > 0){
var hasDifferentPartners = function(prop){
return !prop.partner_id || prop.partner_id != line.reconciliation_proposition[0].partner_id;
}
if(!_.any(line.reconciliation_proposition, hasDifferentPartners)){
defs.push(self.changePartner(line.handle, {
'id': line.reconciliation_proposition[0].partner_id,
'display_name': line.reconciliation_proposition[0].partner_name,
}, true));
}
}
defs.push(self._computeLine(line));
if(!_.any(line.reconciliation_proposition, hasDifferentPartners)){
return self.changePartner(line.handle, {
'id': line.reconciliation_proposition[0].partner_id,
'display_name': line.reconciliation_proposition[0].partner_name,
}, true);
}
}
return true;
})
.then(function(){
return data.status === 'write_off'? self.quickCreateProposition(line.handle, data.model_id) : true;
})
);
});
return $.when.apply($, defs);
return $.when(defs);
},
/**
* Format the server value then compute the line
@@ -84,6 +84,29 @@ var StatementRenderer = Widget.extend(FieldManagerMixin, {
hideLoadMoreButton: function () {
this.$('.js_load_more').hide();
},
showRainbowMan(state){
var dt = Date.now()-this.time;
var $done = $(qweb.render("reconciliation.done", {
'duration': moment(dt).utc().format(time.getLangTimeFormat()),
'number': state.valuenow,
'timePerTransaction': Math.round(dt/1000/state.valuemax),
'context': state.context,
}));
$done.find('.button_close_statement').click(this._onCloseBankStatement.bind(this));
$done.find('.button_back_to_statement').click(this._onGoToBankStatement.bind(this));
this.$el.children().hide();
// display rainbowman after full reconciliation
if (session.show_effect) {
this.trigger_up('show_effect', {
type: 'rainbow_man',
fadeout: 'no',
message: $done,
});
this.$el.css('min-height', '450px');
} else {
$done.appendTo(this.$el);
}
},
/**
* update the statement rendering
*
@@ -103,27 +126,7 @@ var StatementRenderer = Widget.extend(FieldManagerMixin, {
.css('width', (state.valuenow/state.valuemax*100) + '%');
if (state.valuenow === state.valuemax && !this.$('.done_message').length) {
var dt = Date.now()-this.time;
var $done = $(qweb.render("reconciliation.done", {
'duration': moment(dt).utc().format(time.getLangTimeFormat()),
'number': state.valuenow,
'timePerTransaction': Math.round(dt/1000/state.valuemax),
'context': state.context,
}));
$done.find('.button_close_statement').click(this._onCloseBankStatement.bind(this));
$done.find('.button_back_to_statement').click(this._onGoToBankStatement.bind(this));
this.$el.children().hide();
// display rainbowman after full reconciliation
if (session.show_effect) {
this.trigger_up('show_effect', {
type: 'rainbow_man',
fadeout: 'no',
message: $done,
});
this.$el.css('min-height', '450px');
} else {
$done.appendTo(this.$el);
}
this.showRainbowMan(state);
}
if (state.notifications) {
@@ -145,11 +148,7 @@ var StatementRenderer = Widget.extend(FieldManagerMixin, {
_renderNotifications: function(notifications) {
this.$(".notification_area").empty();
for (var i=0; i<notifications.length; i++) {
var $notification = $(qweb.render("reconciliation.notification", {
type: notifications[i].type,
message: notifications[i].message,
details: notifications[i].details,
})).hide();
var $notification = $(qweb.render("reconciliation.notification", notifications[i])).hide();
$notification.appendTo(this.$(".notification_area")).slideDown(300);
}
},
@@ -363,38 +362,48 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
// reconciliation_proposition
var $props = this.$('.accounting_view tbody').empty();
// loop state propositions
// Search propositions that could be a partial credit/debit.
var props = [];
var partialDebitProps = 0;
var partialCreditProps = 0;
var partialDebitProp;
var partialCreditProp;
var balance = state.balance.amount_currency;
_.each(state.reconciliation_proposition, function (prop) {
if (prop.display) {
props.push(prop);
if (prop.amount > 0 && prop.amount > state.st_line.amount) {
partialDebitProps++;
} else if (prop.amount < 0 && prop.amount < state.st_line.amount) {
partialCreditProps++;
}
/*
Examples:
statement line | 100 | |
move line 1 | | 200 | <- can be a partial of 100
balance: -100
statement line | 500 | |
move line 1 | | 300 | <- is not a eligible to be a partial due to the second line.
move line 2 | | 300 | <- can be a partial of 200
balance: -100
statement line | 500 | |
move line 1 | | 700 | <- must not be a partial (debit = 800 > 700 = credit).
move line 2 | 300 | |
balance: 100
*/
if(!prop.display_new && balance < 0 && prop.amount > 0 && balance + prop.amount > 0)
partialDebitProp = prop;
else if(!prop.display_new && balance > 0 && prop.amount < 0 && balance + prop.amount < 0)
partialCreditProp = prop;
}
});
var targetLineAmount = state.st_line.amount;
_.each(props, function (line) {
var display_triangle = (line.already_paid === false &&
((state.balance.amount_currency < 0 || line.partial_reconcile)
&& line.amount > 0 && state.st_line.amount > 0 && targetLineAmount < line.amount && partialDebitProps <= 1) ||
((state.balance.amount_currency > 0 || line.partial_reconcile)
&& line.amount < 0 && state.st_line.amount < 0 && targetLineAmount > line.amount && partialCreditProps <= 1));
var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state, 'display_triangle': display_triangle}));
line.display_triangle = (line.already_paid === false &&
((state.balance.amount_currency < 0 || line.partial_reconcile) && partialDebitProp && partialDebitProp === line) ||
((state.balance.amount_currency > 0 || line.partial_reconcile) && partialCreditProp && partialCreditProp === line));
var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state}));
if (!isNaN(line.id)) {
$('<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}));
}
targetLineAmount -= line.amount;
$props.append($line);
});
@@ -9,24 +9,15 @@ Tour.register('bank_statement_reconciliation', {
test: true,
// Go to the reconciliation page of the statement: "BNK/2014/001"
}, [
// Reconciliation of 'SAJ/2018/002'
// Select the 'INV/2018/0002' line and click on reconcile.
// Reconciliation of 'INV/2018/0002'
// Click on reconcile (matching done automatically by the reconciliation rule).
{
content: "open the last line in match mode to test the reconcile button",
trigger: '.toggle_match:last',
extra_trigger: '.o_reconciliation',
},
{
content: "select the 'INV/2018/0002' line",
trigger: '.o_reconciliation_line:last .match .cell_label:contains("INV/2018/0002")'
},
{
content: "reconcile the line",
trigger: '.o_reconciliation_line:last .o_reconcile:visible',
trigger: '.o_reconciliation_line:nth-child(1) .o_reconcile:visible',
},
// Reconciliation of 'First 2000 € of SAJ/2014/0001'
// Reconciliation of 'First 2000 $ of INV/2018/0001'
// Make a partial reconciliation
{
@@ -34,10 +25,6 @@ Tour.register('bank_statement_reconciliation', {
extra_trigger: '.o_reconciliation_line:first[data-mode="match"]',
trigger: '.o_reconciliation_line:last .cell_label:contains("First")'
},
{
content: "select a line with with a higher amount",
trigger: '.o_reconciliation_line:last .match .cell_label:contains("INV/2018/0001")'
},
{
content: "click on partial reconcile",
trigger: '.o_reconciliation_line:last .accounting_view .do_partial_reconcile_true'
@@ -67,7 +54,6 @@ Tour.register('bank_statement_reconciliation', {
},
{
content: "select a line linked to Deco Addict ",
extra_trigger: '.o_reconciliation_line:nth-child(2) .match:not(:has(tr:eq(1)))',
trigger: ".o_reconciliation_line:nth-child(2) .match .line_info_button[data-content*='Deco Addict']"
},
{
@@ -32,7 +32,6 @@
<div t-if="widget._initialState.valuemax &gt; widget._initialState.defaultDisplayQty">
<button class="btn btn-secondary js_load_more">Load more</button>
</div>
<div class="float-right text-muted">Tip: Hit CTRL-Enter to reconcile all the balanced items in the sheet.</div>
</div>
<div t-else="" class="o_view_noreconciliation">
<p>Nothing to do!</p>
@@ -157,7 +156,11 @@
<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-att-data-reconcile-model-id="reconcileModel.id"><t t-esc="reconcileModel.name"/></button>
<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))"
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>
@@ -214,7 +217,7 @@
<t t-name="reconciliation.line.mv_line.triangle">
<t t-if="display_triangle || line.display_triangle">
<t t-if="line.display_triangle">
<t t-if="line.partial_reconcile">
<span t-att-class="'do_partial_reconcile_'+(!line.partial_reconcile)+' line_info_button fa fa-exclamation-triangle'"
data-content="Undo the partial reconciliation."/>
@@ -231,7 +234,12 @@
<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_action"><span class="fa fa-add-remove" role="img" aria-label="Set" title="Set"/></td>
<td class="cell_account_code"><t t-esc="line.account_code"/></td>
<td class="cell_due_date"><t t-esc="line.date_maturity === false ? line.date : line.date_maturity"/></td>
<td class="cell_due_date">
<t t-if="typeof(line.id) != 'number'">
<span class="badge badge-secondary">New</span>
</t>
<t t-else="" t-esc="line.date_maturity === false ? line.date : line.date_maturity"/>
</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">
@@ -297,16 +305,41 @@
</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-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-if="template">
<t t-call="{{template}}"/>
</t>
<t t-else="">
<t t-call="reconciliation.notification.default"/>
</t>
</div>
</t>
@@ -159,6 +159,7 @@ var db = {
fields: {
id: {string: "ID", type: 'integer'},
name: {string: "Button Label", type: 'char'},
rule_type: {string: "Type", type: 'selection', selection: [['writeoff_button', 'Create a Button'], ['writeoff_suggestion', 'Write off Suggestion'], ['invoice_matching', 'Invoice matching']], default:'writeoff_button'},
has_second_line: {string: "Add a second line", type: 'boolean'},
account_id: {string: "Account", type: 'many2one', relation:'account.account'},
journal_id: {string: "Journal", type: 'many2one', relation:'account.journal'},
@@ -174,14 +175,15 @@ var db = {
second_amount: {string: "Second Amount", type: 'float', digits:0, help:"Fixed amount will count as a debit if it is negative, as a credit if it is positive.", default:100.0},
second_tax_id: {string: "Second Tax", type: 'many2one', relation:'account.tax', domain:[('type_tax_use', '=', 'purchase')]},
second_analytic_account_id: {string: "Second Analytic Account", type: 'many2one', relation:'account.analytic.account'},
match_journal_ids: {string: "Journal Ids", type: 'many2many', relation: 'account.journal'}
},
records: [
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 4, 'analytic_account_id': false, 'display_name': "Int\u00e9rrets", 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 282, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "fixed", 'name': "Int\u00e9rrets", 'amount': 0.0, 'second_amount': 100.0},
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 2, 'analytic_account_id': false, 'display_name': "Perte et Profit", 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 283, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Perte et Profit", 'amount': 100.0, 'second_amount': 100.0},
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 5, 'analytic_account_id': false, 'display_name': "Fs bank", 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 284, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Fs bank", 'amount': 100.0, 'second_amount': 100.0},
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 8, 'analytic_account_id': false, 'display_name': "Caisse Sand.", 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': "Caisse Sand.", 'second_label': false, 'second_account_id': false, 'account_id': 308, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Caisse Sand.", 'amount': 100.0, 'second_amount': 100.0},
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 3, 'analytic_account_id': false, 'display_name': "ATOS", 'second_tax_id': 7, 'has_second_line': true, 'journal_id': false, 'label': "ATOS Banque", 'second_label': "ATOS Frais", 'second_account_id': 286, 'account_id': 285, 'company_id': [1, "Demo SPRL"], 'tax_id': 6, 'amount_type': "percentage", 'name': "ATOS", 'amount': 97.5, 'second_amount': 2.5},
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 10, 'analytic_account_id': false, 'display_name': "Double", 'second_tax_id': false, 'has_second_line': true, 'journal_id': false, 'label': "Double Banque", 'second_label': "Double Frais", 'second_account_id': 286, 'account_id': 285, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Double", 'amount': 97.5, 'second_amount': 2.5},
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 4, 'analytic_account_id': false, 'display_name': "Int\u00e9rrets", 'rule_type': 'writeoff_button', 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 282, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "fixed", 'name': "Int\u00e9rrets", 'amount': 0.0, 'second_amount': 100.0, 'match_journal_ids': []},
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 2, 'analytic_account_id': false, 'display_name': "Perte et Profit", 'rule_type': 'writeoff_button', 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 283, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Perte et Profit", 'amount': 100.0, 'second_amount': 100.0, 'match_journal_ids': []},
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 5, 'analytic_account_id': false, 'display_name': "Fs bank", 'rule_type': 'writeoff_button', 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': false, 'second_label': false, 'second_account_id': false, 'account_id': 284, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Fs bank", 'amount': 100.0, 'second_amount': 100.0},
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 8, 'analytic_account_id': false, 'display_name': "Caisse Sand.", 'rule_type': 'writeoff_button', 'second_tax_id': false, 'has_second_line': false, 'journal_id': false, 'label': "Caisse Sand.", 'second_label': false, 'second_account_id': false, 'account_id': 308, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Caisse Sand.", 'amount': 100.0, 'second_amount': 100.0, 'match_journal_ids': []},
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 3, 'analytic_account_id': false, 'display_name': "ATOS", 'rule_type': 'writeoff_button', 'second_tax_id': 7, 'has_second_line': true, 'journal_id': false, 'label': "ATOS Banque", 'second_label': "ATOS Frais", 'second_account_id': 286, 'account_id': 285, 'company_id': [1, "Demo SPRL"], 'tax_id': 6, 'amount_type': "percentage", 'name': "ATOS", 'amount': 97.5, 'second_amount': 2.5},
{'second_analytic_account_id': false, 'second_amount_type': "percentage", 'second_journal_id': false, 'id': 10, 'analytic_account_id': false, 'display_name': "Double", 'rule_type': 'writeoff_button', 'second_tax_id': false, 'has_second_line': true, 'journal_id': false, 'label': "Double Banque", 'second_label': "Double Frais", 'second_account_id': 286, 'account_id': 285, 'company_id': [1, "Demo SPRL"], 'tax_id': false, 'amount_type': "percentage", 'name': "Double", 'amount': 97.5, 'second_amount': 2.5, 'match_journal_ids': []},
]
},
'account.reconciliation.widget': {
@@ -222,10 +224,17 @@ var db = {
},
get_bank_statement_line_data: function (args) {
var ids = args[0];
return $.when(_.filter(Datas.used.data_widget, function (w) {return _.contains(ids, w.st_line.id);}));
var results = {
value_min: 0,
value_max: ids.length,
lines: _.filter(Datas.used.data_widget, function (w) {return _.contains(ids, w.st_line.id);})
};
return $.when(results);
},
get_bank_statement_data: function () {
return $.when(Datas.used.data_preprocess);
var results = Datas.used.data_preprocess;
results.lines = _.filter(Datas.used.data_widget, function (w) {return _.contains(results.st_lines_ids, w.st_line.id);});
return $.when(results);
},
get_move_lines_for_manual_reconciliation: function (args) {
var excluded_ids = args.splice(2, 1)[0];
@@ -267,6 +276,8 @@ var db = {
};
var data_preprocess = {
value_min: 0,
value_max: 4,
notifications: [],
num_already_reconciled_lines: 0,
st_lines_ids: [5, 6, 7, 8],
@@ -865,7 +876,7 @@ QUnit.module('account', {
},
});
clientAction.appendTo($('body'));
clientAction.appendTo($('#qunit-fixture'));
var widget = clientAction.widgets[0];
@@ -1048,7 +1059,7 @@ QUnit.module('account', {
assert.notOk(widget.$('.accounting_view tfoot .cell_label').text(), "should not display 'Open Balance' line because the rest to reconcile is null");
assert.strictEqual(widget.$('.accounting_view tbody tr').length, 1, "should have only the created reconcile line");
assert.strictEqual(widget.$('.accounting_view tbody tr').text().replace(/[\n\r\s$,]+/g, ' '), " 101200 SAJ/2014/002 and SAJ/2014/003 1175.00 ",
assert.strictEqual(widget.$('.accounting_view tbody tr').text().replace(/[\n\r\s$,]+/g, ' '), " 101200 New SAJ/2014/002 and SAJ/2014/003 1175.00 ",
"the new line should have the selected account, name and amout");
assert.ok(widget.$('caption button.btn-primary:visible').length, "should display the 'Reconcile' button");
@@ -1071,7 +1082,7 @@ QUnit.module('account', {
assert.strictEqual(widget.$('.accounting_view tbody .cell_right').text().trim().replace(/[\n\r\s\u00a0]+/g, ' '), "$ 1100.00", "should display the value 1100.00 in right column");
assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "$ 75.00", "should display 'Open Balance' line because the rest to reconcile is 75.00");
assert.strictEqual(widget.$('.accounting_view tbody tr').length, 1, "should have ever only the created reconcile line");
assert.strictEqual(widget.$('.accounting_view tbody tr').text().replace(/[\n\r\s$,]+/g, ' '), " 101200 SAJ/2014/002 and SAJ/2014/003 1100.00 ",
assert.strictEqual(widget.$('.accounting_view tbody tr').text().replace(/[\n\r\s$,]+/g, ' '), " 101200 New SAJ/2014/002 and SAJ/2014/003 1100.00 ",
"the new line should be update the amout");
assert.ok(widget.$('caption button.btn-secondary:visible').length, "should display the 'validate' button");
@@ -1085,7 +1096,7 @@ QUnit.module('account', {
assert.strictEqual(widget.$('.accounting_view tfoot .cell_label').text(), "Open balance", "should display 'Open Balance'");
assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "$ 175.00", "should display 'Open Balance' line because the rest to reconcile is 175.00");
assert.strictEqual(widget.$('.accounting_view tbody tr').length, 2, "should have 2 created reconcile lines");
assert.strictEqual(widget.$('.accounting_view tbody tr:eq(1)').text().replace(/[\n\r\s$,]+/g, ' '), " 101000 test0 100.00 ",
assert.strictEqual(widget.$('.accounting_view tbody tr:eq(1)').text().replace(/[\n\r\s$,]+/g, ' '), " 101000 New test0 100.00 ",
"the new line should have the selected account, name and amout");
widget.$('.accounting_view tfoot td:first').trigger('click');
@@ -1236,7 +1247,7 @@ QUnit.module('account', {
widget.$('.create .create_amount input').val('100').trigger('input');
assert.strictEqual(widget.$('.accounting_view tbody').text().replace(/[\n\r\s$,]+/g, ' '),
" 101120 ATOS Banque 1075.00 101120 Tax 20.00% 215.00 101130 ATOS Frais 90.91 101300 Tax 10.00% include 9.09 ",
" 101120 New ATOS Banque 1075.00 101120 New Tax 20.00% 215.00 101130 New ATOS Frais 90.91 101300 New Tax 10.00% include 9.09 ",
"should update the value of the 4 lines (because the line must have 100% of the value)");
assert.strictEqual(widget.$('.accounting_view tfoot .cell_label, .accounting_view tfoot .cell_left').text().replace(/[\n\r\s$,]+/g, ' '),
"Open balance215.00", "should change the 'Open balance' line because the 20.00% tax is not an include tax");
@@ -1249,7 +1260,7 @@ QUnit.module('account', {
widget.$('.create .quick_add button:contains(Double)').trigger('click');
assert.strictEqual(widget.$('.accounting_view tbody').text().replace(/[\n\r\s$,]+/g, ' '),
" 101120 Double Banque 1145.62 101130 Double Frais 29.38 ",
" 101120 New Double Banque 1145.62 101130 New Double Frais 29.38 ",
"should have a sum of reconciliation proposition amounts equal to the line amount");
clientAction.destroy();
@@ -1306,240 +1317,6 @@ QUnit.module('account', {
clientAction.destroy();
});
QUnit.test('Reconciliation pager', function (assert) {
assert.expect(6);
this.params.data['account.bank.statement.line'].records.push(
{id: 9, display_name: "add 9"},
{id: 10, display_name: "add 10"},
{id: 11, display_name: "add 11"},
{id: 12, display_name: "add 12"},
{id: 13, display_name: "add 13"},
{id: 14, display_name: "add 14"},
{id: 15, display_name: "add 15"},
{id: 16, display_name: "add 16"}
);
var data_preprocess = this.params.data_preprocess;
data_preprocess.st_lines_ids.push(9, 10, 11, 12, 13, 14, 15, 16);
this.params.data_widget.push(
{
'st_line': {
'currency_id': 3,
'communication_partner_name': false,
'open_balance_account_id': 287,
'name': "add 9",
'partner_name': "Camptocamp",
'partner_id': 12,
'has_no_partner': false,
'journal_id': 84,
'account_name': "Bank",
'note': "",
'amount': 650.0,
'amount_str': "$ 650.00",
'amount_currency_str': "",
'date': "2017-01-01",
'account_code': "101401",
'ref': "",
'id': 9,
'statement_id': 2
},
'reconciliation_proposition': []
},
{
'st_line': {
'currency_id': 3,
'communication_partner_name': false,
'open_balance_account_id': 287,
'name': "add 10",
'partner_name': "Camptocamp",
'partner_id': 12,
'has_no_partner': false,
'journal_id': 84,
'account_name': "Bank",
'note': "",
'amount': 650.0,
'amount_str': "$ 650.00",
'amount_currency_str': "",
'date': "2017-01-01",
'account_code': "101401",
'ref': "",
'id': 10,
'statement_id': 2
},
'reconciliation_proposition': []
},
{
'st_line': {
'currency_id': 3,
'communication_partner_name': false,
'open_balance_account_id': 287,
'name': "add 11",
'partner_name': "Camptocamp",
'partner_id': 12,
'has_no_partner': false,
'journal_id': 84,
'account_name': "Bank",
'note': "",
'amount': 650.0,
'amount_str': "$ 650.00",
'amount_currency_str': "",
'date': "2017-01-01",
'account_code': "101401",
'ref': "",
'id': 11,
'statement_id': 2
},
'reconciliation_proposition': []
},
{
'st_line': {
'currency_id': 3,
'communication_partner_name': false,
'open_balance_account_id': 287,
'name': "add 12",
'partner_name': "Camptocamp",
'partner_id': 12,
'has_no_partner': false,
'journal_id': 84,
'account_name': "Bank",
'note': "",
'amount': 650.0,
'amount_str': "$ 650.00",
'amount_currency_str': "",
'date': "2017-01-01",
'account_code': "101401",
'ref': "",
'id': 12,
'statement_id': 2
},
'reconciliation_proposition': []
},
{
'st_line': {
'currency_id': 3,
'communication_partner_name': false,
'open_balance_account_id': 287,
'name': "add 13",
'partner_name': "Camptocamp",
'partner_id': 12,
'has_no_partner': false,
'journal_id': 84,
'account_name': "Bank",
'note': "",
'amount': 650.0,
'amount_str': "$ 650.00",
'amount_currency_str': "",
'date': "2017-01-01",
'account_code': "101401",
'ref': "",
'id': 13,
'statement_id': 2
},
'reconciliation_proposition': []
},
{
'st_line': {
'currency_id': 3,
'communication_partner_name': false,
'open_balance_account_id': 287,
'name': "add 14",
'partner_name': "Camptocamp",
'partner_id': 12,
'has_no_partner': false,
'journal_id': 84,
'account_name': "Bank",
'note': "",
'amount': 650.0,
'amount_str': "$ 650.00",
'amount_currency_str': "",
'date': "2017-01-01",
'account_code': "101401",
'ref': "",
'id': 14,
'statement_id': 2
},
'reconciliation_proposition': []
},
{
'st_line': {
'currency_id': 3,
'communication_partner_name': false,
'open_balance_account_id': 287,
'name': "add 15",
'partner_name': "Camptocamp",
'partner_id': 12,
'has_no_partner': false,
'journal_id': 84,
'account_name': "Bank",
'note': "",
'amount': 650.0,
'amount_str': "$ 650.00",
'amount_currency_str': "",
'date': "2017-01-01",
'account_code': "101401",
'ref': "",
'id': 15,
'statement_id': 2
},
'reconciliation_proposition': []
},
{
'st_line': {
'currency_id': 3,
'communication_partner_name': false,
'open_balance_account_id': 287,
'name': "add 16",
'partner_name': "Camptocamp",
'partner_id': 12,
'has_no_partner': false,
'journal_id': 84,
'account_name': "Bank",
'note': "",
'amount': 650.0,
'amount_str': "$ 650.00",
'amount_currency_str': "",
'date': "2017-01-01",
'account_code': "101401",
'ref': "",
'id': 16,
'statement_id': 2
},
'reconciliation_proposition': []
}
);
var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options);
testUtils.addMockEnvironment(clientAction, {
data: this.params.data,
session: {
currencies: {
3: {
digits: [69, 2],
position: "before",
symbol: "$"
}
}
},
});
clientAction.appendTo($('#qunit-fixture'));
assert.strictEqual(clientAction.$('.o_reconciliation_line').length, 10, "should display 10 lines");
assert.strictEqual(clientAction.$('.js_load_more:visible').length, 1, "should display the load more button");
clientAction.$('.accounting_view:has(.o_reconcile:visible) thead .cell_action').click();
clientAction.$('.o_reconcile:visible').click();
assert.strictEqual(clientAction.$('.o_reconcile:visible').length, 0, "should remove the reconciled line");
assert.strictEqual(clientAction.$('.o_reconciliation_line').length, 10, "should load one line to complete the 10");
clientAction.$('thead .cell_action:eq(1)').click();
clientAction.$('.js_load_more').click();
assert.strictEqual(clientAction.$('.o_reconciliation_line').length, 11, "should load the last record");
assert.strictEqual(clientAction.$('.js_load_more:visible').length, 0, "should hide the load more button");
clientAction.destroy();
});
QUnit.test('Reconciliation: Payment < inv1 + inv2(partial)', function (assert) {
assert.expect(4);
@@ -1780,8 +1557,7 @@ QUnit.module('account', {
// Add first prop
widget.$('.match .cell_account_code:first').trigger('click');
assert.notOk( widget.$('.cell_right .line_info_button').length,
"should not display the partial reconciliation alert");
assert.equal( widget.$('.cell_right .line_info_button').length, 1, "should display the partial reconciliation alert");
// Add second prop
widget.$('.match .cell_account_code:first').trigger('click');
+1
View File
@@ -18,3 +18,4 @@ from . import test_account_move_taxes_edition
from . import test_templates_consistency
from . import test_account_fiscal_year
from . import test_account_all_l10n
from . import test_reconciliation_matching_rules
@@ -19,7 +19,7 @@ class TestBankStatementReconciliation(AccountingTestCase):
st_line = self.create_statement_line(100)
# exact amount match
rec_prop = self.reconciliation_widget.get_bank_statement_line_data(st_line.ids)
rec_prop = self.reconciliation_widget.get_bank_statement_line_data(st_line.ids)['lines']
prop = rec_prop[0]['reconciliation_proposition']
self.assertEqual(len(prop), 1)
@@ -0,0 +1,284 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.account_test_classes import AccountingTestCase
from odoo.tests.common import Form
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestReconciliationMatchingRules(AccountingTestCase):
def _create_invoice_line(self, amount, partner, type):
''' Create an invoice on the fly.'''
self_ctx = self.env['account.invoice'].with_context(type=type)
journal_id = self_ctx._default_journal().id
self_ctx = self_ctx.with_context(journal_id=journal_id)
view = type in ('in_invoice', 'in_refund') and 'account.invoice_supplier_form' or 'account.invoice_form'
with Form(self_ctx, view=view) as invoice_form:
invoice_form.partner_id = partner
with invoice_form.invoice_line_ids.new() as invoice_line_form:
invoice_line_form.name = 'xxxx'
invoice_line_form.quantity = 1
invoice_line_form.price_unit = amount
invoice_line_form.invoice_line_tax_ids.clear()
invoice = invoice_form.save()
invoice.action_invoice_open()
lines = invoice.move_id.line_ids
return lines.filtered(lambda l: l.account_id == invoice.account_id)
def _check_statement_matching(self, rules, expected_values):
statement_lines = (self.bank_st + self.cash_st).mapped('line_ids')
matching_values = rules._apply_rules(statement_lines)
for st_line_id, values in matching_values.items():
values.pop('reconciled_lines', None)
self.assertDictEqual(values, expected_values[st_line_id])
def setUp(self):
super(AccountingTestCase, self).setUp()
self.partner_1 = self.env['res.partner'].create({'name': 'partner_1'})
self.partner_2 = self.env['res.partner'].create({'name': 'partner_2'})
self.invoice_line_1 = self._create_invoice_line(100, self.partner_1, 'out_invoice')
self.invoice_line_2 = self._create_invoice_line(200, self.partner_1, 'out_invoice')
self.invoice_line_3 = self._create_invoice_line(300, self.partner_1, 'in_refund')
self.invoice_line_4 = self._create_invoice_line(1000, self.partner_2, 'in_invoice')
current_assets_account = self.env['account.account'].search(
[('user_type_id', '=', self.env.ref('account.data_account_type_current_assets').id)], limit=1)
self.rule_1 = self.env.ref('account.reconciliation_model_default_rule')
self.rule_1.account_id = current_assets_account
self.rule_1.match_partner = True
self.rule_1.match_partner_ids |= self.partner_1 + self.partner_2
self.rule_2 = self.env['account.reconcile.model'].create({
'name': 'write-off model',
'rule_type': 'writeoff_suggestion',
'match_partner': True,
'match_partner_ids': [6, 0, (self.partner_1 + self.partner_2).ids],
'account_id': current_assets_account.id,
})
invoice_number = self.invoice_line_1.move_id.name
bank_journal = self.env['account.journal'].search([('type', '=', 'bank')], limit=1)
self.bank_st = self.env['account.bank.statement'].create({
'name': 'test bank journal', 'journal_id': bank_journal.id,
})
self.bank_line_1 = self.env['account.bank.statement.line'].create({
'statement_id': self.bank_st.id,
'name': 'invoice %s-%s' % (invoice_number.split('/')[1], invoice_number.split('/')[2]),
'partner_id': self.partner_1.id,
'amount': 100,
'sequence': 1,
})
self.bank_line_2 = self.env['account.bank.statement.line'].create({
'statement_id': self.bank_st.id,
'name': 'xxxxx',
'partner_id': self.partner_1.id,
'amount': 600,
'sequence': 2,
})
cash_journal = self.env['account.journal'].search([('type', '=', 'cash')], limit=1)
self.cash_st = self.env['account.bank.statement'].create({
'name': 'test cash journal', 'journal_id': cash_journal.id,
})
self.cash_line_1 = self.env['account.bank.statement.line'].create({
'statement_id': self.cash_st.id,
'name': 'yyyyy',
'partner_id': self.partner_2.id,
'amount': -1000,
'sequence': 1,
})
def test_matching_fields(self):
''' Test all fields used to restrict the rules's applicability.'''
# Check without restriction.
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1},
self.bank_line_2.id: {'aml_ids': [
self.invoice_line_2.id,
self.invoice_line_3.id,
], 'model': self.rule_1},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1},
})
# Check match_journal_ids.
self.rule_1.match_journal_ids |= self.cash_st.journal_id
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': []},
self.bank_line_2.id: {'aml_ids': []},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1},
})
self.rule_1.match_journal_ids |= self.bank_st.journal_id + self.cash_st.journal_id
# Check match_nature.
self.rule_1.match_nature = 'amount_received'
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1},
self.bank_line_2.id: {'aml_ids': [
self.invoice_line_2.id,
self.invoice_line_3.id,
], 'model': self.rule_1},
self.cash_line_1.id: {'aml_ids': []},
})
self.rule_1.match_nature = 'amount_paid'
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': []},
self.bank_line_2.id: {'aml_ids': []},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1},
})
self.rule_1.match_nature = 'both'
# Check match_amount.
self.rule_1.match_amount = 'lower'
self.rule_1.match_amount_max = 150
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1},
self.bank_line_2.id: {'aml_ids': []},
self.cash_line_1.id: {'aml_ids': []},
})
self.rule_1.match_amount = 'greater'
self.rule_1.match_amount_min = 200
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': []},
self.bank_line_2.id: {'aml_ids': [
self.invoice_line_1.id,
self.invoice_line_2.id,
self.invoice_line_3.id,
], 'model': self.rule_1},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1},
})
self.rule_1.match_amount = 'between'
self.rule_1.match_amount_min = 200
self.rule_1.match_amount_max = 800
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': []},
self.bank_line_2.id: {'aml_ids': [
self.invoice_line_1.id,
self.invoice_line_2.id,
self.invoice_line_3.id,
], 'model': self.rule_1},
self.cash_line_1.id: {'aml_ids': []},
})
self.rule_1.match_amount = False
# Check match_label.
self.rule_1.match_label = 'contains'
self.rule_1.match_label_param = 'yyyyy'
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': []},
self.bank_line_2.id: {'aml_ids': []},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1},
})
self.rule_1.match_label = 'not_contains'
self.rule_1.match_label_param = 'xxxxx'
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1},
self.bank_line_2.id: {'aml_ids': []},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1},
})
self.rule_1.match_label = 'match_regex'
self.rule_1.match_label_param = 'xxxxx|yyyyy'
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': []},
self.bank_line_2.id: {'aml_ids': [
self.invoice_line_1.id,
self.invoice_line_2.id,
self.invoice_line_3.id,
], 'model': self.rule_1},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1},
})
self.rule_1.match_label = False
# Check match_total_amount.
self.rule_1.match_total_amount_param = 90.0
self.bank_line_1.amount += 5
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'write_off'},
self.bank_line_2.id: {'aml_ids': [
self.invoice_line_2.id,
self.invoice_line_3.id,
], 'model': self.rule_1},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1},
})
self.rule_1.match_total_amount_param = 100.0
self.bank_line_1.amount -= 5
# Check match_partner_category_ids.
test_category = self.env.ref('base.res_partner_category_8')
self.partner_2.category_id = test_category
self.rule_1.match_partner_category_ids |= test_category
self._check_statement_matching(self.rule_1, {
self.bank_line_1.id: {'aml_ids': []},
self.bank_line_2.id: {'aml_ids': []},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1},
})
self.rule_1.match_partner_category_ids = False
def test_mixin_rules(self):
''' Test usage of rules together.'''
# rule_1 is used before rule_2.
self.rule_1.sequence = 1
self.rule_2.sequence = 2
self._check_statement_matching(self.rule_1 + self.rule_2, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1},
self.bank_line_2.id: {'aml_ids': [
self.invoice_line_2.id,
self.invoice_line_3.id,
], 'model': self.rule_1},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1},
})
# rule_2 is used before rule_1.
self.rule_1.sequence = 2
self.rule_2.sequence = 1
self._check_statement_matching(self.rule_1 + self.rule_2, {
self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off'},
self.bank_line_2.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off'},
self.cash_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off'},
})
# rule_2 is used before rule_1 but only on partner_1.
self.rule_2.match_partner_ids |= self.partner_1
self._check_statement_matching(self.rule_1 + self.rule_2, {
self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off'},
self.bank_line_2.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off'},
self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1},
})
def test_auto_reconcile(self):
''' Test auto reconciliation.'''
self.rule_1.sequence = 2
self.rule_1.auto_reconcile = True
self.rule_1.match_total_amount_param = 90
self.rule_2.sequence = 1
self.rule_2.match_partner_ids |= self.partner_2
self.rule_2.auto_reconcile = True
self.bank_line_1.amount += 5
self._check_statement_matching(self.rule_1 + self.rule_2, {
self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'reconciled'},
self.bank_line_2.id: {'aml_ids': []},
self.cash_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'reconciled'},
})
# Check first line has been well reconciled.
self.assertRecordValues(self.bank_line_1.journal_entry_ids, [
{'partner_id': self.partner_1.id, 'debit': 105.0, 'credit': 0.0},
{'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 100.0},
{'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 5.0},
])
# Check second line has been well reconciled.
self.assertRecordValues(self.cash_line_1.journal_entry_ids, [
{'partner_id': self.partner_2.id, 'debit': 0.0, 'credit': 1000.0},
{'partner_id': self.partner_2.id, 'debit': 1000.0, 'credit': 0.0},
])
@@ -1,5 +1,6 @@
import logging
import odoo.tests
import time
_logger = logging.getLogger(__name__)
@@ -10,9 +11,10 @@ _logger = logging.getLogger(__name__)
class TestUi(odoo.tests.HttpCase):
def test_01_admin_bank_statement_reconciliation(self):
bank_stmt = self.env['account.bank.statement'].search([('name', '=', 'BNK/2014/001')])
bank_stmt_name = 'BNK/%s/0001' % time.strftime('%Y')
bank_stmt = self.env['account.bank.statement'].search([('name', '=', bank_stmt_name)])
if not bank_stmt:
_logger.exception('Could not find bank statement BNK/2014/001')
_logger.exception('Could not find bank statement %s' % bank_stmt_name)
# To be able to test reconciliation, admin user must have access to accounting features, so we give him the right group for that
self.env.ref('base.user_admin').write({'groups_id': [(4, self.env.ref('account.group_account_user').id)]})
+78 -6
View File
@@ -867,7 +867,75 @@
</div>
<group>
<group>
<field name="account_id" domain="[('company_id', '=', company_id)]"/>
<field name="rule_type" widget="radio"/>
</group>
<group>
<field name="auto_reconcile" attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}"/>
</group>
</group>
<group string="Conditions on Bank Statement Line">
<group>
<field name="match_journal_ids"
options="{'no_create': True}"
widget="many2many_tags"/>
<field name="match_nature" widget="radio"
attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}"/>
</group>
<group attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}">
<label for="match_amount"/>
<div>
<field name="match_amount" style="width: 30% !important" class="oe_inline"/>
<span class="o_form_label" style="width: 2% !important"> </span> <!-- Make a little space. -->
<field name="match_amount_min" style="width: 30% !important;" class="oe_inline"
attrs="{'invisible': [('match_amount', 'in', (False, 'lower'))], 'required': [('match_amount', '!=', False)]}"/>
<span class="o_form_label"
attrs="{'invisible': [('match_amount', '!=', 'between')]}">and</span>
<field name="match_amount_max" style="width: 30% !important;" class="oe_inline"
attrs="{'invisible': [('match_amount', 'in', (False, 'greater'))], 'required': [('match_amount', '=', 'between')]}"/>
</div>
<label for="match_label"/>
<div>
<field name="match_label" style="width: 30% !important" class="oe_inline"/>
<span class="o_form_label" style="width: 2% !important"> </span> <!-- Make a little space. -->
<field name="match_label_param" style="width: 68% !important;" class="oe_inline"
attrs="{'invisible': [('match_label', '=', False)], 'required': [('match_label', '!=', False)]}"/>
</div>
<field name="match_same_currency" attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}"/>
<label for="match_total_amount"
attrs="{'invisible': ['|', ('rule_type', '!=', 'invoice_matching'), ('match_same_currency', '=', False)]}"/>
<div attrs="{'invisible': ['|', ('rule_type', '!=', 'invoice_matching'), ('match_same_currency', '=', False)]}">
<field name="match_total_amount" class="oe_inline"/>
<field name="match_total_amount_param" class="oe_inline"
attrs="{'invisible': [('match_total_amount', '=', False)]}"/>
<span class="o_form_label oe_inline"
attrs="{'invisible': [('match_total_amount', '=', False)]}">%</span>
<div class="text-muted" attrs="{'invisible': ['|', ('rule_type', '!=', 'invoice_matching'), ('match_total_amount', '=', False)]}">
This parameter will be bypassed in case of a statement line communication matching exactly existing entries</div>
</div>
</group>
<group attrs="{'invisible': [('rule_type', '=', 'writeoff_button')]}">
<field name="match_partner"
attrs="{'invisible': [('rule_type', '!=', 'writeoff_suggestion')]}"/>
<field name="match_partner"
string="Partner Is Set &amp; Matches"
attrs="{'invisible': [('rule_type', '!=', 'invoice_matching')]}"/>
<label for="match_partner_ids" class="ml16"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
<field name="match_partner_ids" nolabel="1"
widget="many2many_tags"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
<label for="match_partner_category_ids" class="ml16"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
<field name="match_partner_category_ids" nolabel="1"
widget="many2many_tags"
attrs="{'invisible': [('match_partner', '=', False)]}"/>
</group>
</group>
<group string="Write-off Values"
attrs="{'invisible': [('rule_type', '=', 'invoice_matching'), '|', ('match_total_amount', '=', False), '&amp;', ('match_total_amount', '=', True), ('match_total_amount_param', '=', 100.0)]}">
<group>
<field name="account_id" domain="[('company_id', '=', company_id)]"
attrs="{'required': ['|', ('rule_type', '!=', 'invoice_matching'), '&amp;', '&amp;', ('rule_type', '=', 'invoice_matching'), ('match_total_amount', '=', True), ('match_total_amount_param', '!=', 100.0)]}"/>
<field name="amount_type"/>
<field name="tax_id"
domain="[('company_id', '=', company_id)]"
@@ -888,16 +956,19 @@
<field name="amount" class="oe_inline"/>
<span class="o_form_label oe_inline" attrs="{'invisible':[('amount_type','!=','percentage')]}">%</span>
</div>
<field name="journal_id" domain="[('company_id', '=', company_id)]" widget="selection"/>
<field name="journal_id" domain="[('company_id', '=', company_id)]" widget="selection"
attrs="{'invisible': [('rule_type', '!=', 'writeoff_button')]}"/>
</group>
</group>
<div class="oe_edit_only">
<div class="oe_edit_only"
attrs="{'invisible': [('rule_type', '=', 'invoice_matching'), '|', ('match_total_amount', '=', False), '&amp;', ('match_total_amount', '=', True), ('match_total_amount_param', '=', 100.0)]}">
<field name="has_second_line" />
<label for="has_second_line" string="Add a second line"/>
</div>
<group attrs="{'invisible':[('has_second_line','!=',True)]}">
<group attrs="{'invisible':['|', ('has_second_line', '=', False), '&amp;', ('rule_type', '=', 'invoice_matching'), '|', ('match_total_amount', '=', False), '&amp;', ('match_total_amount', '=', True), ('match_total_amount_param', '=', 100.0)]}">
<group>
<field name="second_account_id" string="Account" domain="[('company_id', '=', company_id)]"/>
<field name="second_account_id" string="Account" domain="[('company_id', '=', company_id)]"
attrs="{'required': [('has_second_line', '=', True), '|', ('rule_type', '!=', 'invoice_matching'), '&amp;', '&amp;', ('rule_type', '=', 'invoice_matching'), ('match_total_amount', '=', True), ('match_total_amount_param', '!=', 100.0)]}"/>
<field name="second_amount_type" string="Amount type"/>
<field name="second_tax_id"
string="Tax"
@@ -918,7 +989,8 @@
<field name="second_amount" class="oe_inline"/>
<span class="o_form_label oe_inline" attrs="{'invisible':[('amount_type','!=','percentage')]}">%</span>
</div>
<field name="second_journal_id" string="Journal" domain="[('company_id', '=', company_id)]" widget="selection"/>
<field name="second_journal_id" string="Journal" domain="[('company_id', '=', company_id)]" widget="selection"
attrs="{'invisible': [('rule_type', '!=', 'writeoff_button')]}"/>
</group>
</group>
</sheet>
@@ -6,7 +6,7 @@
('type', '=', 'bank'),
('company_id', '=', obj().env['res.company']._company_default_get('account.journal').id)]"/>
<field name="date" eval="time.strftime('%Y')+'-01-01'"/>
<field name="name">BNK/2014/001</field>
<field name="name" eval="'BNK/%s/0001' % time.strftime('%Y')"/>
<field name="balance_end_real">8998.2</field>
<field name="balance_start">5103.0</field>
</record>
@@ -15,11 +15,11 @@
<field name="ref"></field>
<field name="statement_id" ref="l10n_generic_coa.demo_bank_statement_1"/>
<field name="sequence">1</field>
<field name="name">SAJ/2014/002 and SAJ/2014/003</field>
<field name="name" eval="'INV/%s/0002 and INV/%s/0003' % (time.strftime('%Y'), time.strftime('%Y'))"/>
<field name="journal_id" model="account.journal" search="[
('type', '=', 'bank'),
('company_id', '=', obj().env['res.company']._company_default_get('account.journal').id)]"/>
<field name="amount">1175.0</field>
<field name="amount">1275.0</field>
<field name="date" eval="time.strftime('%Y')+'-01-01'"/>
<field name="partner_id" ref="base.res_partner_2"/>
</record>
@@ -53,7 +53,7 @@
<field name="ref"></field>
<field name="statement_id" ref="l10n_generic_coa.demo_bank_statement_1"/>
<field name="sequence">4</field>
<field name="name">First 2000 € of SAJ/2014/001</field>
<field name="name" eval="'First 2000 $ of invoice %s/0001' % time.strftime('%Y')"/>
<field name="journal_id" model="account.journal" search="[
('type', '=', 'bank'),
('company_id', '=', obj().env['res.company']._company_default_get('account.journal').id)]"/>
@@ -78,7 +78,7 @@
<field name="ref"></field>
<field name="statement_id" ref="l10n_generic_coa.demo_bank_statement_1"/>
<field name="sequence">1</field>
<field name="name" eval="'SAJ/'+time.strftime('%Y')+'/002'"/>
<field name="name" eval="'INV/'+time.strftime('%Y')+'/0002'"/>
<field name="journal_id" model="account.journal" search="[
('type', '=', 'bank'),
('company_id', '=', obj().env['res.company']._company_default_get('account.journal').id)]"/>
+1 -1
View File
@@ -32,7 +32,7 @@ class Currency(models.Model):
help='The rate of the currency to the currency of rate 1.')
rate_ids = fields.One2many('res.currency.rate', 'currency_id', string='Rates')
rounding = fields.Float(string='Rounding Factor', digits=(12, 6), default=0.01)
decimal_places = fields.Integer(compute='_compute_decimal_places')
decimal_places = fields.Integer(compute='_compute_decimal_places', store=True)
active = fields.Boolean(default=True)
position = fields.Selection([('after', 'After Amount'), ('before', 'Before Amount')], default='after',
string='Symbol Position', help="Determines where the currency symbol should be placed after or before the amount.")