4 Commits

Author SHA1 Message Date
gsinghpal
5020129c45 refactor(fusion_accounting_ai): route legacy reconcile tools through engine
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
When fusion_accounting_bank_rec is installed, match_bank_line_to_payments
and auto_reconcile_bank_lines now use fusion.reconcile.engine via the
BankRecAdapter, gaining precedent recording, AI suggestion superseding,
and shared validation. Legacy paths preserved for Enterprise/Community-
only installs (engine model absent -> fall back to set_line_bank_statement_line
and _try_auto_reconcile_statement_lines).

Also wraps engine.reconcile_batch's per-line loop in a savepoint so a
single bad line's DB error (e.g. check-constraint violation) no longer
poisons the whole batch transaction; the existing per-line try/except
now isolates failures as originally intended.

Made-with: Cursor
2026-04-19 11:37:34 -04:00
gsinghpal
3993f58910 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
2026-04-19 11:31:40 -04:00
gsinghpal
8eee64f053 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
2026-04-19 11:25:41 -04:00
gsinghpal
2d099b2d0d feat(fusion_accounting_ai): bank_rec_prompt for AI re-rank step
Provider-agnostic system + user prompt builder for the confidence
scoring pipeline's Pass 3 (AI re-rank). Output contract is JSON with
"ranked" array; works with OpenAI, Claude, and local OpenAI-compatible
servers (LM Studio, Ollama).

Made-with: Cursor
2026-04-19 11:20:56 -04:00
10 changed files with 772 additions and 12 deletions

View File

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

View File

@@ -1,2 +1,3 @@
from . import system_prompt
from . import domain_prompts
from . import bank_rec_prompt

View File

@@ -0,0 +1,107 @@
"""Bank reconciliation AI re-rank prompt.
Used by fusion_accounting_bank_rec/services/confidence_scoring.py to ask
an LLM to refine the statistical ranking of candidate matches.
Output contract: the LLM MUST respond with valid JSON of shape:
{"ranked": [{"candidate_id": int, "confidence": float, "reason": str}, ...]}
System prompt is provider-agnostic - works with OpenAI Chat Completions,
Claude Messages, and local OpenAI-compatible servers (LM Studio, Ollama).
"""
from datetime import date
SYSTEM_PROMPT = """You are an expert accountant assisting with bank reconciliation.
Your job: given a bank statement line and a list of candidate journal items
that statistically scored well as potential matches, re-rank them based on
domain expertise. Consider:
1. **Amount-exact matches** are almost always correct unless the partner is wrong.
2. **Memo / reference clues** - bank memos often contain invoice numbers, partner
names, or transaction references that disambiguate matches.
3. **Date proximity** - invoices are typically reconciled within 30 days of issue.
4. **Pattern conformance** - if the partner has a learned pattern (e.g. "always
pays exact amount, weekly cadence"), favor candidates that fit that pattern.
5. **Precedent similarity** - if a near-identical reconcile happened before,
it's likely the right one.
Return ONLY valid JSON of this exact shape:
{
"ranked": [
{"candidate_id": <int>, "confidence": <float 0-1>, "reason": "<short string>"},
...
]
}
Do NOT include any prose before or after the JSON. Do NOT use markdown code fences.
The "ranked" array MUST contain every candidate_id from the input, in your
preferred order (highest confidence first).
"""
def build_prompt(statement_line, scored_candidates, pattern=None, precedents=None):
"""Build (system_prompt, user_prompt) for AI re-rank.
Args:
statement_line: account.bank.statement.line recordset (singleton)
scored_candidates: list of ScoredCandidate dataclasses (from confidence_scoring)
pattern: fusion.reconcile.pattern recordset for the partner, or None
precedents: list of PrecedentMatch dataclasses, or None
Returns:
(system_prompt: str, user_prompt: str) tuple
"""
user_parts = []
user_parts.append("BANK LINE:")
user_parts.append(f" Date: {statement_line.date}")
user_parts.append(
f" Amount: {statement_line.amount} {statement_line.currency_id.name or ''}"
)
user_parts.append(
f" Memo / payment ref: {statement_line.payment_ref or '(none)'}"
)
if statement_line.partner_id:
user_parts.append(f" Partner: {statement_line.partner_id.name}")
if pattern:
user_parts.append("")
user_parts.append("PARTNER PATTERN (learned from past reconciles):")
user_parts.append(f" Reconcile count: {pattern.reconcile_count}")
user_parts.append(f" Preferred strategy: {pattern.pref_strategy}")
user_parts.append(
f" Typical cadence: ~{pattern.typical_cadence_days} days between reconciles"
)
if pattern.typical_amount_range:
user_parts.append(f" Typical amount range: {pattern.typical_amount_range}")
if pattern.common_memo_tokens:
user_parts.append(f" Common memo tokens: {pattern.common_memo_tokens}")
if precedents:
user_parts.append("")
user_parts.append("RECENT PRECEDENTS (most-similar past reconciles for this partner):")
# Cap at 3 precedents to keep prompt small and reduce token cost.
for p in precedents[:3]:
user_parts.append(
f" - amount={p.amount}, similarity={p.similarity_score:.2f}, "
f"matched {p.matched_move_line_count} line(s), tokens={p.memo_tokens}"
)
user_parts.append("")
user_parts.append("CANDIDATES (scored by statistical pipeline):")
for s in scored_candidates:
user_parts.append(
f" - candidate_id={s.candidate_id}, statistical_confidence={s.confidence}, "
f"amount_match={s.score_amount_match}, pattern_fit={s.score_partner_pattern}, "
f"precedent_sim={s.score_precedent_similarity}, "
f"reason=\"{s.reasoning}\""
)
user_parts.append("")
user_parts.append("Re-rank these candidates and return JSON per the system prompt.")
user_prompt = "\n".join(user_parts)
return (SYSTEM_PROMPT, user_prompt)

View File

@@ -67,7 +67,16 @@ def match_bank_line_to_payments(env, params):
st_line = env['account.bank.statement.line'].browse(st_line_id)
if not st_line.exists():
return {'error': 'Statement line not found'}
st_line.set_line_bank_statement_line(move_line_ids)
# Phase 1 Task 23: route through engine when available
if 'fusion.reconcile.engine' in env.registry:
cands = env['account.move.line'].browse(move_line_ids).exists()
if not cands:
return {'error': 'No valid move_line_ids'}
env['fusion.reconcile.engine'].reconcile_one(
st_line, against_lines=cands)
st_line.invalidate_recordset(['is_reconciled'])
else:
st_line.set_line_bank_statement_line(move_line_ids)
return {
'status': 'matched',
'statement_line_id': st_line_id,
@@ -83,7 +92,12 @@ def auto_reconcile_bank_lines(env, params):
('company_id', '=', int(company_id)),
])
before_count = len(lines)
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
# Phase 1 Task 23: route through engine when available
if 'fusion.reconcile.engine' in env.registry:
env['fusion.reconcile.engine'].reconcile_batch(
lines, strategy='auto')
else:
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
still_unreconciled = env['account.bank.statement.line'].search([
('is_reconciled', '=', False),
('company_id', '=', int(company_id)),
@@ -946,6 +960,171 @@ def _format_aml_candidates(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 = {
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
'get_unreconciled_receipts': get_unreconciled_receipts,
@@ -962,4 +1141,10 @@ TOOLS = {
'reconcile_payroll_cheques': reconcile_payroll_cheques,
'suggest_bank_line_matches': suggest_bank_line_matches,
'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,
}

View File

@@ -177,14 +177,19 @@ class FusionReconcileEngine(models.AbstractModel):
if line.is_reconciled:
skipped += 1
continue
# Per-line savepoint so a single DB-level failure (e.g. a
# check-constraint violation on one bad line) doesn't poison
# the whole batch's transaction.
try:
candidates = self._fetch_candidates(line)
picked = self._apply_strategy(line, candidates, strategy)
if picked:
self.reconcile_one(line, against_lines=picked)
reconciled += 1
else:
skipped += 1
with self.env.cr.savepoint():
candidates = self._fetch_candidates(line)
picked = self._apply_strategy(
line, candidates, strategy)
if picked:
self.reconcile_one(line, against_lines=picked)
reconciled += 1
else:
skipped += 1
except Exception as e: # noqa: BLE001
errors.append({'line_id': line.id, 'error': str(e)})
_logger.warning(

View File

@@ -9,3 +9,7 @@ from . import test_reconcile_engine_unit
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
from . import test_bank_rec_tools
from . import test_legacy_tools_refactor

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

View File

@@ -0,0 +1,92 @@
"""Smoke tests for bank_rec_prompt module."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import (
SYSTEM_PROMPT,
build_prompt,
)
from odoo.addons.fusion_accounting_bank_rec.services.confidence_scoring import (
ScoredCandidate,
)
from . import _factories as f
@tagged('post_install', '-at_install')
class TestBankRecPrompt(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Prompt Test Partner'})
self.bank_line = f.make_bank_line(
self.env,
amount=1847.50,
partner=self.partner,
memo='RBC ETF DEP REF 4831',
)
self.scored = [
ScoredCandidate(
candidate_id=101,
confidence=0.92,
reasoning='Exact amount match',
score_amount_match=1.0,
score_partner_pattern=0.5,
score_precedent_similarity=0.85,
),
ScoredCandidate(
candidate_id=102,
confidence=0.71,
reasoning='Close amount',
score_amount_match=0.95,
score_partner_pattern=0.5,
score_precedent_similarity=0.6,
),
]
def test_system_prompt_requires_json_output(self):
self.assertIn('JSON', SYSTEM_PROMPT)
self.assertIn('"ranked"', SYSTEM_PROMPT)
def test_build_prompt_returns_tuple(self):
result = build_prompt(self.bank_line, self.scored)
self.assertEqual(len(result), 2)
system, user = result
self.assertIsInstance(system, str)
self.assertIsInstance(user, str)
def test_user_prompt_includes_bank_line_details(self):
_, user = build_prompt(self.bank_line, self.scored)
self.assertIn('1847.5', user)
self.assertIn('RBC ETF DEP REF 4831', user)
self.assertIn('Prompt Test Partner', user)
def test_user_prompt_includes_all_candidates(self):
_, user = build_prompt(self.bank_line, self.scored)
self.assertIn('candidate_id=101', user)
self.assertIn('candidate_id=102', user)
def test_user_prompt_omits_pattern_section_when_none(self):
_, user = build_prompt(self.bank_line, self.scored, pattern=None)
self.assertNotIn('PARTNER PATTERN', user)
def test_user_prompt_includes_pattern_section_when_provided(self):
pattern = f.make_pattern(self.env, partner=self.partner, reconcile_count=15)
_, user = build_prompt(self.bank_line, self.scored, pattern=pattern)
self.assertIn('PARTNER PATTERN', user)
self.assertIn('15', user)
def test_user_prompt_includes_precedents_when_provided(self):
from odoo.addons.fusion_accounting_bank_rec.services.precedent_lookup import (
PrecedentMatch,
)
precedents = [
PrecedentMatch(
precedent_id=1,
amount=1847.50,
memo_tokens='RBC,ETF',
matched_move_line_count=1,
similarity_score=0.95,
),
]
_, user = build_prompt(self.bank_line, self.scored, precedents=precedents)
self.assertIn('RECENT PRECEDENTS', user)
self.assertIn('0.95', user)

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

View File

@@ -0,0 +1,59 @@
"""Tests verifying legacy tools route through fusion.reconcile.engine when present.
These tests run in the fusion_accounting_bank_rec context where the engine IS
available, so they assert the engine path is taken and produces correct
results. The fallback path is exercised by the existing fusion_accounting_ai
tests when fusion_accounting_bank_rec is not installed."""
from unittest.mock import patch
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 TestLegacyToolsRefactor(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Refactor Test Partner'})
def test_match_bank_line_to_payments_uses_engine(self):
"""When engine is present, match_bank_line_to_payments must produce
a partial reconcile via the engine, not via set_line_bank_statement_line."""
bank_line, recv_lines = f.make_reconcileable_pair(
self.env, amount=180.00, partner=self.partner)
result = tools.match_bank_line_to_payments(self.env, {
'statement_line_id': bank_line.id,
'move_line_ids': recv_lines.ids,
})
self.assertEqual(result.get('status'), 'matched')
bank_line.invalidate_recordset(['is_reconciled'])
self.assertTrue(bank_line.is_reconciled)
# Verify a precedent was recorded - engine-only behaviour
Precedent = self.env['fusion.reconcile.precedent']
precedents = Precedent.search([('partner_id', '=', self.partner.id)])
self.assertGreater(len(precedents), 0,
"Engine path should record a precedent; legacy path would not")
def test_auto_reconcile_bank_lines_uses_engine(self):
"""When engine is present, auto_reconcile_bank_lines must call
fusion.reconcile.engine.reconcile_batch (not the Enterprise-only
_try_auto_reconcile_statement_lines fallback). We patch
reconcile_batch to verify routing without running the real engine
across every legacy unreconciled line in the test DB."""
Engine = type(self.env['fusion.reconcile.engine'])
with patch.object(
Engine, 'reconcile_batch', autospec=True,
return_value={'reconciled_count': 2, 'skipped': 0, 'errors': []},
) as engine_call:
result = tools.auto_reconcile_bank_lines(self.env, {
'company_id': self.env.company.id,
})
self.assertEqual(result['status'], 'completed')
self.assertTrue(engine_call.called,
"Engine path must invoke fusion.reconcile.engine.reconcile_batch")
# Verify the engine was passed the strategy='auto' kwarg per spec
_self, _lines = engine_call.call_args.args[0], engine_call.call_args.args[1]
self.assertEqual(engine_call.call_args.kwargs.get('strategy'), 'auto')