feat(fusion_accounting_bank_rec): 10 JSON-RPC endpoints for OWL widget
All endpoints route through fusion.reconcile.engine via BankRecAdapter (or directly for engine methods adapter doesn't expose). Uses V19's type='jsonrpc' (replacement for deprecated type='json'). Auth=user. Endpoints: - get_state, list_unreconciled, get_line_detail (read) - suggest_matches, accept_suggestion (AI surface) - reconcile_manual, unreconcile, write_off, bulk_reconcile (write) - get_partner_history (precedent + pattern read) Tests use HttpCase to exercise the real Werkzeug stack as a Fusion Accounting administrator. Includes a smoke test for the deferred write-off path (Task 12) and a negative test confirming auth='user' rejects anonymous requests. Helper _make_pair shares one bank journal across pairs to avoid the (code, company) unique-constraint collision that the default factory would hit on repeat calls. Verified: 11/11 controller tests pass, 134/134 module tests pass. Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||||
'version': '19.0.1.0.7',
|
'version': '19.0.1.0.8',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 28,
|
'sequence': 28,
|
||||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import bank_rec_controller
|
||||||
|
|||||||
325
fusion_accounting_bank_rec/controllers/bank_rec_controller.py
Normal file
325
fusion_accounting_bank_rec/controllers/bank_rec_controller.py
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"""HTTP controller: 10 JSON-RPC endpoints for the OWL bank-rec widget.
|
||||||
|
|
||||||
|
All endpoints route through ``BankRecAdapter`` (which lives in
|
||||||
|
``fusion_accounting_ai`` and already encapsulates fusion / enterprise /
|
||||||
|
community routing) or directly through ``fusion.reconcile.engine`` for
|
||||||
|
methods the adapter does not yet expose. The controller never touches
|
||||||
|
``account.partial.reconcile`` directly.
|
||||||
|
|
||||||
|
V19: uses ``@route(type='jsonrpc')``, the V19-blessed replacement for the
|
||||||
|
deprecated ``type='json'`` (Odoo 19 logs a deprecation warning if you
|
||||||
|
still use ``json``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import _, http
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _adapter():
|
||||||
|
"""Resolve the bank-rec data adapter from fusion_accounting_ai."""
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.data_adapters import (
|
||||||
|
get_adapter,
|
||||||
|
)
|
||||||
|
return get_adapter(request.env, 'bank_rec')
|
||||||
|
|
||||||
|
|
||||||
|
class FusionBankRecController(http.Controller):
|
||||||
|
"""JSON-RPC surface consumed by the OWL bank-reconciliation widget.
|
||||||
|
|
||||||
|
All routes are ``auth='user'`` -- anonymous traffic is rejected by
|
||||||
|
Odoo's HTTP layer before reaching the handler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. get_state -- initial widget bootstrap
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/get_state', type='jsonrpc', auth='user')
|
||||||
|
def get_state(self, journal_id, company_id):
|
||||||
|
"""Return the journal summary that seeds the kanban widget."""
|
||||||
|
Journal = request.env['account.journal']
|
||||||
|
Line = request.env['account.bank.statement.line']
|
||||||
|
journal = Journal.browse(int(journal_id))
|
||||||
|
if not journal.exists():
|
||||||
|
raise ValidationError(_("Journal %s not found") % journal_id)
|
||||||
|
company_id = int(company_id) if company_id else request.env.company.id
|
||||||
|
unreconciled_lines = Line.search([
|
||||||
|
('journal_id', '=', journal.id),
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
])
|
||||||
|
total_amount = sum(abs(l.amount) for l in unreconciled_lines)
|
||||||
|
last_stmt = request.env['account.bank.statement'].search(
|
||||||
|
[('journal_id', '=', journal.id)],
|
||||||
|
order='date desc', limit=1)
|
||||||
|
currency = journal.currency_id or journal.company_id.currency_id
|
||||||
|
return {
|
||||||
|
'journal': {
|
||||||
|
'id': journal.id,
|
||||||
|
'name': journal.name,
|
||||||
|
'currency_code': currency.name,
|
||||||
|
},
|
||||||
|
'unreconciled_count': len(unreconciled_lines),
|
||||||
|
'total_pending_amount': total_amount,
|
||||||
|
'last_statement_date': str(last_stmt.date) if last_stmt and last_stmt.date else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. list_unreconciled -- paginated, fusion-enriched
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/list_unreconciled', type='jsonrpc', auth='user')
|
||||||
|
def list_unreconciled(self, journal_id, limit=50, offset=0,
|
||||||
|
company_id=None, date_from=None, date_to=None,
|
||||||
|
min_amount=None):
|
||||||
|
"""Return enriched, paginated unreconciled bank lines."""
|
||||||
|
limit = int(limit)
|
||||||
|
offset = int(offset)
|
||||||
|
company_id = (int(company_id) if company_id
|
||||||
|
else request.env.company.id)
|
||||||
|
# The adapter doesn't take an offset; over-fetch and slice.
|
||||||
|
rows = _adapter().list_unreconciled(
|
||||||
|
journal_id=int(journal_id),
|
||||||
|
limit=limit + offset,
|
||||||
|
company_id=company_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
min_amount=min_amount,
|
||||||
|
)
|
||||||
|
sliced = rows[offset:offset + limit]
|
||||||
|
Line = request.env['account.bank.statement.line']
|
||||||
|
domain = [
|
||||||
|
('journal_id', '=', int(journal_id)),
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
]
|
||||||
|
if date_from:
|
||||||
|
domain.append(('date', '>=', date_from))
|
||||||
|
if date_to:
|
||||||
|
domain.append(('date', '<=', date_to))
|
||||||
|
if min_amount is not None:
|
||||||
|
domain.append(('amount', '>=', float(min_amount)))
|
||||||
|
total = Line.search_count(domain)
|
||||||
|
return {
|
||||||
|
'count': len(sliced),
|
||||||
|
'total': total,
|
||||||
|
'lines': sliced,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. get_line_detail -- one line + suggestions + attachments
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/get_line_detail', type='jsonrpc', auth='user')
|
||||||
|
def get_line_detail(self, statement_line_id):
|
||||||
|
"""Return full detail for one line including pending suggestions."""
|
||||||
|
Line = request.env['account.bank.statement.line']
|
||||||
|
line = Line.browse(int(statement_line_id))
|
||||||
|
if not line.exists():
|
||||||
|
raise ValidationError(
|
||||||
|
_("Statement line %s not found") % statement_line_id)
|
||||||
|
Sug = request.env['fusion.reconcile.suggestion']
|
||||||
|
suggestions = Sug.search([
|
||||||
|
('statement_line_id', '=', line.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
], order='confidence desc, rank asc')
|
||||||
|
Att = request.env['ir.attachment']
|
||||||
|
attachments = Att.search([
|
||||||
|
('res_model', '=', 'account.move'),
|
||||||
|
('res_id', '=', line.move_id.id),
|
||||||
|
]) if line.move_id else Att.browse()
|
||||||
|
currency = line.currency_id or line.company_id.currency_id
|
||||||
|
return {
|
||||||
|
'line': {
|
||||||
|
'id': line.id,
|
||||||
|
'date': str(line.date) if line.date else None,
|
||||||
|
'payment_ref': line.payment_ref or '',
|
||||||
|
'amount': line.amount,
|
||||||
|
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||||
|
'partner_name': line.partner_id.name if line.partner_id else None,
|
||||||
|
'currency_id': currency.id,
|
||||||
|
'currency_code': currency.name,
|
||||||
|
'journal_id': line.journal_id.id,
|
||||||
|
'journal_name': line.journal_id.name,
|
||||||
|
'is_reconciled': line.is_reconciled,
|
||||||
|
},
|
||||||
|
'suggestions': [{
|
||||||
|
'id': s.id,
|
||||||
|
'candidate_ids': s.proposed_move_line_ids.ids,
|
||||||
|
'confidence': s.confidence,
|
||||||
|
'rank': s.rank,
|
||||||
|
'reasoning': s.reasoning or '',
|
||||||
|
'scores': {
|
||||||
|
'amount_match': s.score_amount_match,
|
||||||
|
'partner_pattern': s.score_partner_pattern,
|
||||||
|
'precedent_similarity': s.score_precedent_similarity,
|
||||||
|
'ai_rerank': s.score_ai_rerank,
|
||||||
|
},
|
||||||
|
} for s in suggestions],
|
||||||
|
'attachments': [{
|
||||||
|
'id': a.id,
|
||||||
|
'name': a.name,
|
||||||
|
'mimetype': a.mimetype,
|
||||||
|
} for a in attachments],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 4. suggest_matches -- lazy AI suggest for a line
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/suggest_matches', type='jsonrpc', auth='user')
|
||||||
|
def suggest_matches(self, statement_line_ids, limit_per_line=3):
|
||||||
|
"""Trigger AI suggest for one or more statement lines."""
|
||||||
|
ids = [int(i) for i in (statement_line_ids or [])]
|
||||||
|
result = _adapter().suggest_matches(
|
||||||
|
statement_line_ids=ids,
|
||||||
|
limit_per_line=int(limit_per_line),
|
||||||
|
)
|
||||||
|
return {'suggestions': result}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 5. accept_suggestion -- promote AI suggestion to real reconcile
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/accept_suggestion', type='jsonrpc', auth='user')
|
||||||
|
def accept_suggestion(self, suggestion_id):
|
||||||
|
"""Accept a fusion suggestion. Returns the partial IDs created."""
|
||||||
|
sug = request.env['fusion.reconcile.suggestion'].browse(
|
||||||
|
int(suggestion_id))
|
||||||
|
if not sug.exists():
|
||||||
|
raise ValidationError(
|
||||||
|
_("Suggestion %s not found") % suggestion_id)
|
||||||
|
# Capture the journal/company before reconcile (the sug may go stale).
|
||||||
|
journal_id = sug.statement_line_id.journal_id.id
|
||||||
|
company_id = sug.company_id.id
|
||||||
|
result = _adapter().accept_suggestion(suggestion_id=int(suggestion_id))
|
||||||
|
unreconciled_count_after = request.env[
|
||||||
|
'account.bank.statement.line'].search_count([
|
||||||
|
('journal_id', '=', journal_id),
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
'status': 'accepted',
|
||||||
|
'partial_ids': result.get('partial_ids', []),
|
||||||
|
'unreconciled_count_after': unreconciled_count_after,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 6. reconcile_manual -- user picked candidates manually
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/reconcile_manual', type='jsonrpc', auth='user')
|
||||||
|
def reconcile_manual(self, statement_line_id, against_move_line_ids):
|
||||||
|
"""Reconcile a line against an explicit set of journal items."""
|
||||||
|
line = request.env['account.bank.statement.line'].browse(
|
||||||
|
int(statement_line_id))
|
||||||
|
if not line.exists():
|
||||||
|
raise ValidationError(
|
||||||
|
_("Statement line %s not found") % statement_line_id)
|
||||||
|
cands = request.env['account.move.line'].browse(
|
||||||
|
[int(i) for i in (against_move_line_ids or [])])
|
||||||
|
result = request.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
line, against_lines=cands)
|
||||||
|
return {
|
||||||
|
'status': 'reconciled',
|
||||||
|
'partial_ids': result.get('partial_ids', []),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 7. unreconcile -- reverse a prior reconcile
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/unreconcile', type='jsonrpc', auth='user')
|
||||||
|
def unreconcile(self, partial_reconcile_ids):
|
||||||
|
"""Reverse one or more partial reconciles."""
|
||||||
|
ids = [int(i) for i in (partial_reconcile_ids or [])]
|
||||||
|
result = _adapter().unreconcile(partial_reconcile_ids=ids)
|
||||||
|
return {
|
||||||
|
'status': 'unreconciled',
|
||||||
|
'unreconciled_line_ids': result.get('unreconciled_line_ids', []),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 8. write_off -- absorb residual into a write-off account
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/write_off', type='jsonrpc', auth='user')
|
||||||
|
def write_off(self, statement_line_id, account_id, amount, label,
|
||||||
|
tax_id=None):
|
||||||
|
"""Apply a write-off against a bank statement line."""
|
||||||
|
line = request.env['account.bank.statement.line'].browse(
|
||||||
|
int(statement_line_id))
|
||||||
|
if not line.exists():
|
||||||
|
raise ValidationError(
|
||||||
|
_("Statement line %s not found") % statement_line_id)
|
||||||
|
account = request.env['account.account'].browse(int(account_id))
|
||||||
|
tax = (request.env['account.tax'].browse(int(tax_id))
|
||||||
|
if tax_id else None)
|
||||||
|
result = request.env['fusion.reconcile.engine'].write_off(
|
||||||
|
line, account=account, amount=float(amount),
|
||||||
|
tax_id=tax, label=label)
|
||||||
|
return {
|
||||||
|
'status': 'written_off',
|
||||||
|
'partial_ids': result.get('partial_ids', []),
|
||||||
|
'write_off_move_id': result.get('write_off_move_id'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 9. bulk_reconcile -- batch auto-reconcile a recordset
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/bulk_reconcile', type='jsonrpc', auth='user')
|
||||||
|
def bulk_reconcile(self, statement_line_ids, strategy='auto'):
|
||||||
|
"""Batch auto-reconcile. Returns counts + per-line errors."""
|
||||||
|
ids = [int(i) for i in (statement_line_ids or [])]
|
||||||
|
lines = request.env['account.bank.statement.line'].browse(ids)
|
||||||
|
result = request.env['fusion.reconcile.engine'].reconcile_batch(
|
||||||
|
lines, strategy=strategy)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 10. get_partner_history -- partner reconcile history panel
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/get_partner_history', type='jsonrpc', auth='user')
|
||||||
|
def get_partner_history(self, partner_id, limit=20):
|
||||||
|
"""Return a partner's reconcile history + learned pattern."""
|
||||||
|
Partner = request.env['res.partner']
|
||||||
|
partner = Partner.browse(int(partner_id))
|
||||||
|
if not partner.exists():
|
||||||
|
raise ValidationError(_("Partner %s not found") % partner_id)
|
||||||
|
Precedent = request.env['fusion.reconcile.precedent']
|
||||||
|
recent = Precedent.search(
|
||||||
|
[('partner_id', '=', partner.id)],
|
||||||
|
order='reconciled_at desc, id desc',
|
||||||
|
limit=int(limit),
|
||||||
|
)
|
||||||
|
Pattern = request.env['fusion.reconcile.pattern']
|
||||||
|
pattern = Pattern.search(
|
||||||
|
[('partner_id', '=', partner.id)], limit=1)
|
||||||
|
return {
|
||||||
|
'partner': {
|
||||||
|
'id': partner.id,
|
||||||
|
'name': partner.name,
|
||||||
|
},
|
||||||
|
'recent_reconciles': [{
|
||||||
|
'precedent_id': p.id,
|
||||||
|
'date': str(p.date) if p.date else None,
|
||||||
|
'amount': p.amount,
|
||||||
|
'memo_tokens': p.memo_tokens or '',
|
||||||
|
'matched_count': p.matched_move_line_count,
|
||||||
|
'source': p.source,
|
||||||
|
} for p in recent],
|
||||||
|
'pattern': ({
|
||||||
|
'reconcile_count': pattern.reconcile_count,
|
||||||
|
'pref_strategy': pattern.pref_strategy or None,
|
||||||
|
'common_memo_tokens': pattern.common_memo_tokens or None,
|
||||||
|
'typical_cadence_days': pattern.typical_cadence_days,
|
||||||
|
} if pattern else None),
|
||||||
|
}
|
||||||
@@ -15,3 +15,4 @@ from . import test_bank_rec_tools
|
|||||||
from . import test_legacy_tools_refactor
|
from . import test_legacy_tools_refactor
|
||||||
from . import test_mv_unreconciled
|
from . import test_mv_unreconciled
|
||||||
from . import test_cron_methods
|
from . import test_cron_methods
|
||||||
|
from . import test_controller
|
||||||
|
|||||||
333
fusion_accounting_bank_rec/tests/test_controller.py
Normal file
333
fusion_accounting_bank_rec/tests/test_controller.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
"""Tests for the fusion bank-rec HTTP controller (Task 26).
|
||||||
|
|
||||||
|
Uses ``HttpCase`` so we exercise the full Werkzeug stack -- the JSON-RPC
|
||||||
|
dispatcher, auth check, and route resolution all run for real. Tests
|
||||||
|
authenticate as a Fusion Accounting administrator (the realistic role
|
||||||
|
for a user driving the bank-rec widget); a separate test confirms the
|
||||||
|
``auth='user'`` decorator rejects anonymous traffic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo.tests.common import HttpCase, new_test_user, tagged
|
||||||
|
|
||||||
|
from . import _factories as f
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestBankRecController(HttpCase):
|
||||||
|
"""End-to-end coverage of the 10 JSON-RPC endpoints."""
|
||||||
|
|
||||||
|
USER_LOGIN = 'ctrl_test_user'
|
||||||
|
USER_PASSWORD = 'ctrl_test_user'
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# group_account_user grants accounting write perms AND auto-implies
|
||||||
|
# fusion_accounting_user via the security XML's implied_ids hook;
|
||||||
|
# group_fusion_accounting_admin grants full CRUD on the fusion
|
||||||
|
# suggestion / precedent / pattern models the engine writes to.
|
||||||
|
self.user = new_test_user(
|
||||||
|
self.env,
|
||||||
|
login=self.USER_LOGIN,
|
||||||
|
password=self.USER_PASSWORD,
|
||||||
|
groups=(
|
||||||
|
'base.group_user,'
|
||||||
|
'account.group_account_user,'
|
||||||
|
'fusion_accounting_core.group_fusion_accounting_admin'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.partner = self.env['res.partner'].create(
|
||||||
|
{'name': 'Controller Test Partner'})
|
||||||
|
self.journal = f.make_bank_journal(
|
||||||
|
self.env, name='Ctrl Bank', code='CBNK')
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _jsonrpc(self, endpoint, params, *, authenticate=True):
|
||||||
|
"""POST a JSON-RPC envelope to ``/fusion/bank_rec/<endpoint>``.
|
||||||
|
|
||||||
|
Returns the ``result`` dict on success and fails the test if
|
||||||
|
the body has an ``error`` key (so endpoint test failures show
|
||||||
|
the actual server-side exception, not just the HTTP status).
|
||||||
|
"""
|
||||||
|
if authenticate:
|
||||||
|
self.authenticate(self.USER_LOGIN, self.USER_PASSWORD)
|
||||||
|
url = f'/fusion/bank_rec/{endpoint}'
|
||||||
|
body = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'call',
|
||||||
|
'params': params,
|
||||||
|
'id': 1,
|
||||||
|
}
|
||||||
|
response = self.url_open(
|
||||||
|
url,
|
||||||
|
data=json.dumps(body),
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code, 200,
|
||||||
|
f"Endpoint {endpoint} returned {response.status_code}: "
|
||||||
|
f"{response.text[:300]}")
|
||||||
|
payload = response.json()
|
||||||
|
if 'error' in payload:
|
||||||
|
self.fail(
|
||||||
|
f"Endpoint {endpoint} errored: "
|
||||||
|
f"{json.dumps(payload['error'])[:600]}")
|
||||||
|
return payload.get('result', {})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. get_state
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_get_state(self):
|
||||||
|
result = self._jsonrpc('get_state', {
|
||||||
|
'journal_id': self.journal.id,
|
||||||
|
'company_id': self.env.company.id,
|
||||||
|
})
|
||||||
|
self.assertIn('journal', result)
|
||||||
|
self.assertEqual(result['journal']['id'], self.journal.id)
|
||||||
|
self.assertIn('unreconciled_count', result)
|
||||||
|
self.assertIn('total_pending_amount', result)
|
||||||
|
self.assertIn('last_statement_date', result)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. list_unreconciled
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_list_unreconciled(self):
|
||||||
|
# Reuse a single statement so we don't trip the
|
||||||
|
# (journal_id, name) uniqueness or hit the parent-move autocreate
|
||||||
|
# path twice in the same flush window.
|
||||||
|
statement = f.make_bank_statement(
|
||||||
|
self.env, journal=self.journal, name='List Stmt')
|
||||||
|
f.make_bank_line(
|
||||||
|
self.env, journal=self.journal, statement=statement,
|
||||||
|
amount=100, partner=self.partner, memo='List 1')
|
||||||
|
f.make_bank_line(
|
||||||
|
self.env, journal=self.journal, statement=statement,
|
||||||
|
amount=200, partner=self.partner, memo='List 2')
|
||||||
|
result = self._jsonrpc('list_unreconciled', {
|
||||||
|
'journal_id': self.journal.id,
|
||||||
|
'limit': 50,
|
||||||
|
'offset': 0,
|
||||||
|
'company_id': self.env.company.id,
|
||||||
|
})
|
||||||
|
self.assertIn('lines', result)
|
||||||
|
self.assertGreaterEqual(len(result['lines']), 2)
|
||||||
|
self.assertGreaterEqual(result['total'], 2)
|
||||||
|
first = result['lines'][0]
|
||||||
|
for key in ('id', 'amount', 'fusion_top_suggestion_id',
|
||||||
|
'fusion_confidence_band', 'attachment_count'):
|
||||||
|
self.assertIn(key, first)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. get_line_detail
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_get_line_detail(self):
|
||||||
|
line = f.make_bank_line(
|
||||||
|
self.env, journal=self.journal, amount=100, partner=self.partner)
|
||||||
|
f.make_suggestion(
|
||||||
|
self.env, statement_line=line, confidence=0.85)
|
||||||
|
result = self._jsonrpc(
|
||||||
|
'get_line_detail', {'statement_line_id': line.id})
|
||||||
|
self.assertEqual(result['line']['id'], line.id)
|
||||||
|
self.assertEqual(result['line']['amount'], 100.0)
|
||||||
|
self.assertGreaterEqual(len(result['suggestions']), 1)
|
||||||
|
sug = result['suggestions'][0]
|
||||||
|
for key in ('id', 'candidate_ids', 'confidence', 'rank',
|
||||||
|
'reasoning', 'scores'):
|
||||||
|
self.assertIn(key, sug)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 4. suggest_matches
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_suggest_matches(self):
|
||||||
|
f.make_invoice(self.env, partner=self.partner, amount=300)
|
||||||
|
line = f.make_bank_line(
|
||||||
|
self.env, journal=self.journal, amount=300, partner=self.partner)
|
||||||
|
result = self._jsonrpc('suggest_matches', {
|
||||||
|
'statement_line_ids': [line.id],
|
||||||
|
'limit_per_line': 3,
|
||||||
|
})
|
||||||
|
self.assertIn('suggestions', result)
|
||||||
|
self.assertIsInstance(result['suggestions'], dict)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 5. accept_suggestion
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_accept_suggestion(self):
|
||||||
|
invoice = f.make_invoice(
|
||||||
|
self.env, partner=self.partner, amount=400)
|
||||||
|
recv = invoice.line_ids.filtered(
|
||||||
|
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||||
|
line = f.make_bank_line(
|
||||||
|
self.env, journal=self.journal, amount=400, partner=self.partner)
|
||||||
|
sug = f.make_suggestion(
|
||||||
|
self.env, statement_line=line,
|
||||||
|
candidate_move_lines=recv, confidence=0.92)
|
||||||
|
result = self._jsonrpc(
|
||||||
|
'accept_suggestion', {'suggestion_id': sug.id})
|
||||||
|
self.assertEqual(result['status'], 'accepted')
|
||||||
|
self.assertGreater(len(result['partial_ids']), 0)
|
||||||
|
self.assertIn('unreconciled_count_after', result)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 6. reconcile_manual
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_pair(self, *, amount, statement=None):
|
||||||
|
"""Inline reconcile-able pair against ``self.journal``.
|
||||||
|
|
||||||
|
The shared ``make_reconcileable_pair`` factory creates a fresh bank
|
||||||
|
journal per call (default code 'TEST'), which collides with the
|
||||||
|
unique (code, company) constraint when used multiple times in one
|
||||||
|
test. Reusing ``self.journal`` (and optionally a shared statement)
|
||||||
|
keeps every pair on the same journal.
|
||||||
|
"""
|
||||||
|
invoice = f.make_invoice(
|
||||||
|
self.env, partner=self.partner, amount=amount)
|
||||||
|
recv = invoice.line_ids.filtered(
|
||||||
|
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||||
|
line = f.make_bank_line(
|
||||||
|
self.env, journal=self.journal, statement=statement,
|
||||||
|
amount=amount, partner=self.partner)
|
||||||
|
return line, recv
|
||||||
|
|
||||||
|
def test_reconcile_manual(self):
|
||||||
|
line, recv = self._make_pair(amount=550)
|
||||||
|
result = self._jsonrpc('reconcile_manual', {
|
||||||
|
'statement_line_id': line.id,
|
||||||
|
'against_move_line_ids': recv.ids,
|
||||||
|
})
|
||||||
|
self.assertEqual(result['status'], 'reconciled')
|
||||||
|
self.assertGreater(len(result['partial_ids']), 0)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 7. unreconcile
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_unreconcile(self):
|
||||||
|
line, recv = self._make_pair(amount=625)
|
||||||
|
rec = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
line, against_lines=recv)
|
||||||
|
result = self._jsonrpc('unreconcile', {
|
||||||
|
'partial_reconcile_ids': rec['partial_ids'],
|
||||||
|
})
|
||||||
|
self.assertEqual(result['status'], 'unreconciled')
|
||||||
|
self.assertGreater(len(result['unreconciled_line_ids']), 0)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 8. write_off -- smoke only (Task 12 deferred full coverage)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_write_off_smoke(self):
|
||||||
|
line = f.make_bank_line(
|
||||||
|
self.env, journal=self.journal, amount=12.34,
|
||||||
|
partner=self.partner)
|
||||||
|
# Pick any expense-type account that exists in the chart.
|
||||||
|
wo_account = self.env['account.account'].search([
|
||||||
|
('account_type', '=', 'expense'),
|
||||||
|
('company_ids', 'in', self.env.company.id),
|
||||||
|
], limit=1)
|
||||||
|
if not wo_account:
|
||||||
|
self.skipTest("No expense account available for write-off smoke")
|
||||||
|
# Endpoint must respond without 500-erroring; engine may legitimately
|
||||||
|
# raise a ValidationError for an over-allocation, in which case the
|
||||||
|
# JSON-RPC response will include an 'error' key. We accept either
|
||||||
|
# success or a structured error -- what we are guarding against is a
|
||||||
|
# routing-layer regression (NameError, missing import, etc.).
|
||||||
|
url = '/fusion/bank_rec/write_off'
|
||||||
|
self.authenticate(self.USER_LOGIN, self.USER_PASSWORD)
|
||||||
|
body = {
|
||||||
|
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
|
||||||
|
'params': {
|
||||||
|
'statement_line_id': line.id,
|
||||||
|
'account_id': wo_account.id,
|
||||||
|
'amount': line.amount,
|
||||||
|
'label': 'Smoke write-off',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response = self.url_open(
|
||||||
|
url, data=json.dumps(body),
|
||||||
|
headers={'Content-Type': 'application/json'})
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code, 200,
|
||||||
|
f"write_off returned {response.status_code}: "
|
||||||
|
f"{response.text[:300]}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 9. bulk_reconcile
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_bulk_reconcile(self):
|
||||||
|
statement = f.make_bank_statement(
|
||||||
|
self.env, journal=self.journal, name='Bulk Stmt')
|
||||||
|
line_ids = []
|
||||||
|
for amt in (110, 220, 330):
|
||||||
|
line, _recv = self._make_pair(amount=amt, statement=statement)
|
||||||
|
line_ids.append(line.id)
|
||||||
|
result = self._jsonrpc('bulk_reconcile', {
|
||||||
|
'statement_line_ids': line_ids,
|
||||||
|
'strategy': 'auto',
|
||||||
|
})
|
||||||
|
self.assertIn('reconciled_count', result)
|
||||||
|
self.assertGreaterEqual(result['reconciled_count'], 3)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 10. get_partner_history
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_get_partner_history(self):
|
||||||
|
for d in (5, 12, 20):
|
||||||
|
f.make_precedent(
|
||||||
|
self.env, partner=self.partner, days_ago=d, amount=1000)
|
||||||
|
f.make_pattern(
|
||||||
|
self.env, partner=self.partner, reconcile_count=3)
|
||||||
|
result = self._jsonrpc('get_partner_history', {
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'limit': 10,
|
||||||
|
})
|
||||||
|
self.assertEqual(result['partner']['id'], self.partner.id)
|
||||||
|
self.assertGreaterEqual(len(result['recent_reconciles']), 3)
|
||||||
|
self.assertIsNotNone(result['pattern'])
|
||||||
|
self.assertEqual(result['pattern']['reconcile_count'], 3)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 11. unauthenticated traffic is blocked
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_unauthenticated_request_blocked(self):
|
||||||
|
# Use a fresh session by creating a new opener -- self.url_open
|
||||||
|
# reuses the test session, which `authenticate()` would mutate.
|
||||||
|
url = '/fusion/bank_rec/get_state'
|
||||||
|
body = {
|
||||||
|
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
|
||||||
|
'params': {
|
||||||
|
'journal_id': self.journal.id,
|
||||||
|
'company_id': self.env.company.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# No call to self.authenticate() -> session has no uid.
|
||||||
|
response = self.url_open(
|
||||||
|
url, data=json.dumps(body),
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
# Odoo's auth='user' on a JSON-RPC route returns a 200 with an
|
||||||
|
# error envelope (SessionExpiredException) when not authenticated;
|
||||||
|
# what must NOT happen is the handler running and returning our
|
||||||
|
# success payload.
|
||||||
|
if response.status_code == 200:
|
||||||
|
payload = response.json()
|
||||||
|
self.assertIn(
|
||||||
|
'error', payload,
|
||||||
|
"Unauthenticated request should not return a success result")
|
||||||
|
else:
|
||||||
|
# 3xx redirect or 4xx are also acceptable rejections.
|
||||||
|
self.assertGreaterEqual(response.status_code, 300)
|
||||||
Reference in New Issue
Block a user