1843 lines
78 KiB
Python
1843 lines
78 KiB
Python
"""
|
|
Fusion Accounting - Journal Entry Extensions
|
|
|
|
Augments the core account.move and account.move.line models with capabilities
|
|
for deferred revenue/expense management, digital invoice signatures,
|
|
VAT period closing workflows, asset depreciation tracking, and predictive
|
|
line item suggestions.
|
|
"""
|
|
|
|
import ast
|
|
import calendar
|
|
import datetime
|
|
import logging
|
|
import math
|
|
import re
|
|
from contextlib import contextmanager
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
from markupsafe import Markup
|
|
|
|
from odoo import api, fields, models, Command, _
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.osv import expression
|
|
from odoo.tools import SQL, float_compare
|
|
from odoo.tools.misc import format_date, formatLang
|
|
try:
|
|
from odoo.addons.account.models.exceptions import TaxClosingNonPostedDependingMovesError
|
|
except (ImportError, ModuleNotFoundError):
|
|
# Fallback: define a placeholder so references don't break at runtime.
|
|
# Tax-closing redirect will simply not be caught.
|
|
class TaxClosingNonPostedDependingMovesError(Exception):
|
|
pass
|
|
from odoo.addons.web.controllers.utils import clean_action
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
# Date boundaries for deferred entries
|
|
DEFERRED_DATE_MIN = datetime.date(1900, 1, 1)
|
|
DEFERRED_DATE_MAX = datetime.date(9999, 12, 31)
|
|
|
|
|
|
class FusionAccountMove(models.Model):
|
|
"""
|
|
Augments journal entries with deferral tracking, invoice signatures,
|
|
VAT closing workflows, and fixed asset depreciation management.
|
|
"""
|
|
_inherit = "account.move"
|
|
|
|
# ---- Payment State Tracking ----
|
|
payment_state_before_switch = fields.Char(
|
|
string="Cached Payment Status",
|
|
copy=False,
|
|
help="Stores the payment state prior to resetting the entry to draft.",
|
|
)
|
|
|
|
# ---- Deferral Linkage ----
|
|
deferred_move_ids = fields.Many2many(
|
|
comodel_name='account.move',
|
|
relation='account_move_deferred_rel',
|
|
column1='original_move_id',
|
|
column2='deferred_move_id',
|
|
string="Generated Deferrals",
|
|
copy=False,
|
|
help="Journal entries created to spread this document's revenue or expense across periods.",
|
|
)
|
|
deferred_original_move_ids = fields.Many2many(
|
|
comodel_name='account.move',
|
|
relation='account_move_deferred_rel',
|
|
column1='deferred_move_id',
|
|
column2='original_move_id',
|
|
string="Source Documents",
|
|
copy=False,
|
|
help="The originating invoices or bills that produced this deferral entry.",
|
|
)
|
|
deferred_entry_type = fields.Selection(
|
|
selection=[
|
|
('expense', 'Deferred Expense'),
|
|
('revenue', 'Deferred Revenue'),
|
|
],
|
|
string="Deferral Category",
|
|
compute='_compute_deferred_entry_type',
|
|
copy=False,
|
|
)
|
|
|
|
# ---- Invoice Signature ----
|
|
signing_user = fields.Many2one(
|
|
comodel_name='res.users',
|
|
string='Authorized Signer',
|
|
compute='_compute_signing_user',
|
|
store=True,
|
|
copy=False,
|
|
)
|
|
show_signature_area = fields.Boolean(
|
|
compute='_compute_signature',
|
|
)
|
|
signature = fields.Binary(
|
|
compute='_compute_signature',
|
|
)
|
|
|
|
# ---- VAT / Tax Closing ----
|
|
tax_closing_report_id = fields.Many2one(
|
|
comodel_name='account.report',
|
|
string="Tax Report Reference",
|
|
)
|
|
tax_closing_alert = fields.Boolean(
|
|
compute='_compute_tax_closing_alert',
|
|
)
|
|
|
|
# ---- Loan Linkage ----
|
|
fusion_loan_id = fields.Many2one(
|
|
'fusion.loan',
|
|
string='Loan',
|
|
index=True,
|
|
ondelete='set null',
|
|
copy=False,
|
|
help="The loan record this journal entry belongs to.",
|
|
)
|
|
|
|
# ---- Fixed Asset Depreciation ----
|
|
asset_id = fields.Many2one(
|
|
'account.asset',
|
|
string='Asset',
|
|
index=True,
|
|
ondelete='cascade',
|
|
copy=False,
|
|
domain="[('company_id', '=', company_id)]",
|
|
)
|
|
asset_remaining_value = fields.Monetary(
|
|
string='Depreciable Value',
|
|
compute='_compute_depreciation_cumulative_value',
|
|
)
|
|
asset_depreciated_value = fields.Monetary(
|
|
string='Cumulative Depreciation',
|
|
compute='_compute_depreciation_cumulative_value',
|
|
)
|
|
asset_value_change = fields.Boolean(
|
|
help="Indicates this entry results from an asset revaluation.",
|
|
)
|
|
asset_number_days = fields.Integer(
|
|
string="Depreciation Days",
|
|
copy=False,
|
|
)
|
|
asset_depreciation_beginning_date = fields.Date(
|
|
string="Depreciation Start",
|
|
copy=False,
|
|
)
|
|
depreciation_value = fields.Monetary(
|
|
string="Depreciation Amount",
|
|
compute="_compute_depreciation_value",
|
|
inverse="_inverse_depreciation_value",
|
|
store=True,
|
|
)
|
|
asset_ids = fields.One2many(
|
|
'account.asset',
|
|
string='Linked Assets',
|
|
compute="_compute_asset_ids",
|
|
)
|
|
asset_id_display_name = fields.Char(
|
|
compute="_compute_asset_ids",
|
|
)
|
|
count_asset = fields.Integer(
|
|
compute="_compute_asset_ids",
|
|
)
|
|
draft_asset_exists = fields.Boolean(
|
|
compute="_compute_asset_ids",
|
|
)
|
|
asset_move_type = fields.Selection(
|
|
selection=[
|
|
('depreciation', 'Depreciation'),
|
|
('sale', 'Sale'),
|
|
('purchase', 'Purchase'),
|
|
('disposal', 'Disposal'),
|
|
('negative_revaluation', 'Negative revaluation'),
|
|
('positive_revaluation', 'Positive revaluation'),
|
|
],
|
|
string='Asset Move Type',
|
|
compute='_compute_asset_move_type',
|
|
store=True,
|
|
copy=False,
|
|
)
|
|
|
|
# =========================================================================
|
|
# HELPERS
|
|
# =========================================================================
|
|
|
|
def _get_invoice_in_payment_state(self):
|
|
return 'in_payment'
|
|
|
|
def _get_deferred_entries_method(self):
|
|
"""Determine whether this entry uses expense or revenue deferral settings."""
|
|
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
|
|
|
|
# =========================================================================
|
|
# COMPUTE: SIGNATURE
|
|
# =========================================================================
|
|
|
|
@api.depends('state', 'move_type', 'invoice_user_id')
|
|
def _compute_signing_user(self):
|
|
non_sales = self.filtered(lambda m: not m.is_sale_document())
|
|
non_sales.signing_user = False
|
|
|
|
current_is_root = self.env.user == self.env.ref('base.user_root')
|
|
current_is_internal = self.env.user.has_group('base.group_user')
|
|
|
|
for inv in (self - non_sales).filtered(lambda r: r.state == 'posted'):
|
|
company_rep = inv.company_id.signing_user
|
|
if current_is_root:
|
|
salesperson = inv.invoice_user_id
|
|
can_sign = salesperson and salesperson.has_group('base.group_user')
|
|
inv.signing_user = company_rep or (salesperson if can_sign else False)
|
|
else:
|
|
inv.signing_user = company_rep or (self.env.user if current_is_internal else False)
|
|
|
|
@api.depends('state')
|
|
def _compute_signature(self):
|
|
is_portal = self.env.user.has_group('base.group_portal')
|
|
excluded = self.filtered(
|
|
lambda rec: (
|
|
not rec.company_id.sign_invoice
|
|
or rec.state in ('draft', 'cancel')
|
|
or not rec.is_sale_document()
|
|
or (is_portal and not rec.invoice_pdf_report_id)
|
|
)
|
|
)
|
|
excluded.show_signature_area = False
|
|
excluded.signature = None
|
|
|
|
signable = self - excluded
|
|
signable.show_signature_area = True
|
|
for record in signable:
|
|
record.signature = record.signing_user.sign_signature
|
|
|
|
# =========================================================================
|
|
# COMPUTE: DEFERRAL
|
|
# =========================================================================
|
|
|
|
@api.depends('deferred_original_move_ids')
|
|
def _compute_deferred_entry_type(self):
|
|
for entry in self:
|
|
if entry.deferred_original_move_ids:
|
|
first_source = entry.deferred_original_move_ids[0]
|
|
entry.deferred_entry_type = 'expense' if first_source.is_outbound() else 'revenue'
|
|
else:
|
|
entry.deferred_entry_type = False
|
|
|
|
# =========================================================================
|
|
# COMPUTE: TAX CLOSING
|
|
# =========================================================================
|
|
|
|
def _compute_tax_closing_alert(self):
|
|
for entry in self:
|
|
entry.tax_closing_alert = (
|
|
entry.state == 'posted'
|
|
and entry.tax_closing_report_id
|
|
and entry.company_id.tax_lock_date
|
|
and entry.company_id.tax_lock_date < entry.date
|
|
)
|
|
|
|
# =========================================================================
|
|
# COMPUTE: ASSET DEPRECIATION
|
|
# =========================================================================
|
|
|
|
@api.depends('asset_id', 'depreciation_value', 'asset_id.total_depreciable_value',
|
|
'asset_id.already_depreciated_amount_import', 'state')
|
|
def _compute_depreciation_cumulative_value(self):
|
|
self.asset_depreciated_value = 0
|
|
self.asset_remaining_value = 0
|
|
|
|
# Protect these fields during batch assignment to avoid infinite recursion
|
|
# when write() is triggered on non-protected records and needs to read them
|
|
protected = [self._fields['asset_remaining_value'], self._fields['asset_depreciated_value']]
|
|
with self.env.protecting(protected, self.asset_id.depreciation_move_ids):
|
|
for asset_rec in self.asset_id:
|
|
accumulated = 0
|
|
outstanding = asset_rec.total_depreciable_value - asset_rec.already_depreciated_amount_import
|
|
ordered_deps = asset_rec.depreciation_move_ids.sorted(lambda mv: (mv.date, mv._origin.id))
|
|
for dep_move in ordered_deps:
|
|
if dep_move.state != 'cancel':
|
|
outstanding -= dep_move.depreciation_value
|
|
accumulated += dep_move.depreciation_value
|
|
dep_move.asset_remaining_value = outstanding
|
|
dep_move.asset_depreciated_value = accumulated
|
|
|
|
@api.depends('line_ids.balance')
|
|
def _compute_depreciation_value(self):
|
|
for entry in self:
|
|
linked_asset = entry.asset_id or entry.reversed_entry_id.asset_id
|
|
if not linked_asset:
|
|
entry.depreciation_value = 0
|
|
continue
|
|
|
|
target_group = 'expense'
|
|
dep_total = sum(
|
|
entry.line_ids.filtered(
|
|
lambda ln: (
|
|
ln.account_id.internal_group == target_group
|
|
or ln.account_id == linked_asset.account_depreciation_expense_id
|
|
)
|
|
).mapped('balance')
|
|
)
|
|
|
|
# Detect disposal entries: the asset account is fully reversed with more than 2 lines
|
|
is_disposal = (
|
|
any(
|
|
ln.account_id == linked_asset.account_asset_id
|
|
and float_compare(
|
|
-ln.balance, linked_asset.original_value,
|
|
precision_rounding=linked_asset.currency_id.rounding,
|
|
) == 0
|
|
for ln in entry.line_ids
|
|
)
|
|
and len(entry.line_ids) > 2
|
|
)
|
|
if is_disposal:
|
|
sign_factor = -1 if linked_asset.original_value < 0 else 1
|
|
secondary_line = entry.line_ids[1]
|
|
offset_amount = secondary_line.debit if linked_asset.original_value > 0 else secondary_line.credit
|
|
dep_total = (
|
|
linked_asset.original_value
|
|
- linked_asset.salvage_value
|
|
- offset_amount * sign_factor
|
|
)
|
|
|
|
entry.depreciation_value = dep_total
|
|
|
|
@api.depends('asset_id', 'asset_ids')
|
|
def _compute_asset_move_type(self):
|
|
for entry in self:
|
|
if entry.asset_ids:
|
|
entry.asset_move_type = 'positive_revaluation' if entry.asset_ids.parent_id else 'purchase'
|
|
elif not entry.asset_move_type or not entry.asset_id:
|
|
entry.asset_move_type = False
|
|
|
|
@api.depends('line_ids.asset_ids')
|
|
def _compute_asset_ids(self):
|
|
for entry in self:
|
|
entry.asset_ids = entry.line_ids.asset_ids
|
|
entry.count_asset = len(entry.asset_ids)
|
|
entry.asset_id_display_name = _('Asset')
|
|
entry.draft_asset_exists = bool(entry.asset_ids.filtered(lambda a: a.state == 'draft'))
|
|
|
|
# =========================================================================
|
|
# INVERSE
|
|
# =========================================================================
|
|
|
|
def _inverse_depreciation_value(self):
|
|
for entry in self:
|
|
linked_asset = entry.asset_id
|
|
abs_amount = abs(entry.depreciation_value)
|
|
expense_acct = linked_asset.account_depreciation_expense_id
|
|
entry.write({'line_ids': [
|
|
Command.update(ln.id, {
|
|
'balance': abs_amount if ln.account_id == expense_acct else -abs_amount,
|
|
})
|
|
for ln in entry.line_ids
|
|
]})
|
|
|
|
# =========================================================================
|
|
# CONSTRAINTS
|
|
# =========================================================================
|
|
|
|
@api.constrains('state', 'asset_id')
|
|
def _constrains_check_asset_state(self):
|
|
for entry in self.filtered(lambda m: m.asset_id):
|
|
if entry.asset_id.state == 'draft' and entry.state == 'posted':
|
|
raise ValidationError(
|
|
_("Cannot post an entry tied to a draft asset. Confirm the asset first.")
|
|
)
|
|
|
|
# =========================================================================
|
|
# CRUD OVERRIDES
|
|
# =========================================================================
|
|
|
|
def _post(self, soft=True):
|
|
# Process VAT closing entries before delegating to the parent posting logic
|
|
for closing_entry in self.filtered(lambda m: m.tax_closing_report_id):
|
|
rpt = closing_entry.tax_closing_report_id
|
|
rpt_options = closing_entry._get_tax_closing_report_options(
|
|
closing_entry.company_id, closing_entry.fiscal_position_id, rpt, closing_entry.date,
|
|
)
|
|
closing_entry._close_tax_period(rpt, rpt_options)
|
|
|
|
confirmed = super()._post(soft)
|
|
|
|
# Handle on-validation deferral generation for newly posted entries
|
|
for entry in confirmed:
|
|
if (
|
|
entry._get_deferred_entries_method() == 'on_validation'
|
|
and any(entry.line_ids.mapped('deferred_start_date'))
|
|
):
|
|
entry._generate_deferred_entries()
|
|
|
|
# Record depreciation posting in asset chatter
|
|
confirmed._log_depreciation_asset()
|
|
|
|
# Auto-generate assets from bill lines with asset-creating accounts
|
|
confirmed.sudo()._auto_create_asset()
|
|
|
|
return confirmed
|
|
|
|
def action_post(self):
|
|
try:
|
|
result = super().action_post()
|
|
except TaxClosingNonPostedDependingMovesError as exc:
|
|
return {
|
|
"type": "ir.actions.client",
|
|
"tag": "fusion_accounting.redirect_action",
|
|
"target": "new",
|
|
"name": "Dependent Closing Entries",
|
|
"params": {
|
|
"depending_action": exc.args[0],
|
|
"message": _("There are related closing entries that must be posted first"),
|
|
"button_text": _("View dependent entries"),
|
|
},
|
|
'context': {'dialog_size': 'medium'},
|
|
}
|
|
|
|
# Trigger auto-reconciliation for bank statement entries
|
|
if self.statement_line_id and not self.env.context.get('skip_statement_line_cron_trigger'):
|
|
self.env.ref('fusion_accounting.auto_reconcile_bank_statement_line')._trigger()
|
|
|
|
return result
|
|
|
|
def button_draft(self):
|
|
# --- Deferral guard: prevent reset if grouped deferrals exist ---
|
|
for entry in self:
|
|
grouped_deferrals = entry.deferred_move_ids.filtered(
|
|
lambda dm: len(dm.deferred_original_move_ids) > 1
|
|
)
|
|
if grouped_deferrals:
|
|
raise UserError(_(
|
|
"This invoice participates in grouped deferral entries and cannot be reset to draft. "
|
|
"Consider creating a credit note instead."
|
|
))
|
|
|
|
# Undo deferral entries (unlink or reverse depending on audit trail)
|
|
reversal_entries = self.deferred_move_ids._unlink_or_reverse()
|
|
if reversal_entries:
|
|
for rev in reversal_entries:
|
|
rev.with_context(skip_readonly_check=True).write({
|
|
'date': rev._get_accounting_date(rev.date, rev._affect_tax_report()),
|
|
})
|
|
self.deferred_move_ids |= reversal_entries
|
|
|
|
# --- Tax closing guard: prevent reset if carryover impacts locked periods ---
|
|
for closing_entry in self.filtered(lambda m: m.tax_closing_report_id):
|
|
rpt = closing_entry.tax_closing_report_id
|
|
rpt_opts = closing_entry._get_tax_closing_report_options(
|
|
closing_entry.company_id, closing_entry.fiscal_position_id, rpt, closing_entry.date,
|
|
)
|
|
periodicity_delay = closing_entry.company_id._get_tax_periodicity_months_delay(rpt)
|
|
|
|
existing_carryovers = self.env['account.report.external.value'].search([
|
|
('carryover_origin_report_line_id', 'in', rpt.line_ids.ids),
|
|
('date', '=', rpt_opts['date']['date_to']),
|
|
])
|
|
|
|
affected_period_end = (
|
|
fields.Date.from_string(rpt_opts['date']['date_to'])
|
|
+ relativedelta(months=periodicity_delay)
|
|
)
|
|
lock_dt = closing_entry.company_id.tax_lock_date
|
|
if existing_carryovers and lock_dt and lock_dt >= affected_period_end:
|
|
raise UserError(_(
|
|
"Resetting this closing entry would remove carryover values that affect a locked tax period. "
|
|
"Adjust the tax return lock date before proceeding."
|
|
))
|
|
|
|
if self._has_subsequent_posted_closing_moves():
|
|
raise UserError(_(
|
|
"A subsequent tax closing entry has already been posted. "
|
|
"Reset that entry first before modifying this one."
|
|
))
|
|
|
|
existing_carryovers.unlink()
|
|
|
|
# --- Asset guard: prevent reset if linked assets are confirmed ---
|
|
for entry in self:
|
|
if any(a.state != 'draft' for a in entry.asset_ids):
|
|
raise UserError(_("Cannot reset to draft when linked assets are already confirmed."))
|
|
entry.asset_ids.filtered(lambda a: a.state == 'draft').unlink()
|
|
|
|
return super().button_draft()
|
|
|
|
def unlink(self):
|
|
# When audit trail is active, deferral entries should be reversed rather than deleted
|
|
audit_deferrals = self.filtered(
|
|
lambda m: m.company_id.check_account_audit_trail and m.deferred_original_move_ids
|
|
)
|
|
audit_deferrals.deferred_original_move_ids.deferred_move_ids = False
|
|
audit_deferrals._reverse_moves()
|
|
return super(FusionAccountMove, self - audit_deferrals).unlink()
|
|
|
|
def button_cancel(self):
|
|
result = super(FusionAccountMove, self).button_cancel()
|
|
# Deactivate any assets originating from cancelled entries
|
|
self.env['account.asset'].sudo().search(
|
|
[('original_move_line_ids.move_id', 'in', self.ids)]
|
|
).write({'active': False})
|
|
return result
|
|
|
|
def _reverse_moves(self, default_values_list=None, cancel=False):
|
|
if default_values_list is None:
|
|
default_values_list = [{} for _ in self]
|
|
|
|
for entry, defaults in zip(self, default_values_list):
|
|
if not entry.asset_id:
|
|
continue
|
|
|
|
asset_rec = entry.asset_id
|
|
pending_drafts = asset_rec.depreciation_move_ids.filtered(lambda m: m.state == 'draft')
|
|
earliest_draft = min(pending_drafts, key=lambda m: m.date, default=None)
|
|
|
|
if earliest_draft:
|
|
# Transfer the depreciation amount to the next available draft entry
|
|
earliest_draft.depreciation_value += entry.depreciation_value
|
|
elif asset_rec.state != 'close':
|
|
# No drafts remain and asset is still open: create a new depreciation entry
|
|
latest_dep_date = max(asset_rec.depreciation_move_ids.mapped('date'))
|
|
period_method = asset_rec.method_period
|
|
next_offset = relativedelta(months=1) if period_method == "1" else relativedelta(years=1)
|
|
|
|
self.create(self._prepare_move_for_asset_depreciation({
|
|
'asset_id': asset_rec,
|
|
'amount': entry.depreciation_value,
|
|
'depreciation_beginning_date': latest_dep_date + next_offset,
|
|
'date': latest_dep_date + next_offset,
|
|
'asset_number_days': 0,
|
|
}))
|
|
|
|
note = _(
|
|
'Depreciation %(entry_name)s reversed (%(dep_amount)s)',
|
|
entry_name=entry.name,
|
|
dep_amount=formatLang(self.env, entry.depreciation_value, currency_obj=entry.company_id.currency_id),
|
|
)
|
|
asset_rec.message_post(body=note)
|
|
defaults['asset_id'] = asset_rec.id
|
|
defaults['asset_number_days'] = -entry.asset_number_days
|
|
defaults['asset_depreciation_beginning_date'] = defaults.get('date', entry.date)
|
|
|
|
return super(FusionAccountMove, self)._reverse_moves(default_values_list, cancel)
|
|
|
|
# =========================================================================
|
|
# DEFERRAL ENGINE
|
|
# =========================================================================
|
|
|
|
@api.model
|
|
def _get_deferred_diff_dates(self, start, end):
|
|
"""
|
|
Calculates the fractional number of months between two dates using a
|
|
30-day month convention. This normalization ensures equal deferral
|
|
amounts for February, March, and April when spreading monthly.
|
|
"""
|
|
if start > end:
|
|
start, end = end, start
|
|
total_months = end.month - start.month + 12 * (end.year - start.year)
|
|
day_a = start.day
|
|
day_b = end.day
|
|
if day_a == calendar.monthrange(start.year, start.month)[1]:
|
|
day_a = 30
|
|
if day_b == calendar.monthrange(end.year, end.month)[1]:
|
|
day_b = 30
|
|
fractional_days = day_b - day_a
|
|
return (total_months * 30 + fractional_days) / 30
|
|
|
|
@api.model
|
|
def _get_deferred_period_amount(self, calc_method, seg_start, seg_end, full_start, full_end, total_balance):
|
|
"""
|
|
Computes the portion of total_balance attributable to the segment
|
|
[seg_start, seg_end] within the full deferral range [full_start, full_end].
|
|
Supports 'day', 'month', and 'full_months' calculation methods.
|
|
"""
|
|
segment_valid = seg_end > full_start and seg_end > seg_start
|
|
|
|
if calc_method == 'day':
|
|
daily_rate = total_balance / (full_end - full_start).days
|
|
return (seg_end - seg_start).days * daily_rate if segment_valid else 0
|
|
|
|
if calc_method == 'month':
|
|
monthly_rate = total_balance / self._get_deferred_diff_dates(full_end, full_start)
|
|
segment_months = self._get_deferred_diff_dates(seg_end, seg_start)
|
|
return segment_months * monthly_rate if segment_valid else 0
|
|
|
|
if calc_method == 'full_months':
|
|
span_months = self._get_deferred_diff_dates(full_end, full_start)
|
|
period_months = self._get_deferred_diff_dates(seg_end, seg_start)
|
|
|
|
if span_months < 1:
|
|
return total_balance if segment_valid else 0
|
|
|
|
eom_full = full_end.day == calendar.monthrange(full_end.year, full_end.month)[1]
|
|
span_rounded = math.ceil(span_months) if eom_full else math.floor(span_months)
|
|
|
|
eom_seg = seg_end.day == calendar.monthrange(seg_end.year, seg_end.month)[1]
|
|
if eom_seg or full_end != seg_end:
|
|
period_rounded = math.ceil(period_months)
|
|
else:
|
|
period_rounded = math.floor(period_months)
|
|
|
|
per_month = total_balance / span_rounded
|
|
return period_rounded * per_month if segment_valid else 0
|
|
|
|
return 0
|
|
|
|
@api.model
|
|
def _get_deferred_amounts_by_line(self, line_data, time_segments, category):
|
|
"""
|
|
For each line and each time segment, compute the deferred amount.
|
|
|
|
Returns a list of dicts containing line identification fields plus
|
|
an entry for each time_segment tuple as key.
|
|
"""
|
|
output = []
|
|
for item in line_data:
|
|
start_dt = fields.Date.to_date(item['deferred_start_date'])
|
|
end_dt = fields.Date.to_date(item['deferred_end_date'])
|
|
# Guard against inverted date ranges to prevent calculation errors
|
|
if end_dt < start_dt:
|
|
end_dt = start_dt
|
|
|
|
segment_amounts = {}
|
|
for seg in time_segments:
|
|
# "Not Started" column only applies when deferral begins after the report end date
|
|
if seg[2] == 'not_started' and start_dt <= seg[0]:
|
|
segment_amounts[seg] = 0.0
|
|
continue
|
|
|
|
effective_start = max(seg[0], start_dt)
|
|
effective_end = min(seg[1], end_dt)
|
|
|
|
# Adjust the start date to be inclusive in specific circumstances
|
|
should_include_start = (
|
|
seg[2] in ('not_started', 'later') and seg[0] < start_dt
|
|
or len(time_segments) <= 1
|
|
or seg[2] not in ('not_started', 'before', 'later')
|
|
)
|
|
if should_include_start:
|
|
effective_start -= relativedelta(days=1)
|
|
|
|
comp_method = (
|
|
self.env.company.deferred_expense_amount_computation_method
|
|
if category == 'expense'
|
|
else self.env.company.deferred_revenue_amount_computation_method
|
|
)
|
|
segment_amounts[seg] = self._get_deferred_period_amount(
|
|
comp_method,
|
|
effective_start, effective_end,
|
|
start_dt - relativedelta(days=1), end_dt,
|
|
item['balance'],
|
|
)
|
|
|
|
output.append({
|
|
**self.env['account.move.line']._get_deferred_amounts_by_line_values(item),
|
|
**segment_amounts,
|
|
})
|
|
return output
|
|
|
|
@api.model
|
|
def _get_deferred_lines(self, source_line, deferral_acct, category, segment, description, force_balance=None, grouping_field='account_id'):
|
|
"""
|
|
Creates a pair of journal item commands for one deferral period:
|
|
one on the original account and one on the deferral holding account.
|
|
"""
|
|
computed = self._get_deferred_amounts_by_line(source_line, [segment], category)[0]
|
|
amount = computed[segment] if force_balance is None else force_balance
|
|
return [
|
|
Command.create({
|
|
**self.env['account.move.line']._get_deferred_lines_values(
|
|
acct.id, coefficient * amount, description, source_line.analytic_distribution, source_line,
|
|
),
|
|
'partner_id': source_line.partner_id.id,
|
|
'product_id': source_line.product_id.id,
|
|
})
|
|
for acct, coefficient in [(computed[grouping_field], 1), (deferral_acct, -1)]
|
|
]
|
|
|
|
def _generate_deferred_entries(self):
|
|
"""
|
|
Produces the full set of deferral journal entries for this posted
|
|
invoice or bill. For each eligible line, creates an initial
|
|
full-deferral entry and then per-period recognition entries.
|
|
"""
|
|
self.ensure_one()
|
|
if self.state != 'posted':
|
|
return
|
|
if self.is_entry():
|
|
raise UserError(_("Deferral generation is not supported for miscellaneous entries."))
|
|
|
|
category = 'expense' if self.is_purchase_document() else 'revenue'
|
|
holding_account = (
|
|
self.company_id.deferred_expense_account_id
|
|
if category == 'expense'
|
|
else self.company_id.deferred_revenue_account_id
|
|
)
|
|
deferral_journal = (
|
|
self.company_id.deferred_expense_journal_id
|
|
if category == 'expense'
|
|
else self.company_id.deferred_revenue_journal_id
|
|
)
|
|
if not deferral_journal:
|
|
raise UserError(_("Configure the deferral journal in Accounting Settings before generating entries."))
|
|
if not holding_account:
|
|
raise UserError(_("Configure the deferral accounts in Accounting Settings before generating entries."))
|
|
|
|
eligible_lines = self.line_ids.filtered(lambda ln: ln.deferred_start_date and ln.deferred_end_date)
|
|
for src_line in eligible_lines:
|
|
period_ranges = src_line._get_deferred_periods()
|
|
if not period_ranges:
|
|
continue
|
|
|
|
label = _("Deferral of %s", src_line.move_id.name or '')
|
|
base_vals = {
|
|
'move_type': 'entry',
|
|
'deferred_original_move_ids': [Command.set(src_line.move_id.ids)],
|
|
'journal_id': deferral_journal.id,
|
|
'company_id': self.company_id.id,
|
|
'partner_id': src_line.partner_id.id,
|
|
'auto_post': 'at_date',
|
|
'ref': label,
|
|
'name': False,
|
|
}
|
|
|
|
# Step 1: Create the initial full-offset entry on the invoice date
|
|
offset_entry = self.create({**base_vals, 'date': src_line.move_id.date})
|
|
# Write lines after creation so deferred_original_move_ids is set,
|
|
# preventing unintended tax computation on deferral moves
|
|
offset_entry.write({
|
|
'line_ids': [
|
|
Command.create(
|
|
self.env['account.move.line']._get_deferred_lines_values(
|
|
acct.id, coeff * src_line.balance, label, src_line.analytic_distribution, src_line,
|
|
)
|
|
)
|
|
for acct, coeff in [(src_line.account_id, -1), (holding_account, 1)]
|
|
],
|
|
})
|
|
|
|
# Step 2: Create per-period recognition entries
|
|
recognition_entries = self.create([
|
|
{**base_vals, 'date': seg[1]} for seg in period_ranges
|
|
])
|
|
balance_remaining = src_line.balance
|
|
for idx, (seg, recog_entry) in enumerate(zip(period_ranges, recognition_entries)):
|
|
is_final = idx == len(period_ranges) - 1
|
|
override = balance_remaining if is_final else None
|
|
# Same pattern: write lines after creation to prevent tax side effects
|
|
recog_entry.write({
|
|
'line_ids': self._get_deferred_lines(
|
|
src_line, holding_account, category, seg, label, force_balance=override,
|
|
),
|
|
})
|
|
balance_remaining -= recog_entry.line_ids[0].balance
|
|
|
|
# Remove zero-amount deferral entries
|
|
if recog_entry.currency_id.is_zero(recog_entry.amount_total):
|
|
recognition_entries -= recog_entry
|
|
recog_entry.unlink()
|
|
|
|
all_deferrals = offset_entry + recognition_entries
|
|
|
|
# If only one recognition entry falls in the same month as the offset,
|
|
# they cancel each other out and serve no purpose
|
|
if len(recognition_entries) == 1 and offset_entry.date.month == recognition_entries.date.month:
|
|
all_deferrals.unlink()
|
|
continue
|
|
|
|
src_line.move_id.deferred_move_ids |= all_deferrals
|
|
all_deferrals._post(soft=True)
|
|
|
|
# =========================================================================
|
|
# DEFERRAL ACTIONS
|
|
# =========================================================================
|
|
|
|
def open_deferred_entries(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _("Deferral Entries"),
|
|
'res_model': 'account.move.line',
|
|
'domain': [('id', 'in', self.deferred_move_ids.line_ids.ids)],
|
|
'views': [(self.env.ref('fusion_accounting.view_deferred_entries_tree').id, 'list')],
|
|
'context': {'search_default_group_by_move': True, 'expand': True},
|
|
}
|
|
|
|
def open_deferred_original_entry(self):
|
|
self.ensure_one()
|
|
source_moves = self.deferred_original_move_ids
|
|
result = {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _("Originating Entries"),
|
|
'res_model': 'account.move.line',
|
|
'domain': [('id', 'in', source_moves.line_ids.ids)],
|
|
'views': [(False, 'list'), (False, 'form')],
|
|
'context': {'search_default_group_by_move': True, 'expand': True},
|
|
}
|
|
if len(source_moves) == 1:
|
|
result.update({
|
|
'res_model': 'account.move',
|
|
'res_id': source_moves.id,
|
|
'views': [(False, 'form')],
|
|
})
|
|
return result
|
|
|
|
# =========================================================================
|
|
# BANK RECONCILIATION ACTIONS
|
|
# =========================================================================
|
|
|
|
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()
|
|
act = super().action_open_business_doc()
|
|
# Prevent leaking reconciliation-specific context to the document view
|
|
act['context'] = act.get('context', {}) | {
|
|
'preferred_aml_value': None,
|
|
'preferred_aml_currency_id': None,
|
|
}
|
|
return act
|
|
|
|
# =========================================================================
|
|
# MAIL / EDI
|
|
# =========================================================================
|
|
|
|
def _get_mail_thread_data_attachments(self):
|
|
result = super()._get_mail_thread_data_attachments()
|
|
result += self.statement_line_id.statement_id.attachment_ids
|
|
return result
|
|
|
|
@contextmanager
|
|
def _get_edi_creation(self):
|
|
with super()._get_edi_creation() as entry:
|
|
pre_existing_lines = entry.invoice_line_ids
|
|
yield entry.with_context(disable_onchange_name_predictive=True)
|
|
for new_line in entry.invoice_line_ids - pre_existing_lines:
|
|
new_line._onchange_name_predictive()
|
|
|
|
# =========================================================================
|
|
# TAX / VAT CLOSING
|
|
# =========================================================================
|
|
|
|
def _has_subsequent_posted_closing_moves(self):
|
|
"""Returns True if any posted tax closing entry exists after this one."""
|
|
self.ensure_one()
|
|
return bool(self.env['account.move'].search_count([
|
|
('company_id', '=', self.company_id.id),
|
|
('tax_closing_report_id', '!=', False),
|
|
('state', '=', 'posted'),
|
|
('date', '>', self.date),
|
|
('fiscal_position_id', '=', self.fiscal_position_id.id),
|
|
], limit=1))
|
|
|
|
def _get_tax_to_pay_on_closing(self):
|
|
"""Sums the balance on tax payable accounts to determine the tax due."""
|
|
self.ensure_one()
|
|
payable_accounts = self.env['account.tax.group'].search([
|
|
('company_id', '=', self.company_id.id),
|
|
]).tax_payable_account_id
|
|
relevant_lines = self.line_ids.filtered(lambda ln: ln.account_id in payable_accounts)
|
|
return self.currency_id.round(-sum(relevant_lines.mapped('balance')))
|
|
|
|
def _action_tax_to_pay_wizard(self):
|
|
return self.action_open_tax_report()
|
|
|
|
def action_open_tax_report(self):
|
|
act = self.env["ir.actions.actions"]._for_xml_id("fusion_accounting.action_account_report_gt")
|
|
if not self.tax_closing_report_id:
|
|
raise UserError(_("No tax report is associated with this entry."))
|
|
rpt_opts = self._get_tax_closing_report_options(
|
|
self.company_id, self.fiscal_position_id, self.tax_closing_report_id, self.date,
|
|
)
|
|
act.update({'params': {'options': rpt_opts, 'ignore_session': True}})
|
|
return act
|
|
|
|
def refresh_tax_entry(self):
|
|
"""Re-generates tax closing line items for draft closing entries."""
|
|
for entry in self.filtered(lambda m: m.tax_closing_report_id and m.state == 'draft'):
|
|
rpt = entry.tax_closing_report_id
|
|
rpt_opts = entry._get_tax_closing_report_options(
|
|
entry.company_id, entry.fiscal_position_id, rpt, entry.date,
|
|
)
|
|
handler_model = rpt.custom_handler_model_name or 'account.generic.tax.report.handler'
|
|
self.env[handler_model]._generate_tax_closing_entries(rpt, rpt_opts, closing_moves=entry)
|
|
|
|
def _close_tax_period(self, report, options):
|
|
"""
|
|
Executes the full tax period closing workflow: validates permissions,
|
|
handles dependent branch/unit closings, generates carryover values,
|
|
attaches the tax report PDF, and schedules follow-up activities.
|
|
"""
|
|
self.ensure_one()
|
|
if not self.env.user.has_group('account.group_account_manager'):
|
|
raise UserError(_("Only Billing Administrators can modify lock dates."))
|
|
|
|
rpt = self.tax_closing_report_id
|
|
opts = self._get_tax_closing_report_options(self.company_id, self.fiscal_position_id, rpt, self.date)
|
|
|
|
# Update the tax lock date for domestic (non-foreign-VAT) closings
|
|
if (
|
|
not self.fiscal_position_id
|
|
and (not self.company_id.tax_lock_date or self.date > self.company_id.tax_lock_date)
|
|
):
|
|
self.company_id.sudo().tax_lock_date = self.date
|
|
self.env['account.report']._generate_default_external_values(
|
|
opts['date']['date_from'], opts['date']['date_to'], True,
|
|
)
|
|
|
|
reporting_company = rpt._get_sender_company_for_export(opts)
|
|
member_ids = rpt.get_report_company_ids(opts)
|
|
|
|
if reporting_company == self.company_id:
|
|
related_closings = (
|
|
self.env['account.tax.report.handler']._get_tax_closing_entries_for_closed_period(
|
|
rpt, opts, self.env['res.company'].browse(member_ids), posted_only=False,
|
|
) - self
|
|
)
|
|
unposted_related = related_closings.filtered(lambda x: x.state == 'draft')
|
|
|
|
if unposted_related:
|
|
nav_action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
|
|
nav_action = clean_action(nav_action, env=self.env)
|
|
|
|
if len(unposted_related) == 1:
|
|
nav_action['views'] = [(self.env.ref('account.view_move_form').id, 'form')]
|
|
nav_action['res_id'] = unposted_related.id
|
|
else:
|
|
nav_action['domain'] = [('id', 'in', unposted_related.ids)]
|
|
ctx = dict(ast.literal_eval(nav_action['context']))
|
|
ctx.pop('search_default_posted', None)
|
|
nav_action['context'] = ctx
|
|
|
|
# Raise an error caught by action_post to display a redirect component
|
|
raise TaxClosingNonPostedDependingMovesError(nav_action)
|
|
|
|
# Produce carryover values for the next period
|
|
rpt.with_context(allowed_company_ids=member_ids)._generate_carryover_external_values(opts)
|
|
|
|
# Attach the tax report PDF to this closing entry
|
|
report_files = self._get_vat_report_attachments(rpt, opts)
|
|
mail_subject = _(
|
|
"Tax closing from %(start)s to %(end)s",
|
|
start=format_date(self.env, opts['date']['date_from']),
|
|
end=format_date(self.env, opts['date']['date_to']),
|
|
)
|
|
self.with_context(no_new_invoice=True).message_post(
|
|
body=self.ref, subject=mail_subject, attachments=report_files,
|
|
)
|
|
|
|
# Add a cross-reference note on related company closings
|
|
for related_entry in related_closings:
|
|
related_entry.message_post(
|
|
body=Markup("%s") % _(
|
|
"The tax report attachments are available on the "
|
|
"<a href='#' data-oe-model='account.move' data-oe-id='%s'>closing entry</a> "
|
|
"of the representative company.",
|
|
self.id,
|
|
),
|
|
)
|
|
|
|
# Complete the reminder activity and schedule the next one
|
|
reminder = self.company_id._get_tax_closing_reminder_activity(
|
|
rpt.id, self.date, self.fiscal_position_id.id,
|
|
)
|
|
if reminder:
|
|
reminder.action_done()
|
|
|
|
fpos_for_next = self.fiscal_position_id if self.fiscal_position_id.foreign_vat else None
|
|
self.company_id._generate_tax_closing_reminder_activity(
|
|
self.tax_closing_report_id,
|
|
self.date + relativedelta(days=1),
|
|
fpos_for_next,
|
|
)
|
|
|
|
self._close_tax_period_create_activities()
|
|
|
|
def _close_tax_period_create_activities(self):
|
|
"""Creates 'Report Ready to Send' and optionally 'Tax Payment Due' activities."""
|
|
send_type_xmlid = 'fusion_accounting.mail_activity_type_tax_report_to_be_sent'
|
|
send_type = self.env.ref(send_type_xmlid, raise_if_not_found=False)
|
|
|
|
if not send_type:
|
|
# Ensure the activity type exists by creating it on the fly if missing
|
|
send_type = self.env['mail.activity.type'].sudo()._load_records([{
|
|
'xml_id': send_type_xmlid,
|
|
'noupdate': False,
|
|
'values': {
|
|
'name': 'Tax Report Ready',
|
|
'summary': 'Tax report is ready to be sent to the administration',
|
|
'category': 'tax_report',
|
|
'delay_count': '0',
|
|
'delay_unit': 'days',
|
|
'delay_from': 'current_date',
|
|
'res_model': 'account.move',
|
|
'chaining_type': 'suggest',
|
|
},
|
|
}])
|
|
|
|
pay_type_xmlid = 'fusion_accounting.mail_activity_type_tax_report_to_pay'
|
|
pay_type = self.env.ref(pay_type_xmlid, raise_if_not_found=False)
|
|
|
|
responsible = send_type.default_user_id
|
|
if responsible and not (
|
|
self.company_id in responsible.company_ids
|
|
and responsible.has_group('account.group_account_manager')
|
|
):
|
|
responsible = self.env['res.users']
|
|
|
|
entries_needing_activity = self.filtered_domain([
|
|
'|',
|
|
('activity_ids', '=', False),
|
|
('activity_ids', 'not any', [('activity_type_id.id', '=', send_type.id)]),
|
|
])
|
|
|
|
for entry in entries_needing_activity:
|
|
p_start, p_end = entry.company_id._get_tax_closing_period_boundaries(
|
|
entry.date, entry.tax_closing_report_id,
|
|
)
|
|
period_label = entry.company_id._get_tax_closing_move_description(
|
|
entry.company_id._get_tax_periodicity(entry.tax_closing_report_id),
|
|
p_start, p_end,
|
|
entry.fiscal_position_id,
|
|
entry.tax_closing_report_id,
|
|
)
|
|
|
|
entry.with_context(mail_activity_quick_update=True).activity_schedule(
|
|
act_type_xmlid=send_type_xmlid,
|
|
summary=_("Submit tax report: %s", period_label),
|
|
date_deadline=fields.Date.context_today(entry),
|
|
user_id=responsible.id or self.env.user.id,
|
|
)
|
|
|
|
if (
|
|
pay_type
|
|
and pay_type not in entry.activity_ids.activity_type_id
|
|
and entry._get_tax_to_pay_on_closing() > 0
|
|
):
|
|
entry.with_context(mail_activity_quick_update=True).activity_schedule(
|
|
act_type_xmlid=pay_type_xmlid,
|
|
summary=_("Remit tax payment: %s", period_label),
|
|
date_deadline=fields.Date.context_today(entry),
|
|
user_id=responsible.id or self.env.user.id,
|
|
)
|
|
|
|
@api.model
|
|
def _get_tax_closing_report_options(self, company, fiscal_pos, report, reference_date):
|
|
"""
|
|
Constructs the option dict used to generate a tax report for a given
|
|
company, fiscal position, and closing date.
|
|
"""
|
|
_dummy, period_end = company._get_tax_closing_period_boundaries(reference_date, report)
|
|
|
|
if fiscal_pos and fiscal_pos.foreign_vat:
|
|
fpos_val = fiscal_pos.id
|
|
target_country = fiscal_pos.country_id
|
|
else:
|
|
fpos_val = 'domestic'
|
|
target_country = company.account_fiscal_country_id
|
|
|
|
base_options = {
|
|
'date': {
|
|
'date_to': fields.Date.to_string(period_end),
|
|
'filter': 'custom_tax_period',
|
|
'mode': 'range',
|
|
},
|
|
'selected_variant_id': report.id,
|
|
'sections_source_id': report.id,
|
|
'fiscal_position': fpos_val,
|
|
'tax_unit': 'company_only',
|
|
}
|
|
|
|
if report.filter_multi_company == 'tax_units':
|
|
matching_unit = company.account_tax_unit_ids.filtered(
|
|
lambda u: u.country_id == target_country
|
|
)
|
|
if matching_unit:
|
|
base_options['tax_unit'] = matching_unit.id
|
|
active_company_ids = matching_unit.company_ids.ids
|
|
else:
|
|
sibling_companies = self.env.company._get_branches_with_same_vat()
|
|
active_company_ids = sibling_companies.sorted(lambda c: len(c.parent_ids)).ids
|
|
else:
|
|
active_company_ids = self.env.company.ids
|
|
|
|
return report.with_context(allowed_company_ids=active_company_ids).get_options(previous_options=base_options)
|
|
|
|
def _get_vat_report_attachments(self, report, options):
|
|
"""Generates the PDF attachment for the tax report."""
|
|
pdf_result = report.export_to_pdf(options)
|
|
return [(pdf_result['file_name'], pdf_result['file_content'])]
|
|
|
|
# =========================================================================
|
|
# ASSET DEPRECIATION
|
|
# =========================================================================
|
|
|
|
def _log_depreciation_asset(self):
|
|
"""Posts a chatter message on the asset when a depreciation entry is confirmed."""
|
|
for entry in self.filtered(lambda m: m.asset_id):
|
|
msg = _(
|
|
'Depreciation %(entry_ref)s confirmed (%(amount)s)',
|
|
entry_ref=entry.name,
|
|
amount=formatLang(self.env, entry.depreciation_value, currency_obj=entry.company_id.currency_id),
|
|
)
|
|
entry.asset_id.message_post(body=msg)
|
|
|
|
def _auto_create_asset(self):
|
|
"""
|
|
Scans posted invoice lines for accounts configured to auto-create assets.
|
|
Builds asset records and optionally validates them based on account settings.
|
|
"""
|
|
asset_data = []
|
|
linked_invoices = []
|
|
should_validate = []
|
|
|
|
for entry in self:
|
|
if not entry.is_invoice():
|
|
continue
|
|
|
|
for ln in entry.line_ids:
|
|
if not (
|
|
ln.account_id
|
|
and ln.account_id.can_create_asset
|
|
and ln.account_id.create_asset != 'no'
|
|
and not (ln.currency_id or entry.currency_id).is_zero(ln.price_total)
|
|
and not ln.asset_ids
|
|
and not ln.tax_line_id
|
|
and ln.price_total > 0
|
|
and not (
|
|
entry.move_type in ('out_invoice', 'out_refund')
|
|
and ln.account_id.internal_group == 'asset'
|
|
)
|
|
):
|
|
continue
|
|
|
|
if not ln.name:
|
|
if ln.product_id:
|
|
ln.name = ln.product_id.display_name
|
|
else:
|
|
raise UserError(_(
|
|
"Line items on %(acct)s require a description to generate an asset.",
|
|
acct=ln.account_id.display_name,
|
|
))
|
|
|
|
unit_count = max(1, int(ln.quantity)) if ln.account_id.multiple_assets_per_line else 1
|
|
applicable_models = ln.account_id.asset_model_ids
|
|
|
|
base_data = {
|
|
'name': ln.name,
|
|
'company_id': ln.company_id.id,
|
|
'currency_id': ln.company_currency_id.id,
|
|
'analytic_distribution': ln.analytic_distribution,
|
|
'original_move_line_ids': [(6, False, ln.ids)],
|
|
'state': 'draft',
|
|
'acquisition_date': (
|
|
entry.invoice_date
|
|
if not entry.reversed_entry_id
|
|
else entry.reversed_entry_id.invoice_date
|
|
),
|
|
}
|
|
|
|
for model_rec in applicable_models or [None]:
|
|
if model_rec:
|
|
base_data['model_id'] = model_rec.id
|
|
|
|
should_validate.extend([ln.account_id.create_asset == 'validate'] * unit_count)
|
|
linked_invoices.extend([entry] * unit_count)
|
|
for seq in range(1, unit_count + 1):
|
|
row = base_data.copy()
|
|
if unit_count > 1:
|
|
row['name'] = _(
|
|
"%(label)s (%(seq)s of %(total)s)",
|
|
label=ln.name, seq=seq, total=unit_count,
|
|
)
|
|
asset_data.append(row)
|
|
|
|
new_assets = self.env['account.asset'].with_context({}).create(asset_data)
|
|
for asset_rec, data, src_invoice, auto_confirm in zip(new_assets, asset_data, linked_invoices, should_validate):
|
|
if 'model_id' in data:
|
|
asset_rec._onchange_model_id()
|
|
if auto_confirm:
|
|
asset_rec.validate()
|
|
if src_invoice:
|
|
asset_rec.message_post(body=_("Asset created from invoice: %s", src_invoice._get_html_link()))
|
|
asset_rec._post_non_deductible_tax_value()
|
|
return new_assets
|
|
|
|
@api.model
|
|
def _prepare_move_for_asset_depreciation(self, params):
|
|
"""
|
|
Prepares the values dict for creating a depreciation journal entry.
|
|
Required keys in params: asset_id, amount, depreciation_beginning_date, date, asset_number_days.
|
|
"""
|
|
required_keys = {'asset_id', 'amount', 'depreciation_beginning_date', 'date', 'asset_number_days'}
|
|
absent = required_keys - set(params)
|
|
if absent:
|
|
raise UserError(_("Missing required parameters: %s", ', '.join(absent)))
|
|
|
|
asset_rec = params['asset_id']
|
|
dist = asset_rec.analytic_distribution
|
|
dep_date = params.get('date', fields.Date.context_today(self))
|
|
base_currency = asset_rec.company_id.currency_id
|
|
asset_currency = asset_rec.currency_id
|
|
precision = base_currency.decimal_places
|
|
foreign_amount = params['amount']
|
|
local_amount = asset_currency._convert(foreign_amount, base_currency, asset_rec.company_id, dep_date)
|
|
|
|
# Use the partner from the originating document if unambiguous
|
|
originating_partners = asset_rec.original_move_line_ids.mapped('partner_id')
|
|
partner = originating_partners[:1] if len(originating_partners) <= 1 else self.env['res.partner']
|
|
|
|
entry_label = _("%s: Depreciation", asset_rec.name)
|
|
is_positive = float_compare(local_amount, 0.0, precision_digits=precision) > 0
|
|
|
|
contra_line = {
|
|
'name': entry_label,
|
|
'partner_id': partner.id,
|
|
'account_id': asset_rec.account_depreciation_id.id,
|
|
'debit': 0.0 if is_positive else -local_amount,
|
|
'credit': local_amount if is_positive else 0.0,
|
|
'currency_id': asset_currency.id,
|
|
'amount_currency': -foreign_amount,
|
|
}
|
|
expense_line = {
|
|
'name': entry_label,
|
|
'partner_id': partner.id,
|
|
'account_id': asset_rec.account_depreciation_expense_id.id,
|
|
'credit': 0.0 if is_positive else -local_amount,
|
|
'debit': local_amount if is_positive else 0.0,
|
|
'currency_id': asset_currency.id,
|
|
'amount_currency': foreign_amount,
|
|
}
|
|
|
|
if dist:
|
|
contra_line['analytic_distribution'] = dist
|
|
expense_line['analytic_distribution'] = dist
|
|
|
|
return {
|
|
'partner_id': partner.id,
|
|
'date': dep_date,
|
|
'journal_id': asset_rec.journal_id.id,
|
|
'line_ids': [(0, 0, contra_line), (0, 0, expense_line)],
|
|
'asset_id': asset_rec.id,
|
|
'ref': entry_label,
|
|
'asset_depreciation_beginning_date': params['depreciation_beginning_date'],
|
|
'asset_number_days': params['asset_number_days'],
|
|
'asset_value_change': params.get('asset_value_change', False),
|
|
'move_type': 'entry',
|
|
'currency_id': asset_currency.id,
|
|
'asset_move_type': params.get('asset_move_type', 'depreciation'),
|
|
'company_id': asset_rec.company_id.id,
|
|
}
|
|
|
|
# =========================================================================
|
|
# ASSET ACTIONS
|
|
# =========================================================================
|
|
|
|
def open_asset_view(self):
|
|
return self.asset_id.open_asset(['form'])
|
|
|
|
def action_open_asset_ids(self):
|
|
return self.asset_ids.open_asset(['list', 'form'])
|
|
|
|
|
|
class FusionMoveLine(models.Model):
|
|
"""
|
|
Extends journal items with deferral date tracking, predictive field
|
|
suggestions, custom SQL ordering for reconciliation, and asset linkage.
|
|
"""
|
|
_name = "account.move.line"
|
|
_inherit = "account.move.line"
|
|
|
|
move_attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment')
|
|
|
|
# ---- Deferral Date Tracking ----
|
|
deferred_start_date = fields.Date(
|
|
string="Start Date",
|
|
compute='_compute_deferred_start_date',
|
|
store=True,
|
|
readonly=False,
|
|
index='btree_not_null',
|
|
copy=False,
|
|
help="The date when recognition of deferred revenue or expense begins.",
|
|
)
|
|
deferred_end_date = fields.Date(
|
|
string="End Date",
|
|
index='btree_not_null',
|
|
copy=False,
|
|
help="The date when recognition of deferred revenue or expense ends.",
|
|
)
|
|
has_deferred_moves = fields.Boolean(
|
|
compute='_compute_has_deferred_moves',
|
|
)
|
|
has_abnormal_deferred_dates = fields.Boolean(
|
|
compute='_compute_has_abnormal_deferred_dates',
|
|
)
|
|
|
|
# ---- Asset Linkage ----
|
|
asset_ids = fields.Many2many(
|
|
'account.asset', 'asset_move_line_rel', 'line_id', 'asset_id',
|
|
string='Associated Assets',
|
|
copy=False,
|
|
)
|
|
non_deductible_tax_value = fields.Monetary(
|
|
compute='_compute_non_deductible_tax_value',
|
|
currency_field='company_currency_id',
|
|
)
|
|
|
|
# =========================================================================
|
|
# SQL ORDERING
|
|
# =========================================================================
|
|
|
|
def _order_to_sql(self, order, query, alias=None, reverse=False):
|
|
base_sql = super()._order_to_sql(order, query, alias, reverse)
|
|
target_amount = self.env.context.get('preferred_aml_value')
|
|
target_currency_id = self.env.context.get('preferred_aml_currency_id')
|
|
|
|
if target_amount and target_currency_id and order == self._order:
|
|
curr = self.env['res.currency'].browse(target_currency_id)
|
|
rounded_target = round(target_amount, curr.decimal_places)
|
|
tbl = alias or self._table
|
|
residual_sql = self._field_to_sql(tbl, 'amount_residual_currency', query)
|
|
currency_sql = self._field_to_sql(tbl, 'currency_id', query)
|
|
return SQL(
|
|
"ROUND(%(residual)s, %(decimals)s) = %(target)s "
|
|
"AND %(curr_field)s = %(curr_id)s DESC, %(fallback)s",
|
|
residual=residual_sql,
|
|
decimals=curr.decimal_places,
|
|
target=rounded_target,
|
|
curr_field=currency_sql,
|
|
curr_id=curr.id,
|
|
fallback=base_sql,
|
|
)
|
|
return base_sql
|
|
|
|
# =========================================================================
|
|
# CRUD OVERRIDES
|
|
# =========================================================================
|
|
|
|
def copy_data(self, default=None):
|
|
results = super().copy_data(default=default)
|
|
for ln, vals in zip(self, results):
|
|
if 'move_reverse_cancel' in self.env.context:
|
|
vals['deferred_start_date'] = ln.deferred_start_date
|
|
vals['deferred_end_date'] = ln.deferred_end_date
|
|
return results
|
|
|
|
def write(self, vals):
|
|
"""Guard against changing the account on lines with existing deferral entries."""
|
|
if 'account_id' in vals:
|
|
for ln in self:
|
|
if (
|
|
ln.has_deferred_moves
|
|
and ln.deferred_start_date
|
|
and ln.deferred_end_date
|
|
and vals['account_id'] != ln.account_id.id
|
|
):
|
|
raise UserError(_(
|
|
"The account on %(entry_name)s cannot be changed because "
|
|
"deferral entries have already been generated.",
|
|
entry_name=ln.move_id.display_name,
|
|
))
|
|
return super().write(vals)
|
|
|
|
# =========================================================================
|
|
# DEFERRAL COMPUTES
|
|
# =========================================================================
|
|
|
|
def _compute_has_deferred_moves(self):
|
|
for ln in self:
|
|
ln.has_deferred_moves = bool(ln.move_id.deferred_move_ids)
|
|
|
|
@api.depends('deferred_start_date', 'deferred_end_date')
|
|
def _compute_has_abnormal_deferred_dates(self):
|
|
# The deferral computations treat both start and end dates as inclusive.
|
|
# If the user enters dates that result in a fractional month offset of
|
|
# exactly 1/30 (e.g. Jan 1 to Jan 1 next year instead of Dec 31), the
|
|
# resulting amounts may look unexpected. Flag such cases for the user.
|
|
for ln in self:
|
|
ln.has_abnormal_deferred_dates = (
|
|
ln.deferred_start_date
|
|
and ln.deferred_end_date
|
|
and float_compare(
|
|
self.env['account.move']._get_deferred_diff_dates(
|
|
ln.deferred_start_date,
|
|
ln.deferred_end_date + relativedelta(days=1),
|
|
) % 1,
|
|
1 / 30,
|
|
precision_digits=2,
|
|
) == 0
|
|
)
|
|
|
|
def _has_deferred_compatible_account(self):
|
|
"""Checks whether this line's account type supports deferral for its document type."""
|
|
self.ensure_one()
|
|
if self.move_id.is_purchase_document():
|
|
return self.account_id.account_type in ('expense', 'expense_depreciation', 'expense_direct_cost')
|
|
if self.move_id.is_sale_document():
|
|
return self.account_id.account_type in ('income', 'income_other')
|
|
return False
|
|
|
|
@api.onchange('deferred_start_date')
|
|
def _onchange_deferred_start_date(self):
|
|
if not self._has_deferred_compatible_account():
|
|
self.deferred_start_date = False
|
|
|
|
@api.onchange('deferred_end_date')
|
|
def _onchange_deferred_end_date(self):
|
|
if not self._has_deferred_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 ln in self:
|
|
if not ln.deferred_start_date and ln.move_id.invoice_date and ln.deferred_end_date:
|
|
ln.deferred_start_date = ln.move_id.invoice_date
|
|
|
|
@api.constrains('deferred_start_date', 'deferred_end_date', 'account_id')
|
|
def _check_deferred_dates(self):
|
|
for ln in self:
|
|
if ln.deferred_start_date and not ln.deferred_end_date:
|
|
raise UserError(_("A deferral start date requires an end date to be set as well."))
|
|
if (
|
|
ln.deferred_start_date
|
|
and ln.deferred_end_date
|
|
and ln.deferred_start_date > ln.deferred_end_date
|
|
):
|
|
raise UserError(_("The deferral start date must not be later than the end date."))
|
|
|
|
@api.model
|
|
def _get_deferred_ends_of_month(self, from_date, to_date):
|
|
"""
|
|
Generates a list of month-end dates covering the range [from_date, to_date].
|
|
Each date is the last day of the respective month.
|
|
"""
|
|
boundaries = []
|
|
cursor = from_date
|
|
while cursor <= to_date:
|
|
cursor = cursor + relativedelta(day=31)
|
|
boundaries.append(cursor)
|
|
cursor = cursor + relativedelta(days=1)
|
|
return boundaries
|
|
|
|
def _get_deferred_periods(self):
|
|
"""
|
|
Splits the deferral range into monthly segments.
|
|
Returns an empty list if no spreading is needed (single period matching the entry date).
|
|
"""
|
|
self.ensure_one()
|
|
segments = [
|
|
(
|
|
max(self.deferred_start_date, dt.replace(day=1)),
|
|
min(dt, self.deferred_end_date),
|
|
'current',
|
|
)
|
|
for dt in self._get_deferred_ends_of_month(self.deferred_start_date, self.deferred_end_date)
|
|
]
|
|
if not segments or (
|
|
len(segments) == 1
|
|
and segments[0][0].replace(day=1) == self.date.replace(day=1)
|
|
):
|
|
return []
|
|
return segments
|
|
|
|
@api.model
|
|
def _get_deferred_amounts_by_line_values(self, line_data):
|
|
return {
|
|
'account_id': line_data['account_id'],
|
|
'product_id': line_data['product_id'] if isinstance(line_data, dict) else line_data['product_id'].id,
|
|
'product_category_id': line_data['product_category_id'] if isinstance(line_data, dict) else line_data['product_category_id'].id,
|
|
'balance': line_data['balance'],
|
|
'move_id': line_data['move_id'],
|
|
}
|
|
|
|
@api.model
|
|
def _get_deferred_lines_values(self, account_id, balance, ref, analytic_distribution, line_data=None):
|
|
return {
|
|
'account_id': account_id,
|
|
'product_id': line_data['product_id'] if isinstance(line_data, dict) else line_data['product_id'].id,
|
|
'product_category_id': line_data['product_category_id'] if isinstance(line_data, dict) else line_data['product_category_id'].id,
|
|
'balance': balance,
|
|
'name': ref,
|
|
'analytic_distribution': analytic_distribution,
|
|
}
|
|
|
|
# =========================================================================
|
|
# TAX HANDLING
|
|
# =========================================================================
|
|
|
|
def _get_computed_taxes(self):
|
|
# Skip automatic tax recomputation for deferral entries and asset depreciation entries
|
|
if self.move_id.deferred_original_move_ids or self.move_id.asset_id:
|
|
return self.tax_ids
|
|
return super()._get_computed_taxes()
|
|
|
|
# =========================================================================
|
|
# ATTACHMENTS
|
|
# =========================================================================
|
|
|
|
def _compute_attachment(self):
|
|
for ln in self:
|
|
ln.move_attachment_ids = self.env['ir.attachment'].search(
|
|
expression.OR(ln._get_attachment_domains())
|
|
)
|
|
|
|
# =========================================================================
|
|
# RECONCILIATION
|
|
# =========================================================================
|
|
|
|
def action_reconcile(self):
|
|
"""
|
|
Attempts direct reconciliation of selected lines. If a write-off or
|
|
partial reconciliation is needed, opens the reconciliation wizard instead.
|
|
"""
|
|
wiz = self.env['account.reconcile.wizard'].with_context(
|
|
active_model='account.move.line',
|
|
active_ids=self.ids,
|
|
).new({})
|
|
if wiz.is_write_off_required or wiz.force_partials:
|
|
return wiz._action_open_wizard()
|
|
return wiz.reconcile()
|
|
|
|
# =========================================================================
|
|
# PREDICTIVE LINE SUGGESTIONS
|
|
# =========================================================================
|
|
|
|
def _get_predict_postgres_dictionary(self):
|
|
"""Maps the user's language to a PostgreSQL text search dictionary."""
|
|
locale = self.env.context.get('lang', '')[:2]
|
|
return {'fr': 'french'}.get(locale, 'english')
|
|
|
|
@api.model
|
|
def _build_predictive_query(self, source_move, extra_domain=None):
|
|
"""
|
|
Builds the base query for predictive field matching, limited to
|
|
historical posted entries from the same partner and move type.
|
|
"""
|
|
history_limit = int(self.env['ir.config_parameter'].sudo().get_param(
|
|
'account.bill.predict.history.limit', '100',
|
|
))
|
|
move_qry = self.env['account.move']._where_calc([
|
|
('move_type', '=', source_move.move_type),
|
|
('state', '=', 'posted'),
|
|
('partner_id', '=', source_move.partner_id.id),
|
|
('company_id', '=', source_move.journal_id.company_id.id or self.env.company.id),
|
|
])
|
|
move_qry.order = 'account_move.invoice_date'
|
|
move_qry.limit = history_limit
|
|
return self.env['account.move.line']._where_calc([
|
|
('move_id', 'in', move_qry),
|
|
('display_type', '=', 'product'),
|
|
] + (extra_domain or []))
|
|
|
|
@api.model
|
|
def _predicted_field(self, description, partner, target_field, base_query=None, supplementary_sources=None):
|
|
"""
|
|
Uses PostgreSQL full-text search to rank historical line items by
|
|
relevance to the given description, then returns the most likely
|
|
value for target_field.
|
|
|
|
Only returns a prediction when the top result is at least 10%
|
|
more relevant than the runner-up.
|
|
|
|
The search is limited to the most recent entries (configurable via
|
|
the 'account.bill.predict.history.limit' system parameter, default 100).
|
|
"""
|
|
if not description or not partner:
|
|
return False
|
|
|
|
pg_dict = self._get_predict_postgres_dictionary()
|
|
search_text = description + ' account_move_line'
|
|
sanitized = re.sub(r"[*&()|!':<>=%/~@,.;$\[\]]+", " ", search_text)
|
|
ts_expression = ' | '.join(sanitized.split())
|
|
|
|
try:
|
|
primary_source = (
|
|
base_query if base_query is not None else self._build_predictive_query(self.move_id)
|
|
).select(
|
|
SQL("%s AS prediction", target_field),
|
|
SQL(
|
|
"setweight(to_tsvector(%s, account_move_line.name), 'B') "
|
|
"|| setweight(to_tsvector('simple', 'account_move_line'), 'A') AS document",
|
|
pg_dict,
|
|
),
|
|
)
|
|
if "(" in target_field.code:
|
|
primary_source = SQL(
|
|
"%s %s", primary_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 (%(base_lines)s),
|
|
|
|
source AS (%(all_sources)s),
|
|
|
|
ranking AS (
|
|
SELECT prediction, ts_rank(source.document, query_plain) AS rank
|
|
FROM source, to_tsquery(%(dict)s, %(expr)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
|
|
""",
|
|
base_lines=self._build_predictive_query(self.move_id).select(SQL('*')),
|
|
all_sources=SQL('(%s)', SQL(') UNION ALL (').join(
|
|
[primary_source] + (supplementary_sources or [])
|
|
)),
|
|
dict=pg_dict,
|
|
expr=ts_expression,
|
|
))
|
|
|
|
matches = self.env.cr.dictfetchall()
|
|
if matches:
|
|
if len(matches) > 1 and matches[0]['ranking'] < 1.1 * matches[1]['ranking']:
|
|
return False
|
|
return matches[0]['prediction']
|
|
except Exception:
|
|
_log.exception("Prediction query failed for invoice line field suggestion")
|
|
return False
|
|
|
|
def _predict_taxes(self):
|
|
agg_field = SQL(
|
|
'array_agg(account_move_line__tax_rel__tax_ids.id '
|
|
'ORDER BY account_move_line__tax_rel__tax_ids.id)'
|
|
)
|
|
qry = self._build_predictive_query(self.move_id)
|
|
qry.left_join('account_move_line', 'id', 'account_move_line_account_tax_rel', 'account_move_line_id', 'tax_rel')
|
|
qry.left_join('account_move_line__tax_rel', 'account_tax_id', 'account_tax', 'id', 'tax_ids')
|
|
qry.add_where('account_move_line__tax_rel__tax_ids.active IS NOT FALSE')
|
|
suggested_ids = self._predicted_field(self.name, self.partner_id, agg_field, qry)
|
|
if suggested_ids == [None]:
|
|
return False
|
|
if suggested_ids is not False and set(suggested_ids) != set(self.tax_ids.ids):
|
|
return suggested_ids
|
|
return False
|
|
|
|
@api.model
|
|
def _predict_specific_tax(self, source_move, label, partner, amt_type, amt_value, tax_use_type):
|
|
agg_field = SQL(
|
|
'array_agg(account_move_line__tax_rel__tax_ids.id '
|
|
'ORDER BY account_move_line__tax_rel__tax_ids.id)'
|
|
)
|
|
qry = self._build_predictive_query(source_move)
|
|
qry.left_join('account_move_line', 'id', 'account_move_line_account_tax_rel', 'account_move_line_id', 'tax_rel')
|
|
qry.left_join('account_move_line__tax_rel', 'account_tax_id', 'account_tax', 'id', 'tax_ids')
|
|
qry.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
|
|
""", (amt_type, tax_use_type, amt_value))
|
|
return self._predicted_field(label, partner, agg_field, qry)
|
|
|
|
def _predict_product(self):
|
|
feature_enabled = int(self.env['ir.config_parameter'].sudo().get_param(
|
|
'account_predictive_bills.predict_product', '1',
|
|
))
|
|
if feature_enabled and self.company_id.predict_bill_product:
|
|
qry = self._build_predictive_query(
|
|
self.move_id,
|
|
['|', ('product_id', '=', False), ('product_id.active', '=', True)],
|
|
)
|
|
suggested = self._predicted_field(
|
|
self.name, self.partner_id, SQL('account_move_line.product_id'), qry,
|
|
)
|
|
if suggested and suggested != self.product_id.id:
|
|
return suggested
|
|
return False
|
|
|
|
def _predict_account(self):
|
|
target_sql = SQL('account_move_line.account_id')
|
|
blocked_group = 'income' if self.move_id.is_purchase_document(True) else 'expense'
|
|
|
|
acct_qry = self.env['account.account']._where_calc([
|
|
*self.env['account.account']._check_company_domain(self.move_id.company_id or self.env.company),
|
|
('internal_group', 'not in', (blocked_group, 'off')),
|
|
('account_type', 'not in', ('liability_payable', 'asset_receivable')),
|
|
])
|
|
acct_name_sql = self.env['account.account']._field_to_sql('account_account', 'name')
|
|
pg_dict = self._get_predict_postgres_dictionary()
|
|
|
|
extra_sources = [SQL(acct_qry.select(
|
|
SQL("account_account.id AS account_id"),
|
|
SQL(
|
|
"setweight(to_tsvector(%(dict)s, %(name_sql)s), 'B') AS document",
|
|
dict=pg_dict,
|
|
name_sql=acct_name_sql,
|
|
),
|
|
))]
|
|
|
|
line_qry = self._build_predictive_query(self.move_id, [('account_id', 'in', acct_qry)])
|
|
suggested = self._predicted_field(self.name, self.partner_id, target_sql, line_qry, extra_sources)
|
|
if suggested and suggested != self.account_id.id:
|
|
return suggested
|
|
return False
|
|
|
|
@api.onchange('name')
|
|
def _onchange_name_predictive(self):
|
|
if not (
|
|
(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)
|
|
):
|
|
return
|
|
|
|
if not self.product_id:
|
|
suggested_product = self._predict_product()
|
|
if suggested_product:
|
|
guarded = ['price_unit', 'tax_ids', 'name']
|
|
fields_to_protect = [self._fields[f] for f in guarded if self[f]]
|
|
with self.env.protecting(fields_to_protect, self):
|
|
self.product_id = suggested_product
|
|
|
|
# When no product is set, predict account and taxes independently
|
|
if not self.product_id:
|
|
suggested_acct = self._predict_account()
|
|
if suggested_acct:
|
|
self.account_id = suggested_acct
|
|
|
|
suggested_taxes = self._predict_taxes()
|
|
if suggested_taxes:
|
|
self.tax_ids = [Command.set(suggested_taxes)]
|
|
|
|
# =========================================================================
|
|
# READ GROUP EXTENSIONS
|
|
# =========================================================================
|
|
|
|
def _read_group_select(self, aggregate_spec, query):
|
|
"""Enables HAVING clauses that sum values rounded to currency precision."""
|
|
col_name, __, func_name = models.parse_read_group_spec(aggregate_spec)
|
|
if func_name != 'sum_rounded':
|
|
return super()._read_group_select(aggregate_spec, query)
|
|
|
|
curr_alias = query.make_alias(self._table, 'currency_id')
|
|
query.add_join('LEFT JOIN', curr_alias, 'res_currency', SQL(
|
|
"%s = %s",
|
|
self._field_to_sql(self._table, 'currency_id', query),
|
|
SQL.identifier(curr_alias, 'id'),
|
|
))
|
|
return SQL(
|
|
'SUM(ROUND(%s, %s))',
|
|
self._field_to_sql(self._table, col_name, query),
|
|
self.env['res.currency']._field_to_sql(curr_alias, 'decimal_places', query),
|
|
)
|
|
|
|
def _read_group_groupby(self, table, groupby_spec, query):
|
|
"""Enables grouping by absolute rounded values for amount matching."""
|
|
if ':' in groupby_spec:
|
|
col_name, modifier = groupby_spec.split(':')
|
|
if modifier == 'abs_rounded':
|
|
curr_alias = query.make_alias(self._table, 'currency_id')
|
|
query.add_join('LEFT JOIN', curr_alias, 'res_currency', SQL(
|
|
"%s = %s",
|
|
self._field_to_sql(self._table, 'currency_id', query),
|
|
SQL.identifier(curr_alias, 'id'),
|
|
))
|
|
return SQL(
|
|
'ROUND(ABS(%s), %s)',
|
|
self._field_to_sql(self._table, col_name, query),
|
|
self.env['res.currency']._field_to_sql(curr_alias, 'decimal_places', query),
|
|
)
|
|
return super()._read_group_groupby(table, groupby_spec, query)
|
|
|
|
# =========================================================================
|
|
# ASSET OPERATIONS
|
|
# =========================================================================
|
|
|
|
def turn_as_asset(self):
|
|
if len(self.company_id) != 1:
|
|
raise UserError(_("All selected lines must belong to the same company."))
|
|
if any(ln.move_id.state == 'draft' for ln in self):
|
|
raise UserError(_("All selected lines must be from posted entries."))
|
|
if any(ln.account_id != self[0].account_id for ln in self):
|
|
raise UserError(_("All selected lines must share the same account."))
|
|
|
|
ctx = {
|
|
**self.env.context,
|
|
'default_original_move_line_ids': [(6, False, self.env.context['active_ids'])],
|
|
'default_company_id': self.company_id.id,
|
|
}
|
|
return {
|
|
'name': _("Convert to Asset"),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.asset',
|
|
'views': [[False, 'form']],
|
|
'target': 'current',
|
|
'context': ctx,
|
|
}
|
|
|
|
@api.depends('tax_ids.invoice_repartition_line_ids')
|
|
def _compute_non_deductible_tax_value(self):
|
|
"""
|
|
Calculates the portion of tax that is non-deductible based on
|
|
repartition lines excluded from tax closing.
|
|
"""
|
|
excluded_tax_ids = self.tax_ids.invoice_repartition_line_ids.filtered(
|
|
lambda rl: rl.repartition_type == 'tax' and not rl.use_in_tax_closing
|
|
).tax_id
|
|
|
|
aggregated = {}
|
|
if excluded_tax_ids:
|
|
scope_domain = [('move_id', 'in', self.move_id.ids)]
|
|
tax_detail_qry = self._get_query_tax_details_from_domain(scope_domain)
|
|
|
|
self.flush_model()
|
|
self.env.cr.execute(SQL("""
|
|
SELECT
|
|
tdq.base_line_id,
|
|
SUM(tdq.tax_amount_currency)
|
|
FROM (%(detail_query)s) AS tdq
|
|
JOIN account_move_line aml ON aml.id = tdq.tax_line_id
|
|
JOIN account_tax_repartition_line trl ON trl.id = tdq.tax_repartition_line_id
|
|
WHERE tdq.base_line_id IN %(line_ids)s
|
|
AND trl.use_in_tax_closing IS FALSE
|
|
GROUP BY tdq.base_line_id
|
|
""",
|
|
detail_query=tax_detail_qry,
|
|
line_ids=tuple(self.ids),
|
|
))
|
|
aggregated = {row['base_line_id']: row['sum'] for row in self.env.cr.dictfetchall()}
|
|
|
|
for ln in self:
|
|
ln.non_deductible_tax_value = aggregated.get(ln._origin.id, 0.0)
|