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',
|
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||||
'version': '19.0.1.0.6',
|
'version': '19.0.1.0.7',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 28,
|
'sequence': 28,
|
||||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||||
@@ -30,6 +30,7 @@ Built by Nexa Systems Inc.
|
|||||||
},
|
},
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
|
'data/cron.xml',
|
||||||
],
|
],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'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 account_reconcile_model
|
||||||
from . import fusion_reconcile_engine
|
from . import fusion_reconcile_engine
|
||||||
from . import fusion_unreconciled_bank_line_mv
|
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_bank_rec_tools
|
||||||
from . import test_legacy_tools_refactor
|
from . import test_legacy_tools_refactor
|
||||||
from . import test_mv_unreconciled
|
from . import test_mv_unreconciled
|
||||||
|
from . import test_cron_methods
|
||||||
|
|||||||
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