Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,341 @@
# Fusion Accounting - Analytic Group By for Financial Reports
# Enables analytic plan / account column grouping on account reports
from odoo import models, fields, api, osv
from odoo.addons.web.controllers.utils import clean_action
from odoo.tools import SQL, Query
class FusionReportAnalyticGroupby(models.AbstractModel):
"""Extends the accounting report engine to support grouping by
analytic accounts or plans via shadow-table substitution."""
_inherit = 'account.report'
filter_analytic_groupby = fields.Boolean(
string="Analytic Group By",
compute=lambda self: self._compute_report_option_filter('filter_analytic_groupby'),
readonly=False,
store=True,
depends=['root_report_id'],
)
# ------------------------------------------------------------------
# Initialization sequencing
# ------------------------------------------------------------------
def _get_options_initializers_forced_sequence_map(self):
"""Insert the analytic-groupby initializer between column-header
creation and final column building (sequence 995)."""
seq = super()._get_options_initializers_forced_sequence_map()
seq[self._init_options_analytic_groupby] = 995
return seq
# ------------------------------------------------------------------
# Option initializer
# ------------------------------------------------------------------
def _init_options_analytic_groupby(self, options, previous_options):
"""Populate analytic groupby filters in *options* when the report
advertises ``filter_analytic_groupby`` and the user has the
analytic-accounting group."""
if not self.filter_analytic_groupby:
return
has_analytic_perm = self.env.user.has_group('analytic.group_analytic_accounting')
if not has_analytic_perm:
return
options['display_analytic_groupby'] = True
options['display_analytic_plan_groupby'] = True
# --- analytic-without-aml toggle ---
options['include_analytic_without_aml'] = previous_options.get('include_analytic_without_aml', False)
# --- analytic accounts ---
prev_account_ids = [int(v) for v in previous_options.get('analytic_accounts_groupby', [])]
chosen_accounts = (
self.env['account.analytic.account']
.with_context(active_test=False)
.search([('id', 'in', prev_account_ids)])
)
options['analytic_accounts_groupby'] = chosen_accounts.ids
options['selected_analytic_account_groupby_names'] = chosen_accounts.mapped('name')
# --- analytic plans ---
prev_plan_ids = [int(v) for v in previous_options.get('analytic_plans_groupby', [])]
chosen_plans = self.env['account.analytic.plan'].search([('id', 'in', prev_plan_ids)])
options['analytic_plans_groupby'] = chosen_plans.ids
options['selected_analytic_plan_groupby_names'] = chosen_plans.mapped('name')
self._build_analytic_column_headers(options)
# ------------------------------------------------------------------
# Readonly-query interaction
# ------------------------------------------------------------------
def _init_options_readonly_query(self, options, previous_options):
super()._init_options_readonly_query(options, previous_options)
# Analytic columns use a shadow table ⇒ disable readonly shortcut
options['readonly_query'] = (
options['readonly_query'] and not options.get('analytic_groupby_option')
)
# ------------------------------------------------------------------
# Column header generation
# ------------------------------------------------------------------
def _build_analytic_column_headers(self, options):
"""Create extra column headers for every selected analytic plan
or individual analytic account."""
extra_headers = []
# --- plans → accounts within each plan ---
plan_recs = self.env['account.analytic.plan'].browse(options.get('analytic_plans_groupby'))
for plan in plan_recs:
child_accounts = self.env['account.analytic.account'].search([
('plan_id', 'child_of', plan.id),
])
extra_headers.append({
'name': plan.name,
'forced_options': {
'analytic_groupby_option': True,
'analytic_accounts_list': tuple(child_accounts.ids),
},
})
# --- individual accounts ---
acct_recs = self.env['account.analytic.account'].browse(options.get('analytic_accounts_groupby'))
for acct in acct_recs:
extra_headers.append({
'name': acct.name,
'forced_options': {
'analytic_groupby_option': True,
'analytic_accounts_list': (acct.id,),
},
})
if not extra_headers:
return
budget_active = any(b for b in options.get('budgets', []) if b.get('selected'))
if budget_active:
# Place analytic headers next to budget headers on the same level
options['column_headers'][-1] = extra_headers + options['column_headers'][-1]
else:
# Append a new header tier for analytic columns + a blank for totals
extra_headers.append({'name': ''})
options['column_headers'] = [
*options['column_headers'],
extra_headers,
]
# ------------------------------------------------------------------
# Shadow-table preparation
# ------------------------------------------------------------------
@api.model
def _prepare_lines_for_analytic_groupby(self):
"""Build a temporary ``analytic_temp_account_move_line`` table that
mirrors the *account_move_line* schema but is populated from
*account_analytic_line*. Created once per SQL transaction."""
self.env.cr.execute(
"SELECT 1 FROM information_schema.tables "
"WHERE table_name = 'analytic_temp_account_move_line'"
)
if self.env.cr.fetchone():
return # already prepared in this transaction
root_plan, additional_plans = self.env['account.analytic.plan']._get_all_plans()
all_plans = root_plan + additional_plans
analytic_col_refs = SQL(", ").join(
SQL('"account_analytic_line".%s', SQL.identifier(p._column_name()))
for p in all_plans
)
distribution_expr = SQL(
'to_jsonb(UNNEST(ARRAY[%s]))', analytic_col_refs,
)
field_mapping = {
'id': SQL("account_analytic_line.id"),
'balance': SQL("-amount"),
'display_type': 'product',
'parent_state': 'posted',
'account_id': SQL.identifier("general_account_id"),
'debit': SQL("CASE WHEN amount < 0 THEN -amount ELSE 0 END"),
'credit': SQL("CASE WHEN amount > 0 THEN amount ELSE 0 END"),
'analytic_distribution': distribution_expr,
}
# Fill in the remaining stored fields with values from the linked AML
aml_fields_meta = self.env['account.move.line'].fields_get()
persisted_fields = {
fname
for fname, meta in aml_fields_meta.items()
if meta['type'] not in ('many2many', 'one2many') and meta.get('store')
}
for fname in persisted_fields:
if fname not in field_mapping:
field_mapping[fname] = SQL('"account_move_line".%s', SQL.identifier(fname))
col_names_sql, val_exprs_sql = (
self.env['account.move.line']._prepare_aml_shadowing_for_report(field_mapping)
)
shadow_sql = SQL("""
-- Build temporary shadow table inheriting AML schema
CREATE TEMPORARY TABLE IF NOT EXISTS analytic_temp_account_move_line ()
INHERITS (account_move_line) ON COMMIT DROP;
ALTER TABLE analytic_temp_account_move_line NO INHERIT account_move_line;
ALTER TABLE analytic_temp_account_move_line
DROP CONSTRAINT IF EXISTS account_move_line_check_amount_currency_balance_sign;
ALTER TABLE analytic_temp_account_move_line ALTER COLUMN move_id DROP NOT NULL;
ALTER TABLE analytic_temp_account_move_line ALTER COLUMN currency_id DROP NOT NULL;
INSERT INTO analytic_temp_account_move_line (%(col_names)s)
SELECT %(val_exprs)s
FROM account_analytic_line
LEFT JOIN account_move_line
ON account_analytic_line.move_line_id = account_move_line.id
WHERE account_analytic_line.general_account_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS analytic_temp_aml__composite_idx
ON analytic_temp_account_move_line (analytic_distribution, journal_id, date, company_id);
ANALYZE analytic_temp_account_move_line;
""", col_names=col_names_sql, val_exprs=val_exprs_sql)
self.env.cr.execute(shadow_sql)
# ------------------------------------------------------------------
# Query overrides
# ------------------------------------------------------------------
def _get_report_query(self, options, date_scope, domain=None) -> Query:
"""When analytic-groupby columns are active, inject the context
flag that causes `_where_calc` to swap the AML table."""
ctx_self = self.with_context(
account_report_analytic_groupby=options.get('analytic_groupby_option'),
)
query = super(FusionReportAnalyticGroupby, ctx_self)._get_report_query(options, date_scope, domain)
if options.get('analytic_accounts'):
if 'analytic_accounts_list' in options:
# Shadow table stores bare integer ids in analytic_distribution
acct_str_ids = tuple(str(aid) for aid in options['analytic_accounts'])
query.add_where(SQL(
"account_move_line.analytic_distribution IN %s",
acct_str_ids,
))
else:
# Real AML table JSON distribution with percentages
acct_id_list = [[str(aid) for aid in options['analytic_accounts']]]
query.add_where(SQL(
'%s && %s',
acct_id_list,
self.env['account.move.line']._query_analytic_accounts(),
))
return query
# ------------------------------------------------------------------
# Audit action
# ------------------------------------------------------------------
def action_audit_cell(self, options, params):
"""Redirect the audit action to analytic lines when the column
being audited belongs to an analytic-groupby column group."""
col_opts = self._get_column_group_options(options, params['column_group_key'])
if not col_opts.get('analytic_groupby_option'):
return super().action_audit_cell(options, params)
# Translate AML domain → analytic line domain
rpt_line = self.env['account.report.line'].browse(params['report_line_id'])
expr = rpt_line.expression_ids.filtered(lambda e: e.label == params['expression_label'])
raw_domain = self._get_audit_line_domain(col_opts, expr, params)
AnalyticLine = self.env['account.analytic.line']
converted_domain = []
for leaf in raw_domain:
if len(leaf) == 1:
converted_domain.append(leaf)
continue
fld, op, val = leaf
root_field = fld.split('.')[0]
if root_field == 'account_id':
converted_domain.append((fld.replace('account_id', 'general_account_id'), op, val))
elif fld == 'analytic_distribution':
converted_domain.append(('auto_account_id', 'in', val))
elif root_field not in AnalyticLine._fields:
expr_leaf = [(f'move_line_id.{fld}', op, val)]
if options.get('include_analytic_without_aml'):
expr_leaf = osv.expression.OR([
[('move_line_id', '=', False)],
expr_leaf,
])
converted_domain.extend(expr_leaf)
else:
converted_domain.append(leaf)
act = clean_action(
self.env.ref('analytic.account_analytic_line_action_entries')._get_action_dict(),
env=self.env,
)
act['domain'] = converted_domain
return act
# ------------------------------------------------------------------
# Journal domain
# ------------------------------------------------------------------
@api.model
def _get_options_journals_domain(self, options):
"""Allow journal-less lines when analytic lines without a parent
move line are included."""
base_domain = super()._get_options_journals_domain(options)
if options.get('include_analytic_without_aml'):
base_domain = osv.expression.OR([
base_domain,
[('journal_id', '=', False)],
])
return base_domain
# ------------------------------------------------------------------
# Options domain
# ------------------------------------------------------------------
def _get_options_domain(self, options, date_scope):
self.ensure_one()
base = super()._get_options_domain(options, date_scope)
acct_filter = options.get('analytic_accounts_list')
if acct_filter:
base = osv.expression.AND([
base,
[('analytic_distribution', 'in', list(acct_filter))],
])
return base
class FusionAMLAnalyticShadow(models.Model):
"""Hooks into `_where_calc` to swap the AML table for the analytic
shadow table when the report context flag is set."""
_inherit = "account.move.line"
def _where_calc(self, domain, active_test=True):
"""Replace the base ``account_move_line`` table reference with the
``analytic_temp_account_move_line`` shadow table whenever the
``account_report_analytic_groupby`` context key is truthy, unless
a cash-basis report is active (which already replaces the table)."""
qry = super()._where_calc(domain, active_test)
ctx = self.env.context
if ctx.get('account_report_analytic_groupby') and not ctx.get('account_report_cash_basis'):
self.env['account.report']._prepare_lines_for_analytic_groupby()
qry._tables['account_move_line'] = SQL.identifier('analytic_temp_account_move_line')
return qry