changes
This commit is contained in:
@@ -1,12 +1,21 @@
|
||||
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__)
|
||||
|
||||
# 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'
|
||||
@@ -41,9 +50,14 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
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': tool.description,
|
||||
'description': desc,
|
||||
}
|
||||
if tool.parameters_schema:
|
||||
try:
|
||||
@@ -117,6 +131,21 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
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()
|
||||
@@ -132,6 +161,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
total_tokens_in = 0
|
||||
total_tokens_out = 0
|
||||
response = {'text': '', 'tool_calls': None}
|
||||
has_pending_tier3 = False
|
||||
|
||||
for turn in range(max_turns):
|
||||
response = adapter.call_with_tools(
|
||||
@@ -151,6 +181,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
tier = tool_rec.tier if tool_rec else '1'
|
||||
|
||||
if tier == '3':
|
||||
has_pending_tier3 = True
|
||||
history_rec = self._log_match_history(
|
||||
session, tool_name, tool_params, None,
|
||||
reasoning=tc.get('reasoning', ''),
|
||||
@@ -184,7 +215,29 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
messages_json = adapter.append_tool_results(
|
||||
messages_json, response, tool_results,
|
||||
)
|
||||
session.tool_call_count += len(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=[],
|
||||
)
|
||||
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})
|
||||
@@ -210,7 +263,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
'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': self._get_config('ai_provider', 'claude'),
|
||||
'ai_provider': provider,
|
||||
'ai_model': adapter._get_model_name(),
|
||||
})
|
||||
|
||||
@@ -249,6 +302,15 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
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):
|
||||
@@ -312,4 +374,133 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=False)
|
||||
|
||||
return {'status': 'rejected', 'reason': reason}
|
||||
# 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)
|
||||
|
||||
Reference in New Issue
Block a user