Compare commits
4 Commits
8be0caa474
...
5020129c45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5020129c45 | ||
|
|
3993f58910 | ||
|
|
8eee64f053 | ||
|
|
2d099b2d0d |
@@ -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)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import system_prompt
|
||||
from . import domain_prompts
|
||||
from . import bank_rec_prompt
|
||||
|
||||
107
fusion_accounting_ai/services/prompts/bank_rec_prompt.py
Normal file
107
fusion_accounting_ai/services/prompts/bank_rec_prompt.py
Normal 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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
92
fusion_accounting_bank_rec/tests/test_bank_rec_prompt.py
Normal file
92
fusion_accounting_bank_rec/tests/test_bank_rec_prompt.py
Normal 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)
|
||||
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)
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user