Files
gsinghpal 9ebf89bde2 changes
2026-05-16 13:18:52 -04:00

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),
)