Files
enterprise-addons/account_accountant/models/account_move.py
T

1140 lines
56 KiB
Python

import calendar
from contextlib import contextmanager
from itertools import chain
from dateutil.relativedelta import relativedelta
import logging
import markupsafe
import re
from odoo import fields, models, api, _
from odoo.exceptions import UserError
from odoo.fields import Command, Domain
from odoo.tools import SQL, float_compare
from odoo.tools.misc import formatLang
_logger = logging.getLogger(__name__)
DEFERRED_DATE_MIN = '1900-01-01'
DEFERRED_DATE_MAX = '9999-12-31'
class AccountMove(models.Model):
_inherit = "account.move"
# Technical field to keep the value of payment_state when switching from invoicing to accounting
# (using invoicing_switch_threshold setting field). It allows keeping the former payment state, so that
# we can restore it if the user misconfigured the switch date and wants to change it.
payment_state_before_switch = fields.Char(string="Payment State Before Switch", copy=False)
# Deferred management fields
deferred_move_ids = fields.Many2many(
string="Deferred Entries",
comodel_name='account.move',
relation='account_move_deferred_rel',
column1='original_move_id',
column2='deferred_move_id',
help="The deferred entries created by this invoice",
copy=False,
)
deferred_original_move_ids = fields.Many2many(
string="Original Invoices",
comodel_name='account.move',
relation='account_move_deferred_rel',
column1='deferred_move_id',
column2='original_move_id',
help="The original invoices that created the deferred entries",
copy=False,
)
deferred_entry_type = fields.Selection(
string="Deferred Entry Type",
selection=[
('expense', 'Deferred Expense'),
('revenue', 'Deferred Revenue'),
('misc', 'Deferred Miscellaneous'),
],
compute='_compute_deferred_entry_type',
copy=False,
)
signing_user = fields.Many2one(
string='Signer',
comodel_name='res.users',
compute='_compute_signing_user', store=True,
copy=False,
)
show_signature_area = fields.Boolean(compute='_compute_signature')
signature = fields.Binary(compute='_compute_signature') # can't be `related`: the sign module might not be there
@api.depends('state', 'move_type', 'invoice_user_id')
def _compute_signing_user(self):
other_moves = self.filtered(lambda move: not move.is_sale_document())
other_moves.signing_user = False
is_odoobot_user = self.env.user == self.env.ref('base.user_root')
is_backend_user = self.env.user.has_group('base.group_user')
for invoice in (self - other_moves).filtered(lambda inv: inv.state == 'posted'):
# signer priority:
# - res.user set in res.settings
# - real backend user posting the invoice
# - if odoobot: the person that initiated the invoice ie: The salesman
# - if invoice initiated by a portal user -> No signature
representative = invoice.company_id.signing_user
# checking `has_group('base.group_user')` ensure we never keep a portal user to sign
if is_odoobot_user:
user_can_sign = invoice.invoice_user_id and invoice.invoice_user_id.has_group('base.group_user')
invoice.signing_user = representative or invoice.invoice_user_id if user_can_sign else False
else:
invoice.signing_user = representative or self.env.user if is_backend_user else False
@api.depends('state')
def _compute_signature(self):
is_portal_user = self.env.user.has_group('base.group_portal')
# Checking `company_id.sign_invoice` removes the needs to check if the sign module is installed
# Setting it to True through `res.settings` auto install the sign module
moves_not_to_sign = self.filtered(
lambda inv: not inv.company_id.sign_invoice
or inv.state in {'draft', 'cancel'}
or not inv.is_sale_document()
# Allow signature for portal user only if the invoice already went through the send&print workflow
or (is_portal_user and not inv.invoice_pdf_report_id)
)
moves_not_to_sign.show_signature_area = False
moves_not_to_sign.signature = None
invoice_with_signature = self - moves_not_to_sign
invoice_with_signature.show_signature_area = True
for invoice in invoice_with_signature:
invoice.signature = invoice.signing_user.sudo().sign_signature
def _post(self, soft=True):
# Deferred management
posted = super()._post(soft)
for move in self:
if move._get_deferred_entries_method() == 'on_validation' and any(move.line_ids.mapped('deferred_start_date')):
move._generate_deferred_entries()
return posted
def button_draft(self):
if any(len(deferral_move.deferred_original_move_ids) > 1 for deferral_move in self.deferred_move_ids):
raise UserError(_("You cannot reset to draft an invoice that is grouped in deferral entry. You can create a credit note instead."))
reversed_moves = self.deferred_move_ids._unlink_or_reverse()
if reversed_moves:
for move in reversed_moves:
move.with_context(skip_readonly_check=True).write({
'date': move._get_accounting_date(move.date, move._affect_tax_report()),
})
self.deferred_move_ids |= reversed_moves
return super().button_draft()
def unlink(self):
# Prevent deferred moves under audit trail restriction from being unlinked
deferral_moves = self.filtered(lambda move: move._is_protected_by_audit_trail() and move.deferred_original_move_ids)
deferral_moves.deferred_original_move_ids.deferred_move_ids = False
deferral_moves._reverse_moves()
return super(AccountMove, self - deferral_moves).unlink()
# ============================= START - Deferred Management ====================================
def _get_deferred_entries_method(self):
self.ensure_one()
if self.is_entry():
move_types = set(self.line_ids.filtered('deferred_start_date').account_id.mapped("internal_group"))
if (
"expense" in move_types and "income" in move_types
and self.company_id.generate_deferred_expense_entries_method != self.company_id.generate_deferred_revenue_entries_method
):
raise UserError(self.env._(
"Having different deferred entries generation methods for expenses and revenues is not supported on "
"journal entries involving both expense and revenue accounts. You can split this entry into two entries instead."
))
elif "expense" in move_types:
return self.company_id.generate_deferred_expense_entries_method
else:
return self.company_id.generate_deferred_revenue_entries_method
if self.is_purchase_document():
return self.company_id.generate_deferred_expense_entries_method
return self.company_id.generate_deferred_revenue_entries_method
@api.depends('deferred_original_move_ids')
def _compute_deferred_entry_type(self):
for move in self:
if move.deferred_original_move_ids:
move_types = set(move.deferred_original_move_ids.mapped('move_type'))
if len(move_types) > 1 or move.deferred_original_move_ids[0].is_entry():
move.deferred_entry_type = 'misc'
elif move.deferred_original_move_ids[0].is_purchase_document():
move.deferred_entry_type = 'expense'
else:
move.deferred_entry_type = 'revenue'
else:
move.deferred_entry_type = False
@api.model
def _get_deferred_diff_dates(self, start, end):
"""
Returns the number of months between two dates [start, end[
The computation is done by using months of 30 days so that the deferred amount for february
(28-29 days), march (31 days) and april (30 days) are all the same (in case of monthly computation).
See test_deferred_management_get_diff_dates for examples.
"""
if start > end:
start, end = end, start
nb_months = end.month - start.month + 12 * (end.year - start.year)
start_day, end_day = start.day, end.day
if start_day == calendar.monthrange(start.year, start.month)[1]:
start_day = 30
if end_day == calendar.monthrange(end.year, end.month)[1]:
end_day = 30
nb_days = end_day - start_day
return (nb_months * 30 + nb_days) / 30
@api.model
def _get_deferred_period_amount(self, method, period_start, period_end, line_start, line_end, balance):
"""
Returns the amount to defer for the given period taking into account the deferred method (day/month/full_months).
"""
if period_end <= line_start or period_end <= period_start:
return 0 # invalid period
if method == 'day':
amount_per_day = balance / (line_end - line_start).days
return (period_end - period_start).days * amount_per_day
elif method in ('month', 'full_months'):
if method == 'full_months':
reset_day_1 = relativedelta(day=1)
line_start, line_end = line_start + reset_day_1, line_end + reset_day_1
period_start, period_end = period_start + reset_day_1, period_end + reset_day_1
line_diff = self._get_deferred_diff_dates(line_end, line_start)
period_diff = self._get_deferred_diff_dates(period_end, period_start)
return period_diff / line_diff * balance if line_diff else balance
@api.model
def _get_deferred_amounts_by_line(self, lines, periods, deferred_type):
"""
:return: a list of dictionaries containing the deferred amounts for each line and each period
E.g. (where period1 = (date1, date2, label1), period2 = (date2, date3, label2), ...)
[
{'account_id': 1, period_1: 100, period_2: 200},
{'account_id': 1, period_1: 100, period_2: 200},
{'account_id': 2, period_1: 300, period_2: 400},
]
"""
values = []
for line in lines:
line_start = fields.Date.to_date(line['deferred_start_date'])
line_end = fields.Date.to_date(line['deferred_end_date'])
if line_end < line_start:
# This normally shouldn't happen, but if it does, would cause calculation errors later on.
# To not make the reports crash, we just set both dates to the same day.
# The user should fix the dates manually.
line_end = line_start
columns = {}
for period in periods:
if period[2] == 'not_started' and line_start <= period[0]:
# The 'Not Started' column only considers lines starting the deferral after the report end date
columns[period] = 0.0
continue
# periods = [Total, Not Started, Before, ..., Current, ..., Later]
# The dates to calculate the amount for the current period
period_start = max(period[0], line_start)
period_end = min(period[1], line_end) + relativedelta(days=1) # +1 to include end date of report
columns[period] = self._get_deferred_period_amount(
self.env.company.deferred_expense_amount_computation_method if deferred_type == "expense" else self.env.company.deferred_revenue_amount_computation_method,
period_start, period_end,
line_start, line_end + relativedelta(days=1), # +1 to include the end date of the line
line['balance']
)
values.append({
**self.env['account.move.line']._get_deferred_amounts_by_line_values(line),
**columns,
})
return values
@api.model
def _get_deferred_lines(self, line, deferred_account, deferred_type, period, ref, force_balance=None, grouping_field='account_id'):
"""
:return: a list of Command objects to create the deferred lines of a single given period
"""
deferred_amounts = self._get_deferred_amounts_by_line(line, [period], deferred_type)[0]
balance = deferred_amounts[period] if force_balance is None else force_balance
return [
Command.create({
**self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * balance, ref, line.analytic_distribution, line),
'partner_id': line.partner_id.id,
'product_id': line.product_id.id,
})
for (account, coeff) in [(deferred_amounts[grouping_field], 1), (deferred_account, -1)]
]
def _generate_deferred_entries(self):
"""
Generates the deferred entries for the invoice.
"""
self.ensure_one()
if self.state != 'posted':
return
for deferred_type in ('expense', 'revenue'):
moves_vals_to_create = []
lines_vals_to_create = []
lines_periods = []
lines = self.line_ids.filtered(lambda l: (
l.account_id.internal_group == ('expense' if deferred_type == 'expense' else 'income')
and l.deferred_start_date
and l.deferred_end_date
))
if not lines:
continue
deferred_account = self.company_id.deferred_expense_account_id if deferred_type == "expense" else self.company_id.deferred_revenue_account_id
deferred_journal = self.company_id.deferred_expense_journal_id if deferred_type == "expense" else self.company_id.deferred_revenue_journal_id
deferred_method = self.company_id.deferred_expense_amount_computation_method if deferred_type == "expense" else self.company_id.deferred_revenue_amount_computation_method
if not deferred_journal:
raise UserError(_("Please set the deferred journal in the accounting settings."))
if not deferred_account:
raise UserError(_("Please set the deferred accounts in the accounting settings."))
for line in lines:
periods = line._get_deferred_periods()
if not periods:
continue
start_date = line.deferred_start_date
end_date = line.deferred_end_date
accounting_date = line.date
# When using the 'full_months' computation method, every consumed month counts as a full month.
# We therefore need to subtract one month from the end date for the following check on dates.
if deferred_method == 'full_months':
# We need to add one day to the end date since it's excluded by _get_deferred_diff_dates().
if self._get_deferred_diff_dates(start_date.replace(day=1), end_date + relativedelta(days=1)) < 2:
end_date += relativedelta(months=-1)
# When all move line dates (start, end, accounting) are within the same month, we skip the line.
# It would otherwise lead to the creation of both a reversal and a deferral move that would cancel each other out.
if start_date.replace(day=1) == end_date.replace(day=1) == accounting_date.replace(day=1):
continue
ref = _("Deferral of %s", line.move_id.name or '')
moves_vals_to_create.append({
'move_type': 'entry',
'deferred_original_move_ids': [Command.set(line.move_id.ids)],
'journal_id': deferred_journal.id,
'company_id': self.company_id.id,
'partner_id': line.partner_id.id,
'auto_post': 'at_date',
'ref': ref,
'name': False,
'date': line.move_id.date,
})
lines_vals_to_create.append([
{
**self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * line.balance, ref, line.analytic_distribution, line),
'partner_id': line.partner_id.id,
}
for (account, coeff) in [(line.account_id, -1), (deferred_account, 1)]
])
lines_periods.append((line, periods))
# create the deferred moves
moves_fully_deferred = self.create(moves_vals_to_create)
# We write the lines after creation, to make sure the `deferred_original_move_ids` is set.
# This way we can avoid adding taxes for deferred moves.
for move_fully_deferred, lines_vals in zip(moves_fully_deferred, lines_vals_to_create):
for line_vals in lines_vals:
# This will link the moves to the lines. Instead of move.write('line_ids': lines_ids)
line_vals['move_id'] = move_fully_deferred.id
self.env['account.move.line'].create(list(chain(*lines_vals_to_create)))
deferral_moves_vals = []
deferral_moves_line_vals = []
# Create the deferred entries for the periods [deferred_start_date, deferred_end_date]
for (line, periods), move_vals in zip(lines_periods, moves_vals_to_create):
remaining_balance = line.balance
for period_index, period in enumerate(periods):
# For the last deferral move the balance is forced to remaining balance to avoid rounding errors
force_balance = remaining_balance if period_index == len(periods) - 1 else None
deferred_amounts = self._get_deferred_amounts_by_line(line, [period], deferred_type)[0]
balance = deferred_amounts[period] if force_balance is None else force_balance
remaining_balance -= line.currency_id.round(balance)
deferral_moves_vals.append({**move_vals, 'date': period[1]})
deferral_moves_line_vals.append([
{
**self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * balance, move_vals['ref'], line.analytic_distribution, line),
'partner_id': line.partner_id.id,
'product_id': line.product_id.id,
}
for (account, coeff) in [(deferred_amounts['account_id'], 1), (deferred_account, -1)]
])
deferral_moves = self.create(deferral_moves_vals)
for deferral_move, lines_vals in zip(deferral_moves, deferral_moves_line_vals):
for line_vals in lines_vals:
# This will link the moves to the lines. Instead of move.write('line_ids': lines_ids)
line_vals['move_id'] = deferral_move.id
self.env['account.move.line'].create(list(chain(*deferral_moves_line_vals)))
# Avoid having deferral moves with a total amount of 0.
to_unlink = deferral_moves.filtered(lambda move: move.currency_id.is_zero(move.amount_total))
to_unlink.unlink()
(moves_fully_deferred + deferral_moves - to_unlink)._post(soft=True)
def open_deferred_entries(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _("Deferred Entries"),
'res_model': 'account.move.line',
'domain': [('id', 'in', self.deferred_move_ids.line_ids.ids)],
'views': [(self.env.ref('account_accountant.view_deferred_entries_tree').id, 'list')],
'context': {
'search_default_group_by_move': True,
'expand': True,
}
}
def open_deferred_original_entry(self):
self.ensure_one()
action = {
'type': 'ir.actions.act_window',
'name': _("Original Deferred Entries"),
'res_model': 'account.move.line',
'domain': [('id', 'in', self.deferred_original_move_ids.line_ids.ids)],
'views': [(False, 'list'), (False, 'form')],
'context': {
'search_default_group_by_move': True,
'expand': True,
}
}
if len(self.deferred_original_move_ids) == 1:
action.update({
'res_model': 'account.move',
'res_id': self.deferred_original_move_ids[0].id,
'views': [(False, 'form')],
})
return action
# ============================= END - Deferred management ======================================
def action_open_bank_reconciliation_widget(self):
return self.statement_line_id._action_open_bank_reconciliation_widget(
default_context={
'search_default_journal_id': self.statement_line_id.journal_id.id,
'search_default_statement_line_id': self.statement_line_id.id,
'default_st_line_id': self.statement_line_id.id,
}
)
def action_open_bank_reconciliation_widget_statement(self):
return self.statement_line_id._action_open_bank_reconciliation_widget(
extra_domain=[('statement_id', 'in', self.statement_id.ids)],
)
def action_open_business_doc(self):
if self.statement_line_id:
return self.action_open_bank_reconciliation_widget()
else:
action = super().action_open_business_doc()
# prevent propagation of the following keys
action['context'] = action.get('context', {}) | {
'preferred_aml_value': None,
'preferred_aml_currency_id': None,
}
return action
def _get_mail_thread_data_attachments(self):
res = super()._get_mail_thread_data_attachments()
res += self.statement_line_id.statement_id.attachment_ids
if self.env.context.get('from_bank_reco'):
res += self.line_ids.reconciled_lines_ids.move_attachment_ids
return res
@contextmanager
def _get_edi_creation(self):
with super()._get_edi_creation() as move:
previous_lines = move.invoice_line_ids
yield move.with_context(disable_onchange_name_predictive=True)
for line in move.invoice_line_ids - previous_lines:
line._onchange_name_predictive()
def _get_outstanding_bank_statement_lines_domain(self):
"""
Returns a search domain for move lines for unreconciled bank payments
linked to this move's partner.
"""
self.ensure_one()
return [
('parent_state', '=', 'posted'),
('partner_id', '=', self.commercial_partner_id.id),
('partner_id', '!=', False),
('account_id.account_type', '=', 'asset_cash'),
('journal_id', 'in', self.env['account.journal']._search([
*self.env['account.journal']._check_company_domain(self.company_id.id),
('type', '=', 'bank')
])),
('balance', '>' if self.is_inbound() else '<', 0.0),
('statement_line_id', '!=', False),
('move_id.line_ids', 'any', [
('account_id', '=', self.company_id.account_journal_suspense_account_id.id),
('reconciled', '=', False),
]),
]
def _compute_payments_widget_to_reconcile_info(self):
# EXTENDS 'account'
super()._compute_payments_widget_to_reconcile_info()
for move in self:
if move.state not in {'draft', 'posted'} \
or move.payment_state not in ('not_paid', 'partial') \
or not move.is_invoice(include_receipts=True) \
or not move.partner_id:
continue
bank_domain = [
('parent_state', '=', 'posted'),
('partner_id', '=', move.commercial_partner_id.id),
('partner_id', '!=', False),
('account_id.account_type', '=', 'asset_cash'),
('journal_id', 'in', self.env['account.journal']._search([
*self.env['account.journal']._check_company_domain(move.company_id.id),
('type', '=', 'bank')
])),
('balance', '>' if move.is_inbound() else '<', 0.0),
('statement_line_id', '!=', False),
('move_id.line_ids', 'any', [
('account_id', '=', move.company_id.account_journal_suspense_account_id.id),
('reconciled', '=', False),
]),
]
payments_widget_vals = {
'outstanding': True,
'content': [],
'move_id': move.id,
'title': _('Outstanding credits') if move.is_inbound() else _('Outstanding debits')
}
for line in self.env['account.move.line'].search(bank_domain):
st_line = line.statement_line_id
currency = st_line.foreign_currency_id or st_line.currency_id
amount = currency._convert(
from_amount=abs(st_line.amount_residual),
to_currency=move.currency_id,
company=move.company_id,
date=line.date,
)
if move.currency_id.is_zero(amount):
continue
payments_widget_vals['content'].append({
'bank_label': line.name if line.journal_id.type == 'bank' else False,
'journal_name': line.ref or line.move_id.name,
'amount': amount,
'currency_id': move.currency_id.id,
'id': line.id,
'move_id': line.move_id.id,
'date': fields.Date.to_string(line.date),
'account_payment_id': line.payment_id.id,
})
if payments_widget_vals['content']:
if move.invoice_outstanding_credits_debits_widget:
move.invoice_outstanding_credits_debits_widget['content'].extend(payments_widget_vals['content'])
move.invoice_outstanding_credits_debits_widget['content'].sort(key=lambda x: (x['date'], x['id']), reverse=True)
else:
move.invoice_outstanding_credits_debits_widget = payments_widget_vals
def _get_partner_credit_warning_exclude_amount(self):
# EXTENDS 'account'
exclude_amount = super()._get_partner_credit_warning_exclude_amount()
bank_domain = self._get_outstanding_bank_statement_lines_domain()
for line in self.env['account.move.line'].search(bank_domain):
currency = line.statement_line_id.foreign_currency_id or line.statement_line_id.currency_id
exclude_amount += currency._convert(
from_amount=abs(line.statement_line_id.amount_residual),
to_currency=self.company_id.currency_id,
company=self.company_id,
date=line.date,
)
matched_credits = self.line_ids.filtered(lambda line: line.account_id.account_type == 'asset_receivable').matched_credit_ids
if matched_credits:
bank_line_amount = sum(matched_credits.mapped('amount'))
payment_date = max(matched_credits.mapped('max_date'))
amount_company = matched_credits[0].credit_currency_id._convert(
from_amount=bank_line_amount,
to_currency=self.company_id.currency_id,
company=self.company_id,
date=payment_date,
)
exclude_amount += amount_company
return exclude_amount
def js_assign_outstanding_line(self, line_id):
# EXTENDS
super().js_assign_outstanding_line(line_id)
line = self.env['account.move.line'].browse(line_id)
if line.account_id.account_type == 'asset_cash' and line.statement_line_id:
return line.statement_line_id.with_context(
skip_payment_tolerance=True,
stop_reco_at_first_partial=True,
).set_line_bank_statement_line(self.line_ids.filtered(lambda line: line.account_id.account_type in {'asset_receivable', 'liability_payable'}).ids)
def js_remove_outstanding_partial(self, partial_id):
# EXTENDS
if st_line := self.statement_line_id:
partial = self.env['account.partial.reconcile'].browse(partial_id)
st_line.delete_reconciled_line((partial.credit_move_id + partial.debit_move_id).ids)
else:
super().js_remove_outstanding_partial(partial_id)
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
move_attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment')
# Deferred management fields
deferred_start_date = fields.Date(
string="Start Date",
compute='_compute_deferred_start_date', store=True, readonly=False,
index='btree_not_null',
copy=False,
help="Date at which the deferred expense/revenue starts"
)
deferred_end_date = fields.Date(
string="End Date",
index='btree_not_null',
copy=False,
help="Date at which the deferred expense/revenue ends"
)
has_deferred_moves = fields.Boolean(compute='_compute_has_deferred_moves')
has_abnormal_deferred_dates = fields.Boolean(compute='_compute_has_abnormal_deferred_dates')
full_amount_switch_html = fields.Html(compute='_compute_full_amount_switch_html')
def _order_to_sql(self, order, query, alias=None, reverse=False):
sql_order = super()._order_to_sql(order, query, alias, reverse)
preferred_aml_residual_value = self.env.context.get('preferred_aml_value')
preferred_aml_currency_id = self.env.context.get('preferred_aml_currency_id')
if preferred_aml_residual_value and preferred_aml_currency_id and order == self._order:
currency = self.env['res.currency'].browse(preferred_aml_currency_id)
# using round since currency.round(55.55) = 55.550000000000004
preferred_aml_residual_value = round(preferred_aml_residual_value, currency.decimal_places)
sql_residual_currency = self._field_to_sql(alias or self._table, 'amount_residual_currency', query)
sql_currency = self._field_to_sql(alias or self._table, 'currency_id', query)
return SQL(
"ROUND(%(residual_currency)s, %(decimal_places)s) = %(value)s "
"AND %(currency)s = %(currency_id)s DESC, %(order)s",
residual_currency=sql_residual_currency,
decimal_places=currency.decimal_places,
value=preferred_aml_residual_value,
currency=sql_currency,
currency_id=currency.id,
order=sql_order,
)
return sql_order
def copy_data(self, default=None):
data_list = super().copy_data(default=default)
for line, values in zip(self, data_list):
if 'move_reverse_cancel' in self.env.context:
values['deferred_start_date'] = line.deferred_start_date
values['deferred_end_date'] = line.deferred_end_date
return data_list
def write(self, vals):
""" Prevent changing the account of a move line when there are already deferral entries.
"""
if 'account_id' in vals:
for line in self:
if (
line.has_deferred_moves
and line.deferred_start_date
and line.deferred_end_date
and vals['account_id'] != line.account_id.id
):
raise UserError(_(
"You cannot change the account for a deferred line in %(move_name)s if it has already been deferred.",
move_name=line.move_id.display_name
))
return super().write(vals)
@api.depends('balance')
def _compute_full_amount_switch_html(self):
for line in self:
# Ignore lines with no reconciled lines or not linked to a bank statement.
if not (reconciled_lines := line.reconciled_lines_excluding_exchange_diff_ids) or len(reconciled_lines) > 1 or not line.statement_line_id:
line.full_amount_switch_html = False
continue
is_invoice = reconciled_lines.move_id.is_invoice(include_receipts=True)
btn_start = markupsafe.Markup("<a name='apply_full_amount' type='object' class='btn btn-link p-0 align-baseline'>")
if reconciled_lines.currency_id.is_zero(reconciled_lines.amount_currency + line.amount_currency):
lines = [
_("%(display_name_html)s will be entirely paid by the transaction.")
if is_invoice else
_("%(display_name_html)s will be fully reconciled by the transaction.")
]
liquidity_line_amount_currency = line.move_id.line_ids.filtered(lambda line: line.account_id == line.move_id.journal_id.default_account_id).amount_currency
# Means that we possibly want to come back to partial
if float_compare(liquidity_line_amount_currency, reconciled_lines.amount_currency, 2) < 0:
btn_start = markupsafe.Markup("<a name='apply_partial_amount' type='object' class='btn btn-link p-0 align-baseline'>")
lines.append(
_("You might want to record a %(btn_start)spartial payment%(btn_end)s.")
if is_invoice else
_("You might want to make a %(btn_start)spartial reconciliation%(btn_end)s instead.")
)
else:
if is_invoice:
lines = [
_("%(display_name_html)s will be reduced by %(amount)s."),
_("You might want to set the invoice as %(btn_start)sfully paid%(btn_end)s."),
]
else:
lines = [
_("%(display_name_html)s will be reduced by %(amount)s."),
_("You might want to %(btn_start)sfully reconcile%(btn_end)s the document."),
]
# We need to use span instead of button here because button is not displayed otherwise
display_name_html = markupsafe.Markup("""
<a name='action_redirect_to_move' type='object' class="btn btn-link p-0 align-baseline fst-italic">%(display_name)s</a>
""") % {
'display_name': reconciled_lines.move_id._get_move_display_name(show_ref=False),
}
extra_text = markupsafe.Markup("<br/>").join(lines) % {
'amount': formatLang(self.env, line.amount_currency, currency_obj=line.currency_id),
'display_name_html': display_name_html,
'btn_start': btn_start,
'btn_end': markupsafe.Markup("</a>"),
}
line.full_amount_switch_html = markupsafe.Markup("<div class='text-muted'>%s</div>") % extra_text
# ============================= START - Deferred management ====================================
def _compute_has_deferred_moves(self):
for line in self:
line.has_deferred_moves = line.move_id.deferred_move_ids
@api.depends('deferred_start_date', 'deferred_end_date')
def _compute_has_abnormal_deferred_dates(self):
# In the deferred computations, we always assume that both the start and end date are inclusive
# E.g: 1st January -> 31st December is *exactly* 1 year = 12 months
# However, the user may instead put 1st January -> 1st January of next year which is then
# 12 months + 1/30 month = 12.03 months which may result in odd amounts when deferrals are created
# For this reason, we alert the user if we detect such a case
# Other cases were the number of months is not round should not be handled.
for line in self:
line.has_abnormal_deferred_dates = (
line.deferred_start_date
and line.deferred_end_date
and line.deferred_start_date != line.deferred_end_date
and float_compare(
self.env['account.move']._get_deferred_diff_dates(line.deferred_start_date, line.deferred_end_date + relativedelta(days=1)) % 1, # end date is included
1 / 30,
precision_digits=2
) == 0
)
def _has_deferred_compatible_account(self):
self.ensure_one()
return (
self.move_id.is_purchase_document(include_receipts=True)
and
self.account_id.internal_group == 'expense'
) or (
self.move_id.is_sale_document(include_receipts=True)
and
self.account_id.internal_group == 'income'
) or (
self.move_id.is_entry()
and
self.account_id.internal_group in ('expense', 'income')
)
@api.onchange('deferred_start_date', 'account_id')
def _onchange_deferred_start_date(self):
if not self._has_deferred_compatible_account():
self.deferred_start_date = False
if self.deferred_start_date and not self.deferred_end_date:
self.deferred_end_date = self.deferred_start_date
@api.onchange('deferred_end_date', 'account_id')
def _onchange_deferred_end_date(self):
if not self._has_deferred_compatible_account():
self.deferred_end_date = False
if self.deferred_end_date and not self.deferred_start_date:
self.deferred_start_date = self.deferred_end_date
@api.depends('deferred_end_date', 'move_id.invoice_date', 'move_id.state')
def _compute_deferred_start_date(self):
for line in self:
if not line.deferred_start_date and line.move_id.invoice_date and line.deferred_end_date:
line.deferred_start_date = line.move_id.invoice_date
@api.constrains('deferred_start_date', 'deferred_end_date', 'account_id')
def _check_deferred_dates(self):
for line in self:
if line.deferred_start_date and not line.deferred_end_date:
raise UserError(_("You cannot create a deferred entry with a start date but no end date."))
elif line.deferred_start_date and line.deferred_end_date and line.deferred_start_date > line.deferred_end_date:
raise UserError(_("You cannot create a deferred entry with a start date later than the end date."))
@api.model
def _get_deferred_ends_of_month(self, start_date, end_date):
"""
:return: a list of dates corresponding to the end of each month between start_date and end_date.
See test_get_ends_of_month for examples.
"""
dates = []
while start_date <= end_date:
start_date = start_date + relativedelta(day=31) # Go to end of month
dates.append(start_date)
start_date = start_date + relativedelta(days=1) # Go to first day of next month
return dates
def _get_deferred_periods(self):
"""
:return: a list of tuples (start_date, end_date) during which the deferred expense/revenue is spread.
If there is only one period containing the move date, it means that we don't need to defer the
expense/revenue since the invoice deferral and its deferred entry will be created on the same day and will
thus cancel each other.
"""
self.ensure_one()
periods = [
(max(self.deferred_start_date, date.replace(day=1)), min(date, self.deferred_end_date), 'current')
for date in self._get_deferred_ends_of_month(self.deferred_start_date, self.deferred_end_date)
]
if not periods or len(periods) == 1 and periods[0][0].replace(day=1) == self.date.replace(day=1):
return []
else:
return periods
@api.model
def _get_deferred_amounts_by_line_values(self, line):
return {
'account_id': line['account_id'],
# line either be a dict with ids (coming from SQL query), or a real account.move.line object
'product_id': line['product_id'] if isinstance(line, dict) else line['product_id'].id,
'product_category_id': line['product_category_id'] if isinstance(line, dict) else line['product_category_id'].id,
'balance': line['balance'],
'move_id': line['move_id'],
}
@api.model
def _get_deferred_lines_values(self, account_id, balance, ref, analytic_distribution, line=None):
return {
'account_id': account_id,
# line either be a dict with ids (coming from SQL query), or a real account.move.line object
'product_id': line['product_id'] if isinstance(line, dict) else line['product_id'].id,
'product_category_id': line['product_category_id'] if isinstance(line, dict) else line['product_category_id'].id,
'balance': balance,
'name': ref,
'analytic_distribution': analytic_distribution,
}
# ============================= END - Deferred management ====================================
def _get_computed_taxes(self):
if self.move_id.deferred_original_move_ids:
# If this line is part of a deferral move, do not (re)calculate its taxes automatically.
# Doing so might unvoluntarily impact the tax report in deferral moves (if a default tax is set on the account).
return self.tax_ids
return super()._get_computed_taxes()
def _compute_attachment(self):
id_model2attachments = {
(res_model, res_id): attachments
for res_model, res_id, attachments in self.env['ir.attachment']._read_group(
domain=Domain.OR(self._get_attachment_domains()),
groupby=['res_model', 'res_id'],
aggregates=['id:recordset'],
)
}
for record in self:
record.move_attachment_ids = self._get_attachment_by_record(id_model2attachments, record)
def action_reconcile(self):
""" This function is called by the 'Reconcile' button of account.move.line's
list view. It performs reconciliation between the selected lines.
- If the reconciliation can be done directly we do it silently
- Else, if a write-off is required we open the wizard to let the client enter required information
"""
self = self.filtered(lambda x: x.balance or x.amount_currency) # noqa: PLW0642
if not self:
return
wizard = self.env['account.reconcile.wizard'].with_context(
active_model='account.move.line',
active_ids=self.ids,
).new({})
return wizard._action_open_wizard() if (wizard.is_write_off_required or wizard.force_partials) else wizard.reconcile()
def _get_predict_postgres_dictionary(self):
lang = self.env.context.get('lang') and self.env.context.get('lang')[:2]
return {'fr': 'french'}.get(lang, 'english')
@api.model
def _build_predictive_query(self, move_id, additional_domain=None, partner=None):
move_query = self.env['account.move']._search([
('move_type', '=', move_id.move_type),
('state', '=', 'posted'),
('partner_id', '=', (partner or move_id.partner_id).id),
('company_id', '=', move_id.journal_id.company_id.id or self.env.company.id),
], bypass_access=True)
move_query.order = 'account_move.invoice_date'
move_query.limit = int(self.env["ir.config_parameter"].sudo().get_param(
"account.bill.predict.history.limit",
'100',
))
return self.env['account.move.line']._search([
('move_id', 'in', move_query),
('display_type', '=', 'product'),
] + (additional_domain or []), bypass_access=True)
@api.model
def _predicted_field(self, move_id, name, partner_id, field, query=None, additional_queries=None):
r"""Predict the most likely value based on the previous history.
This method uses postgres tsvector in order to try to deduce a field of
an invoice line based on the text entered into the name (description)
field and the partner linked.
We only limit the search on the previous 100 entries, which according
to our tests bore the best results. However this limit parameter is
configurable by creating a config parameter with the key:
account.bill.predict.history.limit
For information, the tests were executed with a dataset of 40 000 bills
from a live database, We split the dataset in 2, removing the 5000 most
recent entries and we tried to use this method to guess the account of
this validation set based on the previous entries.
The result is roughly 90% of success.
:param str field: the sql column that has to be predicted.
/!\ it is injected in the query without any checks.
:param odoo.tools.query.Query query: the query object on
account.move.line that is used to do the ranking, containing the
right domain, limit, etc. If it is omitted, a default query is used.
:param list[str] additional_queries: can be used in addition to the
default query on account.move.line to fetch data coming from other
tables, to have starting values for instance.
/!\ it is injected in the query without any checks.
"""
if not name or not partner_id:
return False
psql_lang = self._get_predict_postgres_dictionary()
description = name + ' account_move_line' # give more priority to main query than additional queries
parsed_description = re.sub(r"[*&()|!':<>=%/~@,.;$\[\]]+", " ", description)
parsed_description = ' | '.join(parsed_description.split())
try:
main_source = (query if query is not None else self._build_predictive_query(move_id, partner=partner_id)).select(
SQL("%s AS prediction", field),
SQL(
"setweight(to_tsvector(%s, account_move_line.name), 'B') || setweight(to_tsvector('simple', 'account_move_line'), 'A') AS document",
psql_lang
),
)
if "(" in field.code: # aggregate function
main_source = SQL("%s %s", main_source, SQL("GROUP BY account_move_line.id, account_move_line.name, account_move_line.partner_id"))
self.env.cr.execute(SQL("""
WITH account_move_line AS MATERIALIZED (%(account_move_line)s),
source AS (%(source)s),
ranking AS (
SELECT prediction, ts_rank(source.document, query_plain) AS rank
FROM source, to_tsquery(%(lang)s, %(description)s) query_plain
WHERE source.document @@ query_plain
)
SELECT prediction, MAX(rank) AS ranking, COUNT(*)
FROM ranking
GROUP BY prediction
ORDER BY ranking DESC, count DESC
LIMIT 2
""",
account_move_line=self._build_predictive_query(move_id, partner=partner_id).select(SQL('*')),
source=SQL('(%s)', SQL(') UNION ALL (').join([main_source] + (additional_queries or []))),
lang=psql_lang,
description=parsed_description,
))
result = self.env.cr.dictfetchall()
if result:
# Only confirm the prediction if it's at least 10% better than the second one
if len(result) > 1 and result[0]['ranking'] < 1.1 * result[1]['ranking']:
return False
return result[0]['prediction']
except Exception:
# In case there is an error while parsing the to_tsquery (wrong character for example)
# We don't want to have a blocking traceback, instead return False
_logger.exception('Error while predicting invoice line fields')
return False
def _predict_taxes(self):
field = SQL('array_agg(account_move_line__tax_rel__tax_ids.id ORDER BY account_move_line__tax_rel__tax_ids.id)')
query = self._build_predictive_query(self.move_id)
query.left_join('account_move_line', 'id', 'account_move_line_account_tax_rel', 'account_move_line_id', 'tax_rel')
query.left_join('account_move_line__tax_rel', 'account_tax_id', 'account_tax', 'id', 'tax_ids')
query.add_where('account_move_line__tax_rel__tax_ids.active IS NOT FALSE')
predicted_tax_ids = self._predicted_field(self.move_id, self.name, self.partner_id, field, query)
if predicted_tax_ids == [None]:
return False
if predicted_tax_ids is not False and set(predicted_tax_ids) != set(self.tax_ids.ids):
return predicted_tax_ids
return False
@api.model
def _predict_specific_tax(self, move, name, partner, amount_type, amount, type_tax_use):
field = SQL('array_agg(account_move_line__tax_rel__tax_ids.id ORDER BY account_move_line__tax_rel__tax_ids.id)')
query = self._build_predictive_query(move, partner=partner)
query.left_join('account_move_line', 'id', 'account_move_line_account_tax_rel', 'account_move_line_id', 'tax_rel')
query.left_join('account_move_line__tax_rel', 'account_tax_id', 'account_tax', 'id', 'tax_ids')
query.add_where("""
account_move_line__tax_rel__tax_ids.active IS NOT FALSE
AND account_move_line__tax_rel__tax_ids.amount_type = %s
AND account_move_line__tax_rel__tax_ids.type_tax_use = %s
AND account_move_line__tax_rel__tax_ids.amount = %s
""", (amount_type, type_tax_use, amount))
return self._predicted_field(move, name, partner, field, query)
@api.model
def _predict_specific_product(self, move, name, partner):
predict_product = int(self.env['ir.config_parameter'].sudo().get_param('account_predictive_bills.predict_product', '1'))
if predict_product and move.company_id.predict_bill_product:
query = self._build_predictive_query(
move_id=move,
additional_domain=['|', ('product_id', '=', False), ('product_id.active', '=', True)],
partner=partner or move.partner_id,
)
return self._predicted_field(move, name, partner, SQL('account_move_line.product_id'), query)
return False
def _predict_product(self):
predicted_product_id = self._predict_specific_product(self.move_id, self.name, self.partner_id)
if predicted_product_id and predicted_product_id != self.product_id.id:
return predicted_product_id
return False
@api.model
def _predict_specific_account(self, move, name, partner):
field = SQL('account_move_line.account_id')
if move.is_purchase_document(True):
excluded_group = 'income'
else:
excluded_group = 'expense'
account_query = self.env['account.account']._search([
*self.env['account.account']._check_company_domain(move.company_id or self.env.company),
('internal_group', 'not in', (excluded_group, 'off')),
('account_type', 'not in', ('liability_payable', 'asset_receivable')),
], bypass_access=True)
account_name = self.env['account.account']._field_to_sql('account_account', 'name')
psql_lang = self._get_predict_postgres_dictionary()
additional_queries = [SQL(account_query.select(
SQL("account_account.id AS account_id"),
SQL("setweight(to_tsvector(%(psql_lang)s, %(account_name)s), 'B') AS document", psql_lang=psql_lang, account_name=account_name),
))]
query = self._build_predictive_query(move, [('account_id', 'in', account_query)], partner=partner)
return self._predicted_field(move, name, partner, field, query, additional_queries)
def _predict_account(self):
predicted_account_id = self._predict_specific_account(self.move_id, self.name, self.partner_id)
if predicted_account_id and predicted_account_id != self.account_id.id:
return predicted_account_id
return False
def _predict_deductible_amount(self):
if self.account_id and self.partner_id and self.env.user.has_group('account.group_partial_purchase_deductibility'):
field = SQL('account_move_line.deductible_amount')
query = self._build_predictive_query(self.move_id, [('account_id', '=', self.account_id.id)])
predicted_deductible_amount = self._predicted_field(self.move_id, self.name, self.partner_id, field, query)
if predicted_deductible_amount and predicted_deductible_amount != self.deductible_amount:
return predicted_deductible_amount
return False
@api.onchange('name')
def _onchange_name_predictive(self):
if ((self.move_id.quick_edit_mode or self.move_id.move_type == 'in_invoice') and self.name and self.display_type == 'product'
and not self.env.context.get('disable_onchange_name_predictive', False)):
if not self.product_id:
predicted_product_id = self._predict_product()
if predicted_product_id:
# We only update the price_unit, tax_ids and name in case they evaluate to False
protected_fields = ['price_unit', 'tax_ids', 'name']
to_protect = [self._fields[fname] for fname in protected_fields if self[fname]]
with self.env.protecting(to_protect, self):
self.product_id = predicted_product_id
# In case no product has been set, the account and taxes
# will not depend on any product and can thus be predicted
if not self.product_id:
# Predict account.
predicted_account_id = self._predict_account()
if predicted_account_id:
self.account_id = predicted_account_id
# Predict taxes
predicted_tax_ids = self._predict_taxes()
if predicted_tax_ids:
self.tax_ids = [Command.set(predicted_tax_ids)]
# Predict Deductibility
predicted_deductible_amount = self._predict_deductible_amount()
if predicted_deductible_amount:
self.deductible_amount = predicted_deductible_amount
def _read_group_select(self, aggregate_spec, query):
# Enable to use HAVING clause that sum rounded values depending on the
# currency precision settings. Limitation: we only handle a having
# clause of one element with that specific method :sum_rounded.
fname, __, func = models.parse_read_group_spec(aggregate_spec)
if func != 'sum_rounded':
return super()._read_group_select(aggregate_spec, query)
currency_alias = query.make_alias(self._table, 'currency_id')
query.add_join('LEFT JOIN', currency_alias, 'res_currency', SQL(
"%s = %s",
self._field_to_sql(self._table, 'currency_id', query),
SQL.identifier(currency_alias, 'id'),
))
return SQL(
'SUM(ROUND(%s, %s))',
self._field_to_sql(self._table, fname, query),
self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query),
)
def _read_group_groupby(self, alias, groupby_spec, query):
# enable grouping by :abs_rounded on fields, which is useful when trying
# to match positive and negative amounts
if ':' in groupby_spec:
fname, method = groupby_spec.split(':')
if method == 'abs_rounded':
# rounds with the used currency settings
currency_alias = query.make_alias(self._table, 'currency_id')
query.add_join('LEFT JOIN', currency_alias, 'res_currency', SQL(
"%s = %s",
self._field_to_sql(self._table, 'currency_id', query),
SQL.identifier(currency_alias, 'id'),
))
return SQL(
'ROUND(ABS(%s), %s)',
self._field_to_sql(self._table, fname, query),
self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query),
)
return super()._read_group_groupby(alias, groupby_spec, query)