changes
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
from . import test_overdue_aging
|
||||
from . import test_level_resolver
|
||||
from . import test_risk_scorer
|
||||
from . import test_tone_selector
|
||||
from . import test_followup_text_generator
|
||||
from . import test_fusion_followup_level
|
||||
from . import test_fusion_followup_run
|
||||
from . import test_fusion_followup_text_cache
|
||||
from . import test_res_partner_inherit
|
||||
from . import test_account_move_line_inherit
|
||||
from . import test_fusion_followup_engine
|
||||
from . import test_engine_integration
|
||||
from . import test_followup_controller
|
||||
from . import test_followup_adapter
|
||||
from . import test_followup_tools
|
||||
from . import test_followup_cron
|
||||
from . import test_engine_property
|
||||
from . import test_followup_full_flow
|
||||
from . import test_performance_benchmarks
|
||||
from . import test_batch_followup_wizard
|
||||
from . import test_migration_round_trip
|
||||
from . import test_coexistence
|
||||
from . import test_followup_tours
|
||||
from . import test_local_llm_compat
|
||||
@@ -0,0 +1,34 @@
|
||||
from odoo import fields as odoo_fields
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAccountMoveLineFollowup(TransactionCase):
|
||||
"""Verify follow-up tracking fields are added to account.move.line."""
|
||||
|
||||
def test_fields_exist_on_model(self):
|
||||
"""Both new fields are declared on account.move.line."""
|
||||
AML = self.env['account.move.line']
|
||||
self.assertIn('fusion_followup_level_id', AML._fields)
|
||||
self.assertIn('fusion_followup_last_run_date', AML._fields)
|
||||
self.assertEqual(
|
||||
AML._fields['fusion_followup_level_id'].comodel_name,
|
||||
'fusion.followup.level',
|
||||
)
|
||||
|
||||
def test_assign_level_and_date_on_existing_line(self):
|
||||
"""We can write the new fields onto an existing move line."""
|
||||
line = self.env['account.move.line'].search([], limit=1)
|
||||
if not line:
|
||||
self.skipTest("No account.move.line records present in DB to test against.")
|
||||
level = self.env['fusion.followup.level'].create({
|
||||
'name': 'Reminder', 'sequence': 601, 'delay_days': 7, 'tone': 'gentle',
|
||||
})
|
||||
when = odoo_fields.Datetime.now()
|
||||
line.write({
|
||||
'fusion_followup_level_id': level.id,
|
||||
'fusion_followup_last_run_date': when,
|
||||
})
|
||||
self.assertEqual(line.fusion_followup_level_id, level)
|
||||
self.assertEqual(line.fusion_followup_last_run_date, when)
|
||||
@@ -0,0 +1,37 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestBatchFollowupWizard(TransactionCase):
|
||||
|
||||
def test_default_loads_active_ids(self):
|
||||
partners = self.env['res.partner'].create([
|
||||
{'name': 'B1'}, {'name': 'B2'},
|
||||
])
|
||||
wizard = self.env['fusion.batch.followup.wizard'].with_context(
|
||||
active_model='res.partner', active_ids=partners.ids,
|
||||
).create({})
|
||||
self.assertEqual(set(wizard.partner_ids.ids), set(partners.ids))
|
||||
|
||||
def test_selected_scope_no_partners_raises(self):
|
||||
wizard = self.env['fusion.batch.followup.wizard'].create({
|
||||
'scope': 'selected', 'partner_ids': [(6, 0, [])],
|
||||
})
|
||||
with self.assertRaises(UserError):
|
||||
wizard.action_run()
|
||||
|
||||
def test_run_completes_with_no_overdue_partners(self):
|
||||
partners = self.env['res.partner'].create([
|
||||
{'name': 'NoOverdue1'}, {'name': 'NoOverdue2'},
|
||||
])
|
||||
wizard = self.env['fusion.batch.followup.wizard'].create({
|
||||
'scope': 'selected',
|
||||
'partner_ids': [(6, 0, partners.ids)],
|
||||
'force': True,
|
||||
})
|
||||
wizard.action_run()
|
||||
self.assertEqual(wizard.state, 'done')
|
||||
# 2 partners with no overdue → both skipped
|
||||
self.assertEqual(wizard.skipped_count, 2)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Coexistence tests: fusion_accounting_followup menu only visible when
|
||||
Enterprise account_followup is NOT installed."""
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFollowupCoexistence(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.coex_group = self.env.ref(
|
||||
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
self.assertIsNotNone(self.coex_group, "Coexistence group must exist")
|
||||
|
||||
def test_engine_always_available(self):
|
||||
self.assertIn('fusion.followup.engine', self.env.registry)
|
||||
|
||||
def test_menu_gated_by_coexistence_group(self):
|
||||
menu = self.env.ref('fusion_accounting_followup.menu_fusion_followup_root',
|
||||
raise_if_not_found=False)
|
||||
if not menu:
|
||||
self.skipTest("Menu not loaded")
|
||||
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
|
||||
self.assertIn(self.coex_group, menu_groups,
|
||||
"Followup root menu must require the coexistence group")
|
||||
|
||||
def test_levels_menu_gated(self):
|
||||
menu = self.env.ref('fusion_accounting_followup.menu_fusion_followup_levels',
|
||||
raise_if_not_found=False)
|
||||
if not menu:
|
||||
self.skipTest("Menu not loaded")
|
||||
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
|
||||
self.assertIn(self.coex_group, menu_groups)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Integration tests: full follow-up flow with real overdue invoices."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestFollowupEngineIntegration(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.followup.engine']
|
||||
self.partner = self.env['res.partner'].create({
|
||||
'name': 'Integration Partner', 'email': 'integ@test.local',
|
||||
})
|
||||
for seq, name, days, tone in [(801, 'Test Reminder', 7, 'gentle'),
|
||||
(802, 'Test Warning', 30, 'firm'),
|
||||
(803, 'Test Legal', 60, 'legal')]:
|
||||
self.env['fusion.followup.level'].create({
|
||||
'name': name, 'sequence': seq, 'delay_days': days, 'tone': tone,
|
||||
})
|
||||
|
||||
line = self.env['account.move.line'].search([
|
||||
('parent_state', '=', 'posted'),
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('reconciled', '=', False),
|
||||
('amount_residual', '>', 0),
|
||||
], limit=1)
|
||||
if not line:
|
||||
self.skipTest("No posted unreconciled receivable lines in test DB")
|
||||
line.write({
|
||||
'partner_id': self.partner.id,
|
||||
'date_maturity': date.today() - timedelta(days=20),
|
||||
})
|
||||
|
||||
def test_get_overdue_finds_lines(self):
|
||||
result = self.engine.get_overdue_for_partner(self.partner)
|
||||
self.assertGreater(result['overdue_line_count'], 0)
|
||||
self.assertGreater(result['aging']['total_overdue_amount'], 0)
|
||||
|
||||
def test_compute_level_picks_reminder_at_20_days(self):
|
||||
level = self.engine.compute_followup_level(self.partner)
|
||||
self.assertTrue(level)
|
||||
self.assertGreater(level.delay_days, 0)
|
||||
|
||||
def test_send_followup_creates_run(self):
|
||||
result = self.engine.send_followup_email(self.partner, force=True)
|
||||
self.assertIn(result['status'], ('sent', 'manual_review'))
|
||||
if 'run_id' in result:
|
||||
run = self.env['fusion.followup.run'].browse(result['run_id'])
|
||||
self.assertEqual(run.partner_id, self.partner)
|
||||
|
||||
def test_pause_blocks_send_unless_force(self):
|
||||
self.engine.pause_followup(self.partner,
|
||||
until_date=date.today() + timedelta(days=30))
|
||||
result = self.engine.send_followup_email(self.partner)
|
||||
self.assertTrue(result['status'].startswith('paused'))
|
||||
result_force = self.engine.send_followup_email(self.partner, force=True)
|
||||
self.assertIn(result_force['status'], ('sent', 'manual_review'))
|
||||
|
||||
def test_history_grows_with_each_send(self):
|
||||
Run = self.env['fusion.followup.run']
|
||||
before = Run.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.engine.send_followup_email(self.partner, force=True)
|
||||
after = Run.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.assertGreater(after, before)
|
||||
|
||||
def test_text_cache_used_on_repeat_call(self):
|
||||
Cache = self.env['fusion.followup.text.cache']
|
||||
self.engine.send_followup_email(self.partner, force=True)
|
||||
cache_count_after_first = Cache.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.engine.send_followup_email(self.partner, force=True)
|
||||
cache_count_after_second = Cache.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.assertEqual(cache_count_after_first, cache_count_after_second,
|
||||
"Repeat send with same params should not create new cache row")
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Property-based invariants for follow-up services."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from hypothesis import given, settings, strategies as st, HealthCheck
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
from odoo.addons.fusion_accounting_followup.services.overdue_aging import (
|
||||
compute_aging, BUCKETS,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_followup.services.risk_scorer import score_partner
|
||||
from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'property_based')
|
||||
class TestAgingInvariants(TransactionCase):
|
||||
|
||||
@given(
|
||||
as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)),
|
||||
amounts=st.lists(
|
||||
st.tuples(
|
||||
st.integers(min_value=-180, max_value=180),
|
||||
st.floats(min_value=0.01, max_value=100000,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
),
|
||||
min_size=0, max_size=20,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=80, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_buckets_sum_equals_total(self, as_of, amounts):
|
||||
lines = [
|
||||
{'date_maturity': as_of + timedelta(days=offset),
|
||||
'amount_residual': round(amt, 2)}
|
||||
for offset, amt in amounts
|
||||
]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
bucket_sum = sum(b.amount for b in report.buckets)
|
||||
self.assertAlmostEqual(bucket_sum, report.total_amount, places=1)
|
||||
|
||||
@given(
|
||||
as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)),
|
||||
days_overdue=st.integers(min_value=1, max_value=365),
|
||||
amount=st.floats(min_value=0.01, max_value=10000,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
@settings(max_examples=50, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_overdue_amount_excludes_current(self, as_of, days_overdue, amount):
|
||||
lines = [
|
||||
{'date_maturity': as_of - timedelta(days=days_overdue),
|
||||
'amount_residual': round(amount, 2)},
|
||||
{'date_maturity': as_of + timedelta(days=10),
|
||||
'amount_residual': 100.0},
|
||||
]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
self.assertAlmostEqual(report.total_overdue_amount, round(amount, 2), places=1)
|
||||
|
||||
@given(
|
||||
invoices=st.integers(min_value=0, max_value=100),
|
||||
late=st.integers(min_value=0, max_value=100),
|
||||
days_late=st.floats(min_value=0, max_value=180,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
@settings(max_examples=80, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_risk_score_in_range(self, invoices, late, days_late):
|
||||
late = min(late, invoices) if invoices > 0 else 0
|
||||
result = score_partner(
|
||||
total_invoices=invoices, paid_late_count=late,
|
||||
avg_days_late=days_late,
|
||||
longest_overdue_days=int(days_late),
|
||||
open_overdue_amount=invoices * 1000.0,
|
||||
average_invoice_amount=1000.0,
|
||||
)
|
||||
self.assertGreaterEqual(result.score, 0)
|
||||
self.assertLessEqual(result.score, 100)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'property_based')
|
||||
class TestToneInvariants(TransactionCase):
|
||||
|
||||
@given(
|
||||
sequence=st.integers(min_value=1, max_value=10),
|
||||
risk=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=1000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_tone_always_in_valid_set(self, sequence, risk):
|
||||
tone = select_tone(level_sequence=sequence, risk_score=risk)
|
||||
self.assertIn(tone, ('gentle', 'firm', 'legal'))
|
||||
@@ -0,0 +1,42 @@
|
||||
"""FollowupAdapter wiring tests — engine paths."""
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from odoo.addons.fusion_accounting_ai.services.data_adapters.followup import (
|
||||
FollowupAdapter,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFollowupAdapter(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.adapter = FollowupAdapter(self.env)
|
||||
|
||||
def test_list_overdue_via_fusion_returns_dict(self):
|
||||
result = self.adapter.list_overdue_via_fusion(
|
||||
company_id=self.env.company.id,
|
||||
)
|
||||
self.assertIn('partners', result)
|
||||
self.assertIn('total', result)
|
||||
self.assertIn('count', result)
|
||||
|
||||
def test_list_overdue_via_community_returns_error(self):
|
||||
result = self.adapter.list_overdue_via_community()
|
||||
self.assertIn('error', result)
|
||||
|
||||
def test_send_followup_via_fusion_no_overdue(self):
|
||||
partner = self.env['res.partner'].create({'name': 'AdapterTest'})
|
||||
result = self.adapter.send_followup_via_fusion(
|
||||
partner_id=partner.id, force=True,
|
||||
)
|
||||
self.assertIn(
|
||||
result.get('status', ''),
|
||||
('no_action', 'no_overdue', 'sent', 'manual_review'),
|
||||
)
|
||||
|
||||
def test_send_followup_via_community_returns_error(self):
|
||||
result = self.adapter.send_followup_via_community(partner_id=1)
|
||||
self.assertIn('error', result)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""HttpCase tests for the 6 follow-up JSON-RPC endpoints."""
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase, new_test_user
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFollowupController(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = new_test_user(
|
||||
self.env, login='fu_test_user',
|
||||
groups='base.group_user,base.group_partner_manager,'
|
||||
'account.group_account_invoice',
|
||||
)
|
||||
|
||||
def _jsonrpc(self, endpoint, params):
|
||||
self.authenticate('fu_test_user', 'fu_test_user')
|
||||
url = f'/fusion/followup/{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} returned {response.status_code}: {response.text[:300]}",
|
||||
)
|
||||
result = response.json()
|
||||
if 'error' in result:
|
||||
self.fail(f"{endpoint} errored: {result['error']}")
|
||||
return result.get('result', {})
|
||||
|
||||
def test_list_overdue_returns_dict(self):
|
||||
result = self._jsonrpc('list_overdue', {'company_id': self.env.company.id})
|
||||
self.assertIn('partners', result)
|
||||
self.assertIn('total', result)
|
||||
|
||||
def test_get_partner_detail(self):
|
||||
partner = self.env['res.partner'].create({
|
||||
'name': 'Ctrl Test Partner', 'email': 'ctrl@test.local',
|
||||
})
|
||||
result = self._jsonrpc('get_partner_detail', {'partner_id': partner.id})
|
||||
self.assertEqual(result['partner']['id'], partner.id)
|
||||
self.assertIn('overdue', result)
|
||||
self.assertIn('history', result)
|
||||
|
||||
def test_pause_sets_paused_until(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Pause Test'})
|
||||
future = (date.today() + timedelta(days=20)).isoformat()
|
||||
result = self._jsonrpc('pause', {
|
||||
'partner_id': partner.id, 'until_date': future,
|
||||
})
|
||||
self.assertEqual(result['paused_until'], future)
|
||||
|
||||
def test_reset_clears_status(self):
|
||||
partner = self.env['res.partner'].create({
|
||||
'name': 'Reset Test',
|
||||
'fusion_followup_status': 'paused',
|
||||
})
|
||||
result = self._jsonrpc('reset', {'partner_id': partner.id})
|
||||
self.assertEqual(result['status'], 'reset')
|
||||
|
||||
def test_send_no_overdue_returns_no_action(self):
|
||||
partner = self.env['res.partner'].create({
|
||||
'name': 'No Overdue', 'email': 'no@test.local',
|
||||
})
|
||||
result = self._jsonrpc('send', {
|
||||
'partner_id': partner.id, 'force': True,
|
||||
})
|
||||
self.assertIn(result.get('status'), ('no_action', 'no_overdue'))
|
||||
|
||||
def test_generate_text_no_level_returns_no_level(self):
|
||||
partner = self.env['res.partner'].create({'name': 'NoLevel Test'})
|
||||
result = self._jsonrpc('generate_text', {'partner_id': partner.id})
|
||||
self.assertIn(result.get('status'), ('no_level', 'ok'))
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Smoke tests for the fusion follow-up cron handlers."""
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFollowupCron(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.cron = self.env['fusion.followup.cron']
|
||||
|
||||
def test_cron_daily_scan_runs(self):
|
||||
self.cron._cron_daily_scan()
|
||||
|
||||
def test_cron_risk_refresh_runs(self):
|
||||
self.cron._cron_risk_refresh()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""End-to-end integration: scan -> escalate -> send -> reset."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestFollowupFullFlow(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.followup.engine']
|
||||
self.partner = self.env['res.partner'].create({
|
||||
'name': 'Full Flow Partner', 'email': 'flow@test.local',
|
||||
})
|
||||
for seq, name, days, tone in [(701, 'FlowReminder', 7, 'gentle'),
|
||||
(702, 'FlowWarning', 30, 'firm'),
|
||||
(703, 'FlowLegal', 60, 'legal')]:
|
||||
self.env['fusion.followup.level'].create({
|
||||
'name': name, 'sequence': seq,
|
||||
'delay_days': days, 'tone': tone,
|
||||
})
|
||||
|
||||
line = self.env['account.move.line'].search([
|
||||
('parent_state', '=', 'posted'),
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('reconciled', '=', False),
|
||||
('amount_residual', '>', 0),
|
||||
], limit=1)
|
||||
if not line:
|
||||
self.skipTest("No posted unreconciled receivable lines in test DB")
|
||||
line.write({
|
||||
'partner_id': self.partner.id,
|
||||
'date_maturity': date.today() - timedelta(days=20),
|
||||
})
|
||||
|
||||
def test_full_flow_scan_send_reset(self):
|
||||
level = self.engine.compute_followup_level(self.partner)
|
||||
self.assertTrue(level)
|
||||
self.assertGreater(level.delay_days, 0)
|
||||
|
||||
Run = self.env['fusion.followup.run']
|
||||
before = Run.search_count([('partner_id', '=', self.partner.id)])
|
||||
result = self.engine.send_followup_email(self.partner, force=True)
|
||||
after = Run.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.assertGreater(after, before)
|
||||
self.assertIn(result['status'], ('sent', 'manual_review'))
|
||||
|
||||
self.engine.pause_followup(self.partner,
|
||||
until_date=date.today() + timedelta(days=14))
|
||||
result_paused = self.engine.send_followup_email(self.partner)
|
||||
self.assertTrue(result_paused['status'].startswith('paused'))
|
||||
|
||||
self.engine.reset_followup(self.partner)
|
||||
self.partner.invalidate_recordset(['fusion_followup_status'])
|
||||
self.assertEqual(self.partner.fusion_followup_status, 'no_action')
|
||||
|
||||
def test_escalate_advances_to_next_level(self):
|
||||
Level = self.env['fusion.followup.level']
|
||||
level1 = Level.search([('sequence', '=', 701)], limit=1)
|
||||
self.engine.send_followup_email(self.partner, level=level1, force=True)
|
||||
self.partner.invalidate_recordset(['fusion_followup_last_level_id'])
|
||||
result = self.engine.escalate_to_next_level(self.partner)
|
||||
self.assertIn('partner_id', result)
|
||||
self.partner.invalidate_recordset(['fusion_followup_last_level_id'])
|
||||
if self.partner.fusion_followup_last_level_id:
|
||||
self.assertGreaterEqual(self.partner.fusion_followup_last_level_id.sequence, 702)
|
||||
|
||||
def test_text_cache_reused_on_repeat(self):
|
||||
Cache = self.env['fusion.followup.text.cache']
|
||||
self.engine.send_followup_email(self.partner, force=True)
|
||||
after_first = Cache.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.engine.send_followup_email(self.partner, force=True)
|
||||
after_second = Cache.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.assertEqual(after_first, after_second)
|
||||
|
||||
def test_history_records_each_send(self):
|
||||
Run = self.env['fusion.followup.run']
|
||||
before = Run.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.engine.send_followup_email(self.partner, force=True)
|
||||
self.engine.send_followup_email(self.partner, force=True)
|
||||
after = Run.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.assertEqual(after - before, 2)
|
||||
@@ -0,0 +1,80 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
|
||||
generate_followup_text,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_followup.services.followup_text_prompt import (
|
||||
SYSTEM_PROMPT, build_prompt,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFollowupTextGenerator(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', 'in', ['fusion_accounting.provider.followup_text',
|
||||
'fusion_accounting.provider.default'])
|
||||
]).unlink()
|
||||
|
||||
def test_fallback_gentle(self):
|
||||
result = generate_followup_text(
|
||||
self.env, partner_name='Acme Corp', total_overdue=1500,
|
||||
currency_code='USD', longest_overdue_days=15, tone='gentle',
|
||||
invoice_count=2,
|
||||
)
|
||||
self.assertEqual(result['tone_used'], 'gentle')
|
||||
self.assertIn('Acme Corp', result['body'])
|
||||
self.assertIn('1,500.00', result['body'])
|
||||
|
||||
def test_fallback_firm(self):
|
||||
result = generate_followup_text(
|
||||
self.env, partner_name='Acme', total_overdue=5000,
|
||||
currency_code='USD', longest_overdue_days=45, tone='firm',
|
||||
invoice_count=3,
|
||||
)
|
||||
self.assertEqual(result['tone_used'], 'firm')
|
||||
|
||||
def test_fallback_legal(self):
|
||||
result = generate_followup_text(
|
||||
self.env, partner_name='Acme', total_overdue=10000,
|
||||
currency_code='USD', longest_overdue_days=90, tone='legal',
|
||||
invoice_count=5,
|
||||
)
|
||||
self.assertEqual(result['tone_used'], 'legal')
|
||||
self.assertIn('FINAL NOTICE', result['subject'])
|
||||
|
||||
def test_returns_required_keys(self):
|
||||
result = generate_followup_text(
|
||||
self.env, partner_name='X', total_overdue=100,
|
||||
currency_code='USD', longest_overdue_days=10, tone='gentle',
|
||||
)
|
||||
for key in ('subject', 'body', 'tone_used', 'key_points'):
|
||||
self.assertIn(key, result)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFollowupTextPrompt(TransactionCase):
|
||||
|
||||
def test_system_prompt_requires_json(self):
|
||||
self.assertIn('JSON', SYSTEM_PROMPT)
|
||||
self.assertIn('"subject"', SYSTEM_PROMPT)
|
||||
self.assertIn('"body"', SYSTEM_PROMPT)
|
||||
|
||||
def test_build_prompt_returns_tuple(self):
|
||||
result = build_prompt(
|
||||
partner_name='X', total_overdue=100, currency_code='USD',
|
||||
longest_overdue_days=10, tone='gentle',
|
||||
)
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertIn('100.00', result[1])
|
||||
|
||||
def test_build_prompt_includes_risk_drivers(self):
|
||||
_, user = build_prompt(
|
||||
partner_name='X', total_overdue=100, currency_code='USD',
|
||||
longest_overdue_days=10, tone='firm',
|
||||
risk_drivers=['Chronic late payer', '5/10 paid late'],
|
||||
)
|
||||
self.assertIn('RISK FACTORS', user)
|
||||
self.assertIn('Chronic late payer', user)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""AI tool dispatch tests for fusion follow-up tools."""
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from odoo.addons.fusion_accounting_ai.services.tools import customer_followup as tools
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionFollowupTools(TransactionCase):
|
||||
|
||||
def test_fusion_list_overdue(self):
|
||||
result = tools.fusion_list_overdue(
|
||||
self.env, {'company_id': self.env.company.id},
|
||||
)
|
||||
self.assertIn('partners', result)
|
||||
|
||||
def test_fusion_get_partner_detail(self):
|
||||
partner = self.env['res.partner'].create({
|
||||
'name': 'Tool Partner', 'email': 't@t.local',
|
||||
})
|
||||
result = tools.fusion_get_partner_followup_detail(
|
||||
self.env, {'partner_id': partner.id},
|
||||
)
|
||||
self.assertEqual(result['partner_id'], partner.id)
|
||||
|
||||
def test_fusion_generate_text_uses_fallback(self):
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', 'in', [
|
||||
'fusion_accounting.provider.followup_text',
|
||||
'fusion_accounting.provider.default',
|
||||
]),
|
||||
]).unlink()
|
||||
result = tools.fusion_generate_followup_text(self.env, {
|
||||
'partner_name': 'Acme', 'total_overdue': 1000,
|
||||
'currency_code': 'USD', 'longest_overdue_days': 15,
|
||||
'tone': 'gentle',
|
||||
})
|
||||
self.assertIn('subject', result)
|
||||
self.assertIn('body', result)
|
||||
|
||||
def test_fusion_get_risk_score(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Risk Test'})
|
||||
result = tools.fusion_get_partner_risk_score(
|
||||
self.env, {'partner_id': partner.id},
|
||||
)
|
||||
self.assertIn('risk', result)
|
||||
|
||||
def test_tools_registered_in_dispatch(self):
|
||||
from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH
|
||||
for tool_name in [
|
||||
'fusion_list_overdue',
|
||||
'fusion_get_partner_followup_detail',
|
||||
'fusion_generate_followup_text',
|
||||
'fusion_send_followup',
|
||||
'fusion_get_partner_risk_score',
|
||||
]:
|
||||
self.assertIn(
|
||||
tool_name, TOOL_DISPATCH,
|
||||
f"{tool_name} not registered in TOOL_DISPATCH",
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Python wrappers for OWL tours via HttpCase.start_tour."""
|
||||
|
||||
from odoo.tests.common import HttpCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'tour')
|
||||
class TestFollowupTours(HttpCase):
|
||||
|
||||
def test_smoke_tour(self):
|
||||
self.start_tour("/odoo", "fusion_followup_smoke", login="admin")
|
||||
|
||||
def test_partners_tour(self):
|
||||
self.start_tour("/odoo", "fusion_followup_partners", login="admin")
|
||||
|
||||
def test_levels_tour(self):
|
||||
self.start_tour("/odoo", "fusion_followup_levels", login="admin")
|
||||
|
||||
def test_history_tour(self):
|
||||
self.start_tour("/odoo", "fusion_followup_history", login="admin")
|
||||
|
||||
def test_batch_wizard_tour(self):
|
||||
self.start_tour("/odoo", "fusion_followup_batch_wizard", login="admin")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Unit tests for the fusion.followup.engine 7-method API."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionFollowupEngine(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.followup.engine']
|
||||
self.partner = self.env['res.partner'].create({
|
||||
'name': 'Engine Test Partner', 'email': 'engine@test.local',
|
||||
})
|
||||
for seq, name, days, tone in [(901, 'Reminder', 7, 'gentle'),
|
||||
(902, 'Warning', 30, 'firm'),
|
||||
(903, 'Legal', 60, 'legal')]:
|
||||
self.env['fusion.followup.level'].create({
|
||||
'name': name, 'sequence': seq,
|
||||
'delay_days': days, 'tone': tone,
|
||||
})
|
||||
|
||||
def test_engine_model_exists(self):
|
||||
self.assertIn('fusion.followup.engine', self.env.registry)
|
||||
|
||||
def test_get_overdue_returns_dict(self):
|
||||
result = self.engine.get_overdue_for_partner(self.partner)
|
||||
self.assertIn('aging', result)
|
||||
self.assertIn('risk', result)
|
||||
self.assertEqual(result['partner_id'], self.partner.id)
|
||||
|
||||
def test_compute_followup_level_no_overdue_returns_empty(self):
|
||||
result = self.engine.compute_followup_level(self.partner)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_pause_sets_partner_state(self):
|
||||
until = date.today() + timedelta(days=14)
|
||||
self.engine.pause_followup(self.partner, until_date=until)
|
||||
self.partner.invalidate_recordset(['fusion_followup_paused_until', 'fusion_followup_status'])
|
||||
self.assertEqual(self.partner.fusion_followup_paused_until, until)
|
||||
self.assertEqual(self.partner.fusion_followup_status, 'paused')
|
||||
|
||||
def test_reset_clears_state(self):
|
||||
self.engine.pause_followup(self.partner)
|
||||
self.engine.reset_followup(self.partner)
|
||||
self.partner.invalidate_recordset([
|
||||
'fusion_followup_status', 'fusion_followup_paused_until',
|
||||
'fusion_followup_last_level_id',
|
||||
])
|
||||
self.assertEqual(self.partner.fusion_followup_status, 'no_action')
|
||||
self.assertFalse(self.partner.fusion_followup_paused_until)
|
||||
|
||||
def test_snapshot_history_returns_runs(self):
|
||||
Run = self.env['fusion.followup.run']
|
||||
run = Run.create({
|
||||
'partner_id': self.partner.id,
|
||||
'state': 'sent',
|
||||
'overdue_amount': 500,
|
||||
})
|
||||
result = self.engine.snapshot_followup_history(self.partner)
|
||||
self.assertEqual(result['count'], 1)
|
||||
self.assertEqual(result['runs'][0]['id'], run.id)
|
||||
|
||||
def test_send_no_overdue_returns_no_action(self):
|
||||
Level = self.env['fusion.followup.level']
|
||||
level = Level.search([('sequence', '=', 901)], limit=1)
|
||||
result = self.engine.send_followup_email(self.partner, level=level, force=True)
|
||||
self.assertEqual(result['status'], 'no_overdue')
|
||||
|
||||
def test_escalate_when_no_current_level(self):
|
||||
result = self.engine.escalate_to_next_level(self.partner)
|
||||
self.assertIn('partner_id', result)
|
||||
@@ -0,0 +1,43 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionFollowupLevel(TransactionCase):
|
||||
|
||||
def test_create_minimal(self):
|
||||
# Note: sequences 1-3 are reserved for seeded default levels.
|
||||
level = self.env['fusion.followup.level'].create({
|
||||
'name': 'Reminder', 'sequence': 901, 'delay_days': 7, 'tone': 'gentle',
|
||||
})
|
||||
self.assertEqual(level.name, 'Reminder')
|
||||
self.assertTrue(level.active)
|
||||
|
||||
def test_negative_delay_rejected(self):
|
||||
with self.assertRaises(Exception):
|
||||
self.env['fusion.followup.level'].create({
|
||||
'name': 'Bad', 'sequence': 902, 'delay_days': -5, 'tone': 'gentle',
|
||||
})
|
||||
|
||||
def test_duplicate_sequence_rejected(self):
|
||||
self.env['fusion.followup.level'].create({
|
||||
'name': 'A', 'sequence': 100, 'delay_days': 7, 'tone': 'gentle',
|
||||
})
|
||||
with self.assertRaises(Exception):
|
||||
self.env['fusion.followup.level'].create({
|
||||
'name': 'B', 'sequence': 100, 'delay_days': 30, 'tone': 'firm',
|
||||
})
|
||||
|
||||
def test_three_levels_escalate(self):
|
||||
for seq, name, days, tone in [(1, 'R', 7, 'gentle'),
|
||||
(2, 'W', 30, 'firm'),
|
||||
(3, 'L', 60, 'legal')]:
|
||||
self.env['fusion.followup.level'].create({
|
||||
'name': name, 'sequence': seq + 200,
|
||||
'delay_days': days, 'tone': tone,
|
||||
})
|
||||
levels = self.env['fusion.followup.level'].search([
|
||||
('sequence', '>', 200),
|
||||
], order='sequence')
|
||||
self.assertEqual(len(levels), 3)
|
||||
self.assertEqual(levels.mapped('tone'), ['gentle', 'firm', 'legal'])
|
||||
@@ -0,0 +1,44 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionFollowupRun(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'Run Test Partner'})
|
||||
cls.level = cls.env['fusion.followup.level'].create({
|
||||
'name': 'Reminder', 'sequence': 301, 'delay_days': 7, 'tone': 'gentle',
|
||||
})
|
||||
|
||||
def test_create_minimal(self):
|
||||
run = self.env['fusion.followup.run'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'level_id': self.level.id,
|
||||
})
|
||||
self.assertEqual(run.state, 'draft')
|
||||
self.assertTrue(run.execution_date)
|
||||
|
||||
def test_action_mark_sent(self):
|
||||
run = self.env['fusion.followup.run'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'level_id': self.level.id,
|
||||
})
|
||||
run.action_mark_sent()
|
||||
self.assertEqual(run.state, 'sent')
|
||||
|
||||
def test_action_mark_failed_records_error(self):
|
||||
run = self.env['fusion.followup.run'].create({
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
run.action_mark_failed(error='SMTP unreachable')
|
||||
self.assertEqual(run.state, 'failed')
|
||||
self.assertEqual(run.error_message, 'SMTP unreachable')
|
||||
|
||||
def test_partner_required(self):
|
||||
with self.assertRaises(Exception):
|
||||
self.env['fusion.followup.run'].create({
|
||||
'level_id': self.level.id,
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionFollowupTextCache(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'Cache Test Partner'})
|
||||
cls.level = cls.env['fusion.followup.level'].create({
|
||||
'name': 'Reminder', 'sequence': 401, 'delay_days': 7, 'tone': 'gentle',
|
||||
})
|
||||
cls.cache = cls.env['fusion.followup.text.cache']
|
||||
|
||||
def _kwargs(self, **overrides):
|
||||
base = dict(
|
||||
partner_id=self.partner.id, level_id=self.level.id,
|
||||
overdue_amount=1234.56, longest_overdue_days=10,
|
||||
invoice_count=3, tone='gentle',
|
||||
)
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
def test_fingerprint_stable_and_unique(self):
|
||||
fp1 = self.cache.compute_fingerprint(**self._kwargs())
|
||||
fp2 = self.cache.compute_fingerprint(**self._kwargs())
|
||||
fp3 = self.cache.compute_fingerprint(**self._kwargs(tone='firm'))
|
||||
self.assertEqual(fp1, fp2)
|
||||
self.assertNotEqual(fp1, fp3)
|
||||
self.assertEqual(len(fp1), 64)
|
||||
|
||||
def test_lookup_returns_empty_when_missing(self):
|
||||
result = self.cache.lookup(**self._kwargs())
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_lookup_finds_cached_entry(self):
|
||||
kwargs = self._kwargs()
|
||||
fp = self.cache.compute_fingerprint(**kwargs)
|
||||
entry = self.cache.create({
|
||||
'partner_id': self.partner.id,
|
||||
'level_id': self.level.id,
|
||||
'fingerprint': fp,
|
||||
'subject': 'Hi',
|
||||
'body': 'Please pay.',
|
||||
'tone_used': 'gentle',
|
||||
})
|
||||
found = self.cache.lookup(**kwargs)
|
||||
self.assertEqual(found.id, entry.id)
|
||||
|
||||
def test_action_increment_use(self):
|
||||
entry = self.cache.create({
|
||||
'partner_id': self.partner.id,
|
||||
'fingerprint': 'abc123',
|
||||
})
|
||||
self.assertEqual(entry.use_count, 0)
|
||||
entry.action_increment_use()
|
||||
entry.action_increment_use()
|
||||
self.assertEqual(entry.use_count, 2)
|
||||
@@ -0,0 +1,58 @@
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.addons.fusion_accounting_followup.services.level_resolver import (
|
||||
FollowupLevelSpec, resolve_level,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_followup.services.overdue_aging import compute_aging
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLevelResolver(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.levels = [
|
||||
FollowupLevelSpec(sequence=1, name='Reminder', delay_days=7, tone='gentle'),
|
||||
FollowupLevelSpec(sequence=2, name='Warning', delay_days=30, tone='firm'),
|
||||
FollowupLevelSpec(sequence=3, name='Legal Notice', delay_days=60, tone='legal'),
|
||||
]
|
||||
|
||||
def test_no_overdue_returns_none(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
result = resolve_level(aging_report=report, levels=self.levels)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_15_days_overdue_picks_reminder(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 100}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
result = resolve_level(aging_report=report, levels=self.levels)
|
||||
self.assertEqual(result.name, 'Reminder')
|
||||
|
||||
def test_45_days_overdue_picks_warning(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': as_of - timedelta(days=45), 'amount_residual': 200}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
result = resolve_level(aging_report=report, levels=self.levels)
|
||||
self.assertEqual(result.name, 'Warning')
|
||||
|
||||
def test_75_days_overdue_picks_legal(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': as_of - timedelta(days=75), 'amount_residual': 300}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
result = resolve_level(aging_report=report, levels=self.levels)
|
||||
self.assertEqual(result.name, 'Legal Notice')
|
||||
|
||||
def test_no_levels_returns_none(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': as_of - timedelta(days=30), 'amount_residual': 100}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
result = resolve_level(aging_report=report, levels=[])
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_invalid_tone_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
FollowupLevelSpec(sequence=1, name='X', delay_days=7, tone='invalid')
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Local LLM compat test for followup_text_generator.
|
||||
|
||||
Auto-detects LM Studio (:1234) or Ollama (:11434), skips when absent."""
|
||||
|
||||
import socket
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
def _server_reachable(host, port, timeout=1.0):
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except (OSError, socket.timeout):
|
||||
return False
|
||||
|
||||
|
||||
def _detect_local_llm():
|
||||
for host, port, default_model in [
|
||||
('host.docker.internal', 1234, 'local-model'),
|
||||
('host.docker.internal', 11434, 'llama3.1:8b'),
|
||||
('localhost', 1234, 'local-model'),
|
||||
('localhost', 11434, 'llama3.1:8b'),
|
||||
]:
|
||||
if _server_reachable(host, port, timeout=0.5):
|
||||
return (f'http://{host}:{port}/v1', default_model)
|
||||
return (None, None)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'local_llm')
|
||||
class TestLocalLLMFollowupText(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.base_url, self.model = _detect_local_llm()
|
||||
if not self.base_url:
|
||||
self.skipTest("No local LLM server detected")
|
||||
|
||||
def test_followup_text_with_local_llm(self):
|
||||
params = self.env['ir.config_parameter'].sudo()
|
||||
prior = {k: params.get_param(k) for k in [
|
||||
'fusion_accounting.openai_base_url',
|
||||
'fusion_accounting.openai_model',
|
||||
'fusion_accounting.provider.followup_text',
|
||||
]}
|
||||
params.set_param('fusion_accounting.openai_base_url', self.base_url)
|
||||
params.set_param('fusion_accounting.openai_model', self.model)
|
||||
params.set_param('fusion_accounting.openai_api_key', 'lm-studio')
|
||||
params.set_param('fusion_accounting.provider.followup_text', 'openai')
|
||||
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
|
||||
generate_followup_text,
|
||||
)
|
||||
result = generate_followup_text(
|
||||
self.env, partner_name='Acme Corp',
|
||||
total_overdue=15000, currency_code='USD',
|
||||
longest_overdue_days=45, tone='firm',
|
||||
invoice_count=3,
|
||||
risk_drivers=['8/12 invoices paid late', 'Avg 30 days late'],
|
||||
)
|
||||
self.assertIn('subject', result)
|
||||
self.assertIn('body', result)
|
||||
self.assertIn('tone_used', result)
|
||||
finally:
|
||||
for k, v in prior.items():
|
||||
if v is not None:
|
||||
params.set_param(k, v)
|
||||
@@ -0,0 +1,30 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFollowupMigrationRoundTrip(TransactionCase):
|
||||
|
||||
def test_bootstrap_step_runs(self):
|
||||
wizard = self.env['fusion.migration.wizard'].create({})
|
||||
result = wizard._followup_bootstrap_step()
|
||||
self.assertEqual(result['step'], 'followup_bootstrap')
|
||||
# Either Enterprise present or not — both OK
|
||||
self.assertIn(result['enterprise_module_present'], [True, False])
|
||||
|
||||
def test_bootstrap_idempotent(self):
|
||||
wizard = self.env['fusion.migration.wizard'].create({})
|
||||
first = wizard._followup_bootstrap_step()
|
||||
second = wizard._followup_bootstrap_step()
|
||||
# Second run skips what first created (or both no-op)
|
||||
if first['enterprise_module_present']:
|
||||
self.assertGreaterEqual(second['skipped'], first['created'])
|
||||
|
||||
def test_partner_state_bootstrap_step(self):
|
||||
"""Verify the partner-state migration step runs without error."""
|
||||
wizard = self.env['fusion.migration.wizard'].create({})
|
||||
result = wizard._followup_partner_state_bootstrap_step()
|
||||
self.assertEqual(result['step'], 'followup_partner_state')
|
||||
self.assertIn(result['enterprise_module_present'], [True, False])
|
||||
self.assertGreaterEqual(result['updated'], 0)
|
||||
self.assertGreaterEqual(result['skipped'], 0)
|
||||
@@ -0,0 +1,69 @@
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.addons.fusion_accounting_followup.services.overdue_aging import (
|
||||
compute_aging, BUCKETS,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestOverdueAging(TransactionCase):
|
||||
|
||||
def test_empty_lines_returns_zero_buckets(self):
|
||||
report = compute_aging(move_lines=[], as_of=date(2026, 4, 19))
|
||||
self.assertEqual(report.total_amount, 0)
|
||||
self.assertEqual(len(report.buckets), len(BUCKETS))
|
||||
for b in report.buckets:
|
||||
self.assertEqual(b.amount, 0)
|
||||
|
||||
def test_current_bucket_for_future_maturity(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': date(2026, 5, 19), 'amount_residual': 100}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
current = next(b for b in report.buckets if b.name == 'current')
|
||||
self.assertEqual(current.amount, 100)
|
||||
self.assertEqual(report.total_overdue_amount, 0)
|
||||
|
||||
def test_30_day_bucket(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
b = next(b for b in report.buckets if b.name == '1_30')
|
||||
self.assertEqual(b.amount, 200)
|
||||
|
||||
def test_60_day_bucket(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': as_of - timedelta(days=45), 'amount_residual': 300}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
b = next(b for b in report.buckets if b.name == '31_60')
|
||||
self.assertEqual(b.amount, 300)
|
||||
|
||||
def test_120_plus_bucket(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
b = next(b for b in report.buckets if b.name == '120_plus')
|
||||
self.assertEqual(b.amount, 500)
|
||||
|
||||
def test_total_overdue_excludes_current(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [
|
||||
{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100},
|
||||
{'date_maturity': as_of - timedelta(days=10), 'amount_residual': 200},
|
||||
{'date_maturity': as_of - timedelta(days=50), 'amount_residual': 300},
|
||||
]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
self.assertEqual(report.total_amount, 600)
|
||||
self.assertEqual(report.total_overdue_amount, 500)
|
||||
|
||||
def test_buckets_sum_equals_total(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [
|
||||
{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100},
|
||||
{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200},
|
||||
{'date_maturity': as_of - timedelta(days=75), 'amount_residual': 300},
|
||||
{'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500},
|
||||
]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
bucket_sum = sum(b.amount for b in report.buckets)
|
||||
self.assertAlmostEqual(bucket_sum, report.total_amount, places=2)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Performance benchmarks tagged 'benchmark'."""
|
||||
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo.tests.common import HttpCase, TransactionCase, new_test_user
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
def _percentile(samples, p):
|
||||
if len(samples) <= 1:
|
||||
return samples[0] if samples else 0
|
||||
sorted_s = sorted(samples)
|
||||
idx = int(len(sorted_s) * p / 100)
|
||||
return sorted_s[min(idx, len(sorted_s) - 1)]
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'benchmark')
|
||||
class TestEngineBenchmarks(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.followup.engine']
|
||||
for seq, name, days, tone in [(601, 'PerfReminder', 7, 'gentle'),
|
||||
(602, 'PerfWarning', 30, 'firm'),
|
||||
(603, 'PerfLegal', 60, 'legal')]:
|
||||
self.env['fusion.followup.level'].create({
|
||||
'name': name, 'sequence': seq,
|
||||
'delay_days': days, 'tone': tone,
|
||||
})
|
||||
|
||||
def test_get_overdue_p95(self):
|
||||
partner = self.env['res.partner'].create({'name': 'PerfPartner'})
|
||||
timings = []
|
||||
for _ in range(10):
|
||||
start = time.perf_counter()
|
||||
self.engine.get_overdue_for_partner(partner)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
p95 = _percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"get_overdue_for_partner: median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <100ms)")
|
||||
self.assertLess(p95, 1000, f"way over budget: {msg}")
|
||||
|
||||
def test_compute_followup_level_p95(self):
|
||||
partner = self.env['res.partner'].create({'name': 'CompLevelPerf'})
|
||||
timings = []
|
||||
for _ in range(10):
|
||||
start = time.perf_counter()
|
||||
self.engine.compute_followup_level(partner)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
p95 = _percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"compute_followup_level: median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <50ms)")
|
||||
self.assertLess(p95, 500)
|
||||
|
||||
def test_send_followup_p95(self):
|
||||
partner = self.env['res.partner'].create({
|
||||
'name': 'SendPerf', 'email': 'sp@test.local',
|
||||
})
|
||||
timings = []
|
||||
for _ in range(5):
|
||||
start = time.perf_counter()
|
||||
self.engine.send_followup_email(partner, force=True)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
p95 = _percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"send_followup_email (no overdue): median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <200ms)")
|
||||
self.assertLess(p95, 2000)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'benchmark')
|
||||
class TestControllerBenchmarks(HttpCase):
|
||||
|
||||
def test_list_overdue_p95(self):
|
||||
new_test_user(self.env, login='fu_perf',
|
||||
groups='base.group_user,account.group_account_invoice,base.group_partner_manager')
|
||||
for i in range(20):
|
||||
self.env['res.partner'].create({'name': f'PerfP{i}'})
|
||||
self.authenticate('fu_perf', 'fu_perf')
|
||||
timings = []
|
||||
for _ in range(5):
|
||||
start = time.perf_counter()
|
||||
response = self.url_open(
|
||||
'/fusion/followup/list_overdue',
|
||||
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'id': 1,
|
||||
'params': {'company_id': self.env.company.id}}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
p95 = _percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"controller.list_overdue: median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <500ms)")
|
||||
self.assertLess(p95, 5000)
|
||||
@@ -0,0 +1,27 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestResPartnerFollowup(TransactionCase):
|
||||
|
||||
def test_default_status_no_action(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Default Status'})
|
||||
self.assertEqual(partner.fusion_followup_status, 'no_action')
|
||||
self.assertEqual(partner.fusion_followup_risk_band, 'low')
|
||||
self.assertEqual(partner.fusion_followup_risk_score, 0)
|
||||
|
||||
def test_run_count_reflects_history(self):
|
||||
partner = self.env['res.partner'].create({'name': 'History Partner'})
|
||||
self.assertEqual(partner.fusion_followup_run_count, 0)
|
||||
for _ in range(3):
|
||||
self.env['fusion.followup.run'].create({'partner_id': partner.id})
|
||||
partner.invalidate_recordset(['fusion_followup_run_count', 'fusion_followup_run_ids'])
|
||||
self.assertEqual(partner.fusion_followup_run_count, 3)
|
||||
|
||||
def test_action_view_followup_history_returns_action(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Action Partner'})
|
||||
action = partner.action_view_followup_history()
|
||||
self.assertEqual(action['res_model'], 'fusion.followup.run')
|
||||
self.assertEqual(action['domain'], [('partner_id', '=', partner.id)])
|
||||
self.assertEqual(action['context']['default_partner_id'], partner.id)
|
||||
@@ -0,0 +1,48 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.addons.fusion_accounting_followup.services.risk_scorer import (
|
||||
score_partner, PartnerRiskScore,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestRiskScorer(TransactionCase):
|
||||
|
||||
def test_no_history_returns_low(self):
|
||||
result = score_partner()
|
||||
self.assertEqual(result.band, 'low')
|
||||
self.assertLess(result.score, 30)
|
||||
|
||||
def test_chronic_late_pays_returns_high(self):
|
||||
result = score_partner(
|
||||
total_invoices=20, paid_late_count=18,
|
||||
avg_days_late=45, longest_overdue_days=90,
|
||||
open_overdue_amount=15000, average_invoice_amount=2000,
|
||||
)
|
||||
self.assertIn(result.band, ('high', 'critical'))
|
||||
self.assertGreater(len(result.drivers), 0)
|
||||
|
||||
def test_one_off_overdue_returns_medium(self):
|
||||
result = score_partner(
|
||||
total_invoices=10, paid_late_count=1,
|
||||
avg_days_late=20, longest_overdue_days=45,
|
||||
open_overdue_amount=2000, average_invoice_amount=2000,
|
||||
)
|
||||
self.assertIn(result.band, ('low', 'medium'))
|
||||
|
||||
def test_score_capped_at_100(self):
|
||||
result = score_partner(
|
||||
total_invoices=10, paid_late_count=10,
|
||||
avg_days_late=180, longest_overdue_days=300,
|
||||
open_overdue_amount=999999, average_invoice_amount=1000,
|
||||
)
|
||||
self.assertLessEqual(result.score, 100)
|
||||
|
||||
def test_score_floored_at_0(self):
|
||||
result = score_partner()
|
||||
self.assertGreaterEqual(result.score, 0)
|
||||
|
||||
def test_band_thresholds(self):
|
||||
for s, expected_band in [(10, 'low'), (40, 'medium'), (70, 'high'), (90, 'critical')]:
|
||||
r = PartnerRiskScore(score=s, band=expected_band, drivers=[])
|
||||
self.assertEqual(r.band, expected_band)
|
||||
@@ -0,0 +1,25 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestToneSelector(TransactionCase):
|
||||
|
||||
def test_level_1_default_gentle(self):
|
||||
self.assertEqual(select_tone(level_sequence=1), 'gentle')
|
||||
|
||||
def test_level_2_default_firm(self):
|
||||
self.assertEqual(select_tone(level_sequence=2), 'firm')
|
||||
|
||||
def test_level_3_default_legal(self):
|
||||
self.assertEqual(select_tone(level_sequence=3), 'legal')
|
||||
|
||||
def test_critical_risk_escalates_gentle_to_firm(self):
|
||||
self.assertEqual(select_tone(level_sequence=1, risk_score=85), 'firm')
|
||||
|
||||
def test_extreme_risk_escalates_firm_to_legal(self):
|
||||
self.assertEqual(select_tone(level_sequence=2, risk_score=95), 'legal')
|
||||
|
||||
def test_unknown_level_defaults_gentle(self):
|
||||
self.assertEqual(select_tone(level_sequence=99), 'gentle')
|
||||
Reference in New Issue
Block a user