This commit is contained in:
gsinghpal
2026-05-16 13:18:52 -04:00
parent 191a9c82be
commit 9ebf89bde2
1080 changed files with 0 additions and 1197 deletions

View File

@@ -0,0 +1,9 @@
from .base import DataAdapter, AdapterMode
from ._registry import get_adapter, register_adapter
from . import bank_rec # noqa: F401
from . import reports # noqa: F401
from . import followup # noqa: F401
from . import assets # noqa: F401
__all__ = ['DataAdapter', 'AdapterMode', 'get_adapter', 'register_adapter']

View File

@@ -0,0 +1,25 @@
"""Registry: lazy-loads data adapter instances per env."""
from .base import DataAdapter
def get_adapter(env, name: str) -> DataAdapter:
"""Return a data adapter by short name. Cached per request via env.context."""
cache = env.context.get('_fusion_data_adapter_cache')
if cache is None:
cache = {}
if name not in cache:
cls = _ADAPTERS.get(name)
if cls is None:
raise KeyError(f"Unknown data adapter: {name!r}. Known: {list(_ADAPTERS)}")
cache[name] = cls(env)
return cache[name]
# Populated as adapter classes are added (Tasks 9, 10, 11).
_ADAPTERS: dict[str, type[DataAdapter]] = {}
def register_adapter(name: str, cls: type[DataAdapter]) -> None:
"""Register an adapter class. Call from each adapter module at import time."""
_ADAPTERS[name] = cls

View File

@@ -0,0 +1,98 @@
"""Assets data adapter — routes asset queries through fusion engine if installed."""
from .base import DataAdapter
from ._registry import register_adapter
class AssetsAdapter(DataAdapter):
FUSION_MODEL = 'fusion.asset.engine'
ENTERPRISE_MODULE = 'account_asset'
# ============================================================
# list_assets
# ============================================================
def list_assets(self, state=None, limit=50, company_id=None):
return self._dispatch(
'list_assets', state=state, limit=limit, company_id=company_id,
)
def list_assets_via_fusion(self, **kwargs):
if 'fusion.asset.engine' not in self.env.registry:
return {'assets': [], 'count': 0, 'total': 0}
Asset = self.env['fusion.asset'].sudo()
domain = [('company_id', '=', kwargs.get('company_id') or self.env.company.id)]
if kwargs.get('state'):
domain.append(('state', '=', kwargs['state']))
total = Asset.search_count(domain)
assets = Asset.search(
domain, limit=int(kwargs.get('limit', 50)),
order='acquisition_date desc',
)
return {
'count': len(assets), 'total': total,
'assets': [{
'id': a.id, 'name': a.name, 'state': a.state,
'cost': a.cost, 'book_value': a.book_value,
'method': a.method,
'category_name': a.category_id.name if a.category_id else None,
} for a in assets],
}
def list_assets_via_enterprise(self, **kwargs):
return {
'assets': [], 'count': 0, 'total': 0,
'error': 'Enterprise account_asset must be queried from Enterprise UI',
}
def list_assets_via_community(self, **kwargs):
return {
'assets': [], 'count': 0, 'total': 0,
'error': 'No assets engine in pure Community',
}
# ============================================================
# suggest_useful_life
# ============================================================
def suggest_useful_life(self, description, amount=None, partner_name=None):
return self._dispatch(
'suggest_useful_life',
description=description, amount=amount, partner_name=partner_name,
)
def suggest_useful_life_via_fusion(self, **kwargs):
if 'fusion.asset.engine' not in self.env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
predict_useful_life,
)
return predict_useful_life(self.env, **kwargs)
def suggest_useful_life_via_enterprise(self, **kwargs):
return {'error': 'AI useful-life suggestion is fusion-only'}
def suggest_useful_life_via_community(self, **kwargs):
return {'error': 'AI useful-life suggestion is fusion-only'}
# ============================================================
# dispose_asset
# ============================================================
def dispose_asset(self, asset_id, **kwargs):
return self._dispatch('dispose_asset', asset_id=asset_id, **kwargs)
def dispose_asset_via_fusion(self, asset_id, **kwargs):
if 'fusion.asset.engine' not in self.env.registry:
return {'error': 'fusion_accounting_assets not installed'}
asset = self.env['fusion.asset'].sudo().browse(int(asset_id))
return self.env['fusion.asset.engine'].sudo().dispose_asset(asset, **kwargs)
def dispose_asset_via_enterprise(self, asset_id, **kwargs):
return {'error': 'Enterprise asset disposal must use Enterprise UI'}
def dispose_asset_via_community(self, asset_id, **kwargs):
return {'error': 'Community has no asset disposal flow'}
register_adapter('assets', AssetsAdapter)

View File

@@ -0,0 +1,229 @@
"""Bank reconciliation data adapter.
Routes bank-rec data lookups across:
- FUSION: fusion.bank.rec.widget (added by fusion_accounting_bank_rec, Phase 1)
- ENTERPRISE: account_accountant's bank_rec_widget JS service
- COMMUNITY: pure search on account.bank.statement.line
In addition to ``list_unreconciled``, the adapter exposes thin wrappers
around the engine's public API: ``suggest_matches``, ``accept_suggestion``,
``unreconcile``. AI tools and the OWL controller go through these wrappers
instead of touching the engine directly so install-mode routing stays in
one place.
"""
from .base import DataAdapter
from ._registry import register_adapter
class BankRecAdapter(DataAdapter):
FUSION_MODEL = 'fusion.bank.rec.widget'
ENTERPRISE_MODULE = 'account_accountant'
# ------------------------------------------------------------
# list_unreconciled
# ------------------------------------------------------------
def list_unreconciled(self, journal_id=None, limit=100, date_from=None,
date_to=None, min_amount=None, company_id=None):
"""Return unreconciled bank statement lines.
All filter params are optional; pass company_id to restrict results to
a single company (the AI tools always do this).
"""
return self._dispatch(
'list_unreconciled',
journal_id=journal_id, limit=limit,
date_from=date_from, date_to=date_to,
min_amount=min_amount, company_id=company_id,
)
def list_unreconciled_via_fusion(self, journal_id=None, limit=100,
date_from=None, date_to=None,
min_amount=None, company_id=None):
"""Community shape + fusion AI fields (top suggestion, band, attachments)."""
base = self.list_unreconciled_via_community(
journal_id=journal_id, limit=limit,
date_from=date_from, date_to=date_to,
min_amount=min_amount, company_id=company_id,
)
if not base:
return base
Line = self.env['account.bank.statement.line'].sudo()
ids = [row['id'] for row in base]
lines_by_id = {line.id: line for line in Line.browse(ids)}
for row in base:
line = lines_by_id.get(row['id'])
if not line:
row['fusion_top_suggestion_id'] = None
row['fusion_confidence_band'] = 'none'
row['attachment_count'] = 0
continue
top = line.fusion_top_suggestion_id
row['fusion_top_suggestion_id'] = top.id if top else None
row['fusion_confidence_band'] = line.fusion_confidence_band or 'none'
row['attachment_count'] = len(line.bank_statement_attachment_ids)
return base
def list_unreconciled_via_enterprise(self, journal_id=None, limit=100,
date_from=None, date_to=None,
min_amount=None, company_id=None):
# Enterprise's bank rec uses a JS-side service; from Python the cleanest
# backend access is the same Community search (the data lives in
# account.bank.statement.line either way). This adapter's purpose is
# to expose a stable shape to AI tools regardless of which UI the user has.
return self.list_unreconciled_via_community(
journal_id=journal_id, limit=limit,
date_from=date_from, date_to=date_to,
min_amount=min_amount, company_id=company_id,
)
def list_unreconciled_via_community(self, journal_id=None, limit=100,
date_from=None, date_to=None,
min_amount=None, company_id=None):
Line = self.env['account.bank.statement.line'].sudo()
domain = [('is_reconciled', '=', False)]
if journal_id is not None:
domain.append(('journal_id', '=', journal_id))
if company_id is not None:
domain.append(('company_id', '=', company_id))
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
if min_amount is not None:
domain.append(('amount', '>=', min_amount))
records = Line.search(domain, limit=limit, order='date desc, id desc')
return [
{
'id': r.id,
'date': r.date,
'payment_ref': r.payment_ref,
'amount': r.amount,
'partner_id': r.partner_id.id if r.partner_id else None,
'partner_name': r.partner_name or (r.partner_id.name if r.partner_id else None),
'currency_id': r.currency_id.id if r.currency_id else None,
'journal_id': r.journal_id.id,
'journal_name': r.journal_id.name,
}
for r in records
]
# ------------------------------------------------------------
# suggest_matches
# ------------------------------------------------------------
def suggest_matches(self, statement_line_ids, *, limit_per_line=3,
company_id=None):
"""Return AI suggestions per bank line.
Shape: ``{line_id: [{'id', 'rank', 'confidence', 'reasoning',
'candidate_id'}, ...]}``. Empty dict when AI suggestions are not
available (Enterprise / Community).
"""
return self._dispatch(
'suggest_matches',
statement_line_ids=statement_line_ids,
limit_per_line=limit_per_line,
company_id=company_id,
)
def suggest_matches_via_fusion(self, statement_line_ids, *,
limit_per_line=3, company_id=None):
Line = self.env['account.bank.statement.line'].sudo()
lines = Line.browse(list(statement_line_ids or [])).exists()
if not lines:
return {}
return self.env['fusion.reconcile.engine'].suggest_matches(
lines, limit_per_line=limit_per_line)
def suggest_matches_via_enterprise(self, statement_line_ids, *,
limit_per_line=3, company_id=None):
# Enterprise has its own suggest mechanism inside bank_rec_widget;
# we don't proxy it from Python.
return {}
def suggest_matches_via_community(self, statement_line_ids, *,
limit_per_line=3, company_id=None):
return {}
# ------------------------------------------------------------
# accept_suggestion
# ------------------------------------------------------------
def accept_suggestion(self, suggestion_id):
"""Accept a fusion AI suggestion and reconcile against its proposal.
Returns ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
'write_off_move_id': int|None}``. Fusion-only.
"""
return self._dispatch(
'accept_suggestion', suggestion_id=suggestion_id)
def accept_suggestion_via_fusion(self, suggestion_id):
return self.env['fusion.reconcile.engine'].accept_suggestion(
int(suggestion_id))
def accept_suggestion_via_enterprise(self, suggestion_id):
raise NotImplementedError("accept_suggestion is fusion-only")
def accept_suggestion_via_community(self, suggestion_id):
raise NotImplementedError("accept_suggestion is fusion-only")
# ------------------------------------------------------------
# unreconcile
# ------------------------------------------------------------
def unreconcile(self, partial_reconcile_ids):
"""Reverse a reconciliation by partial IDs.
Returns ``{'unreconciled_line_ids': [...]}``. Available in all modes
(the engine delegates to V19's standard
``account.bank.statement.line.action_undo_reconciliation``).
"""
return self._dispatch(
'unreconcile', partial_reconcile_ids=partial_reconcile_ids)
def unreconcile_via_fusion(self, partial_reconcile_ids):
Partial = self.env['account.partial.reconcile'].sudo()
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
return self.env['fusion.reconcile.engine'].unreconcile(partials)
def unreconcile_via_enterprise(self, partial_reconcile_ids):
# Enterprise/community paths can't depend on fusion.reconcile.engine
# being loaded (fusion_accounting_ai does NOT depend on
# fusion_accounting_bank_rec). Mirror the engine's behaviour using
# only Community-available helpers.
return self._unreconcile_standalone(partial_reconcile_ids)
def unreconcile_via_community(self, partial_reconcile_ids):
return self._unreconcile_standalone(partial_reconcile_ids)
def _unreconcile_standalone(self, partial_reconcile_ids):
"""Engine-free unreconcile for installs without fusion_accounting_bank_rec.
Mirrors ``fusion.reconcile.engine.unreconcile``: finds bank lines whose
moves own any of the partials' journal items, runs the standard undo
on them, then unlinks any leftovers.
"""
Partial = self.env['account.partial.reconcile'].sudo()
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
if not partials:
return {'unreconciled_line_ids': []}
all_lines = (
partials.mapped('debit_move_id')
| partials.mapped('credit_move_id')
)
line_ids = all_lines.ids
affected = self.env['account.bank.statement.line'].sudo().search([
('move_id', 'in', all_lines.mapped('move_id').ids),
])
if affected:
affected.action_undo_reconciliation()
remaining = partials.exists()
if remaining:
remaining.unlink()
return {'unreconciled_line_ids': line_ids}
register_adapter('bank_rec', BankRecAdapter)

View File

@@ -0,0 +1,79 @@
"""Data-adapter base class: routes data lookups across three backends.
The fusion_accounting_ai sub-module's tools (e.g. get_unreconciled_bank_lines)
must work in any of three install profiles:
1. FUSION mode — a fusion native sub-module (e.g. fusion_accounting_bank_rec)
is installed; route to its model.
2. ENTERPRISE mode — Odoo Enterprise (e.g. account_accountant) is installed;
route to Enterprise APIs.
3. COMMUNITY mode — neither; fall back to a pure Odoo Community search/read.
Subclasses implement the three backend methods and define which fusion model
and which Enterprise module they probe.
"""
import enum
import logging
from typing import Any
_logger = logging.getLogger(__name__)
class AdapterMode(enum.Enum):
FUSION = "fusion"
ENTERPRISE = "enterprise"
COMMUNITY = "community"
class DataAdapter:
"""Base class. Subclasses set FUSION_MODEL and ENTERPRISE_MODULE class attrs
and implement _via_fusion(...), _via_enterprise(...), _via_community(...)."""
# Override in subclasses.
FUSION_MODEL: str = ""
ENTERPRISE_MODULE: str = ""
def __init__(self, env):
self.env = env
def _select_mode(
self,
fusion_native_model: str | None = None,
enterprise_module: str | None = None,
) -> AdapterMode:
"""Pick FUSION if the model is loaded, else ENTERPRISE if the module
is installed, else COMMUNITY."""
fusion_model = fusion_native_model or self.FUSION_MODEL
ent_module = enterprise_module or self.ENTERPRISE_MODULE
if fusion_model and fusion_model in self.env:
return AdapterMode.FUSION
if ent_module:
installed = self.env['ir.module.module'].sudo().search_count([
('name', '=', ent_module),
('state', '=', 'installed'),
])
if installed:
return AdapterMode.ENTERPRISE
return AdapterMode.COMMUNITY
def _dispatch(self, method_name: str, *args, **kwargs) -> Any:
"""Look up <method_name>_via_<mode> on self and call it.
E.g. method_name='list_unreconciled', mode=FUSION calls
self.list_unreconciled_via_fusion(*args, **kwargs).
"""
mode = self._select_mode()
attr = f"{method_name}_via_{mode.value}"
impl = getattr(self, attr, None)
if impl is None:
_logger.warning(
"DataAdapter %s has no implementation for %s in mode %s; "
"returning empty result",
type(self).__name__, method_name, mode.value,
)
return []
return impl(*args, **kwargs)

View File

@@ -0,0 +1,281 @@
"""Follow-up data adapter.
Routes follow-up / aged-balance / collections data lookups across:
- FUSION: fusion.followup.line (added by future fusion_accounting_followup, Phase 2)
- ENTERPRISE: account_followup's account.followup.line + account.followup.report
- COMMUNITY: aggregations on account.move / account.move.line
"""
from datetime import date, timedelta
from .base import DataAdapter
from ._registry import register_adapter
# Default aging bucket edges used for both AR and AP.
_AGING_BUCKETS = ('current', '1_30', '31_60', '61_90', '90_plus')
def _bucket_for_days(days):
if days <= 0:
return 'current'
if days <= 30:
return '1_30'
if days <= 60:
return '31_60'
if days <= 90:
return '61_90'
return '90_plus'
class FollowupAdapter(DataAdapter):
FUSION_MODEL = 'fusion.followup.engine'
ENTERPRISE_MODULE = 'account_followup'
# ------------------------------------------------------------------
# overdue_invoices
# ------------------------------------------------------------------
def overdue_invoices(self, days_overdue=30, partner_id=None, limit=200):
return self._dispatch(
'overdue_invoices',
days_overdue=days_overdue, partner_id=partner_id, limit=limit,
)
def overdue_invoices_via_fusion(self, days_overdue=30, partner_id=None, limit=200):
return self.overdue_invoices_via_community(
days_overdue=days_overdue, partner_id=partner_id, limit=limit,
)
def overdue_invoices_via_enterprise(self, days_overdue=30, partner_id=None, limit=200):
return self.overdue_invoices_via_community(
days_overdue=days_overdue, partner_id=partner_id, limit=limit,
)
def overdue_invoices_via_community(self, days_overdue=30, partner_id=None, limit=200):
cutoff = date.today() - timedelta(days=days_overdue)
domain = [
('move_type', 'in', ('out_invoice', 'out_refund')),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('invoice_date_due', '<=', cutoff),
]
if partner_id:
domain.append(('partner_id', '=', partner_id))
moves = self.env['account.move'].sudo().search(
domain, limit=limit, order='invoice_date_due asc',
)
today = date.today()
return [
{
'id': m.id,
'name': m.name,
'partner_id': m.partner_id.id,
'partner_name': m.partner_id.name,
'partner_email': m.partner_id.email or '',
'partner_phone': m.partner_id.phone or '',
'invoice_date_due': m.invoice_date_due,
'amount_total': m.amount_total,
'amount_residual': m.amount_residual,
'currency_id': m.currency_id.id,
'days_overdue': (today - m.invoice_date_due).days if m.invoice_date_due else 0,
}
for m in moves
]
# ------------------------------------------------------------------
# aged_receivables
# ------------------------------------------------------------------
def aged_receivables(self, company_id=None):
return self._dispatch('aged_receivables', company_id=company_id)
def aged_receivables_via_fusion(self, company_id=None):
return self.aged_receivables_via_community(company_id=company_id)
def aged_receivables_via_enterprise(self, company_id=None):
return self.aged_receivables_via_community(company_id=company_id)
def aged_receivables_via_community(self, company_id=None):
return self._aged_buckets(
account_type='asset_receivable',
company_id=company_id,
sign=1,
)
# ------------------------------------------------------------------
# aged_payables
# ------------------------------------------------------------------
def aged_payables(self, company_id=None):
return self._dispatch('aged_payables', company_id=company_id)
def aged_payables_via_fusion(self, company_id=None):
return self.aged_payables_via_community(company_id=company_id)
def aged_payables_via_enterprise(self, company_id=None):
return self.aged_payables_via_community(company_id=company_id)
def aged_payables_via_community(self, company_id=None):
return self._aged_buckets(
account_type='liability_payable',
company_id=company_id,
sign=-1, # AP residuals are negative; report as positive amounts
)
def _aged_buckets(self, account_type, company_id=None, sign=1):
"""Shared aging-bucket implementation for receivable/payable accounts.
Returns a dict: {'total': ..., 'buckets': {...}, 'line_count': N}.
`sign=-1` flips the sign so payables report as positive owed amounts.
"""
today = date.today()
domain = [
('account_id.account_type', '=', account_type),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
]
if company_id is not None:
domain.append(('company_id', '=', company_id))
amls = self.env['account.move.line'].sudo().search(domain)
buckets = {k: 0.0 for k in _AGING_BUCKETS}
for aml in amls:
amt = aml.amount_residual
if sign < 0:
amt = abs(amt)
if not aml.date_maturity or aml.date_maturity >= today:
buckets['current'] += amt
else:
days = (today - aml.date_maturity).days
buckets[_bucket_for_days(days)] += amt
return {
'total': sum(buckets.values()),
'buckets': buckets,
'line_count': len(amls),
}
# ------------------------------------------------------------------
# followup_report_html — Enterprise-only artifact
# ------------------------------------------------------------------
def followup_report_html(self, partner_id):
return self._dispatch('followup_report_html', partner_id=partner_id)
def followup_report_html_via_fusion(self, partner_id):
# Phase 2 will implement a native version.
return self.followup_report_html_via_community(partner_id=partner_id)
def followup_report_html_via_enterprise(self, partner_id):
partner = self.env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Partner not found'}
report = self.env['account.followup.report']
html = report._get_followup_report_html(partner)
return {'partner': partner.name, 'html': html}
def followup_report_html_via_community(self, partner_id):
return {
'error': (
'Follow-up report is only available when account_followup '
'(Enterprise) or a fusion follow-up module is installed.'
),
}
# ------------------------------------------------------------------
# send_followup — routes to fusion engine when available
# ------------------------------------------------------------------
def send_followup(self, partner_id, level_id=None, force=False, options=None):
return self._dispatch(
'send_followup',
partner_id=partner_id, level_id=level_id,
force=force, options=options,
)
def send_followup_via_fusion(self, partner_id, level_id=None,
force=False, options=None):
if 'fusion.followup.engine' not in self.env.registry:
return {'error': 'fusion_accounting_followup not installed'}
partner = self.env['res.partner'].browse(int(partner_id))
level = None
if level_id:
level = self.env['fusion.followup.level'].browse(int(level_id))
return self.env['fusion.followup.engine'].send_followup_email(
partner, level=level, force=bool(force),
)
def send_followup_via_enterprise(self, partner_id, level_id=None,
force=False, options=None):
partner = self.env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Partner not found'}
result = partner.execute_followup(options or {'partner_id': partner_id})
return {
'status': 'sent',
'partner': partner.name,
'result': str(result) if result else 'done',
}
def send_followup_via_community(self, partner_id, level_id=None,
force=False, options=None):
return {
'error': (
'Sending follow-ups is only available when account_followup '
'(Enterprise) or a fusion follow-up module is installed.'
),
}
# ------------------------------------------------------------------
# list_overdue — partner-centric overdue rollup (fusion engine)
# ------------------------------------------------------------------
def list_overdue(self, status=None, limit=50, company_id=None):
return self._dispatch(
'list_overdue',
status=status, limit=limit, company_id=company_id,
)
def list_overdue_via_fusion(self, status=None, limit=50, company_id=None):
if 'fusion.followup.engine' not in self.env.registry:
return {'partners': [], 'count': 0, 'total': 0}
company_id = company_id or self.env.company.id
Line = self.env['account.move.line'].sudo()
partner_ids = Line.search([
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
('reconciled', '=', False),
('amount_residual', '>', 0),
('date_maturity', '<', date.today()),
('company_id', '=', company_id),
]).mapped('partner_id').ids
Partner = self.env['res.partner'].sudo()
domain = [('id', 'in', partner_ids)]
if status:
domain.append(('fusion_followup_status', '=', status))
partners = Partner.search(domain, limit=int(limit))
engine = self.env['fusion.followup.engine']
rows = []
for p in partners:
try:
overdue = engine.get_overdue_for_partner(p)
rows.append({
'partner_id': p.id,
'partner_name': p.name,
'overdue_amount': overdue['aging']['total_overdue_amount'],
'risk_score': overdue['risk']['score'],
'risk_band': overdue['risk']['band'],
'status': p.fusion_followup_status,
})
except Exception:
pass
return {'count': len(rows), 'total': len(partner_ids), 'partners': rows}
def list_overdue_via_enterprise(self, status=None, limit=50, company_id=None):
return {
'partners': [], 'count': 0, 'total': 0,
'error': 'Enterprise account_followup must be used from its UI',
}
def list_overdue_via_community(self, status=None, limit=50, company_id=None):
return {
'partners': [], 'count': 0, 'total': 0,
'error': 'No follow-up engine in pure Community',
}
register_adapter('followup', FollowupAdapter)

View File

@@ -0,0 +1,330 @@
"""Reports data adapter.
Routes report-data lookups across:
- FUSION: fusion.account.report (added by fusion_accounting_reports, Phase 2)
- ENTERPRISE: account.report from account_reports
- COMMUNITY: raw aggregations on account.move.line
"""
import base64
import logging
from .base import DataAdapter
from ._registry import register_adapter
_logger = logging.getLogger(__name__)
class ReportsAdapter(DataAdapter):
# Phase 2 wires fusion.report.engine as the FUSION-mode backend for
# the new report_type-shaped methods (run_fusion_report, get_anomalies,
# get_commentary). The legacy ref_id-shaped run_report / export_report
# methods continue to defer to community when in FUSION mode (their
# original behavior), so this rename does not change their results.
FUSION_MODEL = 'fusion.report.engine'
ENTERPRISE_MODULE = 'account_reports'
# ------------------------------------------------------------------
# trial_balance (Community-computable from account.move.line)
# ------------------------------------------------------------------
def trial_balance(self, date_to=None, company_ids=None):
return self._dispatch('trial_balance', date_to=date_to, company_ids=company_ids)
def trial_balance_via_fusion(self, date_to=None, company_ids=None):
# Phase 2 will implement; for now defer to community.
return self.trial_balance_via_community(date_to=date_to, company_ids=company_ids)
def trial_balance_via_enterprise(self, date_to=None, company_ids=None):
# Enterprise account_reports has rich filters; for AI-tool consumption,
# the community shape suffices and avoids brittle coupling to Odoo's
# report-line internals.
return self.trial_balance_via_community(date_to=date_to, company_ids=company_ids)
def trial_balance_via_community(self, date_to=None, company_ids=None):
domain = [('parent_state', '=', 'posted')]
if date_to:
domain.append(('date', '<=', date_to))
if company_ids:
domain.append(('company_id', 'in', list(company_ids)))
Line = self.env['account.move.line'].sudo()
groups = Line._read_group(
domain=domain,
groupby=['account_id'],
aggregates=['debit:sum', 'credit:sum'],
)
return [
{
'account_id': account.id,
'account_code': account.code,
'account_name': account.name,
'debit': debit_sum,
'credit': credit_sum,
'balance': debit_sum - credit_sum,
}
for account, debit_sum, credit_sum in groups
]
# ------------------------------------------------------------------
# run_report — generic Enterprise account.report wrapper
#
# Returns either {'report_name', 'lines'} or {'error': ...}.
# Used by profit_loss / balance_sheet / cash_flow / trial_balance_lines
# tool wrappers that want Enterprise's hierarchical report shape when
# available.
# ------------------------------------------------------------------
def run_report(self, ref_id, date_from=None, date_to=None, limit=100):
return self._dispatch(
'run_report',
ref_id=ref_id, date_from=date_from, date_to=date_to, limit=limit,
)
def run_report_via_fusion(self, ref_id, date_from=None, date_to=None, limit=100):
# Phase 2: fusion.account.report will implement equivalent rendering.
return self.run_report_via_community(
ref_id=ref_id, date_from=date_from, date_to=date_to, limit=limit,
)
def run_report_via_enterprise(self, ref_id, date_from=None, date_to=None, limit=100):
try:
report = self.env.ref(ref_id, raise_if_not_found=False)
except Exception:
report = None
if not report:
return {'error': f'Report {ref_id} not found'}
date_opts = {}
if date_from:
date_opts['date_from'] = date_from
if date_to:
date_opts['date_to'] = date_to
options = report.get_options({'date': date_opts} if date_opts else {})
lines = report._get_lines(options)
return {
'report_name': report.name,
'lines': [{
'name': line.get('name', ''),
'level': line.get('level', 0),
'columns': [c.get('no_format', c.get('name', '')) for c in line.get('columns', [])],
} for line in lines[:limit]],
}
def run_report_via_community(self, ref_id, date_from=None, date_to=None, limit=100):
return {
'error': (
f'Report {ref_id!r} is only available when account_reports (Enterprise) '
'or a fusion reports module is installed. For pure Community installs, '
'use the raw trial_balance() adapter method or the tools that aggregate '
'account.move.line directly.'
),
}
# ------------------------------------------------------------------
# export_report — Enterprise-only PDF/XLSX export
# ------------------------------------------------------------------
def export_report(self, ref_id, fmt='pdf', date_from=None, date_to=None):
return self._dispatch(
'export_report',
ref_id=ref_id, fmt=fmt, date_from=date_from, date_to=date_to,
)
def export_report_via_fusion(self, ref_id, fmt='pdf', date_from=None, date_to=None):
return self.export_report_via_community(
ref_id=ref_id, fmt=fmt, date_from=date_from, date_to=date_to,
)
def export_report_via_enterprise(self, ref_id, fmt='pdf', date_from=None, date_to=None):
try:
report = self.env.ref(ref_id, raise_if_not_found=False)
except Exception:
report = None
if not report:
return {'error': f'Report {ref_id} not found'}
date_opts = {}
if date_from:
date_opts['date_from'] = date_from
if date_to:
date_opts['date_to'] = date_to
options = report.get_options({'date': date_opts} if date_opts else {})
try:
if fmt == 'xlsx':
result = report.dispatch_report_action(options, 'export_to_xlsx')
else:
result = report.dispatch_report_action(options, 'export_to_pdf')
if isinstance(result, dict) and result.get('file_content'):
return {
'file_name': result.get('file_name', f'report.{fmt}'),
'file_type': result.get('file_type', fmt),
'file_content_b64': base64.b64encode(result['file_content']).decode(),
}
return {
'status': 'generated',
'message': f'Report exported as {fmt}. Use the Odoo UI to download.',
}
except Exception as e:
return {'error': f'Export failed: {str(e)}'}
def export_report_via_community(self, ref_id, fmt='pdf', date_from=None, date_to=None):
return {
'error': (
f'Exporting report {ref_id!r} is only available with Enterprise '
'account_reports installed.'
),
}
# ==================================================================
# Phase 2 (Task 19): fusion.report.engine-routed report methods
#
# These coexist with the legacy ref_id-shaped run_report/export_report
# API. New callers (financial_reports AI tools, OWL widget) use the
# *_fusion_report methods below; those route through the engine when
# fusion_accounting_reports is installed.
# ==================================================================
# ------------------ run_fusion_report --------------------------
def run_fusion_report(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return self._dispatch(
'run_fusion_report',
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
def run_fusion_report_via_fusion(self, report_type, date_from, date_to,
comparison='none', company_id=None):
if 'fusion.report.engine' not in self.env.registry:
return {'rows': [], 'error': 'fusion.report.engine not installed'}
from datetime import datetime
from odoo.addons.fusion_accounting_reports.services.date_periods import (
Period,
)
df = (datetime.strptime(date_from, '%Y-%m-%d').date()
if isinstance(date_from, str) else date_from)
dt = (datetime.strptime(date_to, '%Y-%m-%d').date()
if isinstance(date_to, str) else date_to)
period = Period(date_from=df, date_to=dt, label=f"{df} - {dt}")
engine = self.env['fusion.report.engine']
company_id = company_id or self.env.company.id
if report_type == 'pnl':
return engine.compute_pnl(
period, comparison=comparison, company_id=company_id,
)
if report_type == 'balance_sheet':
return engine.compute_balance_sheet(
dt, comparison=comparison, company_id=company_id,
)
if report_type == 'trial_balance':
return engine.compute_trial_balance(
period, company_id=company_id,
)
if report_type == 'general_ledger':
return engine.compute_gl(period, company_id=company_id)
return {'rows': [], 'error': f'unknown report_type {report_type}'}
def run_fusion_report_via_enterprise(self, report_type, date_from, date_to,
comparison='none', company_id=None):
# Enterprise's account_reports has its own UI; we don't proxy from
# Python. Callers should use the Enterprise menus or the legacy
# run_report(ref_id=...) method instead.
return {
'rows': [],
'error': 'Enterprise reports must be run from the Enterprise UI',
}
def run_fusion_report_via_community(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return {
'rows': [],
'error': 'No fusion reports engine available in pure Community',
}
# ------------------ get_anomalies ------------------------------
def get_anomalies(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
return self._dispatch(
'get_anomalies',
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
def get_anomalies_via_fusion(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
if 'fusion.report.engine' not in self.env.registry:
return {'anomalies': []}
from odoo.addons.fusion_accounting_reports.services.anomaly_detection import (
detect,
)
report = self.run_fusion_report_via_fusion(
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
if 'error' in report:
return {'anomalies': []}
return {'anomalies': detect(report)}
def get_anomalies_via_enterprise(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
return {'anomalies': []}
def get_anomalies_via_community(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
return {'anomalies': []}
# ------------------ get_commentary -----------------------------
def get_commentary(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return self._dispatch(
'get_commentary',
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
def get_commentary_via_fusion(self, report_type, date_from, date_to,
comparison='none', company_id=None):
empty = {
'summary': '', 'highlights': [],
'concerns': [], 'next_actions': [],
}
if 'fusion.report.engine' not in self.env.registry:
return empty
from odoo.addons.fusion_accounting_reports.services.anomaly_detection import (
detect,
)
from odoo.addons.fusion_accounting_reports.services.commentary_generator import (
generate_commentary,
)
report = self.run_fusion_report_via_fusion(
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
if 'error' in report:
return empty
anomalies = detect(report)
return generate_commentary(
self.env, report_result=report, anomalies=anomalies,
)
def get_commentary_via_enterprise(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return {
'summary': '', 'highlights': [],
'concerns': [], 'next_actions': [],
}
def get_commentary_via_community(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return {
'summary': '', 'highlights': [],
'concerns': [], 'next_actions': [],
}
register_adapter('reports', ReportsAdapter)