forked from Mapan/odoo17e
845 lines
40 KiB
Python
845 lines
40 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import calendar
|
|
from contextlib import contextmanager
|
|
from dateutil.relativedelta import relativedelta
|
|
import logging
|
|
import math
|
|
import re
|
|
|
|
from odoo import fields, models, api, _, Command
|
|
from odoo.exceptions import UserError
|
|
from odoo.osv import expression
|
|
from odoo.tools import frozendict, SQL, float_compare
|
|
|
|
|
|
_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'),
|
|
],
|
|
compute='_compute_deferred_entry_type',
|
|
copy=False,
|
|
)
|
|
|
|
@api.model
|
|
def _get_invoice_in_payment_state(self):
|
|
# OVERRIDE to enable the 'in_payment' state on invoices.
|
|
return 'in_payment'
|
|
|
|
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 action_post(self):
|
|
# EXTENDS 'account' to trigger the CRON auto-reconciling the statement lines.
|
|
res = super().action_post()
|
|
if self.statement_line_id and not self._context.get('skip_statement_line_cron_trigger'):
|
|
self.env.ref('account_accountant.auto_reconcile_bank_statement_line')._trigger()
|
|
return res
|
|
|
|
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.date = move._get_accounting_date(move.date, move._affect_tax_report())
|
|
self.deferred_move_ids |= reversed_moves
|
|
return super().button_draft()
|
|
|
|
# ============================= START - Deferred Management ====================================
|
|
|
|
def _get_deferred_entries_method(self):
|
|
self.ensure_one()
|
|
if self.is_outbound():
|
|
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.deferred_entry_type = 'expense' if move.deferred_original_move_ids[0].is_outbound() else '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).
|
|
"""
|
|
is_valid_period = period_end > line_start and period_end > period_start
|
|
if method == 'day':
|
|
amount_per_day = balance / (line_end - line_start).days
|
|
return (period_end - period_start).days * amount_per_day if is_valid_period else 0
|
|
elif method == "month":
|
|
amount_per_month = balance / self._get_deferred_diff_dates(line_end, line_start)
|
|
nb_months_period = self._get_deferred_diff_dates(period_end, period_start)
|
|
return nb_months_period * amount_per_month if is_valid_period else 0
|
|
elif method == "full_months":
|
|
line_diff = self._get_deferred_diff_dates(line_end, line_start)
|
|
period_diff = self._get_deferred_diff_dates(period_end, period_start)
|
|
if line_diff < 1:
|
|
amount = balance
|
|
else:
|
|
if line_end.day == calendar.monthrange(line_end.year, line_end.month)[1]:
|
|
line_diff = math.ceil(line_diff)
|
|
else:
|
|
line_diff = math.floor(line_diff)
|
|
if period_end.day == calendar.monthrange(period_end.year, period_end.month)[1] or line_end != period_end:
|
|
period_diff = math.ceil(period_diff)
|
|
else:
|
|
period_diff = math.floor(period_diff)
|
|
amount_per_month = balance / line_diff
|
|
amount = period_diff * amount_per_month
|
|
return amount if is_valid_period else 0
|
|
|
|
@api.model
|
|
def _get_deferred_amounts_by_line(self, lines, periods):
|
|
"""
|
|
: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)
|
|
if (
|
|
period[2] in ('not_started', 'later') and period[0] < line_start
|
|
or len(periods) <= 1
|
|
or period[2] not in ('not_started', 'before', 'later')
|
|
):
|
|
# We are subtracting 1 day from `period_start` because the start date should be included when:
|
|
# - in the 'Not Started' or 'Later' period if the deferral has not started yet (line_start, line_end)
|
|
# - we only have one period
|
|
# - not in the 'Not Started', 'Before' or 'Later' period
|
|
period_start -= relativedelta(days=1)
|
|
columns[period] = self._get_deferred_period_amount(
|
|
self.env.company.deferred_amount_computation_method,
|
|
period_start, period_end,
|
|
line_start - relativedelta(days=1), line_end, # -1 because we want to include the start date
|
|
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, period, ref, force_balance=None):
|
|
"""
|
|
: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])[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['account_id'], 1), (deferred_account, -1)]
|
|
]
|
|
|
|
def _generate_deferred_entries(self):
|
|
"""
|
|
Generates the deferred entries for the invoice.
|
|
"""
|
|
self.ensure_one()
|
|
if self.is_entry():
|
|
raise UserError(_("You cannot generate deferred entries for a miscellaneous journal entry."))
|
|
is_deferred_expense = self.is_purchase_document()
|
|
deferred_account = self.company_id.deferred_expense_account_id if is_deferred_expense else self.company_id.deferred_revenue_account_id
|
|
deferred_journal = self.company_id.deferred_journal_id
|
|
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 self.line_ids.filtered(lambda l: l.deferred_start_date and l.deferred_end_date):
|
|
periods = line._get_deferred_periods()
|
|
if not periods:
|
|
continue
|
|
|
|
ref = _("Deferral of %s", line.move_id.name or '')
|
|
|
|
move_vals = {
|
|
'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': '/',
|
|
}
|
|
|
|
# Defer the current invoice
|
|
move_fully_deferred = self.create({
|
|
**move_vals,
|
|
'date': line.move_id.date,
|
|
})
|
|
# 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.
|
|
move_fully_deferred.write({
|
|
'line_ids': [
|
|
Command.create(
|
|
self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * line.balance, ref, line.analytic_distribution, line)
|
|
) for (account, coeff) in [(line.account_id, -1), (deferred_account, 1)]
|
|
],
|
|
})
|
|
|
|
# Create the deferred entries for the periods [deferred_start_date, deferred_end_date]
|
|
deferral_moves = self.create([{
|
|
**move_vals,
|
|
'date': period[1],
|
|
} for period in periods])
|
|
remaining_balance = line.balance
|
|
for period_index, (period, deferral_move) in enumerate(zip(periods, deferral_moves)):
|
|
# 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
|
|
# Same as before, to avoid adding taxes for deferred moves.
|
|
deferral_move.write({
|
|
'line_ids': self._get_deferred_lines(line, deferred_account, period, ref, force_balance=force_balance),
|
|
})
|
|
remaining_balance -= deferral_move.line_ids[0].balance
|
|
# Avoid having deferral moves with a total amount of 0
|
|
if deferral_move.currency_id.is_zero(deferral_move.amount_total):
|
|
deferral_moves -= deferral_move
|
|
deferral_move.unlink()
|
|
|
|
deferred_moves = move_fully_deferred + deferral_moves
|
|
if len(deferral_moves) == 1 and move_fully_deferred.date.month == deferral_moves.date.month:
|
|
# If, after calculation, we have 2 deferral entries in the same month, it means that
|
|
# they simply cancel out each other, so there is no point in creating them.
|
|
deferred_moves.unlink()
|
|
continue
|
|
line.move_id.deferred_move_ids |= deferred_moves
|
|
deferred_moves._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': [(False, 'tree'), (False, 'form')],
|
|
'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, 'tree'), (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
|
|
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()
|
|
|
|
|
|
class AccountMoveLine(models.Model):
|
|
_name = "account.move.line"
|
|
_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')
|
|
|
|
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._context.get('preferred_aml_value')
|
|
preferred_aml_currency_id = self._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._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)
|
|
|
|
# ============================= 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 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 _is_compatible_account(self):
|
|
self.ensure_one()
|
|
return (
|
|
self.move_id.is_purchase_document()
|
|
and
|
|
self.account_id.account_type in ('expense', 'expense_depreciation', 'expense_direct_cost')
|
|
) or (
|
|
self.move_id.is_sale_document()
|
|
and
|
|
self.account_id.account_type in ('income', 'income_other')
|
|
)
|
|
|
|
@api.onchange('deferred_start_date')
|
|
def _onchange_deferred_start_date(self):
|
|
if not self._is_compatible_account():
|
|
self.deferred_start_date = False
|
|
|
|
@api.onchange('deferred_end_date')
|
|
def _onchange_deferred_end_date(self):
|
|
if not self._is_compatible_account():
|
|
self.deferred_end_date = False
|
|
|
|
@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_tax_key(self, line, tax_key, tax_repartition_line_id):
|
|
if (
|
|
line.deferred_start_date
|
|
and line.deferred_end_date
|
|
and line._is_compatible_account()
|
|
and tax_repartition_line_id
|
|
and not tax_repartition_line_id.use_in_tax_closing
|
|
):
|
|
return frozendict(
|
|
**tax_key,
|
|
deferred_start_date=line.deferred_start_date,
|
|
deferred_end_date=line.deferred_end_date,
|
|
)
|
|
return tax_key
|
|
|
|
@api.depends('deferred_start_date', 'deferred_end_date')
|
|
def _compute_tax_key(self):
|
|
super()._compute_tax_key()
|
|
for line in self:
|
|
line.tax_key = self._get_deferred_tax_key(line, line.tax_key, line.tax_repartition_line_id)
|
|
|
|
@api.depends('deferred_start_date', 'deferred_end_date')
|
|
def _compute_all_tax(self):
|
|
super()._compute_all_tax()
|
|
for line in self:
|
|
for key in list(line.compute_all_tax.keys()):
|
|
tax_repartition_line_id = self.env['account.tax.repartition.line'].browse(key.get('tax_repartition_line_id'))
|
|
new_key = self._get_deferred_tax_key(line, key, tax_repartition_line_id)
|
|
line.compute_all_tax[new_key] = line.compute_all_tax.pop(key)
|
|
|
|
@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,
|
|
'balance': line['balance'],
|
|
'move_id': line['move_id'],
|
|
}
|
|
|
|
@api.model
|
|
def _get_deferred_lines_values(self, account_id, balance, ref, analytic_distribution, line=None):
|
|
res = {
|
|
'account_id': account_id,
|
|
'balance': balance,
|
|
'name': ref,
|
|
'analytic_distribution': analytic_distribution,
|
|
}
|
|
# TEMP FIX
|
|
# didn't add the 'product_id' key directly to the dictionary for v17.0
|
|
# because if any existing custom module calls this function, it breaks the flow.
|
|
if line:
|
|
# line either be a dict with ids (coming from SQL query), or a real account.move.line object
|
|
res['product_id'] = line['product_id'] if isinstance(line, dict) else line['product_id'].id
|
|
return res
|
|
|
|
# ============================= 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):
|
|
for record in self:
|
|
record.move_attachment_ids = self.env['ir.attachment'].search(expression.OR(record._get_attachment_domains()))
|
|
|
|
def action_reconcile(self):
|
|
""" This function is called by the 'Reconcile' button of account.move.line's
|
|
tree 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
|
|
"""
|
|
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._context.get('lang') and self._context.get('lang')[:2]
|
|
return {'fr': 'french'}.get(lang, 'english')
|
|
|
|
def _build_predictive_query(self, additional_domain=None):
|
|
move_query = self.env['account.move']._where_calc([
|
|
('move_type', '=', self.move_id.move_type),
|
|
('state', '=', 'posted'),
|
|
('partner_id', '=', self.move_id.partner_id.id),
|
|
('company_id', '=', self.move_id.journal_id.company_id.id or self.env.company.id),
|
|
])
|
|
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']._where_calc([
|
|
('move_id', 'in', move_query),
|
|
('display_type', '=', 'product'),
|
|
] + (additional_domain or []))
|
|
|
|
def _predicted_field(self, 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 field (str): the sql column that has to be predicted.
|
|
/!\ it is injected in the query without any checks.
|
|
:param query (osv.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 additional_queries (list<str>): 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 self.name or not self.partner_id:
|
|
return False
|
|
|
|
psql_lang = self._get_predict_postgres_dictionary()
|
|
description = self.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())
|
|
|
|
from_clause, where_clause, params = (query if query is not None else self._build_predictive_query()).get_sql()
|
|
mask_from_clause, mask_where_clause, mask_params = self._build_predictive_query().get_sql()
|
|
try:
|
|
account_move_line = self.env.cr.mogrify(
|
|
f"SELECT account_move_line.* FROM {mask_from_clause} WHERE {mask_where_clause}",
|
|
mask_params,
|
|
).decode()
|
|
group_by_clause = ""
|
|
if "(" in field: # aggregate function
|
|
group_by_clause = "GROUP BY account_move_line.id, account_move_line.name, account_move_line.partner_id"
|
|
self.env.cr.execute(f"""
|
|
WITH account_move_line AS MATERIALIZED ({account_move_line}),
|
|
source AS ({'(' + ') UNION ALL ('.join([self.env.cr.mogrify(f'''
|
|
SELECT {field} AS prediction,
|
|
setweight(to_tsvector(%%(lang)s, account_move_line.name), 'B')
|
|
|| setweight(to_tsvector('simple', 'account_move_line'), 'A') AS document
|
|
FROM {from_clause}
|
|
WHERE {where_clause}
|
|
{group_by_clause}
|
|
''', params).decode()] + (additional_queries or [])) + ')'}
|
|
),
|
|
|
|
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
|
|
""", {
|
|
'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 = '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()
|
|
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(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
|
|
|
|
def _predict_specific_tax(self, amount_type, amount, type_tax_use):
|
|
field = '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()
|
|
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(field, query)
|
|
|
|
def _predict_product(self):
|
|
predict_product = int(self.env['ir.config_parameter'].sudo().get_param('account_predictive_bills.predict_product', '1'))
|
|
if predict_product and self.company_id.predict_bill_product:
|
|
query = self._build_predictive_query(['|', ('product_id', '=', False), ('product_id.active', '=', True)])
|
|
predicted_product_id = self._predicted_field('account_move_line.product_id', query)
|
|
if predicted_product_id and predicted_product_id != self.product_id.id:
|
|
return predicted_product_id
|
|
return False
|
|
|
|
def _predict_account(self):
|
|
field = 'account_move_line.account_id'
|
|
if self.move_id.is_purchase_document(True):
|
|
excluded_group = 'income'
|
|
else:
|
|
excluded_group = 'expense'
|
|
account_query = self.env['account.account']._where_calc([
|
|
*self.env['account.account']._check_company_domain(self.move_id.company_id or self.env.company),
|
|
('deprecated', '=', False),
|
|
('internal_group', 'not in', (excluded_group, 'off_balance')),
|
|
('account_type', 'not in', ('liability_payable', 'asset_receivable')),
|
|
])
|
|
psql_lang = self._get_predict_postgres_dictionary()
|
|
additional_queries = [self.env.cr.mogrify(*account_query.select(
|
|
"account_account.id AS account_id",
|
|
SQL("setweight(to_tsvector(%s, name), 'B') AS document", psql_lang),
|
|
)).decode()]
|
|
query = self._build_predictive_query([('account_id', 'in', account_query)])
|
|
|
|
predicted_account_id = self._predicted_field(field, query, additional_queries)
|
|
if predicted_account_id and predicted_account_id != self.account_id.id:
|
|
return predicted_account_id
|
|
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)]
|
|
|
|
def _read_group_groupby(self, 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 fname in self and method == 'abs_rounded': # field in self avoids possible injections
|
|
# rounds with the used currency settings
|
|
sql_field = self._field_to_sql(self._table, fname, query)
|
|
currency_alias = query.left_join(self._table, 'currency_id', 'res_currency', 'id', 'currency_id')
|
|
sql_decimal = self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query)
|
|
sql_group = SQL('ROUND(ABS(%s), %s)', sql_field, sql_decimal)
|
|
return sql_group, [fname, 'currency_id']
|
|
|
|
return super()._read_group_groupby(groupby_spec, query)
|
|
|
|
def _read_group_having(self, having_domain, 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.
|
|
if len(having_domain) == 1:
|
|
left, operator, right = having_domain[0]
|
|
fname, *funcs = left.split(':')
|
|
if fname in self and funcs == ['sum_rounded']: # fname in self avoids possible injections
|
|
sql_field = self._field_to_sql(self._table, fname, query)
|
|
currency_alias = query.left_join(self._table, 'currency_id', 'res_currency', 'id', 'currency_id')
|
|
sql_decimal = self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query)
|
|
sql_operator = expression.SQL_OPERATORS[operator]
|
|
sql_expr = SQL(
|
|
'SUM(ROUND(%s, %s)) %s %s',
|
|
sql_field, sql_decimal, sql_operator, right,
|
|
)
|
|
return sql_expr, [fname]
|
|
return super()._read_group_having(having_domain, query)
|