Compare commits
2 Commits
6cbb5f85fe
...
25f033d0c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f033d0c8 | ||
|
|
75850aad73 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||||
'version': '19.0.1.0.18',
|
'version': '19.0.1.0.20',
|
||||||
'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.',
|
||||||
@@ -31,6 +31,8 @@ Built by Nexa Systems Inc.
|
|||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/cron.xml',
|
'data/cron.xml',
|
||||||
|
'wizards/auto_reconcile_wizard_views.xml',
|
||||||
|
'wizards/bulk_reconcile_wizard_views.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ access_fusion_reconcile_suggestion_admin,suggestion admin,model_fusion_reconcile
|
|||||||
access_fusion_bank_rec_widget_user,bank rec widget user,model_fusion_bank_rec_widget,fusion_accounting_core.group_fusion_accounting_user,1,1,1,1
|
access_fusion_bank_rec_widget_user,bank rec widget user,model_fusion_bank_rec_widget,fusion_accounting_core.group_fusion_accounting_user,1,1,1,1
|
||||||
access_fusion_unreconciled_bank_line_mv_user,unreconciled bank line mv user,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
access_fusion_unreconciled_bank_line_mv_user,unreconciled bank line mv user,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||||
access_fusion_unreconciled_bank_line_mv_admin,unreconciled bank line mv admin,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_admin,1,0,0,0
|
access_fusion_unreconciled_bank_line_mv_admin,unreconciled bank line mv admin,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_admin,1,0,0,0
|
||||||
|
access_fusion_auto_reconcile_wizard_user,fusion.auto.reconcile.wizard.user,model_fusion_auto_reconcile_wizard,base.group_user,1,1,1,0
|
||||||
|
access_fusion_bulk_reconcile_wizard_user,fusion.bulk.reconcile.wizard.user,model_fusion_bulk_reconcile_wizard,base.group_user,1,1,1,0
|
||||||
|
|||||||
|
@@ -16,3 +16,5 @@ from . import test_legacy_tools_refactor
|
|||||||
from . import test_mv_unreconciled
|
from . import test_mv_unreconciled
|
||||||
from . import test_cron_methods
|
from . import test_cron_methods
|
||||||
from . import test_controller
|
from . import test_controller
|
||||||
|
from . import test_auto_reconcile_wizard
|
||||||
|
from . import test_bulk_reconcile_wizard
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Tests for fusion.auto.reconcile.wizard."""
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from . import _factories as f
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestAutoReconcileWizard(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.partner = self.env['res.partner'].create({'name': 'Auto Wizard Partner'})
|
||||||
|
self.journal = f.make_bank_journal(self.env, name='Auto Bank', code='AUBK')
|
||||||
|
|
||||||
|
def test_wizard_runs_and_reconciles_matchable_lines(self):
|
||||||
|
statement = f.make_bank_statement(self.env, journal=self.journal)
|
||||||
|
for amount in [100.00, 200.00]:
|
||||||
|
f.make_invoice(self.env, partner=self.partner, amount=amount)
|
||||||
|
f.make_bank_line(
|
||||||
|
self.env, statement=statement, amount=amount, partner=self.partner)
|
||||||
|
|
||||||
|
wizard = self.env['fusion.auto.reconcile.wizard'].create({
|
||||||
|
'journal_id': self.journal.id,
|
||||||
|
'strategy': 'auto',
|
||||||
|
'only_with_partner': True,
|
||||||
|
})
|
||||||
|
wizard.action_run()
|
||||||
|
self.assertEqual(wizard.state, 'done')
|
||||||
|
self.assertGreaterEqual(wizard.reconciled_count, 2)
|
||||||
|
|
||||||
|
def test_wizard_filters_by_date_range(self):
|
||||||
|
wizard = self.env['fusion.auto.reconcile.wizard'].create({
|
||||||
|
'journal_id': self.journal.id,
|
||||||
|
'date_from': '2099-01-01',
|
||||||
|
'date_to': '2099-12-31',
|
||||||
|
'strategy': 'auto',
|
||||||
|
})
|
||||||
|
wizard.action_run()
|
||||||
|
self.assertEqual(wizard.reconciled_count, 0)
|
||||||
|
|
||||||
|
def test_wizard_skips_when_only_with_partner_excludes_orphans(self):
|
||||||
|
statement = f.make_bank_statement(self.env, journal=self.journal)
|
||||||
|
f.make_bank_line(self.env, statement=statement, amount=999, partner=None)
|
||||||
|
wizard = self.env['fusion.auto.reconcile.wizard'].create({
|
||||||
|
'journal_id': self.journal.id,
|
||||||
|
'strategy': 'auto',
|
||||||
|
'only_with_partner': True,
|
||||||
|
})
|
||||||
|
wizard.action_run()
|
||||||
|
self.assertEqual(wizard.reconciled_count, 0)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Tests for fusion.bulk.reconcile.wizard."""
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from . import _factories as f
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestBulkReconcileWizard(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.partner = self.env['res.partner'].create({'name': 'Bulk Wizard Partner'})
|
||||||
|
self.journal = f.make_bank_journal(self.env, name='Bulk Bank', code='BLKBK')
|
||||||
|
self.statement = f.make_bank_statement(self.env, journal=self.journal)
|
||||||
|
|
||||||
|
def test_wizard_default_picks_active_ids(self):
|
||||||
|
line1 = f.make_bank_line(
|
||||||
|
self.env, statement=self.statement, amount=100, partner=self.partner)
|
||||||
|
line2 = f.make_bank_line(
|
||||||
|
self.env, statement=self.statement, amount=200, partner=self.partner)
|
||||||
|
wizard = self.env['fusion.bulk.reconcile.wizard'].with_context(
|
||||||
|
active_model='account.bank.statement.line',
|
||||||
|
active_ids=[line1.id, line2.id],
|
||||||
|
).create({})
|
||||||
|
self.assertEqual(set(wizard.statement_line_ids.ids), {line1.id, line2.id})
|
||||||
|
self.assertEqual(wizard.selected_count, 2)
|
||||||
|
|
||||||
|
def test_wizard_auto_mode_runs_engine_batch(self):
|
||||||
|
line_ids = []
|
||||||
|
for amount in [110.00, 220.00]:
|
||||||
|
f.make_invoice(self.env, partner=self.partner, amount=amount)
|
||||||
|
line = f.make_bank_line(
|
||||||
|
self.env, statement=self.statement, amount=amount, partner=self.partner)
|
||||||
|
line_ids.append(line.id)
|
||||||
|
wizard = self.env['fusion.bulk.reconcile.wizard'].create({
|
||||||
|
'statement_line_ids': [(6, 0, line_ids)],
|
||||||
|
'mode': 'auto',
|
||||||
|
'strategy': 'auto',
|
||||||
|
})
|
||||||
|
wizard.action_run()
|
||||||
|
self.assertEqual(wizard.state, 'done')
|
||||||
|
self.assertGreaterEqual(wizard.reconciled_count, 2)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from . import auto_reconcile_wizard
|
||||||
|
from . import bulk_reconcile_wizard
|
||||||
|
|||||||
78
fusion_accounting_bank_rec/wizards/auto_reconcile_wizard.py
Normal file
78
fusion_accounting_bank_rec/wizards/auto_reconcile_wizard.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Auto-reconcile wizard.
|
||||||
|
|
||||||
|
Lets the user pick filters (journal, date range, strategy) and runs
|
||||||
|
fusion.reconcile.engine.reconcile_batch on all matching unreconciled
|
||||||
|
bank lines. Shows summary of results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import _, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FusionAutoReconcileWizard(models.TransientModel):
|
||||||
|
_name = "fusion.auto.reconcile.wizard"
|
||||||
|
_description = "Auto-Reconcile Bank Statement Lines Wizard"
|
||||||
|
|
||||||
|
journal_id = fields.Many2one(
|
||||||
|
'account.journal', string="Bank Journal",
|
||||||
|
domain=[('type', '=', 'bank')], required=True)
|
||||||
|
date_from = fields.Date(string="Date From")
|
||||||
|
date_to = fields.Date(string="Date To", default=fields.Date.today)
|
||||||
|
strategy = fields.Selection([
|
||||||
|
('auto', 'Auto (try amount-exact, then multi-invoice, then FIFO)'),
|
||||||
|
('amount_exact', 'Amount Exact only'),
|
||||||
|
('fifo', 'FIFO only'),
|
||||||
|
('multi_invoice', 'Multi-invoice combination only'),
|
||||||
|
], default='auto', required=True)
|
||||||
|
only_with_partner = fields.Boolean(
|
||||||
|
string="Only lines with a partner",
|
||||||
|
default=True,
|
||||||
|
help="Most safer matches require a known partner. Untick to attempt "
|
||||||
|
"matching for orphan lines too (uses memo tokenization).")
|
||||||
|
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('done', 'Done'),
|
||||||
|
], default='draft')
|
||||||
|
reconciled_count = fields.Integer(readonly=True)
|
||||||
|
skipped_count = fields.Integer(readonly=True)
|
||||||
|
error_count = fields.Integer(readonly=True)
|
||||||
|
error_summary = fields.Text(readonly=True)
|
||||||
|
|
||||||
|
def _build_domain(self):
|
||||||
|
self.ensure_one()
|
||||||
|
domain = [
|
||||||
|
('journal_id', '=', self.journal_id.id),
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
]
|
||||||
|
if self.date_from:
|
||||||
|
domain.append(('date', '>=', self.date_from))
|
||||||
|
if self.date_to:
|
||||||
|
domain.append(('date', '<=', self.date_to))
|
||||||
|
if self.only_with_partner:
|
||||||
|
domain.append(('partner_id', '!=', False))
|
||||||
|
return domain
|
||||||
|
|
||||||
|
def action_run(self):
|
||||||
|
self.ensure_one()
|
||||||
|
Line = self.env['account.bank.statement.line']
|
||||||
|
lines = Line.search(self._build_domain(), limit=1000)
|
||||||
|
result = self.env['fusion.reconcile.engine'].reconcile_batch(
|
||||||
|
lines, strategy=self.strategy)
|
||||||
|
errors = result.get('errors', [])
|
||||||
|
self.write({
|
||||||
|
'state': 'done',
|
||||||
|
'reconciled_count': result.get('reconciled_count', 0),
|
||||||
|
'skipped_count': result.get('skipped', 0),
|
||||||
|
'error_count': len(errors),
|
||||||
|
'error_summary': '\n'.join(
|
||||||
|
f"Line {e['line_id']}: {e['error']}" for e in errors[:20]
|
||||||
|
) or False,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': self._name,
|
||||||
|
'res_id': self.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': self.env.context,
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fusion_auto_reconcile_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.auto.reconcile.wizard.form</field>
|
||||||
|
<field name="model">fusion.auto.reconcile.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Auto-Reconcile Bank Lines">
|
||||||
|
<group invisible="state == 'done'">
|
||||||
|
<field name="journal_id" options="{'no_create': True}"/>
|
||||||
|
<field name="date_from"/>
|
||||||
|
<field name="date_to"/>
|
||||||
|
<field name="strategy"/>
|
||||||
|
<field name="only_with_partner"/>
|
||||||
|
</group>
|
||||||
|
<group invisible="state != 'done'" string="Results">
|
||||||
|
<field name="reconciled_count"/>
|
||||||
|
<field name="skipped_count"/>
|
||||||
|
<field name="error_count"/>
|
||||||
|
<field name="error_summary"/>
|
||||||
|
</group>
|
||||||
|
<field name="state" invisible="1"/>
|
||||||
|
<footer>
|
||||||
|
<button name="action_run" type="object" string="Run"
|
||||||
|
class="btn-primary" invisible="state == 'done'"/>
|
||||||
|
<button special="cancel" string="Close"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fusion_auto_reconcile_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Auto-Reconcile</field>
|
||||||
|
<field name="res_model">fusion.auto.reconcile.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
93
fusion_accounting_bank_rec/wizards/bulk_reconcile_wizard.py
Normal file
93
fusion_accounting_bank_rec/wizards/bulk_reconcile_wizard.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Bulk reconcile wizard — operates on user-selected records.
|
||||||
|
|
||||||
|
Reads active_ids from context (selected bank lines). Two modes:
|
||||||
|
1. Auto (run engine on all selected with chosen strategy)
|
||||||
|
2. Apply reconcile model (apply a chosen account.reconcile.model to all)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class FusionBulkReconcileWizard(models.TransientModel):
|
||||||
|
_name = "fusion.bulk.reconcile.wizard"
|
||||||
|
_description = "Bulk Reconcile Selected Bank Lines Wizard"
|
||||||
|
|
||||||
|
statement_line_ids = fields.Many2many(
|
||||||
|
'account.bank.statement.line',
|
||||||
|
string="Selected Bank Lines",
|
||||||
|
default=lambda self: [(6, 0, self._default_line_ids())])
|
||||||
|
selected_count = fields.Integer(
|
||||||
|
compute='_compute_selected_count', string="# Selected")
|
||||||
|
mode = fields.Selection([
|
||||||
|
('auto', 'Auto (engine reconcile_batch)'),
|
||||||
|
('reconcile_model', 'Apply Reconcile Model'),
|
||||||
|
], default='auto', required=True)
|
||||||
|
strategy = fields.Selection([
|
||||||
|
('auto', 'Auto'),
|
||||||
|
('amount_exact', 'Amount Exact only'),
|
||||||
|
('fifo', 'FIFO only'),
|
||||||
|
('multi_invoice', 'Multi-invoice'),
|
||||||
|
], default='auto')
|
||||||
|
reconcile_model_id = fields.Many2one(
|
||||||
|
'account.reconcile.model', string="Reconcile Model",
|
||||||
|
domain=[('rule_type', '=', 'writeoff_button')])
|
||||||
|
|
||||||
|
state = fields.Selection(
|
||||||
|
[('draft', 'Draft'), ('done', 'Done')], default='draft')
|
||||||
|
reconciled_count = fields.Integer(readonly=True)
|
||||||
|
skipped_count = fields.Integer(readonly=True)
|
||||||
|
error_count = fields.Integer(readonly=True)
|
||||||
|
error_summary = fields.Text(readonly=True)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _default_line_ids(self):
|
||||||
|
ctx = self.env.context
|
||||||
|
if ctx.get('active_model') == 'account.bank.statement.line':
|
||||||
|
return ctx.get('active_ids', [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
@api.depends('statement_line_ids')
|
||||||
|
def _compute_selected_count(self):
|
||||||
|
for w in self:
|
||||||
|
w.selected_count = len(w.statement_line_ids)
|
||||||
|
|
||||||
|
def action_run(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.mode == 'auto':
|
||||||
|
result = self.env['fusion.reconcile.engine'].reconcile_batch(
|
||||||
|
self.statement_line_ids, strategy=self.strategy)
|
||||||
|
elif self.mode == 'reconcile_model':
|
||||||
|
if not self.reconcile_model_id:
|
||||||
|
raise UserError(_("Pick a reconcile model first."))
|
||||||
|
# Phase 1 fallback: apply the model line-by-line via the engine's
|
||||||
|
# write_off path (simplified — real reconcile-model semantics are
|
||||||
|
# more nuanced; full integration in Task 38 follow-up).
|
||||||
|
result = {'reconciled_count': 0, 'skipped': 0, 'errors': []}
|
||||||
|
for line in self.statement_line_ids:
|
||||||
|
try:
|
||||||
|
self.reconcile_model_id._apply_lines_for_bank_statement_line(line)
|
||||||
|
result['reconciled_count'] += 1
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
result['errors'].append({'line_id': line.id, 'error': str(e)})
|
||||||
|
else:
|
||||||
|
result = {'reconciled_count': 0, 'skipped': 0, 'errors': []}
|
||||||
|
|
||||||
|
errors = result.get('errors', [])
|
||||||
|
self.write({
|
||||||
|
'state': 'done',
|
||||||
|
'reconciled_count': result.get('reconciled_count', 0),
|
||||||
|
'skipped_count': result.get('skipped', 0),
|
||||||
|
'error_count': len(errors),
|
||||||
|
'error_summary': '\n'.join(
|
||||||
|
f"Line {e['line_id']}: {e['error']}" for e in errors[:20]
|
||||||
|
) or False,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': self._name,
|
||||||
|
'res_id': self.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': self.env.context,
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fusion_bulk_reconcile_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.bulk.reconcile.wizard.form</field>
|
||||||
|
<field name="model">fusion.bulk.reconcile.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Bulk Reconcile Selected">
|
||||||
|
<group invisible="state == 'done'">
|
||||||
|
<field name="selected_count" readonly="1"/>
|
||||||
|
<field name="mode" widget="radio"/>
|
||||||
|
<field name="strategy" invisible="mode != 'auto'"/>
|
||||||
|
<field name="reconcile_model_id"
|
||||||
|
invisible="mode != 'reconcile_model'"
|
||||||
|
required="mode == 'reconcile_model'"/>
|
||||||
|
</group>
|
||||||
|
<group invisible="state != 'done'" string="Results">
|
||||||
|
<field name="reconciled_count"/>
|
||||||
|
<field name="skipped_count"/>
|
||||||
|
<field name="error_count"/>
|
||||||
|
<field name="error_summary"/>
|
||||||
|
</group>
|
||||||
|
<field name="state" invisible="1"/>
|
||||||
|
<field name="statement_line_ids" invisible="1"/>
|
||||||
|
<footer>
|
||||||
|
<button name="action_run" type="object" string="Run"
|
||||||
|
class="btn-primary" invisible="state == 'done'"/>
|
||||||
|
<button special="cancel" string="Close"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fusion_bulk_reconcile_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Bulk Reconcile Selected</field>
|
||||||
|
<field name="res_model">fusion.bulk.reconcile.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
<field name="binding_model_id" ref="account.model_account_bank_statement_line"/>
|
||||||
|
<field name="binding_view_types">list</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user