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

Binary file not shown.

View File

@@ -0,0 +1,5 @@
from . import adapters
from . import tools
from . import prompts
from . import agent
from . import scoring

View File

@@ -0,0 +1,3 @@
from . import claude
from . import openai_adapter
from ._base import LLMProvider

View File

@@ -0,0 +1,44 @@
"""LLMProvider contract - every adapter must conform.
Phase 1 generalisation: makes local LLM (Ollama, LM Studio, vLLM, llamafile,
llama.cpp HTTP server) a one-config-line drop-in via the OpenAI-compatible
HTTP API surface that all of them expose.
"""
class LLMProvider:
"""Contract every LLM backend must satisfy. Adapters declare capabilities
as class attributes; the engine inspects them before calling optional methods."""
supports_tool_calling: bool = False
supports_streaming: bool = False
max_context_tokens: int = 4096
supports_embeddings: bool = False
def __init__(self, env):
self.env = env
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
"""Plain text completion. Required for ALL providers.
Returns: {'content': str, 'tokens_used': int, 'model': str}
"""
raise NotImplementedError
def complete_with_tools(self, *, system, messages, tools, max_tokens=2048) -> dict:
"""Tool-calling completion. Optional - caller checks supports_tool_calling first.
Returns: {'content': str, 'tool_calls': [{'name': str, 'arguments': dict}], ...}
"""
raise NotImplementedError(
f"{type(self).__name__} does not support tool-calling. "
f"Check supports_tool_calling before calling.")
def embed(self, texts: list[str]) -> list[list[float]]:
"""Embeddings. Optional - caller checks supports_embeddings first.
Returns: list of float vectors, one per input text.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support embeddings. "
f"Check supports_embeddings before calling.")

View File

@@ -0,0 +1,201 @@
import json
import logging
from odoo import models, api, _
from odoo.exceptions import UserError
from ._base import LLMProvider
_logger = logging.getLogger(__name__)
try:
import anthropic as anthropic_sdk
except ImportError:
anthropic_sdk = None
class ClaudeAdapter(LLMProvider):
"""Plain-Python LLMProvider implementation for Anthropic Claude.
Preserves all existing functionality (extended thinking, native tool_use
blocks) used by the Odoo AbstractModel-based adapter -- this class is
additive for the Phase 1 LLMProvider contract.
"""
supports_tool_calling = True
supports_streaming = True
max_context_tokens = 200000
supports_embeddings = False
def __init__(self, env):
super().__init__(env)
if anthropic_sdk is None:
raise UserError(_("The 'anthropic' Python package is not installed."))
ICP = env['ir.config_parameter'].sudo()
try:
api_key = env['fusion.api.service'].get_api_key(
provider_type='anthropic',
consumer='fusion_accounting',
feature='chat_with_tools',
)
except Exception:
api_key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
if not api_key:
api_key = 'not-needed'
self.client = anthropic_sdk.Anthropic(api_key=api_key)
self.model = ICP.get_param(
'fusion_accounting.claude_model', 'claude-sonnet-4-6')
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
api_messages = [
m for m in messages if m.get('role') in ('user', 'assistant')
]
try:
response = self.client.messages.create(
model=self.model,
max_tokens=max_tokens,
temperature=temperature,
system=system,
messages=api_messages,
)
except Exception as e:
_logger.error("Claude complete error: %s", e)
raise UserError(_("Claude API error: %s", str(e)))
text_parts = [b.text for b in response.content if getattr(b, 'type', None) == 'text']
return {
'content': '\n'.join(text_parts),
'tokens_used': (
getattr(response.usage, 'input_tokens', 0)
+ getattr(response.usage, 'output_tokens', 0)
),
'model': self.model,
}
class FusionAccountingAdapterClaude(models.AbstractModel):
_name = 'fusion.accounting.adapter.claude'
_description = 'Claude AI Adapter'
def _get_client(self):
if anthropic_sdk is None:
raise UserError(_("The 'anthropic' Python package is not installed."))
try:
key = self.env['fusion.api.service'].get_api_key(
provider_type='anthropic',
consumer='fusion_accounting',
feature='chat_with_tools',
)
except Exception:
ICP = self.env['ir.config_parameter'].sudo()
key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
if not key:
raise UserError(_("No Anthropic API key configured."))
return anthropic_sdk.Anthropic(api_key=key)
def _get_model_name(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param('fusion_accounting.claude_model', 'claude-sonnet-4-6')
def _format_tools(self, tools):
formatted = []
for tool in tools:
t = {
'name': tool['name'],
'description': tool['description'],
'input_schema': tool.get('parameters', {'type': 'object', 'properties': {}}),
}
formatted.append(t)
return formatted
def _supports_extended_thinking(self, model):
return '4-6' in model or '4-5' in model or '4-1' in model or '4-0' in model
def call_with_tools(self, system_prompt, messages, tools=None, model_override=None):
client = self._get_client()
model = model_override or self._get_model_name()
api_messages = []
for msg in messages:
if msg['role'] in ('user', 'assistant'):
api_messages.append(msg)
kwargs = {
'model': model,
'max_tokens': 16384,
'system': system_prompt,
'messages': api_messages,
}
if tools:
kwargs['tools'] = self._format_tools(tools)
if self._supports_extended_thinking(model) and tools:
kwargs['thinking'] = {
'type': 'enabled',
'budget_tokens': 8192,
}
try:
response = client.messages.create(**kwargs)
except anthropic_sdk.BadRequestError as e:
if 'thinking' in str(e).lower():
kwargs.pop('thinking', None)
response = client.messages.create(**kwargs)
else:
raise UserError(_("Claude API error: %s", str(e)))
except Exception as e:
_logger.error("Claude API error: %s", e)
raise UserError(_("Claude API error: %s", str(e)))
text_parts = []
tool_calls = []
thinking_text = []
for block in response.content:
if block.type == 'text':
text_parts.append(block.text)
elif block.type == 'tool_use':
tool_calls.append({
'id': block.id,
'name': block.name,
'arguments': block.input,
})
elif block.type == 'thinking':
thinking_text.append(block.thinking)
if thinking_text:
_logger.debug("Claude thinking: %s", thinking_text[0][:500])
return {
'text': '\n'.join(text_parts),
'tool_calls': tool_calls if tool_calls else None,
'tokens_in': response.usage.input_tokens,
'tokens_out': response.usage.output_tokens,
'stop_reason': response.stop_reason,
'raw_content': response.content,
}
def append_tool_results(self, messages, ai_response, tool_results):
assistant_content = []
for block in ai_response.get('raw_content', []):
if hasattr(block, 'type'):
if block.type == 'text':
assistant_content.append({'type': 'text', 'text': block.text})
elif block.type == 'tool_use':
assistant_content.append({
'type': 'tool_use',
'id': block.id,
'name': block.name,
'input': block.input,
})
messages.append({'role': 'assistant', 'content': assistant_content})
tool_result_content = []
for tr in tool_results:
tool_result_content.append({
'type': 'tool_result',
'tool_use_id': tr['tool_call_id'],
'content': tr['result'],
})
messages.append({'role': 'user', 'content': tool_result_content})
return messages

View File

@@ -0,0 +1,204 @@
import json
import logging
from odoo import models, api, _
from odoo.exceptions import UserError
from ._base import LLMProvider
_logger = logging.getLogger(__name__)
try:
from openai import OpenAI
except ImportError:
OpenAI = None
DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
class OpenAIAdapter(LLMProvider):
"""Plain-Python LLMProvider implementation backed by an OpenAI-compatible
HTTP endpoint.
The OpenAI Python SDK speaks to any server that exposes the OpenAI
Chat Completions surface: OpenAI itself, Ollama, LM Studio, vLLM,
llamafile, llama.cpp HTTP server, etc. Configure the endpoint via
the ``fusion_accounting.openai_base_url`` ir.config_parameter.
"""
supports_tool_calling = True
supports_streaming = True
max_context_tokens = 128000
supports_embeddings = True
def __init__(self, env):
super().__init__(env)
if OpenAI is None:
raise UserError(_("The 'openai' Python package is not installed."))
ICP = env['ir.config_parameter'].sudo()
base_url = ICP.get_param(
'fusion_accounting.openai_base_url', DEFAULT_OPENAI_BASE_URL,
) or DEFAULT_OPENAI_BASE_URL
try:
api_key = env['fusion.api.service'].get_api_key(
provider_type='openai',
consumer='fusion_accounting',
feature='chat_with_tools',
)
except Exception:
api_key = ICP.get_param('fusion_accounting.openai_api_key', '')
if not api_key:
# Local LLM servers (Ollama, LM Studio, llama.cpp) usually do not
# require a real key but the SDK insists on a non-empty string.
api_key = 'not-needed'
self.base_url = base_url
self.client = OpenAI(api_key=api_key, base_url=base_url)
self.model = ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
api_messages = [{'role': 'system', 'content': system}]
for msg in messages:
if msg.get('role') in ('user', 'assistant', 'tool'):
api_messages.append(msg)
try:
response = self.client.chat.completions.create(
model=self.model,
messages=api_messages,
max_tokens=max_tokens,
temperature=temperature,
)
except Exception as e:
_logger.error("OpenAI complete error: %s", e)
raise UserError(_("OpenAI API error: %s", str(e)))
choice = response.choices[0]
return {
'content': choice.message.content or '',
'tokens_used': getattr(response.usage, 'total_tokens', 0),
'model': self.model,
}
class FusionAccountingAdapterOpenAI(models.AbstractModel):
_name = 'fusion.accounting.adapter.openai'
_description = 'OpenAI AI Adapter'
def _get_client(self):
if OpenAI is None:
raise UserError(_("The 'openai' Python package is not installed."))
try:
key = self.env['fusion.api.service'].get_api_key(
provider_type='openai',
consumer='fusion_accounting',
feature='chat_with_tools',
)
except Exception:
ICP = self.env['ir.config_parameter'].sudo()
key = ICP.get_param('fusion_accounting.openai_api_key', '')
if not key:
raise UserError(_("No OpenAI API key configured."))
return OpenAI(api_key=key)
def _get_model_name(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
def _format_tools(self, tools):
formatted = []
for tool in tools:
formatted.append({
'type': 'function',
'function': {
'name': tool['name'],
'description': tool['description'],
'parameters': tool.get('parameters', {'type': 'object', 'properties': {}}),
},
})
return formatted
def _is_reasoning_model(self, model):
return model.startswith('o1') or model.startswith('o3') or model.startswith('o4')
def call_with_tools(self, system_prompt, messages, tools=None, model_override=None):
client = self._get_client()
model = model_override or self._get_model_name()
is_reasoning = self._is_reasoning_model(model)
if is_reasoning:
api_messages = [{'role': 'developer', 'content': system_prompt}]
else:
api_messages = [{'role': 'system', 'content': system_prompt}]
for msg in messages:
if msg['role'] in ('user', 'assistant', 'tool'):
api_messages.append(msg)
kwargs = {
'model': model,
'messages': api_messages,
}
if is_reasoning:
kwargs['max_completion_tokens'] = 16384
kwargs['reasoning_effort'] = 'medium'
else:
kwargs['max_tokens'] = 4096
if tools:
kwargs['tools'] = self._format_tools(tools)
try:
response = client.chat.completions.create(**kwargs)
except Exception as e:
_logger.error("OpenAI API error: %s", e)
raise UserError(_("OpenAI API error: %s", str(e)))
choice = response.choices[0]
message = choice.message
tool_calls = []
if message.tool_calls:
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments)
except (json.JSONDecodeError, TypeError):
_logger.warning("Malformed tool args from OpenAI: %s", tc.function.arguments)
args = {}
tool_calls.append({
'id': tc.id,
'name': tc.function.name,
'arguments': args,
})
return {
'text': message.content or '',
'tool_calls': tool_calls if tool_calls else None,
'tokens_in': response.usage.prompt_tokens,
'tokens_out': response.usage.completion_tokens,
'stop_reason': choice.finish_reason,
'raw_message': message,
}
def append_tool_results(self, messages, ai_response, tool_results):
raw_msg = ai_response.get('raw_message')
assistant_msg = {'role': 'assistant', 'content': raw_msg.content or ''}
if raw_msg.tool_calls:
assistant_msg['tool_calls'] = [
{
'id': tc.id,
'type': 'function',
'function': {
'name': tc.function.name,
'arguments': tc.function.arguments,
},
}
for tc in raw_msg.tool_calls
]
messages.append(assistant_msg)
for tr in tool_results:
messages.append({
'role': 'tool',
'tool_call_id': tr['tool_call_id'],
'content': tr['result'],
})
return messages

View File

@@ -0,0 +1,947 @@
import json
import logging
import time
from datetime import timedelta
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# In-memory execution state for live status polling.
# Key: session_id, Value: {thinking, tool_calls, status}
# Cleared after each chat() call completes.
_execution_state = {}
def get_execution_state(session_id):
"""Get the current execution state for a session (called by polling endpoint)."""
return _execution_state.get(session_id, {'status': 'idle', 'thinking': '', 'tool_calls': []})
# Inter-account transfer pairs: (source_journal, cc_journal, cc_account_pattern)
# Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming)
TRANSFER_PAIRS = [
# (source_journal_id, cc_journal_id, outstanding_account_id)
(50, 51, 493), # Scotia Current → Passport Visa, Outstanding Receipts - All Banks
(53, 28, 493), # RBC Chequing → RBC Visa, Outstanding Receipts - All Banks
]
class FusionAccountingAgent(models.AbstractModel):
_name = 'fusion.accounting.agent'
_description = 'Fusion Accounting AI Agent Orchestrator'
def _get_config(self, key, default=None):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param(f'fusion_accounting.{key}', default)
# Domains that need deeper reasoning → use Sonnet
COMPLEX_DOMAINS = {'audit', 'month_end', 'hst_management', 'payroll_management'}
# Keywords in user messages that suggest complex analysis → use Sonnet
COMPLEX_KEYWORDS = {
'audit', 'analyze', 'analyse', 'review all', 'full report', 'investigate',
'month-end', 'month end', 'close the books', 'hst filing', 'tax return',
'what went wrong', 'why is', 'explain the difference', 'compare',
}
def _get_adapter(self):
provider = self._get_config('ai_provider', 'claude')
if provider == 'claude':
return self.env['fusion.accounting.adapter.claude']
return self.env['fusion.accounting.adapter.openai']
def _route_model(self, session, user_message, has_image=False):
"""Smart model routing: Haiku for routine tool calling, Sonnet for complex analysis.
Returns (model_name, can_escalate) — can_escalate=True means Haiku is trying first
and we should check if it needs help."""
provider = session.ai_provider or self._get_config('ai_provider', 'claude')
if provider != 'claude':
return None, False
# Always use Sonnet for images (vision quality matters for OCR)
if has_image:
return 'claude-sonnet-4-6', False
# Use Sonnet for complex domains
if session.context_domain in self.COMPLEX_DOMAINS:
return 'claude-sonnet-4-6', False
# Use Sonnet if the message contains complex analysis keywords
msg_lower = (user_message or '').lower()
if any(kw in msg_lower for kw in self.COMPLEX_KEYWORDS):
return 'claude-sonnet-4-6', False
# Default: Haiku with escalation enabled
return 'claude-haiku-4-5', True
def _should_escalate(self, response, tool_calls_log, turn):
"""Check if Haiku's response suggests it needs Sonnet's help."""
text = (response.get('text') or '').lower()
# Haiku said it can't do something
uncertainty_phrases = [
"i'm not sure", "i cannot determine", "i don't have enough",
"unable to", "i'm unable", "this is complex", "beyond my",
"i need more context", "difficult to assess", "i apologize",
"i'm having trouble", "let me think about this differently",
]
if any(phrase in text for phrase in uncertainty_phrases):
return True
# Haiku made no tool calls on first turn when it probably should have
# (user asked a question but Haiku just gave text without using tools)
if turn == 0 and not response.get('tool_calls') and not text:
return True
# Haiku had multiple tool errors
error_count = sum(1 for tc in tool_calls_log if tc.get('status') == 'error')
if error_count >= 2:
return True
# Response is very short for a data question (Haiku might be confused)
if turn == 0 and not response.get('tool_calls') and len(text) < 50:
return True
return False
def _get_tool_registry(self):
return self.env['fusion.accounting.tool'].search([('active', '=', True)])
def _get_tools_for_user(self, user=None):
user = user or self.env.user
tools = self._get_tool_registry()
filtered = self.env['fusion.accounting.tool']
for tool in tools:
if not tool.required_groups:
filtered |= tool
continue
group_xmlids = [g.strip() for g in tool.required_groups.split(',') if g.strip()]
if all(user.has_group(g) for g in group_xmlids):
filtered |= tool
return filtered
def _build_tool_definitions(self, tools):
definitions = []
for tool in tools:
# A2: Include tier info in description so AI knows which tools need approval
tier_label = {'1': 'Read-only', '2': 'Auto-approved', '3': 'Requires user approval'}.get(tool.tier, '')
desc = tool.description or ''
if tier_label:
desc = f"[Tier {tool.tier}: {tier_label}] {desc}"
defn = {
'name': tool.name,
'description': desc,
}
if tool.parameters_schema:
try:
defn['parameters'] = json.loads(tool.parameters_schema)
except json.JSONDecodeError:
defn['parameters'] = {'type': 'object', 'properties': {}}
else:
defn['parameters'] = {'type': 'object', 'properties': {}}
definitions.append(defn)
return definitions
def _load_rules(self, domain=None):
rule_domain = [('active', '=', True), ('company_id', '=', self.env.company.id)]
if domain:
rule_domain.append(('rule_type', '=', domain))
rules = self.env['fusion.accounting.rule'].search(rule_domain, order='sequence')
admin_rules = rules.filtered(lambda r: r.created_by == 'admin')
ai_auto = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'auto')
ai_pending = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'needs_approval')
return admin_rules | ai_auto | ai_pending
def _load_match_history(self, domain=None, limit=None):
limit = limit or int(self._get_config('history_in_prompt', '50'))
history_domain = [('company_id', '=', self.env.company.id)]
if domain:
history_domain.append(('tool_name', 'ilike', domain))
return self.env['fusion.accounting.match.history'].search(
history_domain, limit=limit, order='proposed_at desc',
)
def _build_system_prompt(self, rules, history, context=None, domain=None):
from .prompts.system_prompt import build_system_prompt
from .prompts.domain_prompts import get_domain_prompt
base = build_system_prompt(rules, history, context)
if domain:
domain_prompt = get_domain_prompt(domain)
if domain_prompt:
base = f"{base}\n\n{domain_prompt}"
return base
def _execute_tool(self, tool_name, params, session_id=None):
from .tools import TOOL_DISPATCH
if tool_name not in TOOL_DISPATCH:
return {'error': f'Unknown tool: {tool_name}'}
try:
result = TOOL_DISPATCH[tool_name](self.env, params)
return result
except Exception as e:
_logger.error("Tool execution error (%s): %s", tool_name, e)
return {'error': str(e)}
def _log_match_history(self, session, tool_name, params, result, reasoning='',
confidence=0.0, rule=None, tier='1'):
vals = {
'session_id': session.id if session else False,
'tool_name': tool_name,
'tool_params': json.dumps(params, indent=2, default=str) if params else '{}',
'tool_result': json.dumps(result, indent=2, default=str) if result else '{}',
'ai_reasoning': reasoning,
'ai_confidence': confidence,
'rule_id': rule.id if rule else False,
'proposed_at': fields.Datetime.now(),
'decision': 'auto' if tier in ('1', '2') else 'pending',
'company_id': self.env.company.id,
}
return self.env['fusion.accounting.match.history'].sudo().create(vals)
def chat(self, session_id, user_message, context=None, image=None):
session = self.env['fusion.accounting.session'].browse(session_id)
if not session.exists():
raise UserError(_("Session not found."))
adapter = self._get_adapter()
provider = self._get_config('ai_provider', 'claude')
# Pin provider to session to prevent cross-adapter message contamination (C5)
if session.ai_provider and session.ai_provider != provider:
_logger.warning(
"Session %s was started with %s but current provider is %s. "
"Keeping original provider to avoid message format conflicts.",
session.name, session.ai_provider, provider,
)
provider = session.ai_provider
if provider == 'claude':
adapter = self.env['fusion.accounting.adapter.claude']
else:
adapter = self.env['fusion.accounting.adapter.openai']
tools = self._get_tools_for_user()
tool_definitions = self._build_tool_definitions(tools)
rules = self._load_rules()
history = self._load_match_history()
system_prompt = self._build_system_prompt(
rules, history, context, domain=session.context_domain,
)
messages_json = json.loads(session.message_ids_json or '[]')
# Build user message — may include image for vision
if image and isinstance(image, dict) and image.get('base64'):
user_content = []
if user_message:
user_content.append({'type': 'text', 'text': user_message})
user_content.append({
'type': 'image',
'source': {
'type': 'base64',
'media_type': image.get('media_type', 'image/png'),
'data': image['base64'],
},
})
messages_json.append({'role': 'user', 'content': user_content})
else:
messages_json.append({'role': 'user', 'content': user_message})
# Smart model routing: Haiku for routine, Sonnet for complex
has_image = bool(image and isinstance(image, dict) and image.get('base64'))
model_override, can_escalate = self._route_model(session, user_message, has_image=has_image)
escalated = False
if model_override:
_logger.info("Model routing: %s%s (escalation=%s)", session.name, model_override, can_escalate)
max_turns = max(int(self._get_config('max_tool_calls', '20')), 1)
total_tokens_in = 0
total_tokens_out = 0
response = {'text': '', 'tool_calls': None}
has_pending_tier3 = False
tool_calls_log = [] # Track tool calls for frontend display
reconciliation_data = None # Raw data from suggest_bank_line_matches
# Initialize live execution state for polling
_execution_state[session.id] = {
'status': 'thinking',
'thinking': '',
'tool_calls': [],
'turn': 0,
}
for turn in range(max_turns):
_execution_state[session.id]['status'] = 'calling_ai'
_execution_state[session.id]['turn'] = turn + 1
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=tool_definitions,
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
# Check if Haiku needs to escalate to Sonnet
if can_escalate and not escalated and self._should_escalate(response, tool_calls_log, turn):
_logger.info("Escalating %s from Haiku → Sonnet (turn %d)", session.name, turn)
model_override = 'claude-sonnet-4-6'
escalated = True
can_escalate = False
_execution_state[session.id]['status'] = 'escalating'
# Re-call with Sonnet
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=tool_definitions,
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
# Capture thinking text for live display
thinking = ''
for block in (response.get('raw_content') or []):
if hasattr(block, 'type') and block.type == 'thinking':
thinking = block.thinking
break
if thinking:
_execution_state[session.id]['thinking'] = thinking[:500] # Truncated for live display
if response.get('tool_calls'):
tool_results = []
_execution_state[session.id]['status'] = 'calling_tools'
for tc in response['tool_calls']:
tool_name = tc['name']
tool_params = tc.get('arguments', {})
tool_rec = tools.filtered(lambda t: t.name == tool_name)
tier = tool_rec.tier if tool_rec else '1'
# Update live state: show which tool is running
_execution_state[session.id]['tool_calls'].append({
'name': tool_name, 'status': 'running',
})
if tier == '3':
has_pending_tier3 = True
history_rec = self._log_match_history(
session, tool_name, tool_params, None,
reasoning=thinking or '',
confidence=tc.get('confidence', 0.0),
tier='3',
)
tool_results.append({
'tool_call_id': tc.get('id', ''),
'result': json.dumps({
'status': 'pending_approval',
'match_history_id': history_rec.id,
'message': f'Action requires user approval. Match history ID: {history_rec.id}',
}),
})
tool_calls_log.append({
'name': tool_name,
'tier': tier,
'status': 'pending_approval',
'summary': self._build_tool_call_summary(tool_name, tool_params, None),
})
_execution_state[session.id]['tool_calls'][-1]['status'] = 'pending'
else:
t0 = time.time()
result = self._execute_tool(tool_name, tool_params, session.id)
elapsed = round((time.time() - t0) * 1000)
self._log_match_history(
session, tool_name, tool_params, result,
reasoning=thinking or '',
tier=tier,
)
tool_results.append({
'tool_call_id': tc.get('id', ''),
'result': json.dumps(result) if not isinstance(result, str) else result,
})
tc_status = 'error' if isinstance(result, dict) and result.get('error') else 'ok'
tc_summary = self._build_tool_call_summary(tool_name, tool_params, result)
# Capture reconciliation data for direct frontend rendering
if tool_name == 'suggest_bank_line_matches' and tc_status == 'ok':
reconciliation_data = result
tool_calls_log.append({
'name': tool_name,
'tier': tier,
'status': tc_status,
'summary': tc_summary,
'duration_ms': elapsed,
})
# Update live state
_execution_state[session.id]['tool_calls'][-1].update({
'status': tc_status, 'summary': tc_summary, 'duration_ms': elapsed,
})
try:
self._check_rule_proposal(tool_name, tool_params, session)
except Exception:
_logger.exception("Error in _check_rule_proposal for tool %s", tool_name)
messages_json = adapter.append_tool_results(
messages_json, response, tool_results,
)
session.write({'tool_call_count': session.tool_call_count + len(tool_results)})
# C2: Short-circuit loop when Tier 3 actions are pending —
# force a final text response so the AI can present approval cards
if has_pending_tier3:
try:
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=[],
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
messages_json.append({
'role': 'assistant',
'content': response.get('text', 'I have proposed actions that require your approval.'),
})
except Exception:
messages_json.append({
'role': 'assistant',
'content': 'I have proposed actions that require your approval. Please review the pending items above.',
})
break
else:
assistant_text = response.get('text', '')
messages_json.append({'role': 'assistant', 'content': assistant_text})
break
else:
# Loop exhausted — force a final text response without tools
try:
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=[],
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
messages_json.append({
'role': 'assistant',
'content': response.get('text', 'I reached the maximum number of steps. Please continue the conversation.'),
})
except Exception:
_logger.warning("Failed to get final summary after max tool calls")
session.write({
'message_ids_json': json.dumps(messages_json),
'token_count_in': session.token_count_in + total_tokens_in,
'token_count_out': session.token_count_out + total_tokens_out,
'ai_provider': provider,
'ai_model': model_override or adapter._get_model_name(),
})
pending = self.env['fusion.accounting.match.history'].search([
('session_id', '=', session.id),
('decision', '=', 'pending'),
])
# Clear live execution state
_execution_state.pop(session.id, None)
# Add escalation marker to tool calls log if it happened
if escalated:
tool_calls_log.insert(0, {
'name': 'model_escalation',
'tier': '-',
'status': 'ok',
'summary': 'Escalated from Haiku to Sonnet for deeper analysis',
'duration_ms': 0,
})
result_payload = {
'text': response.get('text', ''),
'tool_calls_log': tool_calls_log,
'pending_approvals': [self._format_pending_approval(p) for p in pending],
'session_id': session.id,
'model_used': model_override or adapter._get_model_name(),
}
# Attach raw reconciliation data so frontend renders it directly
# (instead of relying on AI to format fusion-table JSON correctly)
if reconciliation_data:
result_payload['reconciliation_table'] = reconciliation_data
return result_payload
def _build_tool_call_summary(self, tool_name, params, result):
"""Build a one-line summary of what a tool call did, for the collapsed tool log."""
try:
# Result-based summaries (when we have output)
if result and isinstance(result, dict) and not result.get('error'):
count = result.get('count')
status = result.get('status')
if status == 'created':
name = result.get('name', '')
return f"Created {name}" if name else "Created successfully"
if status == 'matched':
return "Matched successfully"
if count is not None:
return f"Found {count} result{'s' if count != 1 else ''}"
if 'balance' in result:
return f"Balance: ${result['balance']:,.2f}"
if 'total' in result:
return f"Total: ${result['total']:,.2f}"
if 'entries' in result:
return f"Found {len(result['entries'])} entries"
if 'accounts' in result:
return f"Found {len(result['accounts'])} accounts"
if status:
return str(status)
if result and isinstance(result, dict) and result.get('error'):
err = str(result['error'])
return f"Error: {err[:80]}"
# Params-based summaries (for pending approvals, no result yet)
if params:
ref = params.get('ref', params.get('reference', params.get('name', '')))
amount = params.get('amount')
lines = params.get('lines', [])
if lines:
total = sum(l.get('debit', 0) for l in lines)
return f"{ref} — ${total:,.2f}" if ref else f"${total:,.2f} journal entry"
if ref and amount:
return f"{ref} — ${abs(amount):,.2f}"
if ref:
return str(ref)
return "Completed"
except Exception:
return "Completed"
def _format_pending_approval(self, history):
"""Build a rich approval payload so the UI can show exactly what's being approved."""
params = {}
try:
params = json.loads(history.tool_params or '{}')
except json.JSONDecodeError:
pass
# Extract amount from params — look in common locations
amount = history.amount or 0.0
if not amount:
# Try to compute from journal entry lines
lines = params.get('lines', [])
if lines:
amount = sum(l.get('debit', 0) for l in lines)
# Or from direct amount field
if not amount:
amount = abs(params.get('amount', 0))
# Build a human-readable summary of what this action will do
summary = self._build_approval_summary(history.tool_name, params)
return {
'id': history.id,
'tool_name': history.tool_name,
'params': history.tool_params,
'reasoning': history.ai_reasoning,
'confidence': history.ai_confidence,
'amount': amount,
'summary': summary,
}
def _resolve_account_label(self, account_id):
"""Resolve an account ID to 'code - name' for display."""
if not account_id:
return '?'
try:
acct = self.env['account.account'].browse(int(account_id))
if acct.exists():
return f"{acct.code} {acct.name}"
except Exception:
pass
return str(account_id)
def _build_approval_summary(self, tool_name, params):
"""Generate a short human-readable description of what a Tier 3 action will do."""
try:
if tool_name == 'create_payroll_journal_entry':
ref = params.get('ref', 'Payroll Entry')
date = params.get('date', '?')
lines = params.get('lines', [])
total = sum(l.get('debit', 0) for l in lines)
acct_names = []
for l in lines:
aid = l.get('account_id', '')
acct_label = self._resolve_account_label(aid)
if l.get('debit'):
acct_names.append(f"Dr {acct_label}: ${l['debit']:,.2f}")
elif l.get('credit'):
acct_names.append(f"Cr {acct_label}: ${l['credit']:,.2f}")
detail = ' / '.join(acct_names) if acct_names else ''
return f"{ref} on {date} — ${total:,.2f}\n{detail}"
elif tool_name == 'create_vendor_bill':
partner = params.get('partner_name', params.get('partner_id', '?'))
amount = params.get('amount', 0)
ref = params.get('ref', params.get('reference', ''))
date = params.get('date', '?')
return f"Vendor bill for {partner} — ${abs(amount):,.2f} on {date}" + (f" ({ref})" if ref else "")
elif tool_name == 'register_bill_payment':
bill_id = params.get('bill_id', '?')
amount = params.get('amount', 0)
journal = params.get('journal_id', '?')
return f"Pay bill #{bill_id} — ${abs(amount):,.2f} from journal {journal}"
elif tool_name == 'create_expense_entry':
ref = params.get('ref', params.get('memo', 'Expense'))
amount = params.get('amount', 0)
account = params.get('expense_account_id', '?')
return f"{ref} — ${abs(amount):,.2f} to account {account}"
elif tool_name == 'register_hst_payment':
amount = params.get('amount', 0)
date = params.get('date', '?')
return f"HST remittance — ${abs(amount):,.2f} on {date}"
elif tool_name in ('apply_payment', 'send_followup', 'create_payment_reminder'):
partner = params.get('partner_name', params.get('partner_id', '?'))
amount = params.get('amount', 0)
return f"{tool_name.replace('_', ' ').title()} for {partner}" + (f" — ${abs(amount):,.2f}" if amount else "")
elif tool_name == 'flag_entry':
move_id = params.get('move_id', '?')
reason = params.get('reason', '')
return f"Flag entry #{move_id}" + (f": {reason}" if reason else "")
else:
# Generic fallback: show key params
parts = []
for key in ('ref', 'reference', 'name', 'partner_name', 'date', 'move_id'):
if key in params:
parts.append(f"{key}: {params[key]}")
if 'amount' in params:
parts.append(f"${abs(params['amount']):,.2f}")
return ' | '.join(parts) if parts else json.dumps(params)[:120]
except Exception:
return str(params)[:120]
def approve_action(self, match_history_id):
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
if not history.exists() or history.decision != 'pending':
raise UserError(_("Action not found or already decided."))
params = json.loads(history.tool_params or '{}')
result = self._execute_tool(history.tool_name, params, history.session_id.id)
history.write({
'decision': 'approved',
'decided_at': fields.Datetime.now(),
'decided_by': self.env.user.id,
'tool_result': json.dumps(result) if not isinstance(result, str) else result,
})
if history.rule_id:
history.rule_id.sudo()._record_decision(approved=True)
# C1: Update session messages_json so next chat turn has coherent history
self._update_session_after_decision(history, result)
# M8: Trigger promotion check after approval
try:
self.env['fusion.accounting.scoring'].check_promotions()
except Exception:
_logger.exception("Error checking promotions after approval")
return result
def _check_rule_proposal(self, tool_name, params, session):
"""Detect repeated patterns and propose new rules when 3+ identical matches."""
recent = self.env['fusion.accounting.match.history'].search([
('tool_name', '=', tool_name),
('decision', 'in', ('approved', 'auto')),
('company_id', '=', self.env.company.id),
], limit=20, order='proposed_at desc')
if len(recent) < 3:
return
from collections import Counter
param_keys = []
for h in recent:
try:
p = json.loads(h.tool_params or '{}')
key_parts = []
for k in sorted(p.keys()):
if k not in ('limit', 'date_from', 'date_to'):
key_parts.append(f'{k}={json.dumps(p[k], sort_keys=True)}')
if key_parts:
param_keys.append('|'.join(key_parts))
except json.JSONDecodeError:
continue
counts = Counter(param_keys)
for pattern, count in counts.most_common(3):
if count < 3:
break
existing = self.env['fusion.accounting.rule'].search([
('match_logic', 'ilike', pattern[:50]),
('company_id', '=', self.env.company.id),
], limit=1)
if existing:
continue
self.env['fusion.accounting.rule'].create({
'name': f'AI Pattern: {tool_name} ({pattern[:40]})',
'rule_type': 'match',
'description': f'Automatically detected pattern from {count} approved uses of {tool_name}.',
'match_logic': f'When using {tool_name} with parameters matching: {pattern}',
'created_by': 'ai',
'approval_tier': 'needs_approval',
'company_id': self.env.company.id,
})
_logger.info("AI proposed rule for pattern: %s (%d matches)", tool_name, count)
def reject_action(self, match_history_id, reason=''):
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
if not history.exists() or history.decision != 'pending':
raise UserError(_("Action not found or already decided."))
history.write({
'decision': 'rejected',
'decided_at': fields.Datetime.now(),
'decided_by': self.env.user.id,
'rejection_reason': reason,
})
if history.rule_id:
history.rule_id.sudo()._record_decision(approved=False)
# C1: Update session messages_json so next chat turn has coherent history
reject_result = {'status': 'rejected', 'reason': reason}
self._update_session_after_decision(history, reject_result)
return reject_result
def _update_session_after_decision(self, history, result):
"""Update session messages_json to replace pending_approval placeholder
with actual tool result, preventing dangling tool_use blocks."""
session = history.session_id
if not session or not session.message_ids_json:
return
try:
messages = json.loads(session.message_ids_json)
result_str = json.dumps(result) if not isinstance(result, str) else result
updated = False
for msg in messages:
if msg.get('role') != 'user':
continue
content = msg.get('content')
if isinstance(content, list):
for block in content:
if (isinstance(block, dict) and block.get('type') == 'tool_result'
and 'pending_approval' in str(block.get('content', ''))):
# Check if this is the matching tool_result block
if str(history.id) in str(block.get('content', '')):
block['content'] = result_str
updated = True
break
if updated:
break
if updated:
session.write({'message_ids_json': json.dumps(messages)})
except Exception:
_logger.warning("Failed to update session messages after decision for history %s", history.id)
# ----------------------------------------------------------------
# Cron: Auto-Reconcile Inter-Account Transfers
# ----------------------------------------------------------------
@api.model
def _cron_reconcile_transfers(self):
"""Automatically reconcile inter-account credit card payments.
When a payment is made from a bank account (e.g. Scotia Current) to a
credit card (e.g. Scotia Passport Visa), two bank statement lines appear:
- Source side: "MB-CREDIT CARD" (negative) — reconciled by model 38/35
- CC side: "PAYMENT FROM *7814" (positive) — needs matching
The source-side reconciliation creates outstanding entries on account 493.
This cron matches the CC-side lines against those outstanding entries by
exact amount and closest date (within 3 days).
"""
AML = self.env['account.move.line'].sudo()
BSL = self.env['account.bank.statement.line'].sudo()
company_partner_id = self.env.company.partner_id.id
total_reconciled = 0
for source_jid, cc_jid, outstanding_acct_id in TRANSFER_PAIRS:
# Find all unreconciled INCOMING lines on the credit card journal
cc_lines = BSL.search([
('journal_id', '=', cc_jid),
('is_reconciled', '=', False),
('amount', '>', 0), # Incoming payments only
('company_id', '=', self.env.company.id),
])
if not cc_lines:
continue
journal_name = cc_lines[0].journal_id.name
_logger.info(
"Transfer reconcile: %s%d incoming unreconciled lines",
journal_name, len(cc_lines),
)
reconciled = 0
skipped = 0
for line in cc_lines:
line_date = line.move_id.date
amount = line.amount
# Find outstanding entries with exact matching amount
candidates = AML.search([
('account_id', '=', outstanding_acct_id),
('partner_id', '=', company_partner_id),
('reconciled', '=', False),
('amount_residual', '=', amount),
])
if not candidates:
skipped += 1
continue
# Pick the candidate closest in date (within 3 days)
best = None
best_gap = 999
for c in candidates:
gap = abs((c.date - line_date).days)
if gap < best_gap:
best_gap = gap
best = c
if best_gap > 7:
skipped += 1
continue
# Set partner and reconcile
try:
line.partner_id = company_partner_id
line.set_line_bank_statement_line(best.ids)
reconciled += 1
except Exception as e:
_logger.warning(
"Transfer reconcile failed: line %s (%s, $%.2f): %s",
line.id, line.payment_ref, amount, e,
)
# Commit every 50 lines to avoid long transactions
if reconciled % 50 == 0 and reconciled > 0:
self.env.cr.commit()
self.env.cr.commit()
total_reconciled += reconciled
_logger.info(
"Transfer reconcile: %s — reconciled %d, skipped %d",
journal_name, reconciled, skipped,
)
_logger.info("Transfer reconcile complete: %d total reconciled", total_reconciled)
# ----------------------------------------------------------------
# One-time: Match payroll cheque bank lines against open payroll liability entries
# ----------------------------------------------------------------
@api.model
def _reconcile_payroll_cheques(self):
"""Reconcile payroll cheque bank lines using writeoff to Payroll Liabilities (2201).
Your payroll JEs post:
Dr Salaries / Dr ER CPP-EI / Dr CRA Taxes
Cr 2201 Payroll Liabilities (net pay = cheque amount)
When the cheque clears the bank, the bank line shows:
"Cheque 1773 : Cheque" = -$1,477.95
This method finds cheque bank lines that have a matching payroll liability
entry (same amount) and applies a reconcile model that writes off to account
433 (Payroll Liabilities). This debits 433 to clear the liability.
Non-payroll cheques (no matching entry on 433) are skipped.
"""
PAYROLL_LIABILITY_ACCT_ID = 433 # code 2201
SCOTIA_CURRENT_JOURNAL_ID = 50
AML = self.env['account.move.line'].sudo()
BSL = self.env['account.bank.statement.line'].sudo()
RecModel = self.env['account.reconcile.model'].sudo()
# Find the payroll cheque reconcile model (must be pre-created via XML or manually)
model = RecModel.search([
('name', 'ilike', 'Payroll Cheque'),
('company_id', '=', self.env.company.id),
], limit=1)
if not model:
_logger.warning("Payroll cheque reconcile: no 'Payroll Cheque' model found — create one manually")
return
# Find all unreconciled cheque lines on Scotia Current (negative = outgoing)
# Only process lines after lock date to avoid lock date errors
cheque_lines = BSL.search([
('journal_id', '=', SCOTIA_CURRENT_JOURNAL_ID),
('is_reconciled', '=', False),
('amount', '<', 0),
('payment_ref', 'ilike', 'cheque'),
('company_id', '=', self.env.company.id),
], order='move_id asc')
# Filter to post-lock-date lines only
lock_date = self.env.company.fiscalyear_lock_date
if lock_date:
cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock_date)
_logger.info("Payroll cheque reconcile: found %d unreconciled cheque lines (post lock date)", len(cheque_lines))
# Build set of all known payroll liability credit amounts
payroll_credit_amounts = set()
for aml in AML.search([
('account_id', '=', PAYROLL_LIABILITY_ACCT_ID),
('parent_state', '=', 'posted'),
('credit', '>', 0),
]):
payroll_credit_amounts.add(round(aml.credit, 2))
# Filter: only reconcile cheques that have a matching payroll liability entry
payroll_lines = cheque_lines.filtered(
lambda l: round(abs(l.amount), 2) in payroll_credit_amounts
)
_logger.info(
"Payroll cheque reconcile: %d payroll, %d non-payroll (skipped)",
len(payroll_lines), len(cheque_lines) - len(payroll_lines),
)
if not payroll_lines:
_logger.info("Payroll cheque reconcile: nothing to reconcile")
return
# Apply the reconcile model to payroll cheque lines
try:
model._apply_reconcile_models(payroll_lines)
self.env.cr.commit()
except Exception as e:
_logger.exception("Payroll cheque reconcile batch failed: %s", e)
self.env.cr.rollback()
return
# Count results
still_unreconciled = payroll_lines.filtered(lambda l: not l.is_reconciled)
reconciled = len(payroll_lines) - len(still_unreconciled)
for line in still_unreconciled[:10]:
_logger.info("Payroll cheque still unreconciled: %s $%.2f", line.payment_ref, abs(line.amount))
_logger.info(
"Payroll cheque reconcile complete: %d reconciled, %d still unreconciled",
reconciled, len(still_unreconciled),
)

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)

View File

@@ -0,0 +1,3 @@
from . import system_prompt
from . import domain_prompts
from . import bank_rec_prompt

View File

@@ -0,0 +1,107 @@
"""Bank reconciliation AI re-rank prompt.
Used by fusion_accounting_bank_rec/services/confidence_scoring.py to ask
an LLM to refine the statistical ranking of candidate matches.
Output contract: the LLM MUST respond with valid JSON of shape:
{"ranked": [{"candidate_id": int, "confidence": float, "reason": str}, ...]}
System prompt is provider-agnostic - works with OpenAI Chat Completions,
Claude Messages, and local OpenAI-compatible servers (LM Studio, Ollama).
"""
from datetime import date
SYSTEM_PROMPT = """You are an expert accountant assisting with bank reconciliation.
Your job: given a bank statement line and a list of candidate journal items
that statistically scored well as potential matches, re-rank them based on
domain expertise. Consider:
1. **Amount-exact matches** are almost always correct unless the partner is wrong.
2. **Memo / reference clues** - bank memos often contain invoice numbers, partner
names, or transaction references that disambiguate matches.
3. **Date proximity** - invoices are typically reconciled within 30 days of issue.
4. **Pattern conformance** - if the partner has a learned pattern (e.g. "always
pays exact amount, weekly cadence"), favor candidates that fit that pattern.
5. **Precedent similarity** - if a near-identical reconcile happened before,
it's likely the right one.
Return ONLY valid JSON of this exact shape:
{
"ranked": [
{"candidate_id": <int>, "confidence": <float 0-1>, "reason": "<short string>"},
...
]
}
Do NOT include any prose before or after the JSON. Do NOT use markdown code fences.
The "ranked" array MUST contain every candidate_id from the input, in your
preferred order (highest confidence first).
"""
def build_prompt(statement_line, scored_candidates, pattern=None, precedents=None):
"""Build (system_prompt, user_prompt) for AI re-rank.
Args:
statement_line: account.bank.statement.line recordset (singleton)
scored_candidates: list of ScoredCandidate dataclasses (from confidence_scoring)
pattern: fusion.reconcile.pattern recordset for the partner, or None
precedents: list of PrecedentMatch dataclasses, or None
Returns:
(system_prompt: str, user_prompt: str) tuple
"""
user_parts = []
user_parts.append("BANK LINE:")
user_parts.append(f" Date: {statement_line.date}")
user_parts.append(
f" Amount: {statement_line.amount} {statement_line.currency_id.name or ''}"
)
user_parts.append(
f" Memo / payment ref: {statement_line.payment_ref or '(none)'}"
)
if statement_line.partner_id:
user_parts.append(f" Partner: {statement_line.partner_id.name}")
if pattern:
user_parts.append("")
user_parts.append("PARTNER PATTERN (learned from past reconciles):")
user_parts.append(f" Reconcile count: {pattern.reconcile_count}")
user_parts.append(f" Preferred strategy: {pattern.pref_strategy}")
user_parts.append(
f" Typical cadence: ~{pattern.typical_cadence_days} days between reconciles"
)
if pattern.typical_amount_range:
user_parts.append(f" Typical amount range: {pattern.typical_amount_range}")
if pattern.common_memo_tokens:
user_parts.append(f" Common memo tokens: {pattern.common_memo_tokens}")
if precedents:
user_parts.append("")
user_parts.append("RECENT PRECEDENTS (most-similar past reconciles for this partner):")
# Cap at 3 precedents to keep prompt small and reduce token cost.
for p in precedents[:3]:
user_parts.append(
f" - amount={p.amount}, similarity={p.similarity_score:.2f}, "
f"matched {p.matched_move_line_count} line(s), tokens={p.memo_tokens}"
)
user_parts.append("")
user_parts.append("CANDIDATES (scored by statistical pipeline):")
for s in scored_candidates:
user_parts.append(
f" - candidate_id={s.candidate_id}, statistical_confidence={s.confidence}, "
f"amount_match={s.score_amount_match}, pattern_fit={s.score_partner_pattern}, "
f"precedent_sim={s.score_precedent_similarity}, "
f"reason=\"{s.reasoning}\""
)
user_parts.append("")
user_parts.append("Re-rank these candidates and return JSON per the system prompt.")
user_prompt = "\n".join(user_parts)
return (SYSTEM_PROMPT, user_prompt)

View File

@@ -0,0 +1,237 @@
DOMAIN_PROMPTS = {
'bank_reconciliation': """
BANK RECONCILIATION CONTEXT:
You are helping with bank statement reconciliation. Key concepts:
- Bank statement lines (account.bank.statement.line) represent transactions from the bank feed.
- Each line needs to be matched to one or more journal items (account.move.line).
- Matching is done via set_line_bank_statement_line(move_line_ids).
- Fee differences (e.g., Elavon card processing fees) should be allocated to the fee account.
- Weekend batches may combine multiple days of card payments.
- Always verify amounts before proposing a match.
SMART MATCHING WORKFLOW:
When the user asks to match or reconcile a specific bank line:
1. Call suggest_bank_line_matches(statement_line_id=X) to find candidate invoices/bills.
2. Present the results as a reconciliation-mode fusion-table. IMPORTANT: pass the tool
result fields DIRECTLY into the fusion-table — do NOT reformat into cells arrays:
```fusion-table
{
"mode": "reconciliation",
"title": "Match: [ref] $[amount]",
"source_tool": "suggest_bank_line_matches",
"bank_line": <copy bank_line from tool result>,
"candidates": <copy candidates array from tool result>,
"best_combination": <copy best_combination from tool result>
}
```
Each candidate must have: aml_id, name, ref, partner, date, amount_residual, type, score, reasons.
Do NOT convert candidates into {"id":..., "cells":[...]} format — use the raw tool output.
3. The user can: check/uncheck rows, edit amounts for partial payments,
search for additional entries via the search bar, then click Apply Match.
4. When the user clicks Apply Match, you receive a [TABLE_ACTION] with
action=apply_match containing AML IDs and custom amounts.
5. Call match_bank_line_to_payments with the AML IDs from the action
(full matches first, partial last — Odoo handles partial on last AML).
6. Partial payment: if apply_amount < amount_residual, it's partial.
Only ONE AML can be partial (the last one). Odoo leaves the residual open.
Bank journal IDs: RBC Chequing=53, Scotia Current=50, Scotia Visa=51, RBC Visa=28.
""",
'hst_management': """
HST/GST MANAGEMENT CONTEXT:
You are helping with Canadian HST/GST tax management.
- HST Collected is tracked on account 2005 (credit balance = liability).
- Input Tax Credits (ITCs) are on account 2006 (debit balance = asset).
- Net HST = Collected - ITCs. Positive means owing to CRA.
- Quarterly filing periods. Check for missing tax on invoices/bills.
- All vendor bills should have ITCs unless explicitly exempt.
- HST Purchase tax ID is 20 (13%). No Tax Purchase ID is 32 (0%).
HST FILING WORKFLOW (4 phases — follow this order):
PHASE 1 — REPORTS: Run all at once:
calculate_hst_balance, get_tax_report, find_missing_itc_bills,
find_missing_tax_invoices, audit_tax_compliance.
Present summary with HST position (owing vs refund).
PHASE 2 — BANK SWEEP: Check ALL bank accounts for unreconciled expenses:
Call get_unreconciled_bank_lines for each bank journal (RBC Chequing 9595=53,
Current Account Scotia=50, Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28).
Present ALL unreconciled expense lines (negative amounts) as a fusion-table
with your recommendation per row.
PHASE 3 — PER-LINE PROCESSING: For each flagged expense line:
0. FIRST: check_recurring_pattern(line_id=X) — if match found, follow action_note
instructions EXACTLY (account, HST, partner, reconcile model). No user input needed
for recurring payments. If a reconcile_model_id is returned, use apply_reconcile_model.
1. get_bank_line_details — check if a vendor bill already exists for same amount/date
2. find_similar_bank_lines — check history AND vendor_tax_pattern for coding/tax pattern
3. CRITICAL: Check vendor_tax_pattern.is_po_vendor flag:
- If is_po_vendor=true: This vendor's bills come from Purchase Orders. Do NOT create
a new bill. Instead, use get_unpaid_bills to find the existing bill and propose
match_bank_line_to_payments to match the bank payment to that bill.
- If is_po_vendor=false: Proceed with bill creation workflow below.
4. If bill already exists → propose match_bank_line_to_payments
5. If no bill but history match → propose create_vendor_bill with same coding pattern
6. If no bill and no history → ask user: "Does this expense include HST?"
7. search_partners — find the vendor by keyword from the bank description
8. Once confirmed → create_vendor_bill + register_bill_payment (Tier 3, needs approval)
9. Alternative: user can choose "Direct GL" → create_expense_entry (Tier 3)
For expenses that obviously have no HST (bank fees, interest charges, insurance),
proactively recommend "No HST" and explain why.
PO-TRACKED VENDORS (do NOT create bills for these — bills come from Purchase Orders):
When find_similar_bank_lines returns is_po_vendor=true or the vendor_tax_pattern
note starts with "PO-TRACKED VENDOR", the bill already exists or will be created
from a PO. Your job is ONLY to find the existing unpaid bill and match the bank
payment to it. If no unpaid bill exists, flag it for the user: "This is a PO vendor
but no matching bill was found — the PO may not have been billed yet."
PHASE 4 — VERIFICATION: Re-run calculate_hst_balance and get_tax_report
to show the updated HST position after all expenses are recorded.
BANK JOURNAL IDS: RBC Chequing 9595=53, Current Account Scotia=50,
Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28.
MISC JOURNAL: ID=3 (for direct GL expense entries).
""",
'accounts_receivable': """
ACCOUNTS RECEIVABLE CONTEXT:
- AR aging: current, 1-30, 31-60, 61-90, 90+ days overdue.
- Follow-up actions escalate by aging bucket.
- Payments should be matched to specific invoices.
- Unmatched payments sit on the Outstanding Receipts account (1122).
""",
'accounts_payable': """
ACCOUNTS PAYABLE CONTEXT:
- AP aging mirrors AR: current through 90+ days.
- Watch for duplicate bills (same vendor + amount + date).
- Bills should match purchase orders when applicable.
- Tax on bills should match the vendor's fiscal position.
""",
'journal_review': """
JOURNAL REVIEW CONTEXT:
- Check for wrong-direction balances (e.g., expense account with credit balance).
- Detect duplicate entries (same partner + amount + date + journal).
- Flag entries on unlikely accounts (revenue on a tax account, etc.).
- Sequence gaps may indicate deleted entries.
- Draft entries older than 30 days should be reviewed.
""",
'month_end': """
MONTH-END CLOSE CONTEXT:
- Aggregate all domain checks into a close checklist.
- Verify all bank reconciliations are current.
- Check accrual account balances (vacation, sick leave, etc.).
- Verify no entries exist after lock date.
- Run hash integrity check.
- Produce period trial balance summary.
""",
'payroll_verification': """
PAYROLL VERIFICATION CONTEXT:
- Cross-reference payroll journal entries to bank statement cheques.
- Verify CPP, EI, and income tax deductions against CRA rate tables.
- Check CRA remittance account balance vs payments made.
""",
'inventory': """
INVENTORY & COGS CONTEXT:
- Stock In Hand tracked on account 1069.
- Price differences on account 5010 (PO price vs bill price).
- COGS ratio by product category helps spot anomalies.
- Large inventory adjustments need review.
""",
'adp': """
ADP (ASSISTIVE DEVICE PROGRAM) RECONCILIATION CONTEXT:
- ADP Receivable tracked on account 1101.
- ADP invoices have customer portion + ADP portion = total.
- Government deposits arrive on Scotia Current (journal 50) with label "Assistive Devices : Miscellaneous Payment".
- ADP partner in Odoo: "ADP (Assistive Device Program)" (id 3421).
ADP PAYMENT MATCHING WORKFLOW:
1. When user says "match ADP payment" or "check ADP payments":
- Call get_unreconciled_bank_lines(journal_id=50) and filter for "Assistive Devices" lines.
- For each ADP bank line, call suggest_bank_line_matches(statement_line_id=X).
- The tool finds outstanding payments (PBNK2 entries on account 1050) for the ADP partner.
- Present as reconciliation fusion-table.
2. When user uploads an ADP remittance advice image:
- Read the image. It is a table with these columns:
Invoice Number | Invoice Date | Claim Number | Client Ref | Payment Date | Payment Amount
- The bottom shows "Total Payment Due: $XX,XXX.XX" — this is the bank deposit amount.
- Extract every row: invoice number and payment amount.
- Find the bank line on Scotia Current matching the total amount.
- Call suggest_bank_line_matches for that bank line.
- The outstanding payments on 1050 should sum to the total.
3. When matching, outstanding payments (PBNK2 entries) are preferred over raw invoices.
Each PBNK2 entry represents a registered payment batch. Two or more PBNK2 entries
may combine to equal the bank deposit total.
""",
'reporting': """
FINANCIAL REPORTING CONTEXT:
- Reports use Odoo's account.report engine.
- Available: P&L, Balance Sheet, Trial Balance, Cash Flow.
- Period comparison available for trend analysis.
- Export to PDF or XLSX for external distribution.
""",
'audit': """
AUDIT & INTEGRITY CONTEXT:
- Run comprehensive checks on posted entries.
- Verify hash chain integrity on journals.
- Check sequence continuity.
- Flag entries with chatter messages for review tracking.
- Audit status per account: todo / reviewed / supervised / anomaly.
""",
'payroll_management': """
PAYROLL MANAGEMENT CONTEXT:
- Parse pasted payroll summaries from QBO or fusion_payroll.
- Create payroll journal entries with proper debit/credit lines.
- Match payroll cheques to bank statement lines.
- Calculate CRA obligations (CPP employer + employee, EI, income tax).
- Prepare CRA remittance payment entries.
""",
}
# A3/A5: Aliases so common domain variations still match a prompt
DOMAIN_ALIASES = {
'bank': 'bank_reconciliation',
'bank_recon': 'bank_reconciliation',
'hst': 'hst_management',
'gst': 'hst_management',
'tax': 'hst_management',
'ar': 'accounts_receivable',
'receivable': 'accounts_receivable',
'ap': 'accounts_payable',
'payable': 'accounts_payable',
'journal': 'journal_review',
'close': 'month_end',
'month_end_close': 'month_end',
'payroll': 'payroll_management',
'payroll_verify': 'payroll_verification',
'stock': 'inventory',
'cogs': 'inventory',
'report': 'reporting',
'reports': 'reporting',
'financial': 'reporting',
}
def get_domain_prompt(domain):
if not domain:
return ''
# Try exact match first, then aliases
prompt = DOMAIN_PROMPTS.get(domain, '')
if not prompt:
resolved = DOMAIN_ALIASES.get(domain, domain)
prompt = DOMAIN_PROMPTS.get(resolved, '')
return prompt

View File

@@ -0,0 +1,188 @@
import json
def build_system_prompt(rules, history, context=None):
parts = [
CORE_SYSTEM_PROMPT,
_build_rules_section(rules),
_build_history_section(history),
]
if context:
parts.append(_build_context_section(context))
return '\n\n'.join(p for p in parts if p)
CORE_SYSTEM_PROMPT = """You are Fusion AI, an expert accounting co-pilot embedded in Odoo 19.
You assist with bank reconciliation, HST/GST management, AR/AP analysis, journal review,
month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing.
BEHAVIOUR:
- Use tools to query and act on Odoo data. Never invent financial figures.
- For Tier 1 tools: execute immediately and report results.
- For Tier 2 tools: execute and log. Inform the user what was done.
- For Tier 3 tools: propose the action with clear reasoning. The user must approve.
- When proposing a Tier 3 action, explain: what you want to do, why, the amounts involved, and your confidence level.
- Apply Fusion Rules (below) before general reasoning.
- Reference match history for patterns the user has approved/rejected before.
- Use Canadian English. Format monetary amounts with $ and two decimals.
- When you encounter ambiguity, ask clarifying questions rather than guessing.
RESPONSE FORMATTING:
- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML.
- Use **bold** for account names, amounts, and key terms.
- Use ## and ### headers to organize sections in longer responses.
- Use bullet lists (- item) for findings, issues, and action items.
- Use numbered lists (1. item) for sequential steps or ranked items.
- Use `code` for account codes, reference numbers, and technical IDs.
- Use --- horizontal rules to separate sections in long reports.
INTERACTIVE TABLES (fusion-table) — MANDATORY FOR ACTIONABLE DATA:
IMPORTANT: When a tool returns a list of records that the user could act on, you MUST use
a ```fusion-table block instead of a Markdown table. This is REQUIRED — never use plain
Markdown tables for actionable data. The fusion-table renders an interactive widget with
checkboxes, your AI recommendations per row, user input fields, and bulk action buttons.
YOU MUST USE fusion-table FOR: missing ITCs/tax (find_missing_itc_bills, find_missing_tax_invoices),
duplicate entries (find_duplicate_bills, find_duplicate_entries), overdue invoices (get_overdue_invoices),
unreconciled lines (get_unreconciled_bank_lines, get_unreconciled_receipts, get_unmatched_payments,
find_unreconciled_suspense), draft entries (find_draft_entries), wrong balances
(find_wrong_direction_balances), sequence gaps (find_sequence_gaps), wrong accounts
(find_wrong_account_entries), unpaid bills (get_unpaid_bills), and any other list where
the user needs to review, dismiss, flag, or create rules for individual rows.
USE REGULAR MARKDOWN TABLES ONLY FOR: P&L (get_profit_loss), balance sheet (get_balance_sheet),
trial balance (get_trial_balance), cash flow (get_cash_flow), period summaries, tax reports,
and any purely informational/read-only data where there is nothing to act on per row.
Format: wrap a JSON object in a ```fusion-table fenced code block:
```fusion-table
{
"mode": "interactive",
"title": "Descriptive Title",
"columns": ["Col1", "Col2", "Col3"],
"rows": [
{"id": 123, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "dismiss", "reason": "Brief explanation"}},
{"id": 456, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "flag", "reason": "Brief explanation"}}
],
"actions": ["dismiss", "flag", "create_rule"],
"source_tool": "tool_name_that_produced_this"
}
```
- "mode": "interactive" (actionable) or "readonly" (informational but structured)
- "id": the Odoo record ID (account.move id, account.bank.statement.line id, etc.)
- "recommendation.action": one of "dismiss", "flag", "create_rule"
- "recommendation.reason": short explanation of why you recommend this action
- "actions": which bulk action buttons to show
- "source_tool": the tool name that produced the data
- You MUST provide a recommendation for each row when using interactive mode.
- Format monetary amounts as "$X,XXX.XX" in cells.
- Always include the record ID so actions can target the correct Odoo record.
- Add a brief text summary before or after the fusion-table block for context.
LINKING TO ODOO RECORDS:
- When referencing specific records, include clickable Odoo links.
- Journal entries: [INV/2026/00123](/odoo/accounting/123) where 123 is the move ID.
- Partners: [Customer Name](/odoo/contacts/456) where 456 is the partner ID.
- Accounts: reference by code in bold, e.g. **1001 - Cash**.
- Bank statement lines: mention the date, reference, and amount clearly.
- When tool results include record IDs, always link them.
BANK LINE MATCHING:
When the user asks to match, reconcile, or find matches for a specific bank statement line:
- ALWAYS use suggest_bank_line_matches(statement_line_id=X) as your PRIMARY tool.
- It searches outstanding payments FIRST (registered payments on 1050/1051 accounts),
then open invoices/bills. Outstanding payments are the correct match — not raw invoices.
- Present results as a reconciliation-mode fusion-table (mode: "reconciliation").
- Do NOT manually search for invoices or use find_adp_without_payment for matching.
- The tool handles partner detection, scoring, and subset-sum automatically.
- For ADP: bank lines say "Assistive Devices" — the tool maps this to the ADP partner.
ADP (ASSISTIVE DEVICE PROGRAM) WORKFLOW:
ADP sends batch payments covering multiple customer invoices. The bank deposit label is
"Assistive Devices : Miscellaneous Payment". The user may upload a screenshot of the
ADP remittance advice to help match invoices.
When handling ADP payments:
1. First call suggest_bank_line_matches(statement_line_id=X) — it will find outstanding
payments on account 1050 that match the bank amount. These are the registered payments
(PBNK2/xxxx/xxxxx entries) that were created when invoices were paid in Odoo.
2. Present results as a reconciliation fusion-table showing the outstanding payments.
3. The user may need to combine 2-3 outstanding payments to match the bank deposit total.
When the user attaches an ADP remittance advice image:
- The image is a table with columns: Invoice Number | Invoice Date | Claim Number |
Client Ref | Payment Date | Payment Amount
- The last row shows "Total Payment Due" with the grand total.
- Extract ALL invoice numbers and their payment amounts from the image.
- Present a summary table of what you extracted for confirmation.
- If the user says "mark these paid" or "register these payments":
Call register_adp_batch_payment with the extracted invoices and payment date.
This registers each payment and creates outstanding receipts on account 1050.
Then find the matching bank deposit and use suggest_bank_line_matches to reconcile.
- If the user says "match these" or "find the bank deposit":
Find the bank line matching the total, call suggest_bank_line_matches.
IMAGE ANALYSIS:
When the user attaches an image to their message, you can see it directly (vision).
- Read all text, numbers, and tables from the image.
- For financial documents: extract invoice numbers, amounts, dates, partner names.
- For remittance advices: extract the line items and grand total.
- Always confirm what you extracted before taking action.
TOOL CALLING:
- Call tools by name with the required parameters.
- You may call multiple tools in sequence to gather data before proposing an action.
- Do not exceed the maximum tool calls per turn.
- When presenting tool results, format them richly with tables, bold amounts, and links.
"""
def _build_rules_section(rules):
if not rules:
return ''
lines = ['ACTIVE FUSION RULES:']
for rule in rules:
priority = 'ADMIN' if rule.created_by == 'admin' else 'AI'
tier = 'auto' if rule.approval_tier == 'auto' else 'needs-approval'
conf_str = f', confidence={rule.confidence_score:.0%}, uses={rule.total_uses}' if rule.total_uses > 0 else ''
lines.append(
f'- [{priority}/{tier}{conf_str}] {rule.name} ({rule.rule_type}): '
f'{rule.description or rule.match_logic or "No description"}'
)
if rule.match_logic:
logic_text = rule.match_logic[:500] # Prevent prompt bloat
lines.append(f' Match logic: {logic_text}')
return '\n'.join(lines)
def _build_history_section(history):
if not history:
return ''
lines = ['RECENT MATCH HISTORY (learn from these patterns):']
# A4: Don't hard-cap at 50 — the caller (_load_match_history) already
# respects the history_in_prompt config setting
for h in history:
status = h.decision
reason = ''
if h.rejection_reason:
reason = f' (reason: {h.rejection_reason})'
lines.append(
f'- {h.tool_name}: {status}{reason} '
f'[confidence={h.ai_confidence:.0%}]'
)
if h.ai_reasoning:
lines.append(f' Reasoning: {h.ai_reasoning[:200]}')
return '\n'.join(lines)
def _build_context_section(context):
if not context:
return ''
if isinstance(context, dict):
parts = ['CURRENT CONTEXT:']
for k, v in context.items():
parts.append(f'- {k}: {v}')
return '\n'.join(parts)
return f'CURRENT CONTEXT: {context}'

View File

@@ -0,0 +1,61 @@
import logging
from odoo import models, api
_logger = logging.getLogger(__name__)
class FusionAccountingScoring(models.AbstractModel):
_name = 'fusion.accounting.scoring'
_description = 'Fusion Accounting Confidence Scoring'
def calculate_confidence(self, tool_name, scenario_key=None):
domain = [('tool_name', '=', tool_name)]
if scenario_key:
domain.append(('tool_params', 'ilike', scenario_key))
history = self.env['fusion.accounting.match.history'].search(domain)
if not history:
return 0.0
decided = history.filtered(lambda h: h.decision in ('approved', 'rejected'))
if not decided:
return 0.0
approved = len(decided.filtered(lambda h: h.decision == 'approved'))
return approved / len(decided)
def check_promotions(self):
ICP = self.env['ir.config_parameter'].sudo()
threshold = float(ICP.get_param('fusion_accounting.tier3_threshold', '0.95'))
min_sample = int(ICP.get_param('fusion_accounting.tier3_min_sample', '30'))
rules = self.env['fusion.accounting.rule'].search([
('active', '=', True),
('approval_tier', '=', 'needs_approval'),
])
promoted = self.env['fusion.accounting.rule']
for rule in rules:
if rule.total_uses >= min_sample and rule.confidence_score >= threshold:
rule.approval_tier = 'auto'
promoted |= rule
_logger.info(
"Promoted rule '%s' to auto (confidence=%.2f, sample=%d)",
rule.name, rule.confidence_score, rule.total_uses,
)
return promoted
def get_tool_stats(self, tool_name=None):
domain = []
if tool_name:
domain.append(('tool_name', '=', tool_name))
history = self.env['fusion.accounting.match.history'].search(domain)
stats = {}
for h in history:
if h.tool_name not in stats:
stats[h.tool_name] = {
'total': 0, 'approved': 0, 'rejected': 0,
'pending': 0, 'auto': 0,
}
stats[h.tool_name]['total'] += 1
if h.decision in stats[h.tool_name]:
stats[h.tool_name][h.decision] += 1
return stats

View File

@@ -0,0 +1,23 @@
from .bank_reconciliation import TOOLS as BANK_RECON_TOOLS
from .hst_management import TOOLS as HST_TOOLS
from .accounts_receivable import TOOLS as AR_TOOLS
from .accounts_payable import TOOLS as AP_TOOLS
from .journal_review import TOOLS as JOURNAL_TOOLS
from .month_end import TOOLS as MONTH_END_TOOLS
from .payroll import TOOLS as PAYROLL_TOOLS
from .inventory import TOOLS as INVENTORY_TOOLS
from .adp import TOOLS as ADP_TOOLS
from .reporting import TOOLS as REPORTING_TOOLS
from .audit import TOOLS as AUDIT_TOOLS
from .financial_reports import TOOLS as FINANCIAL_REPORTS_TOOLS
from .asset_management import TOOLS as ASSET_MANAGEMENT_TOOLS
from .customer_followup import TOOLS as CUSTOMER_FOLLOWUP_TOOLS
TOOL_DISPATCH = {}
for tools_dict in [
BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS,
MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS,
REPORTING_TOOLS, AUDIT_TOOLS, FINANCIAL_REPORTS_TOOLS,
ASSET_MANAGEMENT_TOOLS, CUSTOMER_FOLLOWUP_TOOLS,
]:
TOOL_DISPATCH.update(tools_dict)

View File

@@ -0,0 +1,384 @@
import logging
from odoo import fields
from datetime import timedelta
_logger = logging.getLogger(__name__)
def get_ap_aging(env, params):
"""Return AP aging buckets. Routed through FollowupAdapter for tri-mode consistency."""
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'followup')
return adapter.aged_payables(company_id=env.company.id)
def find_duplicate_bills(env, params):
window_days = int(params.get('window_days', 7))
bills = env['account.move'].search([
('move_type', 'in', ('in_invoice', 'in_refund')),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
], order='partner_id, amount_total, date')
duplicates = []
prev = None
for bill in bills:
if prev and (
prev.partner_id == bill.partner_id
and abs(prev.amount_total - bill.amount_total) < 0.01
and abs((prev.date - bill.date).days) <= window_days
):
duplicates.append({
'bill_1': {'id': prev.id, 'name': prev.name, 'date': str(prev.date)},
'bill_2': {'id': bill.id, 'name': bill.name, 'date': str(bill.date)},
'partner': bill.partner_id.name,
'amount': bill.amount_total,
})
prev = bill
return {'count': len(duplicates), 'duplicates': duplicates[:20]}
def match_bill_to_po(env, params):
bill_id = int(params['bill_id'])
bill = env['account.move'].browse(bill_id)
if not bill.exists():
return {'error': 'Bill not found'}
matches = []
for line in bill.invoice_line_ids:
if line.purchase_line_id:
matches.append({
'bill_line': line.name or '',
'po': line.purchase_line_id.order_id.name,
'po_line': line.purchase_line_id.name,
'po_qty': line.purchase_line_id.product_qty,
'bill_qty': line.quantity,
'match': abs(line.quantity - line.purchase_line_id.product_qty) < 0.01,
})
return {'bill': bill.name, 'matches': matches, 'unmatched_lines': len(bill.invoice_line_ids) - len(matches)}
def get_unpaid_bills(env, params):
domain = [
('move_type', 'in', ('in_invoice', 'in_refund')),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('company_id', '=', env.company.id),
]
if params.get('partner_id'):
domain.append(('partner_id', '=', int(params['partner_id'])))
bills = env['account.move'].search(domain, order='invoice_date_due asc', limit=int(params.get('limit', 50)))
return {
'count': len(bills),
'total': sum(b.amount_residual for b in bills),
'bills': [{
'id': b.id, 'name': b.name,
'partner': b.partner_id.name if b.partner_id else '',
'amount_total': b.amount_total,
'amount_residual': b.amount_residual,
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
} for b in bills],
}
def verify_bill_taxes(env, params):
bill_id = int(params['bill_id'])
bill = env['account.move'].browse(bill_id)
if not bill.exists():
return {'error': 'Bill not found'}
issues = []
for line in bill.invoice_line_ids:
if line.product_id and not line.tax_ids:
issues.append({
'line': line.name or line.product_id.name,
'issue': 'No tax applied to product line',
})
return {'bill': bill.name, 'issues': issues, 'clean': len(issues) == 0}
def get_payment_schedule(env, params):
days_ahead = int(params.get('days_ahead', 30))
cutoff = fields.Date.today() + timedelta(days=days_ahead)
bills = env['account.move'].search([
('move_type', '=', 'in_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('invoice_date_due', '<=', cutoff),
('company_id', '=', env.company.id),
], order='invoice_date_due asc')
return {
'period': f'Next {days_ahead} days',
'total': sum(b.amount_residual for b in bills),
'bills': [{
'id': b.id, 'name': b.name,
'partner': b.partner_id.name if b.partner_id else '',
'amount_residual': b.amount_residual,
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
} for b in bills[:50]],
}
def search_partners(env, params):
"""Search for partners/vendors by name keyword."""
keyword = params.get('keyword', '')
if not keyword or len(keyword) < 2:
return {'error': 'Keyword must be at least 2 characters'}
domain = [('name', 'ilike', keyword), ('company_id', 'in', [env.company.id, False])]
if params.get('supplier_only'):
domain.append(('supplier_rank', '>', 0))
partners = env['res.partner'].search(domain, limit=int(params.get('limit', 20)))
return {
'count': len(partners),
'partners': [{
'id': p.id,
'name': p.name,
'supplier_rank': p.supplier_rank,
'customer_rank': p.customer_rank,
'vat': p.vat or '',
'email': p.email or '',
'phone': p.phone or '',
} for p in partners],
}
def find_similar_bank_lines(env, params):
"""Find past reconciled bank lines with similar description to suggest coding patterns.
Also checks vendor bill tax patterns if a partner is identified."""
keyword = params.get('keyword', '')
if not keyword or len(keyword) < 3:
return {'error': 'Keyword must be at least 3 characters'}
# Find reconciled bank lines with matching payment_ref
lines = env['account.bank.statement.line'].search([
('is_reconciled', '=', True),
('payment_ref', 'ilike', keyword),
('company_id', '=', env.company.id),
], order='date desc', limit=int(params.get('limit', 10)))
matches = []
found_partner_id = None
for line in lines:
move = line.move_id
if not move:
continue
expense_info = {'account_code': '', 'account_name': '', 'tax_applied': False, 'tax_amount': 0.0}
for ml in move.line_ids:
if ml.account_id.account_type in ('expense', 'expense_direct_cost', 'expense_depreciation'):
expense_info['account_code'] = ml.account_id.code
expense_info['account_name'] = ml.account_id.name
expense_info['tax_applied'] = bool(ml.tax_ids)
expense_info['tax_amount'] = sum(t.amount for t in ml.tax_ids) if ml.tax_ids else 0.0
break
if line.partner_id and not found_partner_id:
found_partner_id = line.partner_id.id
matches.append({
'id': line.id,
'date': str(line.date),
'payment_ref': line.payment_ref or '',
'amount': line.amount,
'partner': line.partner_id.name if line.partner_id else '',
'partner_id': line.partner_id.id if line.partner_id else None,
'expense_account': expense_info['account_code'],
'expense_account_name': expense_info['account_name'],
'tax_applied': expense_info['tax_applied'],
'tax_rate': expense_info['tax_amount'],
})
result = {
'keyword': keyword,
'count': len(matches),
'matches': matches,
'suggestion': matches[0] if matches else None,
}
# Check vendor tax profile cache first (fast), fall back to live query
partner_id = found_partner_id or (int(params['partner_id']) if params.get('partner_id') else None)
if partner_id:
profile = env['fusion.vendor.tax.profile'].search([
('partner_id', '=', partner_id),
('company_id', '=', env.company.id),
], limit=1)
if profile:
result['vendor_tax_pattern'] = {
'source': 'cached_profile',
'total_bills': profile.total_bills,
'bills_with_tax': profile.bills_with_hst,
'bills_no_tax': profile.bills_zero_rated,
'avg_tax_pct': profile.avg_tax_pct,
'tax_classification': profile.tax_classification,
'tax_note': profile.tax_note,
'primary_account_id': profile.primary_account_id.id if profile.primary_account_id else None,
'primary_account_code': profile.primary_account_code or '',
'is_foreign': profile.is_foreign,
'is_po_vendor': profile.is_po_vendor,
'po_count': profile.po_count,
}
else:
# No cached profile — live query for new/small vendors
bills = env['account.move'].search([
('move_type', '=', 'in_invoice'), ('state', '=', 'posted'),
('partner_id', '=', partner_id),
], order='date desc', limit=10)
tax_stats = {'source': 'live_query', 'total_bills': len(bills),
'bills_with_tax': 0, 'bills_no_tax': 0,
'avg_tax_pct': 0.0, 'tax_note': ''}
tax_pcts = []
for bill in bills:
if bill.amount_tax > 0.01:
tax_stats['bills_with_tax'] += 1
if bill.amount_untaxed > 0:
tax_pcts.append(round(bill.amount_tax / bill.amount_untaxed * 100, 2))
else:
tax_stats['bills_no_tax'] += 1
if tax_pcts:
tax_stats['avg_tax_pct'] = round(sum(tax_pcts) / len(tax_pcts), 2)
if tax_stats['total_bills'] > 0:
if tax_stats['bills_no_tax'] == tax_stats['total_bills']:
tax_stats['tax_note'] = 'This vendor NEVER charges HST. All bills are zero-rated.'
elif tax_stats['avg_tax_pct'] < 2.0 and tax_stats['bills_with_tax'] > 0:
tax_stats['tax_note'] = (
f'HST only on shipping (avg {tax_stats["avg_tax_pct"]}%). '
f'Do NOT apply HST to full amount.'
)
elif tax_stats['avg_tax_pct'] >= 12.0:
tax_stats['tax_note'] = f'Consistently charges HST at ~{tax_stats["avg_tax_pct"]}%.'
result['vendor_tax_pattern'] = tax_stats
return result
def create_vendor_bill(env, params):
"""[Tier 3] Create a vendor bill (account.move with move_type='in_invoice').
Requires user approval before execution."""
partner_id = int(params['partner_id'])
invoice_date = params.get('invoice_date', str(fields.Date.today()))
bill_lines = params.get('lines', [])
if not bill_lines:
return {'error': 'At least one invoice line is required'}
partner = env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': f'Partner not found: {partner_id}'}
invoice_line_vals = []
for line in bill_lines:
line_vals = {
'name': line.get('description', 'Expense'),
'price_unit': float(line.get('price_unit', 0)),
'quantity': float(line.get('quantity', 1)),
}
if line.get('account_id'):
line_vals['account_id'] = int(line['account_id'])
if line.get('tax_ids'):
line_vals['tax_ids'] = [(6, 0, [int(t) for t in line['tax_ids']])]
invoice_line_vals.append((0, 0, line_vals))
try:
bill = env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': partner_id,
'invoice_date': invoice_date,
'date': invoice_date,
'invoice_line_ids': invoice_line_vals,
'company_id': env.company.id,
})
if params.get('post', False):
bill.action_post()
return {
'status': 'created',
'bill_id': bill.id,
'bill_name': bill.name,
'partner': partner.name,
'amount_total': bill.amount_total,
'state': bill.state,
}
except Exception as e:
_logger.error("Failed to create vendor bill: %s", e)
return {'error': str(e)}
def register_bill_payment(env, params):
"""[Tier 3] Register payment on a posted vendor bill and optionally reconcile to bank line.
Requires user approval before execution."""
bill_id = int(params['bill_id'])
journal_id = int(params['journal_id'])
bill = env['account.move'].browse(bill_id)
if not bill.exists() or bill.state != 'posted':
return {'error': 'Bill not found or not posted'}
payment_date = params.get('payment_date', str(fields.Date.today()))
try:
# Use the payment register wizard
ctx = {
'active_model': 'account.move',
'active_ids': [bill_id],
}
wizard = env['account.payment.register'].with_context(**ctx).create({
'journal_id': journal_id,
'payment_date': payment_date,
})
# Optionally set amount if provided (otherwise defaults to bill amount)
if params.get('amount'):
wizard.amount = float(params['amount'])
payments = wizard.action_create_payments()
# Find the created payment
payment = None
if isinstance(payments, dict) and payments.get('res_id'):
payment = env['account.payment'].browse(payments['res_id'])
elif isinstance(payments, dict) and payments.get('domain'):
payment = env['account.payment'].search(payments['domain'], limit=1)
else:
# Fallback: find the latest payment for this bill
payment = env['account.payment'].search([
('partner_id', '=', bill.partner_id.id),
], order='create_date desc', limit=1)
result = {
'status': 'paid',
'bill_id': bill_id,
'bill_name': bill.name,
'payment_state': bill.payment_state,
}
if payment:
result['payment_id'] = payment.id
result['payment_name'] = payment.name
# Optionally reconcile to a bank statement line
if params.get('statement_line_id') and payment:
try:
st_line = env['account.bank.statement.line'].browse(int(params['statement_line_id']))
if st_line.exists() and not st_line.is_reconciled:
# Find the payment's move lines on the bank's outstanding account
pay_move_lines = payment.move_id.line_ids.filtered(
lambda l: l.account_id.reconcile and not l.reconciled
)
if pay_move_lines:
st_line.set_line_bank_statement_line(pay_move_lines.ids)
result['reconciled'] = True
result['statement_line_id'] = st_line.id
except Exception as e:
_logger.warning("Payment created but bank reconciliation failed: %s", e)
result['reconcile_error'] = str(e)
return result
except Exception as e:
_logger.error("Failed to register payment: %s", e)
return {'error': str(e)}
TOOLS = {
'get_ap_aging': get_ap_aging,
'find_duplicate_bills': find_duplicate_bills,
'match_bill_to_po': match_bill_to_po,
'get_unpaid_bills': get_unpaid_bills,
'verify_bill_taxes': verify_bill_taxes,
'get_payment_schedule': get_payment_schedule,
'search_partners': search_partners,
'find_similar_bank_lines': find_similar_bank_lines,
'create_vendor_bill': create_vendor_bill,
'register_bill_payment': register_bill_payment,
}

View File

@@ -0,0 +1,159 @@
import logging
_logger = logging.getLogger(__name__)
def get_ar_aging(env, params):
"""Return AR aging buckets. Routed through FollowupAdapter for tri-mode consistency."""
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'followup')
return adapter.aged_receivables(company_id=env.company.id)
def get_overdue_invoices(env, params):
"""Return overdue customer invoices. Routed through FollowupAdapter."""
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'followup')
rows = adapter.overdue_invoices(
days_overdue=int(params.get('min_days_overdue', 1)),
limit=int(params.get('limit', 50)),
)
return {
'count': len(rows),
'invoices': [{
'id': r['id'],
'name': r['name'],
'partner': r['partner_name'] or '',
'email': r['partner_email'],
'phone': r['partner_phone'],
'amount_total': r['amount_total'],
'amount_residual': r['amount_residual'],
'date_due': str(r['invoice_date_due']) if r['invoice_date_due'] else '',
'days_overdue': r['days_overdue'],
} for r in rows],
}
def get_partner_balance(env, params):
"""Get AR and AP balance for a partner. Accepts partner_id or partner_name."""
partner = None
if params.get('partner_id'):
partner = env['res.partner'].browse(int(params['partner_id']))
elif params.get('partner_name'):
partner = env['res.partner'].search([
('name', 'ilike', params['partner_name']),
], limit=1)
if not partner or not partner.exists():
return {'error': f"Partner not found: {params.get('partner_name', params.get('partner_id', '?'))}"}
# AR balance (receivable)
ar_amls = env['account.move.line'].search([
('partner_id', '=', partner.id),
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
])
ar_balance = sum(aml.amount_residual for aml in ar_amls)
# AP balance (payable)
ap_amls = env['account.move.line'].search([
('partner_id', '=', partner.id),
('account_id.account_type', '=', 'liability_payable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
])
ap_balance = sum(aml.amount_residual for aml in ap_amls)
open_items = [{
'id': aml.id,
'move_name': aml.move_id.name,
'ref': aml.ref or '',
'date': str(aml.date),
'amount_residual': aml.amount_residual,
'type': 'receivable' if aml.account_id.account_type == 'asset_receivable' else 'payable',
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
} for aml in (ar_amls | ap_amls)[:30]]
return {
'partner': partner.name,
'partner_id': partner.id,
'ar_balance': ar_balance,
'ap_balance': ap_balance,
'net_balance': ar_balance + ap_balance,
'they_owe_us': ar_balance if ar_balance > 0 else 0,
'we_owe_them': abs(ap_balance) if ap_balance < 0 else 0,
'open_items': open_items,
}
def send_followup(env, params):
"""Send a follow-up to a partner. Routed through FollowupAdapter so the
Enterprise-only execute_followup path is isolated behind the adapter."""
from ..data_adapters import get_adapter
partner_id = int(params['partner_id'])
options = {
'partner_id': partner_id,
'email': params.get('send_email', False),
'print': params.get('print_letter', False),
'sms': False,
}
if params.get('email_subject'):
options['email_subject'] = params['email_subject']
if params.get('body'):
options['body'] = params['body']
adapter = get_adapter(env, 'followup')
return adapter.send_followup(partner_id=partner_id, options=options)
def get_followup_report(env, params):
"""Return the follow-up report HTML for a partner. Routed through FollowupAdapter."""
from ..data_adapters import get_adapter
partner_id = int(params['partner_id'])
adapter = get_adapter(env, 'followup')
return adapter.followup_report_html(partner_id=partner_id)
def reconcile_payment_to_invoice(env, params):
move_line_ids = [int(x) for x in params['move_line_ids']]
amls = env['account.move.line'].browse(move_line_ids)
if len(amls) < 2:
return {'error': 'Need at least 2 journal items to reconcile'}
amls.reconcile()
return {
'status': 'reconciled',
'move_line_ids': move_line_ids,
}
def get_unmatched_payments(env, params):
domain = [
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('move_id.payment_id', '!=', False),
('company_id', '=', env.company.id),
]
amls = env['account.move.line'].search(domain, order='date desc')
return {
'count': len(amls),
'payments': [{
'id': aml.id,
'date': str(aml.date),
'ref': aml.ref or aml.move_id.name,
'partner': aml.partner_id.name if aml.partner_id else '',
'amount': abs(aml.amount_residual),
} for aml in amls[:50]],
}
TOOLS = {
'get_ar_aging': get_ar_aging,
'get_overdue_invoices': get_overdue_invoices,
'get_partner_balance': get_partner_balance,
'send_followup': send_followup,
'get_followup_report': get_followup_report,
'reconcile_payment_to_invoice': reconcile_payment_to_invoice,
'get_unmatched_payments': get_unmatched_payments,
}

View File

@@ -0,0 +1,237 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_adp_receivable_aging(env, params):
accounts = env['account.account'].search([
('code', '=like', '1101%'),
('company_ids', 'in', env.company.id),
])
today = fields.Date.today()
amls = env['account.move.line'].search([
('account_id', 'in', accounts.ids),
('reconciled', '=', False),
('parent_state', '=', 'posted'),
])
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
for aml in amls:
amt = abs(aml.amount_residual)
if not aml.date_maturity or aml.date_maturity >= today:
buckets['current'] += amt
else:
days = (today - aml.date_maturity).days
if days <= 30:
buckets['1_30'] += amt
elif days <= 60:
buckets['31_60'] += amt
elif days <= 90:
buckets['61_90'] += amt
else:
buckets['90_plus'] += amt
return {'total': sum(buckets.values()), 'buckets': buckets}
def match_adp_payment_to_invoice(env, params):
move_line_ids = [int(x) for x in params['move_line_ids']]
amls = env['account.move.line'].browse(move_line_ids).exists()
if len(amls) < 2:
return {'error': 'Need at least 2 existing journal items to reconcile'}
amls.reconcile()
return {'status': 'matched', 'move_line_ids': amls.ids}
def verify_adp_split(env, params):
invoice_id = int(params['invoice_id'])
invoice = env['account.move'].browse(invoice_id)
if not invoice.exists():
return {'error': 'Invoice not found'}
lines = invoice.invoice_line_ids
total = invoice.amount_untaxed
return {
'invoice': invoice.name,
'total_untaxed': total,
'total_with_tax': invoice.amount_total,
'lines': [{'name': l.name, 'subtotal': l.price_subtotal, 'total': l.price_total} for l in lines],
'balanced': abs(sum(l.price_subtotal for l in lines) - total) < 0.01,
}
def find_adp_without_payment(env, params):
adp_partner = env['res.partner'].search([('name', 'ilike', 'ADP')], limit=1)
if not adp_partner:
return {'status': 'info', 'message': 'No ADP partner found in the system.'}
invoices = env['account.move'].search([
('partner_id', '=', adp_partner.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
])
return {
'count': len(invoices),
'invoices': [{
'id': inv.id, 'name': inv.name,
'amount': inv.amount_residual, 'date': str(inv.date),
} for inv in invoices[:20]],
}
def get_adp_summary(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
accounts = env['account.account'].search([
('code', '=like', '1101%'), ('company_ids', 'in', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
('parent_state', '=', 'posted'),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
lines = env['account.move.line'].search(domain)
total_debit = sum(l.debit for l in lines)
total_credit = sum(l.credit for l in lines)
return {
'period': f'{date_from or "all"} to {date_to or "now"}',
'billed': total_debit,
'collected': total_credit,
'outstanding': total_debit - total_credit,
}
def register_adp_batch_payment(env, params):
"""Register payments for a batch of ADP invoices from a remittance advice.
Takes a list of invoice numbers with payment amounts and a payment date.
Registers a payment for each invoice via Odoo's payment wizard, which
creates outstanding receipt entries (PBNK2) on account 1050.
After calling this, use suggest_bank_line_matches on the bank deposit line
to match the outstanding receipts against the bank line.
"""
invoices_data = params.get('invoices', [])
payment_date = params.get('payment_date')
journal_id = int(params.get('journal_id', 50)) # Default Scotia Current
if not invoices_data:
return {'error': 'No invoices provided'}
if not payment_date:
return {'error': 'payment_date is required (YYYY-MM-DD)'}
ADP_PARTNER_ID = 3421 # ADP (Assistive Device Program)
results = []
total_paid = 0.0
errors = []
for inv_data in invoices_data:
inv_number = str(inv_data.get('invoice_number', '')).strip()
amount = float(inv_data.get('amount', 0))
if not inv_number or not amount:
errors.append(f"Skipped: missing invoice_number or amount in {inv_data}")
continue
# Find the invoice by name/number
invoice = env['account.move'].search([
('name', 'ilike', inv_number),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
], limit=1)
if not invoice:
# Try without leading zeros or with different format
invoice = env['account.move'].search([
('name', '=like', f'%{inv_number}'),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
], limit=1)
if not invoice:
errors.append(f"Invoice {inv_number} not found")
continue
if invoice.payment_state == 'paid':
results.append({
'invoice': inv_number,
'status': 'already_paid',
'move_id': invoice.id,
})
continue
# Check if amount matches residual (allow partial)
if amount > invoice.amount_residual + 0.01:
errors.append(
f"Invoice {inv_number}: payment ${amount:.2f} exceeds "
f"residual ${invoice.amount_residual:.2f}"
)
continue
# Register payment via the payment wizard
try:
payment_vals = {
'payment_type': 'inbound',
'partner_type': 'customer',
'partner_id': invoice.partner_id.id or ADP_PARTNER_ID,
'amount': amount,
'date': payment_date,
'journal_id': journal_id,
'ref': f'ADP Remittance - {inv_number}',
}
# Use the payment register wizard
ctx = {
'active_model': 'account.move',
'active_ids': [invoice.id],
}
wizard = env['account.payment.register'].with_context(**ctx).create({
'payment_date': payment_date,
'amount': amount,
'journal_id': journal_id,
'payment_method_line_id': env['account.payment.method.line'].search([
('journal_id', '=', journal_id),
('payment_type', '=', 'inbound'),
], limit=1).id,
})
wizard.action_create_payments()
results.append({
'invoice': inv_number,
'status': 'paid',
'amount': amount,
'move_id': invoice.id,
'move_name': invoice.name,
})
total_paid += amount
except Exception as e:
_logger.warning("ADP payment failed for %s: %s", inv_number, e)
errors.append(f"Invoice {inv_number}: payment failed — {e}")
env.cr.commit()
return {
'status': 'completed',
'paid_count': len([r for r in results if r.get('status') == 'paid']),
'already_paid_count': len([r for r in results if r.get('status') == 'already_paid']),
'total_paid': total_paid,
'results': results,
'errors': errors,
'message': (
f"Registered payments for {len([r for r in results if r.get('status') == 'paid'])} invoices "
f"totalling ${total_paid:,.2f}. "
+ (f"{len(errors)} errors." if errors else "No errors.")
+ " Now use suggest_bank_line_matches to match the bank deposit."
),
}
TOOLS = {
'get_adp_receivable_aging': get_adp_receivable_aging,
'match_adp_payment_to_invoice': match_adp_payment_to_invoice,
'verify_adp_split': verify_adp_split,
'find_adp_without_payment': find_adp_without_payment,
'get_adp_summary': get_adp_summary,
'register_adp_batch_payment': register_adp_batch_payment,
}

View File

@@ -0,0 +1,77 @@
"""Fusion-engine-routed AI tools for asset management."""
import logging
_logger = logging.getLogger(__name__)
def fusion_list_assets(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'assets')
return adapter.list_assets(
state=params.get('state'),
limit=int(params.get('limit', 50)),
company_id=int(params['company_id']) if params.get('company_id') else env.company.id,
)
def fusion_get_asset_detail(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
Asset = env['fusion.asset']
asset = Asset.browse(int(params['asset_id']))
if not asset.exists():
return {'error': 'Asset not found'}
return {
'asset': {
'id': asset.id, 'name': asset.name, 'state': asset.state,
'cost': asset.cost, 'book_value': asset.book_value,
'total_depreciated': asset.total_depreciated,
'method': asset.method, 'useful_life_years': asset.useful_life_years,
},
'depreciation_count': len(asset.depreciation_line_ids),
}
def fusion_compute_asset_schedule(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
asset = env['fusion.asset'].browse(int(params['asset_id']))
return env['fusion.asset.engine'].compute_depreciation_schedule(
asset, recompute=bool(params.get('recompute', False)),
)
def fusion_dispose_asset(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'assets')
return adapter.dispose_asset(
asset_id=int(params['asset_id']),
sale_amount=float(params.get('sale_amount', 0)),
disposal_type=params.get('disposal_type', 'sale'),
)
def fusion_suggest_asset_useful_life(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'assets')
return adapter.suggest_useful_life(
description=params.get('description', ''),
amount=float(params['amount']) if params.get('amount') else None,
partner_name=params.get('partner_name'),
)
TOOLS = {
'fusion_list_assets': fusion_list_assets,
'fusion_get_asset_detail': fusion_get_asset_detail,
'fusion_compute_asset_schedule': fusion_compute_asset_schedule,
'fusion_dispose_asset': fusion_dispose_asset,
'fusion_suggest_asset_useful_life': fusion_suggest_asset_useful_life,
}

View File

@@ -0,0 +1,164 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def audit_posted_entry(env, params):
move_id = int(params['move_id'])
move = env['account.move'].browse(move_id)
if not move.exists():
return {'error': 'Entry not found'}
issues = []
total_debit = sum(l.debit for l in move.line_ids)
total_credit = sum(l.credit for l in move.line_ids)
if abs(total_debit - total_credit) > 0.01:
issues.append({'severity': 'critical', 'issue': f'Unbalanced entry: debit={total_debit}, credit={total_credit}'})
for line in move.line_ids:
if not line.account_id:
issues.append({'severity': 'critical', 'issue': f'Line missing account: {line.name}'})
if not move.line_ids:
issues.append({'severity': 'warning', 'issue': 'Entry has no lines'})
return {
'move': move.name, 'date': str(move.date),
'issues': issues, 'clean': len(issues) == 0,
}
def audit_account_balances(env, params):
from .journal_review import find_wrong_direction_balances
return find_wrong_direction_balances(env, params)
def audit_tax_compliance(env, params):
from .hst_management import find_missing_tax_invoices, find_missing_itc_bills
invoices = find_missing_tax_invoices(env, params)
bills = find_missing_itc_bills(env, params)
return {
'missing_tax_invoices': invoices.get('missing_tax_count', 0),
'missing_itc_bills': bills.get('missing_itc_count', 0),
'total_issues': invoices.get('missing_tax_count', 0) + bills.get('missing_itc_count', 0),
}
def audit_reconciliation_integrity(env, params):
from .journal_review import verify_reconciliation_integrity
return verify_reconciliation_integrity(env, params)
def check_hash_chain(env, params):
from .month_end import run_hash_integrity_check
return run_hash_integrity_check(env, params)
def check_sequence_gaps(env, params):
from .journal_review import find_sequence_gaps
return find_sequence_gaps(env, params)
def flag_entry(env, params):
move_id = int(params['move_id'])
flag = params.get('flag', 'Review Required')
recommendation = params.get('recommendation', '')
move = env['account.move'].browse(move_id)
if not move.exists():
return {'error': 'Entry not found'}
body = f'<strong>🏴 {flag}</strong><br/>{recommendation}'
move.message_post(body=body, message_type='comment', subtype_xmlid='mail.mt_note')
return {'status': 'flagged', 'move': move.name, 'flag': flag}
def get_audit_status(env, params):
try:
AuditStatus = env['account.audit.account.status']
except KeyError:
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
statuses = AuditStatus.search([])
return {
'statuses': [{
'id': s.id,
'account': s.account_id.name,
'status': s.status,
'audit': s.audit_id.display_name if s.audit_id else '',
} for s in statuses[:50]],
}
def set_audit_status(env, params):
try:
AuditStatus = env['account.audit.account.status']
except KeyError:
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
status_id = int(params['status_id'])
new_status = params['status']
rec = AuditStatus.browse(status_id)
if not rec.exists():
return {'error': 'Audit status record not found'}
rec.status = new_status
return {'status': 'updated', 'id': status_id, 'new_status': new_status}
def get_audit_trail(env, params):
move_id = int(params['move_id'])
move = env['account.move'].browse(move_id)
if not move.exists():
return {'error': 'Entry not found'}
messages = env['mail.message'].search([
('model', '=', 'account.move'),
('res_id', '=', move_id),
], order='date desc', limit=20)
return {
'move': move.name,
'messages': [{
'date': str(m.date),
'author': m.author_id.name if m.author_id else '',
'body': m.body or '',
'type': m.message_type,
} for m in messages],
}
def run_full_audit(env, params):
results = {}
results['account_balances'] = audit_account_balances(env, params)
results['tax_compliance'] = audit_tax_compliance(env, params)
results['reconciliation'] = audit_reconciliation_integrity(env, params)
results['hash_chain'] = check_hash_chain(env, params)
results['sequence_gaps'] = check_sequence_gaps(env, params)
total_issues = 0
for key, val in results.items():
total_issues += val.get('count', 0) + val.get('total_issues', 0)
score = max(0, 100 - total_issues * 5)
return {
'score': min(100, score),
'total_issues': total_issues,
'details': results,
}
def get_audit_report(env, params):
audit = run_full_audit(env, params)
report_lines = [f"Audit Score: {audit['score']}/100", f"Total Issues: {audit['total_issues']}", '']
for domain, detail in audit.get('details', {}).items():
report_lines.append(f"--- {domain.replace('_', ' ').title()} ---")
count = detail.get('count', detail.get('total_issues', 0))
report_lines.append(f" Issues: {count}")
return {'report': '\n'.join(report_lines), 'score': audit['score']}
TOOLS = {
'audit_posted_entry': audit_posted_entry,
'audit_account_balances': audit_account_balances,
'audit_tax_compliance': audit_tax_compliance,
'audit_reconciliation_integrity': audit_reconciliation_integrity,
'check_hash_chain': check_hash_chain,
'check_sequence_gaps': check_sequence_gaps,
'flag_entry': flag_entry,
'get_audit_status': get_audit_status,
'set_audit_status': set_audit_status,
'get_audit_trail': get_audit_trail,
'run_full_audit': run_full_audit,
'get_audit_report': get_audit_report,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
"""Fusion-engine-routed AI tools for customer follow-ups.
These tools are exposed through TOOL_DISPATCH and let the assistant query
the customer follow-up engine via natural language. All tools degrade
gracefully when fusion_accounting_followup is not installed.
"""
import logging
_logger = logging.getLogger(__name__)
def fusion_list_overdue(env, params):
"""List partners with overdue invoices, sorted by risk."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'followup')
return adapter.list_overdue(
status=params.get('status'),
limit=int(params.get('limit', 50)),
company_id=int(params['company_id'])
if params.get('company_id') else env.company.id,
)
def fusion_get_partner_followup_detail(env, params):
"""Detailed follow-up state for a single partner: aging, risk, history."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
Partner = env['res.partner']
partner = Partner.browse(int(params['partner_id']))
if not partner.exists():
return {'error': 'Partner not found'}
engine = env['fusion.followup.engine']
overdue = engine.get_overdue_for_partner(partner)
history = engine.snapshot_followup_history(partner, limit=10)
return {
'partner_id': partner.id,
'partner_name': partner.name,
'overdue': overdue,
'history': history,
}
def fusion_generate_followup_text(env, params):
"""Generate (or fall back to template) follow-up subject + body."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
generate_followup_text,
)
return generate_followup_text(
env,
partner_name=params.get('partner_name', ''),
total_overdue=float(params.get('total_overdue', 0)),
currency_code=params.get('currency_code', 'USD'),
longest_overdue_days=int(params.get('longest_overdue_days', 0)),
tone=params.get('tone', 'gentle'),
invoice_count=int(params.get('invoice_count', 0)),
)
def fusion_send_followup(env, params):
"""Send a follow-up email via the engine (creates a fusion.followup.run)."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'followup')
return adapter.send_followup(
partner_id=int(params['partner_id']),
level_id=int(params['level_id']) if params.get('level_id') else None,
force=bool(params.get('force', False)),
)
def fusion_get_partner_risk_score(env, params):
"""Compute and return the payment-risk score + drivers for a partner."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
partner = env['res.partner'].browse(int(params['partner_id']))
if not partner.exists():
return {'error': 'Partner not found'}
overdue = env['fusion.followup.engine'].get_overdue_for_partner(partner)
return {
'partner_id': partner.id,
'partner_name': partner.name,
'risk': overdue['risk'],
}
TOOLS = {
'fusion_list_overdue': fusion_list_overdue,
'fusion_get_partner_followup_detail': fusion_get_partner_followup_detail,
'fusion_generate_followup_text': fusion_generate_followup_text,
'fusion_send_followup': fusion_send_followup,
'fusion_get_partner_risk_score': fusion_get_partner_risk_score,
}

View File

@@ -0,0 +1,127 @@
"""Fusion-engine-routed AI tools for financial reports.
These 5 tools route through ReportsAdapter's Phase-2 methods
(run_fusion_report / get_anomalies / get_commentary), which in turn
call fusion.report.engine when fusion_accounting_reports is installed.
"""
import logging
_logger = logging.getLogger(__name__)
def _company_id(env, params):
raw = params.get('company_id')
return int(raw) if raw else env.company.id
def fusion_run_report(env, params):
"""Run a fusion financial report.
Params: report_type (pnl|balance_sheet|trial_balance|general_ledger),
date_from, date_to, comparison (none|previous_period|previous_year),
optional company_id.
"""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
result = adapter.run_fusion_report(
report_type=params.get('report_type'),
date_from=params.get('date_from'),
date_to=params.get('date_to'),
comparison=params.get('comparison', 'none'),
company_id=_company_id(env, params),
)
rows = result.get('rows', [])
return {
'report_type': params.get('report_type'),
'period': result.get('period'),
'comparison_period': result.get('comparison_period'),
'row_count': len(rows),
'rows': rows,
}
def fusion_get_anomalies(env, params):
"""Detect variance anomalies in a report."""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
result = adapter.get_anomalies(
report_type=params.get('report_type'),
date_from=params.get('date_from'),
date_to=params.get('date_to'),
comparison=params.get('comparison', 'previous_year'),
company_id=_company_id(env, params),
)
anomalies = result.get('anomalies', [])
return {'count': len(anomalies), 'anomalies': anomalies}
def fusion_generate_commentary(env, params):
"""Generate AI commentary for a report."""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
result = adapter.get_commentary(
report_type=params.get('report_type'),
date_from=params.get('date_from'),
date_to=params.get('date_to'),
comparison=params.get('comparison', 'none'),
company_id=_company_id(env, params),
)
return {
'summary': result.get('summary', ''),
'highlights': result.get('highlights', []),
'concerns': result.get('concerns', []),
'next_actions': result.get('next_actions', []),
}
def fusion_drill_down_report_line(env, params):
"""Drill from a report line into the underlying journal items."""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from datetime import datetime
from odoo.addons.fusion_accounting_reports.services.date_periods import (
Period,
)
date_from = params['date_from']
date_to = params['date_to']
if isinstance(date_from, str):
date_from = datetime.strptime(date_from, '%Y-%m-%d').date()
if isinstance(date_to, str):
date_to = datetime.strptime(date_to, '%Y-%m-%d').date()
period = Period(date_from=date_from, date_to=date_to, label='drill')
engine = env['fusion.report.engine']
rows = engine.drill_down(
account_id=int(params['account_id']),
period=period,
company_id=_company_id(env, params),
)
return {'count': len(rows), 'rows': rows}
def fusion_compare_periods(env, params):
"""Run a report with period comparison side-by-side.
Defaults comparison to 'previous_year' so callers get a comparison
column without specifying it explicitly.
"""
return fusion_run_report(env, {
**params,
'comparison': params.get('comparison', 'previous_year'),
})
TOOLS = {
'fusion_run_report': fusion_run_report,
'fusion_get_anomalies': fusion_get_anomalies,
'fusion_generate_commentary': fusion_generate_commentary,
'fusion_drill_down_report_line': fusion_drill_down_report_line,
'fusion_compare_periods': fusion_compare_periods,
}

View File

@@ -0,0 +1,290 @@
import logging
_logger = logging.getLogger(__name__)
def calculate_hst_balance(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
base_domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
base_domain.append(('date', '>=', date_from))
if date_to:
base_domain.append(('date', '<=', date_to))
# Odoo 19 Enterprise: account.account may not have company_id field
# (shared chart of accounts). Use try/except to handle both cases.
try:
collected_accounts = env['account.account'].search([
('code', '=like', '2005%'), ('company_ids', 'in', env.company.id),
])
itc_accounts = env['account.account'].search([
('code', '=like', '2006%'), ('company_ids', 'in', env.company.id),
])
except Exception:
collected_accounts = env['account.account'].search([
('code', '=like', '2005%'),
])
itc_accounts = env['account.account'].search([
('code', '=like', '2006%'),
])
collected_lines = env['account.move.line'].search(
base_domain + [('account_id', 'in', collected_accounts.ids)]
)
itc_lines = env['account.move.line'].search(
base_domain + [('account_id', 'in', itc_accounts.ids)]
)
hst_collected = abs(sum(l.balance for l in collected_lines))
itcs = abs(sum(l.balance for l in itc_lines))
return {
'hst_collected': hst_collected,
'input_tax_credits': itcs,
'net_hst': hst_collected - itcs,
'status': 'owing' if (hst_collected - itcs) > 0 else 'refund',
'period': f'{date_from or "all"} to {date_to or "now"}',
}
def get_tax_report(env, params):
"""Route through ReportsAdapter for tri-mode consistency. The Community
fallback returns an error dict explaining the report is Enterprise-only."""
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
return adapter.run_report(
ref_id=params.get('report_ref', 'account.generic_tax_report'),
date_from=params.get('date_from'),
date_to=params.get('date_to'),
limit=50,
)
def find_missing_tax_invoices(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', 'in', ('out_invoice', 'out_refund')),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
invoices = env['account.move'].search(domain)
missing = invoices.filtered(
lambda inv: not any(line.tax_ids for line in inv.invoice_line_ids)
)
return {
'total_invoices': len(invoices),
'missing_tax_count': len(missing),
'invoices': [{
'id': inv.id,
'name': inv.name,
'partner': inv.partner_id.name if inv.partner_id else '',
'amount_total': inv.amount_total,
'date': str(inv.date),
} for inv in missing[:30]],
}
def find_missing_itc_bills(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', 'in', ('in_invoice', 'in_refund')),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
bills = env['account.move'].search(domain)
missing = bills.filtered(
lambda b: not any(line.tax_ids for line in b.invoice_line_ids)
)
return {
'total_bills': len(bills),
'missing_itc_count': len(missing),
'bills': [{
'id': b.id,
'name': b.name,
'partner': b.partner_id.name if b.partner_id else '',
'amount_total': b.amount_total,
'date': str(b.date),
} for b in missing[:30]],
}
def get_tax_return_status(env, params):
try:
AccountReturn = env['account.return']
except KeyError:
return {'error': 'Tax return model (account.return) is not available. The account_tax_report or related Enterprise module may not be installed.'}
returns = AccountReturn.search([
('company_id', '=', env.company.id),
], order='date_start desc', limit=10)
return {
'returns': [{
'id': r.id,
'name': r.display_name,
'date_start': str(r.date_start) if hasattr(r, 'date_start') else '',
'date_end': str(r.date_end) if hasattr(r, 'date_end') else '',
'state': r.state if hasattr(r, 'state') else '',
} for r in returns],
}
def generate_tax_return(env, params):
try:
AccountReturn = env['account.return']
except KeyError:
return {'error': 'Tax return model (account.return) is not available.'}
try:
AccountReturn._generate_or_refresh_all_returns(
company=env.company
)
return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'}
except Exception as e:
return {'error': str(e)}
def validate_tax_return(env, params):
try:
AccountReturn = env['account.return']
except KeyError:
return {'error': 'Tax return model (account.return) is not available.'}
return_id = int(params['return_id'])
tax_return = AccountReturn.browse(return_id)
if not tax_return.exists():
return {'error': 'Tax return not found'}
try:
tax_return.action_validate()
return {'status': 'validated', 'return_id': return_id}
except Exception as e:
return {'error': str(e)}
def create_expense_entry(env, params):
"""[Tier 3] Create a direct GL expense entry in the Misc journal with optional HST split.
This is the 'old school' way of recording expenses without a formal vendor bill.
Requires user approval before execution."""
date = params.get('date', str(env['account.move']._fields['date'].default(env['account.move'])))
description = params.get('description', 'Expense')
expense_account_id = int(params['expense_account_id'])
amount = abs(float(params['amount']))
has_hst = params.get('has_hst', False)
bank_journal_id = int(params.get('bank_journal_id', 0))
# Find the MISC journal
misc_journal = env['account.journal'].search([
('code', '=', 'MISC'), ('company_id', '=', env.company.id),
], limit=1)
if not misc_journal:
return {'error': 'Miscellaneous Operations journal (MISC) not found'}
expense_account = env['account.account'].browse(expense_account_id)
if not expense_account.exists():
return {'error': f'Expense account not found: {expense_account_id}'}
# Determine credit account (bank outstanding or AP)
credit_account = None
if bank_journal_id:
bank_journal = env['account.journal'].browse(bank_journal_id)
if bank_journal.exists():
# Use the bank journal's default debit/credit account
credit_account = (bank_journal.default_account_id
or bank_journal.company_id.account_journal_payment_credit_account_id)
if not credit_account:
# Fallback to AP account
credit_account = env['account.account'].search([
('account_type', '=', 'liability_payable'),
('company_ids', 'in', env.company.id),
], limit=1)
if not credit_account:
return {'error': 'Could not determine credit account for the expense entry'}
line_ids = []
if has_hst:
# Split: net expense + 13% HST ITC
hst_rate = 0.13
net_amount = round(amount / (1 + hst_rate), 2)
hst_amount = round(amount - net_amount, 2)
# Find HST ITC account (2006%)
itc_account = env['account.account'].search([
('code', '=like', '2006%'),
], limit=1)
if not itc_account:
# Fallback: use the HST purchase tax account
hst_tax = env['account.tax'].search([
('type_tax_use', '=', 'purchase'), ('amount', '=', 13.0),
('company_id', '=', env.company.id),
], limit=1)
if hst_tax and hst_tax.invoice_repartition_line_ids:
for rep in hst_tax.invoice_repartition_line_ids:
if rep.repartition_type == 'tax' and rep.account_id:
itc_account = rep.account_id
break
if not itc_account:
return {'error': 'HST ITC account (2006) not found'}
line_ids = [
(0, 0, {'name': description, 'account_id': expense_account_id,
'debit': net_amount, 'credit': 0.0}),
(0, 0, {'name': f'HST ITC - {description}', 'account_id': itc_account.id,
'debit': hst_amount, 'credit': 0.0}),
(0, 0, {'name': description, 'account_id': credit_account.id,
'debit': 0.0, 'credit': amount}),
]
else:
# Simple: debit expense / credit bank
line_ids = [
(0, 0, {'name': description, 'account_id': expense_account_id,
'debit': amount, 'credit': 0.0}),
(0, 0, {'name': description, 'account_id': credit_account.id,
'debit': 0.0, 'credit': amount}),
]
try:
move = env['account.move'].create({
'move_type': 'entry',
'journal_id': misc_journal.id,
'date': date,
'ref': description,
'line_ids': line_ids,
'company_id': env.company.id,
})
move.action_post()
return {
'status': 'posted',
'move_id': move.id,
'move_name': move.name,
'amount': amount,
'has_hst': has_hst,
'hst_amount': round(amount - amount / 1.13, 2) if has_hst else 0.0,
}
except Exception as e:
_logger.error("Failed to create expense entry: %s", e)
return {'error': str(e)}
TOOLS = {
'calculate_hst_balance': calculate_hst_balance,
'get_tax_report': get_tax_report,
'find_missing_tax_invoices': find_missing_tax_invoices,
'find_missing_itc_bills': find_missing_itc_bills,
'get_tax_return_status': get_tax_return_status,
'generate_tax_return': generate_tax_return,
'validate_tax_return': validate_tax_return,
'create_expense_entry': create_expense_entry,
}

View File

@@ -0,0 +1,113 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_stock_valuation(env, params):
accounts = env['account.account'].search([
('code', '=like', '1069%'),
('company_ids', 'in', env.company.id),
])
result = []
for acct in accounts:
balance = sum(env['account.move.line'].search([
('account_id', '=', acct.id),
('parent_state', '=', 'posted'),
]).mapped('balance'))
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
return {'accounts': result, 'total': sum(r['balance'] for r in result)}
def get_price_differences(env, params):
accounts = env['account.account'].search([
('code', '=like', '5010%'),
('company_ids', 'in', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if params.get('date_from'):
domain.append(('date', '>=', params['date_from']))
if params.get('date_to'):
domain.append(('date', '<=', params['date_to']))
lines = env['account.move.line'].search(domain, order='date desc', limit=50)
return {
'total': sum(l.balance for l in lines),
'entries': [{
'id': l.id, 'date': str(l.date),
'move': l.move_id.name, 'amount': l.balance,
'partner': l.partner_id.name if l.partner_id else '',
} for l in lines],
}
def get_cogs_ratio_by_category(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
base_domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
base_domain.append(('date', '>=', date_from))
if date_to:
base_domain.append(('date', '<=', date_to))
revenue_lines = env['account.move.line'].search(
base_domain + [('account_id.account_type', '=', 'income')]
)
cogs_lines = env['account.move.line'].search(
base_domain + [('account_id.account_type', '=', 'expense_direct_cost')]
)
revenue = abs(sum(l.balance for l in revenue_lines))
cogs = abs(sum(l.balance for l in cogs_lines))
ratio = (cogs / revenue * 100) if revenue else 0
return {'revenue': revenue, 'cogs': cogs, 'ratio_pct': round(ratio, 2)}
def find_unusual_adjustments(env, params):
threshold = float(params.get('threshold', 1000))
domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
('account_id.account_type', '=', 'expense_direct_cost'),
]
lines = env['account.move.line'].search(domain)
unusual = lines.filtered(lambda l: abs(l.balance) > threshold)
return {
'count': len(unusual),
'adjustments': [{
'id': l.id, 'date': str(l.date), 'move': l.move_id.name,
'amount': l.balance, 'name': l.name or '',
} for l in unusual[:20]],
}
def get_inventory_turnover(env, params):
from .reporting import get_profit_loss
pl = get_profit_loss(env, params)
stock = get_stock_valuation(env, params)
avg_inventory = stock.get('total', 0)
cogs = 0
for line in pl.get('lines', []):
if 'cost' in line.get('name', '').lower():
cols = line.get('columns', [])
if cols:
try:
cogs = float(cols[0])
except (ValueError, TypeError):
pass
turnover = (cogs / avg_inventory) if avg_inventory else 0
return {'cogs': cogs, 'avg_inventory': avg_inventory, 'turnover': round(turnover, 2)}
TOOLS = {
'get_stock_valuation': get_stock_valuation,
'get_price_differences': get_price_differences,
'get_cogs_ratio_by_category': get_cogs_ratio_by_category,
'find_unusual_adjustments': find_unusual_adjustments,
'get_inventory_turnover': get_inventory_turnover,
}

View File

@@ -0,0 +1,220 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
ACCOUNT_TYPE_EXPECTED_DIRECTION = {
'asset_receivable': 'debit',
'asset_cash': 'debit',
'asset_current': 'debit',
'asset_non_current': 'debit',
'asset_prepayments': 'debit',
'asset_fixed': 'debit',
'liability_payable': 'credit',
'liability_credit_card': 'credit',
'liability_current': 'credit',
'liability_non_current': 'credit',
'equity': 'credit',
'equity_unaffected': 'credit',
'income': 'credit',
'income_other': 'credit',
'expense': 'debit',
'expense_depreciation': 'debit',
'expense_direct_cost': 'debit',
'off_balance': None,
}
def find_wrong_direction_balances(env, params):
balance_data = env['account.move.line'].read_group(
[('parent_state', '=', 'posted'), ('company_id', '=', env.company.id)],
['balance:sum'], ['account_id'],
)
acct_ids = [row['account_id'][0] for row in balance_data if row.get('account_id')]
acct_map = {}
if acct_ids:
for acct in env['account.account'].browse(acct_ids):
acct_map[acct.id] = acct
issues = []
for row in balance_data:
if not row.get('account_id'):
continue
acct = acct_map.get(row['account_id'][0])
if not acct:
continue
expected = ACCOUNT_TYPE_EXPECTED_DIRECTION.get(acct.account_type)
if not expected:
continue
balance = row.get('balance', 0) or 0
if (expected == 'debit' and balance < -0.01) or (expected == 'credit' and balance > 0.01):
issues.append({
'account_id': acct.id,
'code': acct.code,
'name': acct.name,
'balance': balance,
'expected': expected,
'actual': 'credit' if balance < 0 else 'debit',
})
return {'count': len(issues), 'issues': issues}
def find_duplicate_entries(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
moves = env['account.move'].search(domain, order='partner_id, amount_total, date')
duplicates = []
prev = None
for move in moves:
if prev and (
prev.partner_id == move.partner_id and prev.partner_id
and abs(prev.amount_total - move.amount_total) < 0.01
and prev.date == move.date
and prev.journal_id == move.journal_id
):
duplicates.append({
'entry_1': {'id': prev.id, 'name': prev.name},
'entry_2': {'id': move.id, 'name': move.name},
'partner': move.partner_id.name,
'amount': move.amount_total,
'date': str(move.date),
})
prev = move
return {'count': len(duplicates), 'duplicates': duplicates[:20]}
def find_wrong_account_entries(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
issues = []
tax_accounts = env['account.account'].search([
('account_type', 'in', ('liability_current', 'asset_current')),
('code', '=like', '2005%'),
('company_ids', 'in', env.company.id),
])
if tax_accounts:
revenue_on_tax = env['account.move.line'].search(
domain + [
('account_id', 'in', tax_accounts.ids),
('product_id', '!=', False),
]
)
for line in revenue_on_tax[:20]:
issues.append({
'id': line.id,
'move': line.move_id.name,
'account': f'{line.account_id.code} {line.account_id.name}',
'product': line.product_id.name,
'amount': line.balance,
'issue': 'Product line on tax account',
})
return {'count': len(issues), 'issues': issues}
def find_sequence_gaps(env, params):
moves = env['account.move'].search([
('state', '=', 'posted'),
('company_id', '=', env.company.id),
('made_sequence_gap', '=', True),
], order='date desc', limit=50)
return {
'count': len(moves),
'gaps': [{
'id': m.id,
'name': m.name,
'date': str(m.date),
'journal': m.journal_id.name,
} for m in moves],
}
def find_draft_entries(env, params):
min_age_days = int(params.get('min_age_days', 30))
from datetime import timedelta
cutoff = fields.Date.today() - timedelta(days=min_age_days)
drafts = env['account.move'].search([
('state', '=', 'draft'),
('date', '<=', cutoff),
('company_id', '=', env.company.id),
], order='date asc', limit=50)
return {
'count': len(drafts),
'entries': [{
'id': d.id,
'name': d.name or 'Draft',
'date': str(d.date),
'journal': d.journal_id.name,
'amount': d.amount_total,
'partner': d.partner_id.name if d.partner_id else '',
} for d in drafts],
}
def find_unreconciled_suspense(env, params):
suspense_accounts = env['account.account'].search([
('code', '=like', '999%'),
('company_ids', 'in', env.company.id),
])
issues = []
for acct in suspense_accounts:
balance = sum(env['account.move.line'].search([
('account_id', '=', acct.id),
('parent_state', '=', 'posted'),
]).mapped('balance'))
if abs(balance) > 0.01:
issues.append({
'account_id': acct.id,
'code': acct.code,
'name': acct.name,
'balance': balance,
})
return {'count': len(issues), 'accounts': issues}
def verify_reconciliation_integrity(env, params):
partials = env['account.partial.reconcile'].search([
('company_id', '=', env.company.id),
], limit=500)
issues = []
for p in partials:
debit_ok = p.debit_move_id.reconciled or abs(p.debit_move_id.amount_residual) < 0.01
credit_ok = p.credit_move_id.reconciled or abs(p.credit_move_id.amount_residual) < 0.01
if not debit_ok and not credit_ok:
issues.append({
'id': p.id,
'debit_move': p.debit_move_id.move_id.name,
'credit_move': p.credit_move_id.move_id.name,
'amount': p.amount,
'debit_residual': p.debit_move_id.amount_residual,
'credit_residual': p.credit_move_id.amount_residual,
})
return {'count': len(issues), 'issues': issues[:20]}
TOOLS = {
'find_wrong_direction_balances': find_wrong_direction_balances,
'find_duplicate_entries': find_duplicate_entries,
'find_wrong_account_entries': find_wrong_account_entries,
'find_sequence_gaps': find_sequence_gaps,
'find_draft_entries': find_draft_entries,
'find_unreconciled_suspense': find_unreconciled_suspense,
'verify_reconciliation_integrity': verify_reconciliation_integrity,
}

View File

@@ -0,0 +1,139 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_close_checklist(env, params):
from .bank_reconciliation import get_unreconciled_bank_lines
from .journal_review import find_draft_entries, find_sequence_gaps
from .hst_management import calculate_hst_balance
period = params.get('period', str(fields.Date.today())[:7])
date_from = f'{period}-01'
import calendar
year, month = int(period[:4]), int(period[5:7])
last_day = calendar.monthrange(year, month)[1]
date_to = f'{period}-{last_day:02d}'
p = {'date_from': date_from, 'date_to': date_to}
bank = get_unreconciled_bank_lines(env, p)
drafts = find_draft_entries(env, {'min_age_days': '0'})
gaps = find_sequence_gaps(env, p)
hst = calculate_hst_balance(env, p)
checklist = [
{'item': 'Bank Reconciliation', 'status': 'ok' if bank['count'] == 0 else 'attention', 'detail': f"{bank['count']} unreconciled lines"},
{'item': 'Draft Entries', 'status': 'ok' if drafts['count'] == 0 else 'attention', 'detail': f"{drafts['count']} draft entries"},
{'item': 'Sequence Gaps', 'status': 'ok' if gaps['count'] == 0 else 'warning', 'detail': f"{gaps['count']} gaps found"},
{'item': 'HST Balance', 'status': 'info', 'detail': f"Net HST: ${hst['net_hst']:.2f}"},
]
return {'period': period, 'checklist': checklist}
def get_unreconciled_counts(env, params):
accounts = env['account.account'].search([
('reconcile', '=', True),
('company_ids', 'in', env.company.id),
])
result = []
for acct in accounts:
count = env['account.move.line'].search_count([
('account_id', '=', acct.id),
('reconciled', '=', False),
('parent_state', '=', 'posted'),
])
if count > 0:
result.append({
'account_id': acct.id,
'code': acct.code,
'name': acct.name,
'unreconciled_count': count,
})
return {'accounts': sorted(result, key=lambda x: -x['unreconciled_count'])}
def find_entries_in_locked_period(env, params):
company = env.company
lock_date = company.fiscalyear_lock_date
if not lock_date:
return {'status': 'no_lock_date', 'entries': []}
entries = env['account.move'].search([
('date', '<=', lock_date),
('state', '=', 'draft'),
('company_id', '=', company.id),
])
return {
'lock_date': str(lock_date),
'count': len(entries),
'entries': [{'id': e.id, 'name': e.name, 'date': str(e.date)} for e in entries[:20]],
}
def get_accrual_status(env, params):
accrual_codes = params.get('account_codes', ['2100', '2110', '2120'])
result = []
for code in accrual_codes:
accounts = env['account.account'].search([
('code', '=like', f'{code}%'),
('company_ids', 'in', env.company.id),
])
for acct in accounts:
balance = sum(env['account.move.line'].search([
('account_id', '=', acct.id),
('parent_state', '=', 'posted'),
]).mapped('balance'))
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
return {'accruals': result}
def run_hash_integrity_check(env, params):
try:
result = env.company._check_hash_integrity()
return {
'status': 'completed',
'results': result.get('results', []),
'printing_date': result.get('printing_date', ''),
}
except Exception as e:
return {'error': str(e)}
def get_period_summary(env, params):
"""Period summary via trial-balance. Routed through ReportsAdapter so the
Enterprise-only account_reports.trial_balance_report path is isolated;
Community installs fall back to the adapter's trial_balance() aggregation."""
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
date_from = params.get('date_from')
date_to = params.get('date_to')
result = adapter.run_report(
ref_id='account_reports.trial_balance_report',
date_from=date_from, date_to=date_to,
)
if isinstance(result, dict) and result.get('error'):
rows = adapter.trial_balance(
date_to=date_to, company_ids=[env.company.id],
)
return {
'period': f'{date_from} to {date_to}',
'lines': [{
'name': f"{r['account_code']} {r['account_name']}",
'columns': [r['debit'], r['credit'], r['balance']],
} for r in rows[:100]],
}
return {
'period': f'{date_from} to {date_to}',
'lines': result.get('lines', []),
}
TOOLS = {
'get_close_checklist': get_close_checklist,
'get_unreconciled_counts': get_unreconciled_counts,
'find_entries_in_locked_period': find_entries_in_locked_period,
'get_accrual_status': get_accrual_status,
'run_hash_integrity_check': run_hash_integrity_check,
'get_period_summary': get_period_summary,
}

View File

@@ -0,0 +1,256 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_payroll_entries(env, params):
payroll_journals = env['account.journal'].search([
('name', 'ilike', 'payroll'),
('company_id', '=', env.company.id),
])
if not payroll_journals and params.get('journal_id'):
payroll_journals = env['account.journal'].browse(int(params['journal_id']))
domain = [
('journal_id', 'in', payroll_journals.ids),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if params.get('date_from'):
domain.append(('date', '>=', params['date_from']))
if params.get('date_to'):
domain.append(('date', '<=', params['date_to']))
entries = env['account.move'].search(domain, order='date desc', limit=50)
return {
'count': len(entries),
'entries': [{
'id': e.id, 'name': e.name, 'date': str(e.date),
'amount': e.amount_total, 'ref': e.ref or '',
} for e in entries],
}
def compare_payroll_to_bank(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
if not date_from or not date_to:
return {'error': 'date_from and date_to are required'}
payroll_journals = env['account.journal'].search([
('name', 'ilike', 'payroll'), ('company_id', '=', env.company.id),
])
payroll_entries = env['account.move'].search([
('journal_id', 'in', payroll_journals.ids),
('state', '=', 'posted'),
('date', '>=', date_from), ('date', '<=', date_to),
])
bank_lines = env['account.bank.statement.line'].search([
('date', '>=', date_from), ('date', '<=', date_to),
('company_id', '=', env.company.id),
])
payroll_total = sum(e.amount_total for e in payroll_entries)
bank_payroll = sum(abs(l.amount) for l in bank_lines if 'payroll' in (l.payment_ref or '').lower())
return {
'payroll_journal_total': payroll_total,
'bank_payroll_total': bank_payroll,
'difference': payroll_total - bank_payroll,
}
def verify_source_deductions(env, params):
return {
'status': 'info',
'message': 'Source deduction verification requires CRA rate tables. Use fusion_payroll for full verification.',
}
def get_cra_remittance_status(env, params):
cra_accounts = env['account.account'].search([
('name', 'ilike', 'CRA'),
('company_ids', 'in', env.company.id),
])
result = []
for acct in cra_accounts:
balance = sum(env['account.move.line'].search([
('account_id', '=', acct.id),
('parent_state', '=', 'posted'),
]).mapped('balance'))
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
return {'accounts': result}
def find_unmatched_payroll_cheques(env, params):
bank_lines = env['account.bank.statement.line'].search([
('is_reconciled', '=', False),
('company_id', '=', env.company.id),
('payment_ref', 'ilike', 'cheque'),
])
return {
'count': len(bank_lines),
'cheques': [{
'id': l.id, 'date': str(l.date),
'ref': l.payment_ref, 'amount': l.amount,
} for l in bank_lines[:30]],
}
def parse_payroll_summary(env, params):
import re
raw_data = params.get('data', '')
if not raw_data:
return {'error': 'No payroll data provided'}
lines = raw_data.strip().split('\n')
entries = []
totals = {'gross': 0, 'cpp': 0, 'ei': 0, 'tax': 0, 'net': 0}
for line in lines:
amounts = re.findall(r'\$?([\d,]+\.?\d*)', line)
if len(amounts) >= 2:
name_part = re.sub(r'\$?[\d,]+\.?\d*', '', line).strip(' \t,|-')
parsed_amounts = [float(a.replace(',', '')) for a in amounts]
entry = {'name': name_part or 'Employee', 'amounts': parsed_amounts}
if len(parsed_amounts) >= 5:
entry.update({
'gross': parsed_amounts[0],
'cpp': parsed_amounts[1],
'ei': parsed_amounts[2],
'tax': parsed_amounts[3],
'net': parsed_amounts[4] if len(parsed_amounts) > 4 else parsed_amounts[0] - sum(parsed_amounts[1:4]),
})
for k in ('gross', 'cpp', 'ei', 'tax', 'net'):
totals[k] += entry.get(k, 0)
entries.append(entry)
return {
'status': 'parsed',
'employee_count': len(entries),
'entries': entries,
'totals': totals,
'raw_lines': len(lines),
}
def _resolve_account_id(env, val):
"""Resolve an account code or ID to a valid account ID.
Accepts: integer ID, string ID, or account code string like '2201'."""
if not val:
return False
val_str = str(val).strip()
# Try as a direct ID first
try:
acct = env['account.account'].browse(int(val_str))
if acct.exists():
return acct.id
except (ValueError, TypeError):
pass
# Try as an account code
acct = env['account.account'].search([
('code', '=', val_str),
('company_ids', 'in', env.company.id),
], limit=1)
if acct:
return acct.id
return False
def create_payroll_journal_entry(env, params):
journal_id = int(params['journal_id'])
date = params['date']
ref = params.get('ref', 'Payroll Entry')
lines_data = params['lines']
# Duplicate check: same journal + date + ref + similar amount
total_debit = sum(float(l.get('debit', 0)) for l in lines_data)
existing = env['account.move'].search([
('journal_id', '=', journal_id),
('date', '=', date),
('ref', 'ilike', ref[:30]),
('state', 'in', ('draft', 'posted')),
], limit=1)
if existing:
return {
'status': 'duplicate',
'error': f'Entry already exists: {existing.name} (ref: {existing.ref}) on {existing.date} '
f'for ${existing.amount_total:,.2f}. Skipping to avoid duplicate.',
'existing_move_id': existing.id,
'existing_name': existing.name,
}
# Resolve account codes to IDs
resolved_lines = []
for line in lines_data:
account_id = _resolve_account_id(env, line['account_id'])
if not account_id:
return {'error': f"Account not found: {line['account_id']}. "
f"Provide a valid account code (e.g. '2201') or database ID."}
resolved_lines.append((0, 0, {
'account_id': account_id,
'name': line.get('name', 'Payroll'),
'debit': float(line.get('debit', 0)),
'credit': float(line.get('credit', 0)),
'partner_id': int(line['partner_id']) if line.get('partner_id') else False,
}))
move_vals = {
'journal_id': journal_id,
'date': date,
'ref': ref,
'line_ids': resolved_lines,
}
move = env['account.move'].create(move_vals)
return {'status': 'created', 'move_id': move.id, 'name': move.name}
def get_payroll_schedule(env, params):
return {'status': 'info', 'message': 'Payroll schedule available via fusion_payroll module.'}
def match_payroll_cheques(env, params):
st_line_id = int(params['statement_line_id'])
move_line_ids = [int(x) for x in params['move_line_ids']]
st_line = env['account.bank.statement.line'].browse(st_line_id)
st_line.set_line_bank_statement_line(move_line_ids)
return {'status': 'matched', 'statement_line_id': st_line_id}
def verify_payroll_deductions(env, params):
return verify_source_deductions(env, params)
def get_cra_remittance_due(env, params):
return get_cra_remittance_status(env, params)
def prepare_cra_payment(env, params):
return create_payroll_journal_entry(env, params)
def generate_t4(env, params):
return {'status': 'info', 'message': 'T4 generation available via fusion_payroll module.'}
def generate_roe(env, params):
return {'status': 'info', 'message': 'ROE generation available via fusion_payroll module.'}
def get_payroll_cost_report(env, params):
return get_payroll_entries(env, params)
TOOLS = {
'get_payroll_entries': get_payroll_entries,
'compare_payroll_to_bank': compare_payroll_to_bank,
'verify_source_deductions': verify_source_deductions,
'get_cra_remittance_status': get_cra_remittance_status,
'find_unmatched_payroll_cheques': find_unmatched_payroll_cheques,
'parse_payroll_summary': parse_payroll_summary,
'create_payroll_journal_entry': create_payroll_journal_entry,
'get_payroll_schedule': get_payroll_schedule,
'match_payroll_cheques': match_payroll_cheques,
'verify_payroll_deductions': verify_payroll_deductions,
'get_cra_remittance_due': get_cra_remittance_due,
'prepare_cra_payment': prepare_cra_payment,
'generate_t4': generate_t4,
'generate_roe': generate_roe,
'get_payroll_cost_report': get_payroll_cost_report,
}

View File

@@ -0,0 +1,292 @@
import logging
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Enterprise account.report wrappers — all routed through ReportsAdapter.
# ---------------------------------------------------------------------------
def get_profit_loss(env, params):
"""Route through ReportsAdapter for tri-mode consistency."""
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
return adapter.run_report(
ref_id='account_reports.profit_and_loss',
date_from=params.get('date_from'),
date_to=params.get('date_to'),
)
def get_balance_sheet(env, params):
"""Route through ReportsAdapter for tri-mode consistency."""
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
return adapter.run_report(
ref_id='account_reports.balance_sheet',
date_from=params.get('date_from'),
date_to=params.get('date_to'),
)
def get_trial_balance(env, params):
"""Route through ReportsAdapter for tri-mode consistency.
In Enterprise mode returns the hierarchical report lines. In Community
mode falls back to the adapter's trial_balance() aggregation so the tool
continues to return useful data with a compatible shape.
"""
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
result = adapter.run_report(
ref_id='account_reports.trial_balance_report',
date_from=params.get('date_from'),
date_to=params.get('date_to'),
)
if isinstance(result, dict) and result.get('error'):
rows = adapter.trial_balance(
date_to=params.get('date_to'),
company_ids=[env.company.id],
)
return {
'report_name': 'Trial Balance (Community aggregation)',
'lines': [{
'name': f"{r['account_code']} {r['account_name']}",
'level': 2,
'columns': [r['debit'], r['credit'], r['balance']],
} for r in rows],
}
return result
def get_cash_flow(env, params):
"""Route through ReportsAdapter for tri-mode consistency."""
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
return adapter.run_report(
ref_id='account_reports.cash_flow_statement',
date_from=params.get('date_from'),
date_to=params.get('date_to'),
)
def compare_periods(env, params):
"""Run the same report over two periods and return both results. Routes
both runs through ReportsAdapter."""
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
period1 = adapter.run_report(
ref_id=report_ref,
date_from=params.get('period1_from'),
date_to=params.get('period1_to'),
)
period2 = adapter.run_report(
ref_id=report_ref,
date_from=params.get('period2_from'),
date_to=params.get('period2_to'),
)
return {'period_1': period1, 'period_2': period2}
def answer_financial_question(env, params):
question = params.get('question', '')
sql_query = params.get('sql_query')
if sql_query:
return {'error': 'Direct SQL not permitted. Use report tools instead.'}
return {'status': 'info', 'message': f'Use specific report tools to answer: {question}'}
def export_report(env, params):
"""Route through ReportsAdapter for tri-mode consistency."""
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
return adapter.export_report(
ref_id=params.get('report_ref', 'account_reports.profit_and_loss'),
fmt=params.get('format', 'pdf'),
date_from=params.get('date_from'),
date_to=params.get('date_to'),
)
# ---------------------------------------------------------------------------
# Pure-Community tools — search account.move / account.payment directly.
# These are tri-mode safe (the data lives in the same tables regardless of
# install profile) so they don't need adapter routing.
# ---------------------------------------------------------------------------
def get_invoicing_summary(env, params):
"""Get invoicing summary — total invoiced by month, by partner, or for a date range.
Supports: monthly breakdown for a year, current month totals, or filtered by partner."""
from datetime import date
import calendar
year = int(params.get('year', date.today().year))
partner_name = params.get('partner_name')
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if partner_name:
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
if partner:
domain.append(('partner_id', '=', partner.id))
else:
return {'error': f'Partner not found: {partner_name}'}
if date_from and date_to:
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
invoices = env['account.move'].search(domain, order='date desc')
total = sum(inv.amount_total for inv in invoices)
return {
'period': f'{date_from} to {date_to}',
'count': len(invoices),
'total': total,
'invoices': [{
'id': inv.id, 'name': inv.name, 'partner': inv.partner_id.name,
'date': str(inv.date), 'amount': inv.amount_total,
'payment_state': inv.payment_state,
} for inv in invoices[:30]],
}
months = []
grand_total = 0
for month in range(1, 13):
m_start = f'{year}-{month:02d}-01'
last_day = calendar.monthrange(year, month)[1]
m_end = f'{year}-{month:02d}-{last_day}'
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
invoices = env['account.move'].search(m_domain)
total = sum(inv.amount_total for inv in invoices)
grand_total += total
months.append({
'month': f'{year}-{month:02d}',
'month_name': calendar.month_name[month],
'count': len(invoices),
'total': round(total, 2),
})
return {
'year': year,
'grand_total': round(grand_total, 2),
'months': months,
'partner': partner_name or 'All',
}
def get_billing_summary(env, params):
"""Get billing (vendor bills) summary — total billed by month or date range."""
from datetime import date
import calendar
year = int(params.get('year', date.today().year))
partner_name = params.get('partner_name')
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', '=', 'in_invoice'),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if partner_name:
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
if partner:
domain.append(('partner_id', '=', partner.id))
else:
return {'error': f'Partner not found: {partner_name}'}
if date_from and date_to:
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
bills = env['account.move'].search(domain, order='date desc')
total = sum(b.amount_total for b in bills)
return {
'period': f'{date_from} to {date_to}',
'count': len(bills),
'total': total,
'bills': [{
'id': b.id, 'name': b.name, 'partner': b.partner_id.name,
'date': str(b.date), 'amount': b.amount_total,
'payment_state': b.payment_state,
} for b in bills[:30]],
}
months = []
grand_total = 0
for month in range(1, 13):
m_start = f'{year}-{month:02d}-01'
last_day = calendar.monthrange(year, month)[1]
m_end = f'{year}-{month:02d}-{last_day}'
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
bills = env['account.move'].search(m_domain)
total = sum(b.amount_total for b in bills)
grand_total += total
months.append({
'month': f'{year}-{month:02d}',
'month_name': calendar.month_name[month],
'count': len(bills),
'total': round(total, 2),
})
return {
'year': year,
'grand_total': round(grand_total, 2),
'months': months,
'partner': partner_name or 'All',
}
def get_collections_summary(env, params):
"""Get payment collections summary — how much was collected (received) in a period."""
date_from = params.get('date_from')
date_to = params.get('date_to')
if not date_from or not date_to:
from datetime import date
today = date.today()
date_from = date_from or f'{today.year}-{today.month:02d}-01'
date_to = date_to or str(today)
payments = env['account.payment'].search([
('payment_type', '=', 'inbound'),
('state', '=', 'posted'),
('date', '>=', date_from),
('date', '<=', date_to),
('company_id', '=', env.company.id),
], order='date desc')
total = sum(p.amount for p in payments)
by_partner = {}
for p in payments:
pname = p.partner_id.name if p.partner_id else 'Unknown'
by_partner.setdefault(pname, {'count': 0, 'total': 0})
by_partner[pname]['count'] += 1
by_partner[pname]['total'] += p.amount
top_partners = sorted(by_partner.items(), key=lambda x: -x[1]['total'])[:15]
return {
'period': f'{date_from} to {date_to}',
'total_collected': round(total, 2),
'payment_count': len(payments),
'by_partner': [{'partner': k, 'count': v['count'], 'total': round(v['total'], 2)} for k, v in top_partners],
}
TOOLS = {
'get_profit_loss': get_profit_loss,
'get_balance_sheet': get_balance_sheet,
'get_trial_balance': get_trial_balance,
'get_cash_flow': get_cash_flow,
'compare_periods': compare_periods,
'answer_financial_question': answer_financial_question,
'export_report': export_report,
'get_invoicing_summary': get_invoicing_summary,
'get_billing_summary': get_billing_summary,
'get_collections_summary': get_collections_summary,
}