217 lines
8.5 KiB
Python
217 lines
8.5 KiB
Python
"""Property-based tests for reconcile engine invariants.
|
|
|
|
Hypothesis generates random input combinations to catch edge cases that
|
|
example-based TDD missed. Each test runs N times (default 50 -- bumpable
|
|
via @settings)."""
|
|
|
|
from datetime import date
|
|
|
|
from hypothesis import HealthCheck, given, settings, strategies as st
|
|
from odoo.tests.common import TransactionCase, tagged
|
|
|
|
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import (
|
|
AmountExactStrategy,
|
|
Candidate,
|
|
FIFOStrategy,
|
|
MultiInvoiceStrategy,
|
|
)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'property_based')
|
|
class TestMatchingStrategyInvariants(TransactionCase):
|
|
"""Pure-Python invariants on the matching strategies (no ORM needed).
|
|
Faster + more iterations than DB-backed property tests."""
|
|
|
|
@given(
|
|
bank_amount=st.floats(min_value=0.01, max_value=100000.00,
|
|
allow_nan=False, allow_infinity=False),
|
|
invoice_amounts=st.lists(
|
|
st.floats(min_value=0.01, max_value=100000.00,
|
|
allow_nan=False, allow_infinity=False),
|
|
min_size=1, max_size=10,
|
|
),
|
|
)
|
|
@settings(max_examples=100, deadline=2000,
|
|
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_amount_exact_picks_only_when_amount_matches(
|
|
self, bank_amount, invoice_amounts):
|
|
"""AmountExactStrategy returns picks IFF some candidate amount matches
|
|
bank_amount within tolerance."""
|
|
candidates = [
|
|
Candidate(id=i, amount=round(amt, 2), partner_id=1, age_days=10)
|
|
for i, amt in enumerate(invoice_amounts)
|
|
]
|
|
bank_amount = round(bank_amount, 2)
|
|
result = AmountExactStrategy().match(
|
|
bank_amount=bank_amount, candidates=candidates)
|
|
|
|
has_match = any(
|
|
abs(c.amount - bank_amount) < 0.005 for c in candidates)
|
|
if has_match:
|
|
self.assertEqual(
|
|
len(result.picked_ids), 1,
|
|
f"bank=${bank_amount} candidates={[c.amount for c in candidates]} "
|
|
f"has_match={has_match} -> expected 1 pick, got {result.picked_ids}",
|
|
)
|
|
self.assertEqual(result.confidence, 1.0)
|
|
else:
|
|
self.assertEqual(result.picked_ids, [])
|
|
|
|
@given(
|
|
bank_amount=st.floats(min_value=10.00, max_value=10000.00,
|
|
allow_nan=False, allow_infinity=False),
|
|
invoice_amounts=st.lists(
|
|
st.floats(min_value=1.00, max_value=10000.00,
|
|
allow_nan=False, allow_infinity=False),
|
|
min_size=1, max_size=8,
|
|
),
|
|
)
|
|
@settings(max_examples=100, deadline=2000,
|
|
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_fifo_picks_oldest_first(self, bank_amount, invoice_amounts):
|
|
"""FIFOStrategy picks candidates in order of decreasing age_days
|
|
(oldest first), stopping when remaining <= 0."""
|
|
candidates = [
|
|
Candidate(id=i, amount=round(amt, 2), partner_id=1,
|
|
age_days=100 - i)
|
|
for i, amt in enumerate(invoice_amounts)
|
|
]
|
|
bank_amount = round(bank_amount, 2)
|
|
result = FIFOStrategy().match(
|
|
bank_amount=bank_amount, candidates=candidates)
|
|
|
|
if not candidates:
|
|
return
|
|
|
|
oldest_first_ids = [
|
|
c.id for c in sorted(candidates, key=lambda c: -c.age_days)]
|
|
self.assertEqual(
|
|
result.picked_ids,
|
|
oldest_first_ids[:len(result.picked_ids)],
|
|
)
|
|
|
|
picked_sum = sum(
|
|
c.amount for c in candidates if c.id in result.picked_ids)
|
|
self.assertAlmostEqual(
|
|
result.residual, bank_amount - picked_sum, places=2)
|
|
|
|
@given(
|
|
amounts=st.lists(
|
|
st.floats(min_value=1.00, max_value=1000.00,
|
|
allow_nan=False, allow_infinity=False),
|
|
min_size=2, max_size=6,
|
|
),
|
|
)
|
|
@settings(max_examples=50, deadline=2000,
|
|
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_multi_invoice_finds_combination_when_one_exists(self, amounts):
|
|
"""If amounts can sum to a target via <=3 elements, MultiInvoiceStrategy
|
|
finds SOME valid combination."""
|
|
rounded = [round(a, 2) for a in amounts]
|
|
candidates = [
|
|
Candidate(id=i, amount=amt, partner_id=1, age_days=10)
|
|
for i, amt in enumerate(rounded)
|
|
]
|
|
target = round(rounded[0] + rounded[1], 2)
|
|
result = MultiInvoiceStrategy(max_combinations=3).match(
|
|
bank_amount=target, candidates=candidates)
|
|
|
|
if result.picked_ids:
|
|
picked_sum = sum(
|
|
c.amount for c in candidates if c.id in result.picked_ids)
|
|
self.assertAlmostEqual(
|
|
picked_sum, target, places=2,
|
|
msg=(f"target={target} picks={result.picked_ids} "
|
|
f"sum={picked_sum} candidates={rounded}"),
|
|
)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'property_based', 'engine_invariants')
|
|
class TestReconcileEngineInvariants(TransactionCase):
|
|
"""ORM-backed property tests against the engine.
|
|
Slower because each test creates real bank_lines + invoices."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.partner = self.env['res.partner'].create(
|
|
{'name': 'Engine Property Partner'})
|
|
self.journal = self.env['account.journal'].create({
|
|
'name': 'Engine Property Bank',
|
|
'type': 'bank',
|
|
'code': 'EPB',
|
|
})
|
|
self.receivable_account = self.env['account.account'].search([
|
|
('account_type', '=', 'asset_receivable'),
|
|
('company_ids', 'in', self.env.company.id),
|
|
], limit=1)
|
|
if not self.receivable_account:
|
|
self.skipTest("No receivable account in chart of accounts")
|
|
|
|
def _make_bank_line(self, amount):
|
|
statement = self.env['account.bank.statement'].create({
|
|
'name': f'Test stmt {amount}',
|
|
'journal_id': self.journal.id,
|
|
})
|
|
return self.env['account.bank.statement.line'].create({
|
|
'statement_id': statement.id,
|
|
'journal_id': self.journal.id,
|
|
'date': date.today(),
|
|
'payment_ref': f'Test {amount}',
|
|
'amount': amount,
|
|
'partner_id': self.partner.id,
|
|
})
|
|
|
|
def _make_invoice(self, amount):
|
|
product = self.env['product.product'].search(
|
|
[('type', '=', 'service')], limit=1)
|
|
if not product:
|
|
product = self.env['product.product'].create({
|
|
'name': 'Property Test Service',
|
|
'type': 'service',
|
|
})
|
|
move = self.env['account.move'].create({
|
|
'move_type': 'out_invoice',
|
|
'partner_id': self.partner.id,
|
|
'invoice_date': date.today(),
|
|
'invoice_line_ids': [(0, 0, {
|
|
'product_id': product.id,
|
|
'name': 'Property Test',
|
|
'quantity': 1,
|
|
'price_unit': amount,
|
|
})],
|
|
})
|
|
move.action_post()
|
|
return move
|
|
|
|
@given(amount=st.floats(min_value=10.00, max_value=10000.00,
|
|
allow_nan=False, allow_infinity=False))
|
|
@settings(max_examples=10, deadline=10000,
|
|
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
def test_invariant_simple_reconcile_balances(self, amount):
|
|
"""For any bank_amount = invoice_amount, reconcile_one produces:
|
|
- exactly 1 partial reconcile
|
|
- amount equal to the bank line amount
|
|
- bank line is_reconciled = True"""
|
|
amount = round(amount, 2)
|
|
bank_line = self._make_bank_line(amount)
|
|
invoice = self._make_invoice(amount)
|
|
invoice_recv_lines = invoice.line_ids.filtered(
|
|
lambda line: line.account_id.account_type == 'asset_receivable')
|
|
|
|
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
|
bank_line, against_lines=invoice_recv_lines)
|
|
|
|
self.assertGreater(
|
|
len(result['partial_ids']), 0,
|
|
f"Expected partial_ids non-empty for amount={amount}, got {result}",
|
|
)
|
|
partials = self.env['account.partial.reconcile'].browse(
|
|
result['partial_ids'])
|
|
self.assertAlmostEqual(
|
|
sum(partials.mapped('amount')), amount, places=2)
|
|
bank_line.invalidate_recordset(['is_reconciled'])
|
|
self.assertTrue(
|
|
bank_line.is_reconciled,
|
|
f"is_reconciled expected True after reconcile for amount={amount}",
|
|
)
|