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)
|
- FUSION: fusion.bank.rec.widget (added by fusion_accounting_bank_rec, Phase 1)
|
||||||
- ENTERPRISE: account_accountant's bank_rec_widget JS service
|
- ENTERPRISE: account_accountant's bank_rec_widget JS service
|
||||||
- COMMUNITY: pure search on account.bank.statement.line
|
- 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
|
from .base import DataAdapter
|
||||||
@@ -14,6 +20,10 @@ class BankRecAdapter(DataAdapter):
|
|||||||
FUSION_MODEL = 'fusion.bank.rec.widget'
|
FUSION_MODEL = 'fusion.bank.rec.widget'
|
||||||
ENTERPRISE_MODULE = 'account_accountant'
|
ENTERPRISE_MODULE = 'account_accountant'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# list_unreconciled
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
def list_unreconciled(self, journal_id=None, limit=100, date_from=None,
|
def list_unreconciled(self, journal_id=None, limit=100, date_from=None,
|
||||||
date_to=None, min_amount=None, company_id=None):
|
date_to=None, min_amount=None, company_id=None):
|
||||||
"""Return unreconciled bank statement lines.
|
"""Return unreconciled bank statement lines.
|
||||||
@@ -31,13 +41,29 @@ class BankRecAdapter(DataAdapter):
|
|||||||
def list_unreconciled_via_fusion(self, journal_id=None, limit=100,
|
def list_unreconciled_via_fusion(self, journal_id=None, limit=100,
|
||||||
date_from=None, date_to=None,
|
date_from=None, date_to=None,
|
||||||
min_amount=None, company_id=None):
|
min_amount=None, company_id=None):
|
||||||
# Phase 1 will add fusion.bank.rec.widget; this method becomes the primary path.
|
"""Community shape + fusion AI fields (top suggestion, band, attachments)."""
|
||||||
# For now: even when the model exists, delegate to community read shape.
|
base = self.list_unreconciled_via_community(
|
||||||
return self.list_unreconciled_via_community(
|
|
||||||
journal_id=journal_id, limit=limit,
|
journal_id=journal_id, limit=limit,
|
||||||
date_from=date_from, date_to=date_to,
|
date_from=date_from, date_to=date_to,
|
||||||
min_amount=min_amount, company_id=company_id,
|
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,
|
def list_unreconciled_via_enterprise(self, journal_id=None, limit=100,
|
||||||
date_from=None, date_to=None,
|
date_from=None, date_to=None,
|
||||||
@@ -83,5 +109,121 @@ class BankRecAdapter(DataAdapter):
|
|||||||
for r in records
|
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)
|
register_adapter('bank_rec', BankRecAdapter)
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from . import system_prompt
|
from . import system_prompt
|
||||||
from . import domain_prompts
|
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)
|
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||||
if not st_line.exists():
|
if not st_line.exists():
|
||||||
return {'error': 'Statement line not found'}
|
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 {
|
return {
|
||||||
'status': 'matched',
|
'status': 'matched',
|
||||||
'statement_line_id': st_line_id,
|
'statement_line_id': st_line_id,
|
||||||
@@ -83,7 +92,12 @@ def auto_reconcile_bank_lines(env, params):
|
|||||||
('company_id', '=', int(company_id)),
|
('company_id', '=', int(company_id)),
|
||||||
])
|
])
|
||||||
before_count = len(lines)
|
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([
|
still_unreconciled = env['account.bank.statement.line'].search([
|
||||||
('is_reconciled', '=', False),
|
('is_reconciled', '=', False),
|
||||||
('company_id', '=', int(company_id)),
|
('company_id', '=', int(company_id)),
|
||||||
@@ -946,6 +960,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 +1141,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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,14 +177,19 @@ class FusionReconcileEngine(models.AbstractModel):
|
|||||||
if line.is_reconciled:
|
if line.is_reconciled:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
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:
|
try:
|
||||||
candidates = self._fetch_candidates(line)
|
with self.env.cr.savepoint():
|
||||||
picked = self._apply_strategy(line, candidates, strategy)
|
candidates = self._fetch_candidates(line)
|
||||||
if picked:
|
picked = self._apply_strategy(
|
||||||
self.reconcile_one(line, against_lines=picked)
|
line, candidates, strategy)
|
||||||
reconciled += 1
|
if picked:
|
||||||
else:
|
self.reconcile_one(line, against_lines=picked)
|
||||||
skipped += 1
|
reconciled += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
errors.append({'line_id': line.id, 'error': str(e)})
|
errors.append({'line_id': line.id, 'error': str(e)})
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
|
|||||||
@@ -9,3 +9,7 @@ from . import test_reconcile_engine_unit
|
|||||||
from . import test_reconcile_engine_property
|
from . import test_reconcile_engine_property
|
||||||
from . import test_factories
|
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_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