feat(fusion_accounting_bank_rec): 3 cron schedules + handler model
- cron_suggest (every 30min): warm AI suggestions for unreconciled lines that don't have a recent pending one - cron_pattern_refresh (daily 02:00): recompute fusion.reconcile.pattern for each (company, partner) pair with precedents - cron_mv_refresh (every 5min): REFRESH MATERIALIZED VIEW CONCURRENTLY using a dedicated autocommit cursor (REFRESH CONCURRENTLY can't run inside a regular Odoo transaction) V19 note: ir.cron dropped the numbercall field, so the data XML omits it (cron now repeats indefinitely as long as active=True). Tests: 5 new TestFusionBankRecCron tests pass; full module suite is 0 failed / 0 errors of 123 logical tests on westin-v19. Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||
'version': '19.0.1.0.6',
|
||||
'version': '19.0.1.0.7',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 28,
|
||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||
@@ -30,6 +30,7 @@ Built by Nexa Systems Inc.
|
||||
},
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/cron.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
35
fusion_accounting_bank_rec/data/cron.xml
Normal file
35
fusion_accounting_bank_rec/data/cron.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="cron_fusion_bank_rec_suggest" model="ir.cron">
|
||||
<field name="name">Fusion Bank Rec — Warm AI Suggestions</field>
|
||||
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_suggest_pending()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="cron_fusion_bank_rec_pattern_refresh" model="ir.cron">
|
||||
<field name="name">Fusion Bank Rec — Refresh Partner Patterns</field>
|
||||
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_refresh_patterns()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="nextcall" eval="(DateTime.now().replace(hour=2, minute=0, second=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="cron_fusion_bank_rec_mv_refresh" model="ir.cron">
|
||||
<field name="name">Fusion Bank Rec — Refresh Unreconciled MV</field>
|
||||
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_refresh_mv()</field>
|
||||
<field name="interval_number">5</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -6,3 +6,4 @@ from . import account_bank_statement_line
|
||||
from . import account_reconcile_model
|
||||
from . import fusion_reconcile_engine
|
||||
from . import fusion_unreconciled_bank_line_mv
|
||||
from . import fusion_bank_rec_cron
|
||||
|
||||
119
fusion_accounting_bank_rec/models/fusion_bank_rec_cron.py
Normal file
119
fusion_accounting_bank_rec/models/fusion_bank_rec_cron.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Cron handler model for fusion_accounting_bank_rec.
|
||||
|
||||
Three scheduled jobs:
|
||||
- _cron_suggest_pending: warm AI suggestions for unreconciled lines (30 min)
|
||||
- _cron_refresh_patterns: recompute fusion.reconcile.pattern aggregates (daily 02:00)
|
||||
- _cron_refresh_mv: REFRESH MATERIALIZED VIEW CONCURRENTLY (5 min)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import odoo
|
||||
from odoo import api, fields, models
|
||||
|
||||
from ..services.pattern_extractor import extract_pattern_for_partner
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBankRecCron(models.AbstractModel):
|
||||
_name = "fusion.bank.rec.cron"
|
||||
_description = "Fusion Bank Reconciliation Cron Handlers"
|
||||
|
||||
@api.model
|
||||
def _cron_suggest_pending(self, batch_size=50):
|
||||
"""For each unreconciled bank line that doesn't have a recent pending
|
||||
suggestion, run engine.suggest_matches.
|
||||
|
||||
Recent = a pending suggestion created within the last 24 hours."""
|
||||
cutoff = fields.Datetime.now() - timedelta(hours=24)
|
||||
Line = self.env['account.bank.statement.line']
|
||||
lines_to_consider = Line.search([
|
||||
('is_reconciled', '=', False),
|
||||
('partner_id', '!=', False),
|
||||
], limit=batch_size * 5)
|
||||
|
||||
Suggestion = self.env['fusion.reconcile.suggestion']
|
||||
lines_needing_suggestions = self.env['account.bank.statement.line']
|
||||
for line in lines_to_consider:
|
||||
recent = Suggestion.search_count([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
('create_date', '>=', cutoff),
|
||||
])
|
||||
if recent == 0:
|
||||
lines_needing_suggestions |= line
|
||||
if len(lines_needing_suggestions) >= batch_size:
|
||||
break
|
||||
|
||||
if not lines_needing_suggestions:
|
||||
_logger.debug("Cron: no bank lines need suggestion warming")
|
||||
return
|
||||
|
||||
_logger.info(
|
||||
"Cron: warming suggestions for %d bank lines",
|
||||
len(lines_needing_suggestions))
|
||||
try:
|
||||
self.env['fusion.reconcile.engine'].suggest_matches(
|
||||
lines_needing_suggestions, limit_per_line=3)
|
||||
except Exception as e:
|
||||
_logger.exception("Cron suggest_pending failed: %s", e)
|
||||
|
||||
@api.model
|
||||
def _cron_refresh_patterns(self):
|
||||
"""For each (company, partner) pair with precedents, recompute and
|
||||
upsert the fusion.reconcile.pattern row."""
|
||||
Pattern = self.env['fusion.reconcile.pattern']
|
||||
self.env.cr.execute("""
|
||||
SELECT DISTINCT company_id, partner_id
|
||||
FROM fusion_reconcile_precedent
|
||||
WHERE partner_id IS NOT NULL
|
||||
""")
|
||||
pairs = self.env.cr.fetchall()
|
||||
_logger.info(
|
||||
"Cron: refreshing patterns for %d (company, partner) pairs",
|
||||
len(pairs))
|
||||
for company_id, partner_id in pairs:
|
||||
try:
|
||||
vals = extract_pattern_for_partner(
|
||||
self.env, company_id=company_id, partner_id=partner_id)
|
||||
existing = Pattern.search([
|
||||
('company_id', '=', company_id),
|
||||
('partner_id', '=', partner_id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
Pattern.create(vals)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Pattern refresh failed for company=%s partner=%s: %s",
|
||||
company_id, partner_id, e)
|
||||
|
||||
@api.model
|
||||
def _cron_refresh_mv(self):
|
||||
"""Refresh the materialized view CONCURRENTLY using an autocommit cursor.
|
||||
|
||||
REFRESH CONCURRENTLY can't run inside a transaction, so we open a
|
||||
fresh connection in autocommit mode (per Task 24's note). On any
|
||||
failure, we fall back to the model's blocking refresh."""
|
||||
try:
|
||||
db_name = self.env.cr.dbname
|
||||
db = odoo.sql_db.db_connect(db_name)
|
||||
with db.cursor() as cron_cr:
|
||||
cron_cr._cnx.set_session(autocommit=True)
|
||||
cron_cr.execute(
|
||||
"REFRESH MATERIALIZED VIEW CONCURRENTLY "
|
||||
"fusion_unreconciled_bank_line_mv")
|
||||
_logger.debug("Cron: MV refresh CONCURRENTLY succeeded")
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Cron MV refresh CONCURRENTLY failed (%s); falling back to "
|
||||
"blocking refresh", e)
|
||||
try:
|
||||
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||
concurrently=False)
|
||||
except Exception as e2:
|
||||
_logger.exception(
|
||||
"Cron MV refresh fallback also failed: %s", e2)
|
||||
@@ -14,3 +14,4 @@ from . import test_bank_rec_adapter
|
||||
from . import test_bank_rec_tools
|
||||
from . import test_legacy_tools_refactor
|
||||
from . import test_mv_unreconciled
|
||||
from . import test_cron_methods
|
||||
|
||||
85
fusion_accounting_bank_rec/tests/test_cron_methods.py
Normal file
85
fusion_accounting_bank_rec/tests/test_cron_methods.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Smoke tests for the cron handler methods.
|
||||
|
||||
We don't test the Odoo cron scheduler itself (it works) — we test that
|
||||
calling the cron methods directly does what they're supposed to do."""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionBankRecCron(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Cron Test Partner'})
|
||||
self.cron = self.env['fusion.bank.rec.cron']
|
||||
|
||||
def test_cron_suggest_pending_creates_suggestions_for_new_line(self):
|
||||
f.make_invoice(self.env, partner=self.partner, amount=420.00)
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, amount=420.00, partner=self.partner)
|
||||
|
||||
Sug = self.env['fusion.reconcile.suggestion']
|
||||
self.assertEqual(
|
||||
Sug.search_count([('statement_line_id', '=', bank_line.id)]), 0)
|
||||
|
||||
self.cron._cron_suggest_pending(batch_size=10)
|
||||
|
||||
self.assertGreater(
|
||||
Sug.search_count([('statement_line_id', '=', bank_line.id)]), 0)
|
||||
|
||||
def test_cron_suggest_pending_skips_lines_with_recent_suggestions(self):
|
||||
f.make_invoice(self.env, partner=self.partner, amount=510.00)
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, amount=510.00, partner=self.partner)
|
||||
f.make_suggestion(
|
||||
self.env, statement_line=bank_line, confidence=0.5)
|
||||
|
||||
Sug = self.env['fusion.reconcile.suggestion']
|
||||
before = Sug.search_count(
|
||||
[('statement_line_id', '=', bank_line.id)])
|
||||
self.cron._cron_suggest_pending(batch_size=10)
|
||||
after = Sug.search_count(
|
||||
[('statement_line_id', '=', bank_line.id)])
|
||||
self.assertEqual(
|
||||
before, after,
|
||||
"Cron should skip lines with a recent pending suggestion")
|
||||
|
||||
def test_cron_refresh_patterns_creates_pattern_for_partner_with_precedents(self):
|
||||
for d in [10, 24, 38]:
|
||||
f.make_precedent(
|
||||
self.env, partner=self.partner, days_ago=d, amount=1000)
|
||||
|
||||
Pattern = self.env['fusion.reconcile.pattern']
|
||||
Pattern.search([('partner_id', '=', self.partner.id)]).unlink()
|
||||
|
||||
self.cron._cron_refresh_patterns()
|
||||
|
||||
pattern = Pattern.search(
|
||||
[('partner_id', '=', self.partner.id)], limit=1)
|
||||
self.assertTrue(
|
||||
pattern, "Cron should create pattern for partner with precedents")
|
||||
self.assertEqual(pattern.reconcile_count, 3)
|
||||
|
||||
def test_cron_refresh_patterns_updates_existing_pattern(self):
|
||||
Pattern = self.env['fusion.reconcile.pattern']
|
||||
Pattern.search([('partner_id', '=', self.partner.id)]).unlink()
|
||||
f.make_pattern(
|
||||
self.env, partner=self.partner, reconcile_count=99)
|
||||
|
||||
for d in [5, 15]:
|
||||
f.make_precedent(
|
||||
self.env, partner=self.partner, days_ago=d, amount=500)
|
||||
|
||||
self.cron._cron_refresh_patterns()
|
||||
|
||||
pattern = Pattern.search(
|
||||
[('partner_id', '=', self.partner.id)], limit=1)
|
||||
self.assertEqual(
|
||||
pattern.reconcile_count, 2,
|
||||
"Cron should update existing pattern with fresh precedent count")
|
||||
|
||||
def test_cron_refresh_mv_does_not_raise(self):
|
||||
# Just verify it runs — full MV behaviour is tested in Task 24
|
||||
self.cron._cron_refresh_mv()
|
||||
Reference in New Issue
Block a user