changes
This commit is contained in:
@@ -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']
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user