git mv preserves history. fusion_accounting/ retains only __manifest__.py, __init__.py, CLAUDE.md, and docs/ — the meta-module shell. All Python, data, views, security, services, static, tests, wizards, report move to fusion_accounting_ai/. Manifest data list updated; security.xml move to _core deferred to Task 12. Made-with: Cursor
948 lines
41 KiB
Python
948 lines
41 KiB
Python
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),
|
|
)
|