feat(fusion_accounting_ai): wire BankRecAdapter fusion paths to engine
Enhances list_unreconciled_via_fusion to include fusion fields (top_suggestion_id, confidence_band, attachment_count). Adds 3 new adapter methods that proxy the engine: suggest_matches, accept_suggestion, unreconcile. AI tools (Task 22+) and OWL controller (Task 26) will call these adapter methods instead of touching the engine directly. Made-with: Cursor
This commit is contained in:
@@ -4,6 +4,12 @@ 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
|
||||
@@ -14,6 +20,10 @@ 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.
|
||||
@@ -31,13 +41,29 @@ class BankRecAdapter(DataAdapter):
|
||||
def list_unreconciled_via_fusion(self, journal_id=None, limit=100,
|
||||
date_from=None, date_to=None,
|
||||
min_amount=None, company_id=None):
|
||||
# Phase 1 will add fusion.bank.rec.widget; this method becomes the primary path.
|
||||
# For now: even when the model exists, delegate to community read shape.
|
||||
return self.list_unreconciled_via_community(
|
||||
"""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,
|
||||
@@ -83,5 +109,121 @@ class BankRecAdapter(DataAdapter):
|
||||
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)
|
||||
|
||||
@@ -10,3 +10,4 @@ from . import test_reconcile_engine_property
|
||||
from . import test_factories
|
||||
from . import test_reconcile_engine_integration
|
||||
from . import test_bank_rec_prompt
|
||||
from . import test_bank_rec_adapter
|
||||
|
||||
81
fusion_accounting_bank_rec/tests/test_bank_rec_adapter.py
Normal file
81
fusion_accounting_bank_rec/tests/test_bank_rec_adapter.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Tests for BankRecAdapter's fusion paths."""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_ai.services.data_adapters.bank_rec import BankRecAdapter
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestBankRecAdapter(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Adapter Test Partner'})
|
||||
self.adapter = BankRecAdapter(self.env)
|
||||
|
||||
def test_list_unreconciled_via_fusion_returns_base_fields(self):
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, amount=100.00, partner=self.partner, memo='Adapter base test')
|
||||
result = self.adapter.list_unreconciled_via_fusion(
|
||||
company_id=self.env.company.id, limit=50)
|
||||
ours = [r for r in result if r['id'] == bank_line.id]
|
||||
self.assertEqual(len(ours), 1)
|
||||
row = ours[0]
|
||||
for f_name in ['id', 'date', 'payment_ref', 'amount', 'partner_id', 'journal_id']:
|
||||
self.assertIn(f_name, row)
|
||||
self.assertIn('fusion_top_suggestion_id', row)
|
||||
self.assertIn('fusion_confidence_band', row)
|
||||
self.assertIn('attachment_count', row)
|
||||
|
||||
def test_list_unreconciled_via_community_omits_fusion_fields(self):
|
||||
bank_line = f.make_bank_line(self.env, amount=200.00, partner=self.partner)
|
||||
result = self.adapter.list_unreconciled_via_community(
|
||||
company_id=self.env.company.id, limit=50)
|
||||
ours = [r for r in result if r['id'] == bank_line.id]
|
||||
self.assertEqual(len(ours), 1)
|
||||
self.assertNotIn('fusion_top_suggestion_id', ours[0])
|
||||
|
||||
def test_suggest_matches_via_fusion_returns_dict(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Suggest Adapter'})
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=350.00)
|
||||
bank_line = f.make_bank_line(self.env, amount=350.00, partner=partner)
|
||||
result = self.adapter.suggest_matches_via_fusion(
|
||||
statement_line_ids=[bank_line.id], limit_per_line=3)
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertIn(bank_line.id, result)
|
||||
self.assertGreater(len(result[bank_line.id]), 0)
|
||||
|
||||
def test_suggest_matches_via_community_returns_empty(self):
|
||||
bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner)
|
||||
result = self.adapter.suggest_matches_via_community(
|
||||
statement_line_ids=[bank_line.id])
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_accept_suggestion_via_fusion(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Accept Adapter'})
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=425.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=425.00, partner=partner)
|
||||
sug = f.make_suggestion(
|
||||
self.env, statement_line=bank_line,
|
||||
candidate_move_lines=recv_lines, confidence=0.95)
|
||||
result = self.adapter.accept_suggestion_via_fusion(suggestion_id=sug.id)
|
||||
self.assertIn('partial_ids', result)
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
|
||||
def test_accept_suggestion_via_community_raises(self):
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.adapter.accept_suggestion_via_community(suggestion_id=1)
|
||||
|
||||
def test_unreconcile_via_fusion(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Unrec Adapter'})
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||
self.env, amount=275.00, partner=partner)
|
||||
rec_result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
partial_ids = rec_result['partial_ids']
|
||||
result = self.adapter.unreconcile_via_fusion(
|
||||
partial_reconcile_ids=partial_ids)
|
||||
self.assertIn('unreconciled_line_ids', result)
|
||||
self.assertGreater(len(result['unreconciled_line_ids']), 0)
|
||||
Reference in New Issue
Block a user