feat(fusion_accounting_ai): 5 new bank-rec AI tools wrapping engine
Adds fusion_suggest_matches, fusion_accept_suggestion, fusion_reconcile_bank_line, fusion_unreconcile, and fusion_get_pending_suggestions. All route through the BankRecAdapter (or direct engine for ones the adapter doesn't expose), giving the AI chat the same reconciliation surface a human operator gets in the OWL UI. Made-with: Cursor
This commit is contained in:
@@ -946,6 +946,171 @@ def _format_aml_candidates(amls):
|
|||||||
} for aml in amls]
|
} for aml in amls]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Phase 1 Bank Reconciliation: engine-backed tools
|
||||||
|
#
|
||||||
|
# These five tools wrap the fusion.reconcile.engine 6-method API via the
|
||||||
|
# bank_rec data adapter (or the engine directly when the adapter does not
|
||||||
|
# expose a wrapper). They give the AI chat the same reconciliation surface
|
||||||
|
# a human gets in the OWL bank-rec UI.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_suggest_matches(env, params):
|
||||||
|
"""Compute and persist AI suggestions for one or more bank statement lines.
|
||||||
|
|
||||||
|
Wraps ``BankRecAdapter.suggest_matches`` -> ``fusion.reconcile.engine``.
|
||||||
|
"""
|
||||||
|
raw_ids = params.get('statement_line_ids')
|
||||||
|
if not raw_ids:
|
||||||
|
return {'error': 'statement_line_ids is required'}
|
||||||
|
statement_line_ids = [int(x) for x in raw_ids]
|
||||||
|
limit_per_line = int(params.get('limit_per_line', 3))
|
||||||
|
|
||||||
|
from ..data_adapters import get_adapter
|
||||||
|
adapter = get_adapter(env, 'bank_rec')
|
||||||
|
raw = adapter.suggest_matches(
|
||||||
|
statement_line_ids=statement_line_ids,
|
||||||
|
limit_per_line=limit_per_line,
|
||||||
|
company_id=env.company.id,
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
suggestions = {}
|
||||||
|
total = 0
|
||||||
|
for line_id, sug_list in raw.items():
|
||||||
|
out = []
|
||||||
|
for s in sug_list:
|
||||||
|
out.append({
|
||||||
|
'suggestion_id': s.get('id'),
|
||||||
|
'candidate_id': s.get('candidate_id'),
|
||||||
|
'confidence': s.get('confidence'),
|
||||||
|
'reasoning': s.get('reasoning') or '',
|
||||||
|
'rank': s.get('rank'),
|
||||||
|
})
|
||||||
|
total += 1
|
||||||
|
suggestions[line_id] = out
|
||||||
|
return {'suggestions': suggestions, 'count': total}
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_accept_suggestion(env, params):
|
||||||
|
"""Accept a fusion.reconcile.suggestion: reconciles the bank line against
|
||||||
|
the suggestion's proposed move lines and marks the suggestion accepted.
|
||||||
|
|
||||||
|
Wraps ``BankRecAdapter.accept_suggestion``.
|
||||||
|
"""
|
||||||
|
if not params.get('suggestion_id'):
|
||||||
|
return {'error': 'suggestion_id is required'}
|
||||||
|
suggestion_id = int(params['suggestion_id'])
|
||||||
|
suggestion = env['fusion.reconcile.suggestion'].browse(suggestion_id)
|
||||||
|
if not suggestion.exists():
|
||||||
|
return {'error': 'Suggestion not found'}
|
||||||
|
|
||||||
|
from ..data_adapters import get_adapter
|
||||||
|
adapter = get_adapter(env, 'bank_rec')
|
||||||
|
result = adapter.accept_suggestion(suggestion_id) or {}
|
||||||
|
statement_line = suggestion.statement_line_id
|
||||||
|
return {
|
||||||
|
'status': 'accepted',
|
||||||
|
'suggestion_id': suggestion_id,
|
||||||
|
'partial_ids': list(result.get('partial_ids') or []),
|
||||||
|
'is_reconciled': bool(statement_line.is_reconciled),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_reconcile_bank_line(env, params):
|
||||||
|
"""Manually reconcile a bank statement line against a set of journal items.
|
||||||
|
|
||||||
|
Routes through ``fusion.reconcile.engine.reconcile_one`` so behaviour
|
||||||
|
matches the OWL widget and ``fusion_accept_suggestion``. Use this for
|
||||||
|
direct AI-initiated matches that did not come from an AI suggestion.
|
||||||
|
"""
|
||||||
|
if not params.get('statement_line_id'):
|
||||||
|
return {'error': 'statement_line_id is required'}
|
||||||
|
raw_against = params.get('against_move_line_ids')
|
||||||
|
if not raw_against:
|
||||||
|
return {'error': 'against_move_line_ids is required'}
|
||||||
|
|
||||||
|
st_line_id = int(params['statement_line_id'])
|
||||||
|
aml_ids = [int(x) for x in raw_against]
|
||||||
|
statement_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||||
|
if not statement_line.exists():
|
||||||
|
return {'error': 'Statement line not found'}
|
||||||
|
against_lines = env['account.move.line'].browse(aml_ids).exists()
|
||||||
|
if not against_lines:
|
||||||
|
return {'error': 'No valid against_move_line_ids'}
|
||||||
|
|
||||||
|
result = env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
statement_line, against_lines=against_lines)
|
||||||
|
return {
|
||||||
|
'status': 'reconciled',
|
||||||
|
'statement_line_id': st_line_id,
|
||||||
|
'partial_ids': list(result.get('partial_ids') or []),
|
||||||
|
'is_reconciled': bool(statement_line.is_reconciled),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_unreconcile(env, params):
|
||||||
|
"""Reverse a reconciliation by partial_reconcile_ids.
|
||||||
|
|
||||||
|
Wraps ``BankRecAdapter.unreconcile``. Works in fusion, Enterprise, and
|
||||||
|
Community installs (the adapter falls back to a standalone path when
|
||||||
|
fusion_accounting_bank_rec is not loaded).
|
||||||
|
"""
|
||||||
|
raw_ids = params.get('partial_reconcile_ids')
|
||||||
|
if not raw_ids:
|
||||||
|
return {'error': 'partial_reconcile_ids is required'}
|
||||||
|
partial_ids = [int(x) for x in raw_ids]
|
||||||
|
|
||||||
|
from ..data_adapters import get_adapter
|
||||||
|
adapter = get_adapter(env, 'bank_rec')
|
||||||
|
result = adapter.unreconcile(partial_ids) or {}
|
||||||
|
unreconciled_line_ids = list(result.get('unreconciled_line_ids') or [])
|
||||||
|
return {
|
||||||
|
'status': 'unreconciled',
|
||||||
|
'unreconciled_line_ids': unreconciled_line_ids,
|
||||||
|
'count': len(unreconciled_line_ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_get_pending_suggestions(env, params):
|
||||||
|
"""List pending fusion.reconcile.suggestion rows.
|
||||||
|
|
||||||
|
Optional filters: ``statement_line_id``, ``min_confidence`` (default 0.0),
|
||||||
|
``limit`` (default 50). Only returns suggestions in the ``pending`` state
|
||||||
|
for the current company.
|
||||||
|
"""
|
||||||
|
domain = [
|
||||||
|
('company_id', '=', env.company.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
]
|
||||||
|
if params.get('statement_line_id'):
|
||||||
|
domain.append(
|
||||||
|
('statement_line_id', '=', int(params['statement_line_id'])))
|
||||||
|
min_confidence = float(params.get('min_confidence') or 0.0)
|
||||||
|
if min_confidence > 0.0:
|
||||||
|
domain.append(('confidence', '>=', min_confidence))
|
||||||
|
limit = int(params.get('limit', 50))
|
||||||
|
|
||||||
|
Suggestion = env['fusion.reconcile.suggestion'].sudo()
|
||||||
|
records = Suggestion.search(
|
||||||
|
domain, limit=limit, order='confidence desc, id desc')
|
||||||
|
rows = []
|
||||||
|
for s in records:
|
||||||
|
st_line = s.statement_line_id
|
||||||
|
rows.append({
|
||||||
|
'id': s.id,
|
||||||
|
'statement_line_id': st_line.id if st_line else None,
|
||||||
|
'statement_line_ref': (
|
||||||
|
st_line.payment_ref or '' if st_line else ''),
|
||||||
|
'candidate_ids': s.proposed_move_line_ids.ids,
|
||||||
|
'confidence': s.confidence,
|
||||||
|
'rank': s.rank,
|
||||||
|
'reasoning': s.reasoning or '',
|
||||||
|
'state': s.state,
|
||||||
|
})
|
||||||
|
return {'count': len(rows), 'suggestions': rows}
|
||||||
|
|
||||||
|
|
||||||
TOOLS = {
|
TOOLS = {
|
||||||
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
|
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
|
||||||
'get_unreconciled_receipts': get_unreconciled_receipts,
|
'get_unreconciled_receipts': get_unreconciled_receipts,
|
||||||
@@ -962,4 +1127,10 @@ TOOLS = {
|
|||||||
'reconcile_payroll_cheques': reconcile_payroll_cheques,
|
'reconcile_payroll_cheques': reconcile_payroll_cheques,
|
||||||
'suggest_bank_line_matches': suggest_bank_line_matches,
|
'suggest_bank_line_matches': suggest_bank_line_matches,
|
||||||
'search_matching_entries': search_matching_entries,
|
'search_matching_entries': search_matching_entries,
|
||||||
|
# Phase 1 engine-backed tools
|
||||||
|
'fusion_suggest_matches': fusion_suggest_matches,
|
||||||
|
'fusion_accept_suggestion': fusion_accept_suggestion,
|
||||||
|
'fusion_reconcile_bank_line': fusion_reconcile_bank_line,
|
||||||
|
'fusion_unreconcile': fusion_unreconcile,
|
||||||
|
'fusion_get_pending_suggestions': fusion_get_pending_suggestions,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ from . import test_factories
|
|||||||
from . import test_reconcile_engine_integration
|
from . import test_reconcile_engine_integration
|
||||||
from . import test_bank_rec_prompt
|
from . import test_bank_rec_prompt
|
||||||
from . import test_bank_rec_adapter
|
from . import test_bank_rec_adapter
|
||||||
|
from . import test_bank_rec_tools
|
||||||
|
|||||||
84
fusion_accounting_bank_rec/tests/test_bank_rec_tools.py
Normal file
84
fusion_accounting_bank_rec/tests/test_bank_rec_tools.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Smoke tests for the 5 new fusion bank-rec AI tools."""
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.tools import bank_reconciliation as tools
|
||||||
|
from . import _factories as f
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestFusionBankRecTools(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.partner = self.env['res.partner'].create({'name': 'Tools Test Partner'})
|
||||||
|
|
||||||
|
def test_fusion_suggest_matches_returns_suggestions(self):
|
||||||
|
invoice = f.make_invoice(self.env, partner=self.partner, amount=550.00)
|
||||||
|
bank_line = f.make_bank_line(
|
||||||
|
self.env, amount=550.00, partner=self.partner, memo='Tool test')
|
||||||
|
result = tools.fusion_suggest_matches(self.env, {
|
||||||
|
'statement_line_ids': [bank_line.id],
|
||||||
|
'limit_per_line': 3,
|
||||||
|
})
|
||||||
|
self.assertIn('suggestions', result)
|
||||||
|
self.assertIn('count', result)
|
||||||
|
self.assertGreater(result['count'], 0)
|
||||||
|
|
||||||
|
def test_fusion_accept_suggestion_reconciles(self):
|
||||||
|
invoice = f.make_invoice(self.env, partner=self.partner, amount=625.00)
|
||||||
|
recv_lines = invoice.line_ids.filtered(
|
||||||
|
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||||
|
bank_line = f.make_bank_line(self.env, amount=625.00, partner=self.partner)
|
||||||
|
sug = f.make_suggestion(
|
||||||
|
self.env, statement_line=bank_line,
|
||||||
|
candidate_move_lines=recv_lines, confidence=0.94)
|
||||||
|
result = tools.fusion_accept_suggestion(self.env, {'suggestion_id': sug.id})
|
||||||
|
self.assertEqual(result['status'], 'accepted')
|
||||||
|
self.assertGreater(len(result['partial_ids']), 0)
|
||||||
|
|
||||||
|
def test_fusion_reconcile_bank_line(self):
|
||||||
|
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||||
|
self.env, amount=375.00, partner=self.partner)
|
||||||
|
result = tools.fusion_reconcile_bank_line(self.env, {
|
||||||
|
'statement_line_id': bank_line.id,
|
||||||
|
'against_move_line_ids': recv_lines.ids,
|
||||||
|
})
|
||||||
|
self.assertEqual(result['status'], 'reconciled')
|
||||||
|
self.assertTrue(result['is_reconciled'])
|
||||||
|
|
||||||
|
def test_fusion_unreconcile(self):
|
||||||
|
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||||
|
self.env, amount=275.00, partner=self.partner)
|
||||||
|
rec = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
bank_line, against_lines=recv_lines)
|
||||||
|
partial_ids = rec['partial_ids']
|
||||||
|
result = tools.fusion_unreconcile(self.env, {
|
||||||
|
'partial_reconcile_ids': partial_ids,
|
||||||
|
})
|
||||||
|
self.assertEqual(result['status'], 'unreconciled')
|
||||||
|
self.assertGreater(result['count'], 0)
|
||||||
|
|
||||||
|
def test_fusion_get_pending_suggestions(self):
|
||||||
|
bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner)
|
||||||
|
sug = f.make_suggestion(
|
||||||
|
self.env, statement_line=bank_line,
|
||||||
|
candidate_move_lines=self.env['account.move.line'],
|
||||||
|
confidence=0.88, state='pending')
|
||||||
|
result = tools.fusion_get_pending_suggestions(self.env, {})
|
||||||
|
self.assertIn('count', result)
|
||||||
|
self.assertGreater(result['count'], 0)
|
||||||
|
ids = [s['id'] for s in result['suggestions']]
|
||||||
|
self.assertIn(sug.id, ids)
|
||||||
|
|
||||||
|
def test_fusion_get_pending_suggestions_filters_by_min_confidence(self):
|
||||||
|
bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner)
|
||||||
|
# One low-confidence suggestion
|
||||||
|
f.make_suggestion(self.env, statement_line=bank_line,
|
||||||
|
confidence=0.30, state='pending')
|
||||||
|
# One high-confidence
|
||||||
|
high = f.make_suggestion(self.env, statement_line=bank_line,
|
||||||
|
confidence=0.95, state='pending')
|
||||||
|
result = tools.fusion_get_pending_suggestions(
|
||||||
|
self.env, {'min_confidence': 0.80})
|
||||||
|
ids = [s['id'] for s in result['suggestions']]
|
||||||
|
self.assertIn(high.id, ids)
|
||||||
Reference in New Issue
Block a user