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',
|
||||
'version': '19.0.1.0.7',
|
||||
'version': '19.0.1.0.8',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 28,
|
||||
'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_mv_unreconciled
|
||||
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