Compare commits
5 Commits
5963aba0a8
...
c20e0888e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c20e0888e1 | ||
|
|
22b277c6b8 | ||
|
|
17053b1603 | ||
|
|
a4728d7ae7 | ||
|
|
b78e6dc842 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting Reports',
|
'name': 'Fusion Accounting Reports',
|
||||||
'version': '19.0.1.0.8',
|
'version': '19.0.1.0.13',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
from . import fusion_report
|
from . import fusion_report
|
||||||
from . import fusion_report_engine
|
from . import fusion_report_engine
|
||||||
|
from . import fusion_report_commentary
|
||||||
|
from . import fusion_report_anomaly
|
||||||
|
|||||||
56
fusion_accounting_reports/models/fusion_report_anomaly.py
Normal file
56
fusion_accounting_reports/models/fusion_report_anomaly.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Persisted anomaly flags from the engine's variance detection.
|
||||||
|
|
||||||
|
Each row captures one flagged report row variance. Used by the OWL
|
||||||
|
anomaly_strip + the audit trail."""
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
SEVERITY = [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')]
|
||||||
|
DIRECTION = [('increase', 'Increase'), ('decrease', 'Decrease')]
|
||||||
|
|
||||||
|
|
||||||
|
class FusionReportAnomaly(models.Model):
|
||||||
|
_name = "fusion.report.anomaly"
|
||||||
|
_description = "Flagged Report Variance"
|
||||||
|
_order = "detected_at desc, severity desc"
|
||||||
|
|
||||||
|
report_id = fields.Many2one('fusion.report', required=True, ondelete='cascade')
|
||||||
|
company_id = fields.Many2one('res.company', required=True,
|
||||||
|
default=lambda self: self.env.company)
|
||||||
|
period_from = fields.Date(required=True)
|
||||||
|
period_to = fields.Date(required=True)
|
||||||
|
|
||||||
|
row_id = fields.Char(required=True, help="Engine-generated row id (e.g. 'line_3').")
|
||||||
|
label = fields.Char(required=True)
|
||||||
|
current_amount = fields.Float()
|
||||||
|
comparison_amount = fields.Float()
|
||||||
|
variance_amount = fields.Float()
|
||||||
|
variance_pct = fields.Float()
|
||||||
|
severity = fields.Selection(SEVERITY, required=True)
|
||||||
|
direction = fields.Selection(DIRECTION, required=True)
|
||||||
|
|
||||||
|
detected_at = fields.Datetime(default=fields.Datetime.now, required=True)
|
||||||
|
state = fields.Selection([
|
||||||
|
('new', 'New'),
|
||||||
|
('acknowledged', 'Acknowledged'),
|
||||||
|
('investigating', 'Investigating'),
|
||||||
|
('resolved', 'Resolved'),
|
||||||
|
('dismissed', 'Dismissed'),
|
||||||
|
], default='new', required=True)
|
||||||
|
notes = fields.Text()
|
||||||
|
acknowledged_by = fields.Many2one('res.users')
|
||||||
|
acknowledged_at = fields.Datetime()
|
||||||
|
|
||||||
|
def action_acknowledge(self):
|
||||||
|
self.write({
|
||||||
|
'state': 'acknowledged',
|
||||||
|
'acknowledged_by': self.env.uid,
|
||||||
|
'acknowledged_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_dismiss(self):
|
||||||
|
self.write({'state': 'dismissed'})
|
||||||
|
|
||||||
|
def action_resolve(self):
|
||||||
|
self.write({'state': 'resolved'})
|
||||||
43
fusion_accounting_reports/models/fusion_report_commentary.py
Normal file
43
fusion_accounting_reports/models/fusion_report_commentary.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Cached AI-generated commentary for a report run.
|
||||||
|
|
||||||
|
One row per (report, period_from, period_to, comparison_mode, company).
|
||||||
|
Refreshed on demand or via cron when the underlying data has changed."""
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FusionReportCommentary(models.Model):
|
||||||
|
_name = "fusion.report.commentary"
|
||||||
|
_description = "AI-Generated Report Commentary Cache"
|
||||||
|
_order = "generated_at desc"
|
||||||
|
|
||||||
|
report_id = fields.Many2one('fusion.report', required=True, ondelete='cascade')
|
||||||
|
company_id = fields.Many2one('res.company', required=True,
|
||||||
|
default=lambda self: self.env.company)
|
||||||
|
period_from = fields.Date(required=True)
|
||||||
|
period_to = fields.Date(required=True)
|
||||||
|
comparison_mode = fields.Selection([
|
||||||
|
('none', 'None'),
|
||||||
|
('previous_period', 'Previous Period'),
|
||||||
|
('previous_year', 'Previous Year'),
|
||||||
|
], default='none', required=True)
|
||||||
|
|
||||||
|
summary = fields.Text()
|
||||||
|
highlights = fields.Json() # list of strings
|
||||||
|
concerns = fields.Json() # list of strings
|
||||||
|
next_actions = fields.Json() # list of strings
|
||||||
|
|
||||||
|
generated_at = fields.Datetime(default=fields.Datetime.now, required=True)
|
||||||
|
generated_by = fields.Selection([
|
||||||
|
('on_demand', 'On Demand'),
|
||||||
|
('cron', 'Cron'),
|
||||||
|
('templated', 'Templated Fallback'),
|
||||||
|
], default='on_demand', required=True)
|
||||||
|
|
||||||
|
provider = fields.Char(help="LLM provider used (e.g. 'openai', 'claude', 'local'). "
|
||||||
|
"Empty for templated fallback.")
|
||||||
|
|
||||||
|
_unique_period = models.Constraint(
|
||||||
|
'UNIQUE(report_id, company_id, period_from, period_to, comparison_mode)',
|
||||||
|
'Only one commentary cache row per report+period+mode.',
|
||||||
|
)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
access_fusion_report_user,fusion.report.user,model_fusion_report,base.group_user,1,0,0,0
|
access_fusion_report_user,fusion.report.user,model_fusion_report,base.group_user,1,0,0,0
|
||||||
access_fusion_report_admin,fusion.report.admin,model_fusion_report,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
access_fusion_report_admin,fusion.report.admin,model_fusion_report,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||||
|
access_fusion_report_commentary,fusion.report.commentary,model_fusion_report_commentary,base.group_user,1,1,1,0
|
||||||
|
access_fusion_report_anomaly,fusion.report.anomaly,model_fusion_report_anomaly,base.group_user,1,1,1,0
|
||||||
|
|||||||
|
@@ -4,3 +4,6 @@ from . import totaling
|
|||||||
from . import currency_conversion
|
from . import currency_conversion
|
||||||
from . import line_resolver
|
from . import line_resolver
|
||||||
from . import drill_down_resolver
|
from . import drill_down_resolver
|
||||||
|
from . import anomaly_detection
|
||||||
|
from . import commentary_prompt
|
||||||
|
from . import commentary_generator
|
||||||
|
|||||||
81
fusion_accounting_reports/services/anomaly_detection.py
Normal file
81
fusion_accounting_reports/services/anomaly_detection.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Anomaly detection for financial reports.
|
||||||
|
|
||||||
|
Compares each row's current-period amount to its comparison-period
|
||||||
|
amount and flags variances exceeding a threshold. Uses both:
|
||||||
|
- Absolute threshold ($X minimum movement)
|
||||||
|
- Percentage threshold (Y% min variance)
|
||||||
|
|
||||||
|
Pure-Python: callers pass the engine's compute_*() result; we return
|
||||||
|
a list of anomaly dicts."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Anomaly:
|
||||||
|
row_id: str
|
||||||
|
label: str
|
||||||
|
current_amount: float
|
||||||
|
comparison_amount: float
|
||||||
|
variance_amount: float
|
||||||
|
variance_pct: float
|
||||||
|
severity: str # 'low', 'medium', 'high'
|
||||||
|
direction: str # 'increase', 'decrease'
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'row_id': self.row_id, 'label': self.label,
|
||||||
|
'current_amount': self.current_amount,
|
||||||
|
'comparison_amount': self.comparison_amount,
|
||||||
|
'variance_amount': self.variance_amount,
|
||||||
|
'variance_pct': self.variance_pct,
|
||||||
|
'severity': self.severity, 'direction': self.direction,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Defaults -- tunable per company via ir.config_parameter
|
||||||
|
DEFAULT_MIN_ABSOLUTE_THRESHOLD = 100.0
|
||||||
|
DEFAULT_MIN_PCT_THRESHOLD = 10.0 # 10%
|
||||||
|
DEFAULT_HIGH_PCT_THRESHOLD = 50.0 # 50%+ flagged 'high'
|
||||||
|
|
||||||
|
|
||||||
|
def detect(report_result: dict, *, min_absolute: float = None,
|
||||||
|
min_pct: float = None, high_pct: float = None) -> list[dict]:
|
||||||
|
"""Detect anomalies in a report_result dict (engine output).
|
||||||
|
|
||||||
|
Returns list of anomaly dicts ordered by severity desc, variance_amount desc.
|
||||||
|
Returns empty list if no comparison period was computed."""
|
||||||
|
if not report_result.get('comparison_period'):
|
||||||
|
return []
|
||||||
|
min_absolute = min_absolute if min_absolute is not None else DEFAULT_MIN_ABSOLUTE_THRESHOLD
|
||||||
|
min_pct = min_pct if min_pct is not None else DEFAULT_MIN_PCT_THRESHOLD
|
||||||
|
high_pct = high_pct if high_pct is not None else DEFAULT_HIGH_PCT_THRESHOLD
|
||||||
|
|
||||||
|
anomalies = []
|
||||||
|
for row in report_result.get('rows', []):
|
||||||
|
comparison = row.get('amount_comparison')
|
||||||
|
current = row.get('amount', 0.0)
|
||||||
|
if comparison is None:
|
||||||
|
continue
|
||||||
|
variance_amount = current - comparison
|
||||||
|
variance_pct = abs(row.get('variance_pct') or 0.0)
|
||||||
|
if abs(variance_amount) < min_absolute:
|
||||||
|
continue
|
||||||
|
if variance_pct < min_pct:
|
||||||
|
continue
|
||||||
|
severity = 'high' if variance_pct >= high_pct else 'medium' if variance_pct >= min_pct * 2 else 'low'
|
||||||
|
direction = 'increase' if variance_amount > 0 else 'decrease'
|
||||||
|
anomalies.append(Anomaly(
|
||||||
|
row_id=row['id'],
|
||||||
|
label=row.get('label', ''),
|
||||||
|
current_amount=current,
|
||||||
|
comparison_amount=comparison,
|
||||||
|
variance_amount=variance_amount,
|
||||||
|
variance_pct=variance_pct,
|
||||||
|
severity=severity,
|
||||||
|
direction=direction,
|
||||||
|
).to_dict())
|
||||||
|
|
||||||
|
severity_order = {'high': 0, 'medium': 1, 'low': 2}
|
||||||
|
anomalies.sort(key=lambda a: (severity_order[a['severity']], -abs(a['variance_amount'])))
|
||||||
|
return anomalies
|
||||||
103
fusion_accounting_reports/services/commentary_generator.py
Normal file
103
fusion_accounting_reports/services/commentary_generator.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""AI-generated narrative commentary for financial reports.
|
||||||
|
|
||||||
|
Takes a report_result dict + optional anomalies list, builds an LLM
|
||||||
|
prompt, parses the structured output. Output contract:
|
||||||
|
{
|
||||||
|
'summary': str, # 2-3 sentence executive summary
|
||||||
|
'highlights': [str, ...], # 3-5 bullet observations
|
||||||
|
'concerns': [str, ...], # things that warrant investigation
|
||||||
|
'next_actions': [str, ...] # suggested follow-ups
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_commentary(env, *, report_result: dict, anomalies: list = None,
|
||||||
|
provider=None) -> dict:
|
||||||
|
"""Generate narrative commentary via LLM. Returns dict per the contract.
|
||||||
|
|
||||||
|
If no provider configured, returns a templated fallback (no LLM)."""
|
||||||
|
if provider is None:
|
||||||
|
provider = _get_provider(env)
|
||||||
|
if provider is None:
|
||||||
|
return _templated_fallback(report_result, anomalies)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from odoo.addons.fusion_accounting_reports.services.commentary_prompt import build_prompt
|
||||||
|
except ImportError:
|
||||||
|
_logger.debug("commentary_prompt module not yet available; using fallback")
|
||||||
|
return _templated_fallback(report_result, anomalies)
|
||||||
|
|
||||||
|
system, user = build_prompt(report_result, anomalies or [])
|
||||||
|
try:
|
||||||
|
response = provider.complete(
|
||||||
|
system=system,
|
||||||
|
messages=[{'role': 'user', 'content': user}],
|
||||||
|
max_tokens=1200,
|
||||||
|
temperature=0.2,
|
||||||
|
)
|
||||||
|
content = response.get('content') if isinstance(response, dict) else response
|
||||||
|
parsed = json.loads(content)
|
||||||
|
# Validate shape
|
||||||
|
for key in ('summary', 'highlights', 'concerns', 'next_actions'):
|
||||||
|
parsed.setdefault(key, [] if key != 'summary' else '')
|
||||||
|
return parsed
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("AI commentary generation failed: %s", e)
|
||||||
|
return _templated_fallback(report_result, anomalies)
|
||||||
|
|
||||||
|
|
||||||
|
def _templated_fallback(report_result: dict, anomalies: list = None) -> dict:
|
||||||
|
"""No-LLM fallback that produces a basic narrative from the report data."""
|
||||||
|
anomalies = anomalies or []
|
||||||
|
rows = report_result.get('rows', [])
|
||||||
|
period = report_result.get('period', {})
|
||||||
|
period_label = period.get('label', 'this period')
|
||||||
|
|
||||||
|
# Find subtotal rows for the summary
|
||||||
|
subtotals = [r for r in rows if r.get('is_subtotal')]
|
||||||
|
summary_parts = [f"{report_result.get('report_name', 'Report')} for {period_label}."]
|
||||||
|
if subtotals:
|
||||||
|
last = subtotals[-1]
|
||||||
|
summary_parts.append(f"{last['label']}: ${last['amount']:,.2f}.")
|
||||||
|
|
||||||
|
highlights = []
|
||||||
|
for row in subtotals[:3]:
|
||||||
|
highlights.append(f"{row['label']}: ${row['amount']:,.2f}")
|
||||||
|
|
||||||
|
concerns = []
|
||||||
|
for a in anomalies[:3]:
|
||||||
|
concerns.append(
|
||||||
|
f"{a['label']} {a['direction']}d {a['variance_pct']:.1f}% "
|
||||||
|
f"(${a['variance_amount']:+,.2f})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'summary': ' '.join(summary_parts),
|
||||||
|
'highlights': highlights,
|
||||||
|
'concerns': concerns,
|
||||||
|
'next_actions': ['Review the flagged anomalies above.'] if concerns else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_provider(env):
|
||||||
|
"""Look up provider for 'reports_commentary' feature; return None if not configured."""
|
||||||
|
param = env['ir.config_parameter'].sudo()
|
||||||
|
provider_name = param.get_param('fusion_accounting.provider.reports_commentary')
|
||||||
|
if not provider_name:
|
||||||
|
provider_name = param.get_param('fusion_accounting.provider.default')
|
||||||
|
if not provider_name:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
if provider_name.startswith('openai'):
|
||||||
|
return OpenAIAdapter(env)
|
||||||
|
elif provider_name.startswith('claude'):
|
||||||
|
return ClaudeAdapter(env)
|
||||||
|
return None
|
||||||
67
fusion_accounting_reports/services/commentary_prompt.py
Normal file
67
fusion_accounting_reports/services/commentary_prompt.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""LLM prompt for AI report commentary.
|
||||||
|
|
||||||
|
Provider-agnostic system + user prompt builder. Output contract:
|
||||||
|
JSON with keys summary, highlights, concerns, next_actions."""
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You are an experienced CFO providing executive-level commentary
|
||||||
|
on a financial report. Your output MUST be valid JSON of this exact shape:
|
||||||
|
|
||||||
|
{
|
||||||
|
"summary": "<2-3 sentence executive summary of the report period>",
|
||||||
|
"highlights": ["<observation 1>", "<observation 2>", ...],
|
||||||
|
"concerns": ["<thing to investigate 1>", ...],
|
||||||
|
"next_actions": ["<suggested action 1>", ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Use the data provided. Do not invent numbers.
|
||||||
|
- Tone: professional, concise, factual.
|
||||||
|
- Currency formatting: always include the $ symbol and 2 decimal places.
|
||||||
|
- For anomalies: explicitly mention the variance percentage AND the dollar amount.
|
||||||
|
- Do NOT include markdown code fences. Do NOT include any prose outside the JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(report_result: dict, anomalies: list) -> tuple[str, str]:
|
||||||
|
"""Build (system_prompt, user_prompt) tuple."""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Report context
|
||||||
|
parts.append(f"REPORT: {report_result.get('report_name', 'Untitled')}")
|
||||||
|
period = report_result.get('period', {})
|
||||||
|
parts.append(f"PERIOD: {period.get('label', '')} "
|
||||||
|
f"({period.get('date_from', '')} to {period.get('date_to', '')})")
|
||||||
|
comp_period = report_result.get('comparison_period')
|
||||||
|
if comp_period:
|
||||||
|
parts.append(f"COMPARED TO: {comp_period.get('label', '')} "
|
||||||
|
f"({comp_period.get('date_from', '')} to {comp_period.get('date_to', '')})")
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
# Rows (the actual numbers)
|
||||||
|
parts.append("REPORT LINES:")
|
||||||
|
for row in report_result.get('rows', []):
|
||||||
|
line = f" - {row.get('label', '?')}: ${row.get('amount', 0):,.2f}"
|
||||||
|
if row.get('amount_comparison') is not None:
|
||||||
|
line += f" (comparison: ${row['amount_comparison']:,.2f}"
|
||||||
|
if row.get('variance_pct') is not None:
|
||||||
|
line += f", {row['variance_pct']:+.1f}%"
|
||||||
|
line += ")"
|
||||||
|
if row.get('is_subtotal'):
|
||||||
|
line += " [SUBTOTAL]"
|
||||||
|
parts.append(line)
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
# Anomalies
|
||||||
|
if anomalies:
|
||||||
|
parts.append("ANOMALIES (variances exceeding threshold):")
|
||||||
|
for a in anomalies[:10]:
|
||||||
|
parts.append(
|
||||||
|
f" - {a['label']}: {a['direction']}d {a['variance_pct']:.1f}% "
|
||||||
|
f"(${a['variance_amount']:+,.2f}, severity: {a['severity']})"
|
||||||
|
)
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
parts.append("Generate the JSON commentary per the system prompt.")
|
||||||
|
|
||||||
|
return (SYSTEM_PROMPT, "\n".join(parts))
|
||||||
@@ -5,3 +5,8 @@ from . import test_line_resolver
|
|||||||
from . import test_drill_down_resolver
|
from . import test_drill_down_resolver
|
||||||
from . import test_fusion_report_engine
|
from . import test_fusion_report_engine
|
||||||
from . import test_seeded_reports
|
from . import test_seeded_reports
|
||||||
|
from . import test_anomaly_detection
|
||||||
|
from . import test_commentary_prompt
|
||||||
|
from . import test_commentary_generator
|
||||||
|
from . import test_fusion_report_commentary
|
||||||
|
from . import test_fusion_report_anomaly
|
||||||
|
|||||||
74
fusion_accounting_reports/tests/test_anomaly_detection.py
Normal file
74
fusion_accounting_reports/tests/test_anomaly_detection.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Unit tests for anomaly_detection service."""
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_reports.services.anomaly_detection import detect
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestAnomalyDetection(TransactionCase):
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_comparison(self):
|
||||||
|
report_result = {
|
||||||
|
'rows': [{'id': 'r1', 'label': 'Test', 'amount': 100,
|
||||||
|
'amount_comparison': None, 'variance_pct': None}],
|
||||||
|
'comparison_period': None,
|
||||||
|
}
|
||||||
|
self.assertEqual(detect(report_result), [])
|
||||||
|
|
||||||
|
def test_flags_significant_increase(self):
|
||||||
|
report_result = {
|
||||||
|
'rows': [{'id': 'r1', 'label': 'Revenue',
|
||||||
|
'amount': 12000, 'amount_comparison': 10000,
|
||||||
|
'variance_pct': 20.0}],
|
||||||
|
'comparison_period': {'date_from': '2025-01-01'},
|
||||||
|
}
|
||||||
|
anomalies = detect(report_result)
|
||||||
|
self.assertEqual(len(anomalies), 1)
|
||||||
|
self.assertEqual(anomalies[0]['direction'], 'increase')
|
||||||
|
self.assertEqual(anomalies[0]['variance_amount'], 2000)
|
||||||
|
|
||||||
|
def test_skips_below_absolute_threshold(self):
|
||||||
|
report_result = {
|
||||||
|
'rows': [{'id': 'r1', 'label': 'Tiny', 'amount': 50,
|
||||||
|
'amount_comparison': 30, 'variance_pct': 67}],
|
||||||
|
'comparison_period': {'date_from': '2025-01-01'},
|
||||||
|
}
|
||||||
|
# variance is $20 < default $100 minimum
|
||||||
|
self.assertEqual(detect(report_result), [])
|
||||||
|
|
||||||
|
def test_skips_below_pct_threshold(self):
|
||||||
|
report_result = {
|
||||||
|
'rows': [{'id': 'r1', 'label': 'Steady',
|
||||||
|
'amount': 10500, 'amount_comparison': 10000,
|
||||||
|
'variance_pct': 5.0}],
|
||||||
|
'comparison_period': {'date_from': '2025-01-01'},
|
||||||
|
}
|
||||||
|
# 5% < default 10%
|
||||||
|
self.assertEqual(detect(report_result), [])
|
||||||
|
|
||||||
|
def test_severity_high_for_50pct_plus(self):
|
||||||
|
report_result = {
|
||||||
|
'rows': [{'id': 'r1', 'label': 'Spike',
|
||||||
|
'amount': 16000, 'amount_comparison': 10000,
|
||||||
|
'variance_pct': 60.0}],
|
||||||
|
'comparison_period': {'date_from': '2025-01-01'},
|
||||||
|
}
|
||||||
|
anomalies = detect(report_result)
|
||||||
|
self.assertEqual(anomalies[0]['severity'], 'high')
|
||||||
|
|
||||||
|
def test_orders_by_severity_then_amount(self):
|
||||||
|
report_result = {
|
||||||
|
'rows': [
|
||||||
|
{'id': 'r1', 'label': 'Med', 'amount': 1300,
|
||||||
|
'amount_comparison': 1000, 'variance_pct': 30.0},
|
||||||
|
{'id': 'r2', 'label': 'High', 'amount': 16000,
|
||||||
|
'amount_comparison': 10000, 'variance_pct': 60.0},
|
||||||
|
{'id': 'r3', 'label': 'Low', 'amount': 1150,
|
||||||
|
'amount_comparison': 1000, 'variance_pct': 15.0},
|
||||||
|
],
|
||||||
|
'comparison_period': {'date_from': '2025-01-01'},
|
||||||
|
}
|
||||||
|
anomalies = detect(report_result)
|
||||||
|
# Should be: High first, then Med, then Low
|
||||||
|
self.assertEqual(anomalies[0]['severity'], 'high')
|
||||||
|
self.assertEqual(anomalies[-1]['severity'], 'low')
|
||||||
54
fusion_accounting_reports/tests/test_commentary_generator.py
Normal file
54
fusion_accounting_reports/tests/test_commentary_generator.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Tests for commentary_generator service."""
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_reports.services.commentary_generator import (
|
||||||
|
generate_commentary, _templated_fallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestCommentaryGenerator(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# Ensure no provider is configured so we exercise the fallback path
|
||||||
|
self.env['ir.config_parameter'].sudo().search([
|
||||||
|
('key', 'in', ['fusion_accounting.provider.reports_commentary',
|
||||||
|
'fusion_accounting.provider.default'])
|
||||||
|
]).unlink()
|
||||||
|
|
||||||
|
def test_fallback_when_no_provider(self):
|
||||||
|
report = {
|
||||||
|
'report_name': 'P&L',
|
||||||
|
'period': {'label': 'Apr 2026'},
|
||||||
|
'rows': [
|
||||||
|
{'id': 'r1', 'label': 'Revenue', 'amount': 100000, 'is_subtotal': False},
|
||||||
|
{'id': 'r2', 'label': 'Net Income', 'amount': 25000, 'is_subtotal': True},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = generate_commentary(self.env, report_result=report)
|
||||||
|
self.assertIn('summary', result)
|
||||||
|
self.assertIn('Net Income', result['summary'])
|
||||||
|
self.assertIn('25,000', result['summary'])
|
||||||
|
|
||||||
|
def test_fallback_includes_anomalies_in_concerns(self):
|
||||||
|
report = {
|
||||||
|
'report_name': 'P&L',
|
||||||
|
'period': {'label': 'Apr 2026'},
|
||||||
|
'rows': [],
|
||||||
|
}
|
||||||
|
anomalies = [
|
||||||
|
{'label': 'Revenue', 'direction': 'increase', 'variance_pct': 30.0,
|
||||||
|
'variance_amount': 5000, 'severity': 'medium'},
|
||||||
|
]
|
||||||
|
result = generate_commentary(self.env, report_result=report, anomalies=anomalies)
|
||||||
|
self.assertEqual(len(result['concerns']), 1)
|
||||||
|
self.assertIn('Revenue', result['concerns'][0])
|
||||||
|
self.assertIn('30.0%', result['concerns'][0])
|
||||||
|
self.assertGreater(len(result['next_actions']), 0)
|
||||||
|
|
||||||
|
def test_returns_dict_with_required_keys(self):
|
||||||
|
report = {'report_name': 'Test', 'period': {'label': 'X'}, 'rows': []}
|
||||||
|
result = generate_commentary(self.env, report_result=report)
|
||||||
|
for key in ('summary', 'highlights', 'concerns', 'next_actions'):
|
||||||
|
self.assertIn(key, result)
|
||||||
50
fusion_accounting_reports/tests/test_commentary_prompt.py
Normal file
50
fusion_accounting_reports/tests/test_commentary_prompt.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Tests for commentary_prompt module."""
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_reports.services.commentary_prompt import (
|
||||||
|
SYSTEM_PROMPT, build_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestCommentaryPrompt(TransactionCase):
|
||||||
|
|
||||||
|
def test_system_prompt_requires_json(self):
|
||||||
|
self.assertIn('JSON', SYSTEM_PROMPT)
|
||||||
|
self.assertIn('"summary"', SYSTEM_PROMPT)
|
||||||
|
self.assertIn('"highlights"', SYSTEM_PROMPT)
|
||||||
|
|
||||||
|
def test_build_prompt_returns_tuple(self):
|
||||||
|
report = {'report_name': 'P&L', 'period': {'label': 'Apr 2026',
|
||||||
|
'date_from': '2026-04-01',
|
||||||
|
'date_to': '2026-04-30'},
|
||||||
|
'rows': []}
|
||||||
|
result = build_prompt(report, [])
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertIn('REPORT', result[1])
|
||||||
|
self.assertIn('Apr 2026', result[1])
|
||||||
|
|
||||||
|
def test_user_prompt_includes_rows(self):
|
||||||
|
report = {
|
||||||
|
'report_name': 'P&L',
|
||||||
|
'period': {'label': 'X', 'date_from': 'a', 'date_to': 'b'},
|
||||||
|
'rows': [
|
||||||
|
{'id': 'r1', 'label': 'Revenue', 'amount': 100000.50},
|
||||||
|
{'id': 'r2', 'label': 'Net Income', 'amount': 25000, 'is_subtotal': True},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_, user = build_prompt(report, [])
|
||||||
|
self.assertIn('Revenue', user)
|
||||||
|
self.assertIn('100,000.50', user)
|
||||||
|
self.assertIn('SUBTOTAL', user)
|
||||||
|
|
||||||
|
def test_user_prompt_includes_anomalies(self):
|
||||||
|
report = {'report_name': 'X', 'period': {'label': 'X', 'date_from': '', 'date_to': ''}, 'rows': []}
|
||||||
|
anomalies = [
|
||||||
|
{'label': 'Revenue', 'direction': 'increase', 'variance_pct': 25.0,
|
||||||
|
'variance_amount': 5000, 'severity': 'medium'},
|
||||||
|
]
|
||||||
|
_, user = build_prompt(report, anomalies)
|
||||||
|
self.assertIn('ANOMALIES', user)
|
||||||
|
self.assertIn('Revenue', user)
|
||||||
|
self.assertIn('25.0%', user)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Tests for fusion.report.anomaly model."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestFusionReportAnomaly(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.report = self.env.ref('fusion_accounting_reports.report_pnl')
|
||||||
|
|
||||||
|
def _make(self, **vals):
|
||||||
|
defaults = {
|
||||||
|
'report_id': self.report.id,
|
||||||
|
'period_from': date(2026, 4, 1),
|
||||||
|
'period_to': date(2026, 4, 30),
|
||||||
|
'row_id': 'line_0',
|
||||||
|
'label': 'Revenue',
|
||||||
|
'current_amount': 12000,
|
||||||
|
'comparison_amount': 10000,
|
||||||
|
'variance_amount': 2000,
|
||||||
|
'variance_pct': 20.0,
|
||||||
|
'severity': 'medium',
|
||||||
|
'direction': 'increase',
|
||||||
|
}
|
||||||
|
defaults.update(vals)
|
||||||
|
return self.env['fusion.report.anomaly'].create(defaults)
|
||||||
|
|
||||||
|
def test_create_basic(self):
|
||||||
|
a = self._make()
|
||||||
|
self.assertEqual(a.severity, 'medium')
|
||||||
|
self.assertEqual(a.state, 'new')
|
||||||
|
self.assertTrue(a.detected_at)
|
||||||
|
|
||||||
|
def test_acknowledge_action(self):
|
||||||
|
a = self._make()
|
||||||
|
a.action_acknowledge()
|
||||||
|
self.assertEqual(a.state, 'acknowledged')
|
||||||
|
self.assertEqual(a.acknowledged_by, self.env.user)
|
||||||
|
self.assertTrue(a.acknowledged_at)
|
||||||
|
|
||||||
|
def test_dismiss_action(self):
|
||||||
|
a = self._make()
|
||||||
|
a.action_dismiss()
|
||||||
|
self.assertEqual(a.state, 'dismissed')
|
||||||
|
|
||||||
|
def test_resolve_action(self):
|
||||||
|
a = self._make()
|
||||||
|
a.action_resolve()
|
||||||
|
self.assertEqual(a.state, 'resolved')
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Tests for fusion.report.commentary cache model."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestFusionReportCommentary(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.report = self.env.ref('fusion_accounting_reports.report_pnl')
|
||||||
|
|
||||||
|
def test_create_minimal(self):
|
||||||
|
c = self.env['fusion.report.commentary'].create({
|
||||||
|
'report_id': self.report.id,
|
||||||
|
'period_from': date(2026, 4, 1),
|
||||||
|
'period_to': date(2026, 4, 30),
|
||||||
|
'summary': 'Test summary.',
|
||||||
|
'highlights': ['point 1', 'point 2'],
|
||||||
|
})
|
||||||
|
self.assertEqual(c.summary, 'Test summary.')
|
||||||
|
self.assertEqual(c.highlights, ['point 1', 'point 2'])
|
||||||
|
self.assertEqual(c.generated_by, 'on_demand')
|
||||||
|
|
||||||
|
def test_uniqueness_per_period(self):
|
||||||
|
self.env['fusion.report.commentary'].create({
|
||||||
|
'report_id': self.report.id,
|
||||||
|
'period_from': date(2026, 4, 1),
|
||||||
|
'period_to': date(2026, 4, 30),
|
||||||
|
'comparison_mode': 'none',
|
||||||
|
})
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
self.env['fusion.report.commentary'].create({
|
||||||
|
'report_id': self.report.id,
|
||||||
|
'period_from': date(2026, 4, 1),
|
||||||
|
'period_to': date(2026, 4, 30),
|
||||||
|
'comparison_mode': 'none',
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_different_comparison_modes_can_coexist(self):
|
||||||
|
for mode in ['none', 'previous_period', 'previous_year']:
|
||||||
|
self.env['fusion.report.commentary'].create({
|
||||||
|
'report_id': self.report.id,
|
||||||
|
'period_from': date(2026, 5, 1),
|
||||||
|
'period_to': date(2026, 5, 31),
|
||||||
|
'comparison_mode': mode,
|
||||||
|
})
|
||||||
|
count = self.env['fusion.report.commentary'].search_count([
|
||||||
|
('report_id', '=', self.report.id),
|
||||||
|
('period_from', '=', date(2026, 5, 1)),
|
||||||
|
])
|
||||||
|
self.assertEqual(count, 3)
|
||||||
Reference in New Issue
Block a user