""" 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 " "closing entry " "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)