Compare commits
21 Commits
fusion_acc
...
8be0caa474
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be0caa474 | ||
|
|
fce748b89c | ||
|
|
fcecf9d925 | ||
|
|
da269a6207 | ||
|
|
80b8100232 | ||
|
|
920a624cd1 | ||
|
|
06e382b27b | ||
|
|
91d09dfca2 | ||
|
|
ef27f0e2c1 | ||
|
|
b37b1d4618 | ||
|
|
e468ae6b0a | ||
|
|
6e945dea95 | ||
|
|
3dc74e3987 | ||
|
|
b75f215808 | ||
|
|
f2d6492efd | ||
|
|
123db4219f | ||
|
|
f44ed0e010 | ||
|
|
77cb0a1309 | ||
|
|
09104007f6 | ||
|
|
c118b7c6b5 | ||
|
|
db8b79d22e |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting AI',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.0.1',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 26,
|
||||
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import claude
|
||||
from . import openai_adapter
|
||||
from ._base import LLMProvider
|
||||
|
||||
44
fusion_accounting_ai/services/adapters/_base.py
Normal file
44
fusion_accounting_ai/services/adapters/_base.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""LLMProvider contract - every adapter must conform.
|
||||
|
||||
Phase 1 generalisation: makes local LLM (Ollama, LM Studio, vLLM, llamafile,
|
||||
llama.cpp HTTP server) a one-config-line drop-in via the OpenAI-compatible
|
||||
HTTP API surface that all of them expose.
|
||||
"""
|
||||
|
||||
|
||||
class LLMProvider:
|
||||
"""Contract every LLM backend must satisfy. Adapters declare capabilities
|
||||
as class attributes; the engine inspects them before calling optional methods."""
|
||||
|
||||
supports_tool_calling: bool = False
|
||||
supports_streaming: bool = False
|
||||
max_context_tokens: int = 4096
|
||||
supports_embeddings: bool = False
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
"""Plain text completion. Required for ALL providers.
|
||||
|
||||
Returns: {'content': str, 'tokens_used': int, 'model': str}
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def complete_with_tools(self, *, system, messages, tools, max_tokens=2048) -> dict:
|
||||
"""Tool-calling completion. Optional - caller checks supports_tool_calling first.
|
||||
|
||||
Returns: {'content': str, 'tool_calls': [{'name': str, 'arguments': dict}], ...}
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support tool-calling. "
|
||||
f"Check supports_tool_calling before calling.")
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Embeddings. Optional - caller checks supports_embeddings first.
|
||||
|
||||
Returns: list of float vectors, one per input text.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support embeddings. "
|
||||
f"Check supports_embeddings before calling.")
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ._base import LLMProvider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -12,6 +14,64 @@ except ImportError:
|
||||
anthropic_sdk = None
|
||||
|
||||
|
||||
class ClaudeAdapter(LLMProvider):
|
||||
"""Plain-Python LLMProvider implementation for Anthropic Claude.
|
||||
|
||||
Preserves all existing functionality (extended thinking, native tool_use
|
||||
blocks) used by the Odoo AbstractModel-based adapter -- this class is
|
||||
additive for the Phase 1 LLMProvider contract.
|
||||
"""
|
||||
|
||||
supports_tool_calling = True
|
||||
supports_streaming = True
|
||||
max_context_tokens = 200000
|
||||
supports_embeddings = False
|
||||
|
||||
def __init__(self, env):
|
||||
super().__init__(env)
|
||||
if anthropic_sdk is None:
|
||||
raise UserError(_("The 'anthropic' Python package is not installed."))
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
try:
|
||||
api_key = env['fusion.api.service'].get_api_key(
|
||||
provider_type='anthropic',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
api_key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
|
||||
if not api_key:
|
||||
api_key = 'not-needed'
|
||||
self.client = anthropic_sdk.Anthropic(api_key=api_key)
|
||||
self.model = ICP.get_param(
|
||||
'fusion_accounting.claude_model', 'claude-sonnet-4-6')
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
api_messages = [
|
||||
m for m in messages if m.get('role') in ('user', 'assistant')
|
||||
]
|
||||
try:
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
system=system,
|
||||
messages=api_messages,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("Claude complete error: %s", e)
|
||||
raise UserError(_("Claude API error: %s", str(e)))
|
||||
text_parts = [b.text for b in response.content if getattr(b, 'type', None) == 'text']
|
||||
return {
|
||||
'content': '\n'.join(text_parts),
|
||||
'tokens_used': (
|
||||
getattr(response.usage, 'input_tokens', 0)
|
||||
+ getattr(response.usage, 'output_tokens', 0)
|
||||
),
|
||||
'model': self.model,
|
||||
}
|
||||
|
||||
|
||||
class FusionAccountingAdapterClaude(models.AbstractModel):
|
||||
_name = 'fusion.accounting.adapter.claude'
|
||||
_description = 'Claude AI Adapter'
|
||||
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ._base import LLMProvider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -12,6 +14,71 @@ except ImportError:
|
||||
OpenAI = None
|
||||
|
||||
|
||||
DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
||||
|
||||
|
||||
class OpenAIAdapter(LLMProvider):
|
||||
"""Plain-Python LLMProvider implementation backed by an OpenAI-compatible
|
||||
HTTP endpoint.
|
||||
|
||||
The OpenAI Python SDK speaks to any server that exposes the OpenAI
|
||||
Chat Completions surface: OpenAI itself, Ollama, LM Studio, vLLM,
|
||||
llamafile, llama.cpp HTTP server, etc. Configure the endpoint via
|
||||
the ``fusion_accounting.openai_base_url`` ir.config_parameter.
|
||||
"""
|
||||
|
||||
supports_tool_calling = True
|
||||
supports_streaming = True
|
||||
max_context_tokens = 128000
|
||||
supports_embeddings = True
|
||||
|
||||
def __init__(self, env):
|
||||
super().__init__(env)
|
||||
if OpenAI is None:
|
||||
raise UserError(_("The 'openai' Python package is not installed."))
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
base_url = ICP.get_param(
|
||||
'fusion_accounting.openai_base_url', DEFAULT_OPENAI_BASE_URL,
|
||||
) or DEFAULT_OPENAI_BASE_URL
|
||||
try:
|
||||
api_key = env['fusion.api.service'].get_api_key(
|
||||
provider_type='openai',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
api_key = ICP.get_param('fusion_accounting.openai_api_key', '')
|
||||
if not api_key:
|
||||
# Local LLM servers (Ollama, LM Studio, llama.cpp) usually do not
|
||||
# require a real key but the SDK insists on a non-empty string.
|
||||
api_key = 'not-needed'
|
||||
self.base_url = base_url
|
||||
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
self.model = ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
api_messages = [{'role': 'system', 'content': system}]
|
||||
for msg in messages:
|
||||
if msg.get('role') in ('user', 'assistant', 'tool'):
|
||||
api_messages.append(msg)
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=api_messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("OpenAI complete error: %s", e)
|
||||
raise UserError(_("OpenAI API error: %s", str(e)))
|
||||
choice = response.choices[0]
|
||||
return {
|
||||
'content': choice.message.content or '',
|
||||
'tokens_used': getattr(response.usage, 'total_tokens', 0),
|
||||
'model': self.model,
|
||||
}
|
||||
|
||||
|
||||
class FusionAccountingAdapterOpenAI(models.AbstractModel):
|
||||
_name = 'fusion.accounting.adapter.openai'
|
||||
_description = 'OpenAI AI Adapter'
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import test_post_migration
|
||||
from . import test_data_adapters
|
||||
from . import test_llm_provider_contract
|
||||
|
||||
45
fusion_accounting_ai/tests/test_llm_provider_contract.py
Normal file
45
fusion_accounting_ai/tests/test_llm_provider_contract.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters._base import LLMProvider
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLLMProviderContract(TransactionCase):
|
||||
"""Every LLM adapter must satisfy the LLMProvider contract."""
|
||||
|
||||
def test_base_class_defines_capability_attrs(self):
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_tool_calling'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_streaming'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'max_context_tokens'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_embeddings'))
|
||||
|
||||
def test_openai_adapter_implements_contract(self):
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
self.assertTrue(issubclass(OpenAIAdapter, LLMProvider))
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||
|
||||
def test_claude_adapter_implements_contract(self):
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||
self.assertTrue(issubclass(ClaudeAdapter, LLMProvider))
|
||||
adapter = ClaudeAdapter(self.env)
|
||||
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||
|
||||
def test_openai_adapter_uses_configurable_base_url(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'fusion_accounting.openai_base_url', 'http://localhost:1234/v1')
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'fusion_accounting.openai_api_key', 'lm-studio-test-key')
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertEqual(str(adapter.client.base_url).rstrip('/'),
|
||||
'http://localhost:1234/v1')
|
||||
|
||||
def test_openai_adapter_default_base_url_when_unset(self):
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', '=', 'fusion_accounting.openai_base_url')
|
||||
]).unlink()
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertIn('api.openai.com', str(adapter.client.base_url))
|
||||
4
fusion_accounting_bank_rec/__init__.py
Normal file
4
fusion_accounting_bank_rec/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import services
|
||||
from . import wizards
|
||||
37
fusion_accounting_bank_rec/__manifest__.py
Normal file
37
fusion_accounting_bank_rec/__manifest__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||
'version': '19.0.1.0.5',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 28,
|
||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||
'description': """
|
||||
Fusion Accounting — Bank Reconciliation
|
||||
========================================
|
||||
Replaces Odoo Enterprise's account_accountant bank-rec widget with a
|
||||
native V19 OWL implementation reading/writing Community's
|
||||
account.partial.reconcile tables.
|
||||
|
||||
Features:
|
||||
- Strict mirror of all Enterprise UI components (zero functional loss)
|
||||
- AI confidence badges with one-click Accept and ranked alternatives
|
||||
- Behavioural learning from historical reconciliations
|
||||
- Local LLM ready (Ollama, LM Studio) via OpenAI-compatible adapter
|
||||
- Coexists with account_accountant (Enterprise wins by default)
|
||||
|
||||
Built by Nexa Systems Inc.
|
||||
""",
|
||||
'icon': '/fusion_accounting_bank_rec/static/description/icon.png',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.ca',
|
||||
'maintainer': 'Nexa Systems Inc.',
|
||||
'depends': ['fusion_accounting_core'],
|
||||
'external_dependencies': {
|
||||
'python': ['hypothesis'],
|
||||
},
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'OPL-1',
|
||||
}
|
||||
0
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
0
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo import api, Command, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountAutoReconcileWizard(models.TransientModel):
|
||||
""" This wizard is used to automatically reconcile account.move.line.
|
||||
It is accessible trough Accounting > Accounting tab > Actions > Auto-reconcile menuitem.
|
||||
"""
|
||||
_name = 'account.auto.reconcile.wizard'
|
||||
_description = 'Account automatic reconciliation wizard'
|
||||
_check_company_auto = True
|
||||
|
||||
company_id = fields.Many2one(
|
||||
comodel_name='res.company',
|
||||
required=True,
|
||||
readonly=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
line_ids = fields.Many2many(comodel_name='account.move.line') # Amls from which we derive a preset for the wizard
|
||||
from_date = fields.Date(string='From')
|
||||
to_date = fields.Date(string='To', default=fields.Date.context_today, required=True)
|
||||
account_ids = fields.Many2many(
|
||||
comodel_name='account.account',
|
||||
string='Accounts',
|
||||
check_company=True,
|
||||
domain="[('reconcile', '=', True), ('account_type', '!=', 'off_balance')]",
|
||||
)
|
||||
partner_ids = fields.Many2many(
|
||||
comodel_name='res.partner',
|
||||
string='Partners',
|
||||
check_company=True,
|
||||
domain="[('company_id', 'in', (False, company_id)), '|', ('parent_id', '=', False), ('is_company', '=', True)]",
|
||||
)
|
||||
search_mode = fields.Selection(
|
||||
selection=[
|
||||
('one_to_one', "Perfect Match"),
|
||||
('zero_balance', "Clear Account"),
|
||||
],
|
||||
string='Reconcile',
|
||||
required=True,
|
||||
default='one_to_one',
|
||||
help="Reconcile journal items with opposite balance or clear accounts with a zero balance",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super().default_get(fields)
|
||||
domain = self.env.context.get('domain')
|
||||
if 'line_ids' in fields and 'line_ids' not in res and domain:
|
||||
amls = self.env['account.move.line'].search(domain)
|
||||
if amls:
|
||||
# pre-configure the wizard
|
||||
res.update(self._get_default_wizard_values(amls))
|
||||
res['line_ids'] = [Command.set(amls.ids)]
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _get_default_wizard_values(self, amls):
|
||||
""" Derive a preset configuration based on amls.
|
||||
For example if all amls have the same account_id we will set it in the wizard.
|
||||
:param amls: account move lines from which we will derive a preset
|
||||
:return: a dict with preset values
|
||||
"""
|
||||
return {
|
||||
'account_ids': [Command.set(amls[0].account_id.ids)] if all(aml.account_id == amls[0].account_id for aml in amls) else [],
|
||||
'partner_ids': [Command.set(amls[0].partner_id.ids)] if all(aml.partner_id == amls[0].partner_id for aml in amls) else [],
|
||||
'search_mode': 'zero_balance' if amls.company_currency_id.is_zero(sum(amls.mapped('balance'))) else 'one_to_one',
|
||||
'from_date': min(amls.mapped('date')),
|
||||
'to_date': max(amls.mapped('date')),
|
||||
}
|
||||
|
||||
def _get_wizard_values(self):
|
||||
""" Get the current configuration of the wizard as a dict of values.
|
||||
:return: a dict with the current configuration of the wizard.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'account_ids': [Command.set(self.account_ids.ids)] if self.account_ids else [],
|
||||
'partner_ids': [Command.set(self.partner_ids.ids)] if self.partner_ids else [],
|
||||
'search_mode': self.search_mode,
|
||||
'from_date': self.from_date,
|
||||
'to_date': self.to_date,
|
||||
}
|
||||
|
||||
# ==== Business methods ====
|
||||
def _get_amls_domain(self):
|
||||
""" Get the domain of amls to be auto-reconciled. """
|
||||
self.ensure_one()
|
||||
if self.line_ids and self._get_wizard_values() == self._get_default_wizard_values(self.line_ids):
|
||||
domain = [('id', 'in', self.line_ids.ids)]
|
||||
else:
|
||||
domain = [
|
||||
('company_id', '=', self.company_id.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
|
||||
('date', '>=', self.from_date or date.min),
|
||||
('date', '<=', self.to_date),
|
||||
('reconciled', '=', False),
|
||||
('account_id.reconcile', '=', True),
|
||||
('amount_residual_currency', '!=', 0.0),
|
||||
('amount_residual', '!=', 0.0), # excludes exchange difference lines
|
||||
]
|
||||
if self.account_ids:
|
||||
domain.append(('account_id', 'in', self.account_ids.ids))
|
||||
if self.partner_ids:
|
||||
domain.append(('partner_id', 'in', self.partner_ids.ids))
|
||||
return domain
|
||||
|
||||
def _auto_reconcile_one_to_one(self):
|
||||
""" Auto-reconcile with one-to-one strategy:
|
||||
We will reconcile 2 amls together if their combined balance is zero.
|
||||
:return: a recordset of reconciled amls
|
||||
"""
|
||||
grouped_amls_data = self.env['account.move.line']._read_group(
|
||||
self._get_amls_domain(),
|
||||
['account_id', 'partner_id', 'currency_id', 'amount_residual_currency:abs_rounded'],
|
||||
['id:recordset'],
|
||||
)
|
||||
all_reconciled_amls = self.env['account.move.line']
|
||||
amls_grouped_by_2 = [] # we need to group amls with right format for _reconcile_plan
|
||||
for *__, grouped_aml_ids in grouped_amls_data:
|
||||
positive_amls = grouped_aml_ids.filtered(lambda aml: aml.amount_residual_currency >= 0).sorted('date')
|
||||
negative_amls = (grouped_aml_ids - positive_amls).sorted('date')
|
||||
min_len = min(len(positive_amls), len(negative_amls))
|
||||
positive_amls = positive_amls[:min_len]
|
||||
negative_amls = negative_amls[:min_len]
|
||||
all_reconciled_amls += positive_amls + negative_amls
|
||||
amls_grouped_by_2 += [pos_aml + neg_aml for (pos_aml, neg_aml) in zip(positive_amls, negative_amls)]
|
||||
self.env['account.move.line']._reconcile_plan(amls_grouped_by_2)
|
||||
return all_reconciled_amls
|
||||
|
||||
def _auto_reconcile_zero_balance(self):
|
||||
""" Auto-reconcile with zero balance strategy:
|
||||
We will reconcile all amls grouped by currency/account/partner that have a total balance of zero.
|
||||
:return: a recordset of reconciled amls
|
||||
"""
|
||||
grouped_amls_data = self.env['account.move.line']._read_group(
|
||||
self._get_amls_domain(),
|
||||
groupby=['account_id', 'partner_id', 'currency_id'],
|
||||
aggregates=['id:recordset'],
|
||||
having=[('amount_residual_currency:sum_rounded', '=', 0)],
|
||||
)
|
||||
all_reconciled_amls = self.env['account.move.line']
|
||||
amls_grouped_together = [] # we need to group amls with right format for _reconcile_plan
|
||||
for aml_data in grouped_amls_data:
|
||||
all_reconciled_amls += aml_data[-1]
|
||||
amls_grouped_together += [aml_data[-1]]
|
||||
self.env['account.move.line']._reconcile_plan(amls_grouped_together)
|
||||
return all_reconciled_amls
|
||||
|
||||
def auto_reconcile(self):
|
||||
""" Automatically reconcile amls given wizard's parameters.
|
||||
:return: an action that opens all reconciled items and related amls (exchange diff, etc)
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.search_mode == 'zero_balance':
|
||||
reconciled_amls = self._auto_reconcile_zero_balance()
|
||||
else:
|
||||
# search_mode == 'one_to_one'
|
||||
reconciled_amls = self._auto_reconcile_one_to_one()
|
||||
reconciled_amls_and_related = self.env['account.move.line'].search([
|
||||
('full_reconcile_id', 'in', reconciled_amls.full_reconcile_id.ids)
|
||||
])
|
||||
if reconciled_amls_and_related:
|
||||
return {
|
||||
'name': _("Automatically Reconciled Entries"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move.line',
|
||||
'context': "{'search_default_group_by_matching': True}",
|
||||
'view_mode': 'list',
|
||||
'domain': [('id', 'in', reconciled_amls_and_related.ids)],
|
||||
}
|
||||
else:
|
||||
raise UserError(self.env._("Nothing to reconcile."))
|
||||
@@ -0,0 +1,325 @@
|
||||
from odoo import SUPERUSER_ID, api, fields, models
|
||||
from odoo.tools import SQL
|
||||
|
||||
|
||||
class AccountReconcileModel(models.Model):
|
||||
_inherit = 'account.reconcile.model'
|
||||
|
||||
# Technical field to know if the rule was created automatically or by a user.
|
||||
created_automatically = fields.Boolean(default=False, copy=False)
|
||||
|
||||
def _apply_lines_for_bank_widget(self, residual_amount_currency, residual_balance, partner, st_line):
|
||||
""" Apply the reconciliation model lines to the statement line passed as parameter.
|
||||
|
||||
:param residual_amount_currency: The open amount currency of the statement line in the bank reconciliation widget
|
||||
expressed in the statement line currency.
|
||||
:param residual_balance: The open balance of the statement line in the bank reconciliation widget
|
||||
expressed in the company currency.
|
||||
:param partner: The partner set on the wizard.
|
||||
:param st_line: The statement line processed by the bank reconciliation widget.
|
||||
:return: A list of python dictionaries (one per reconcile model line) representing
|
||||
the journal items to be created by the current reconcile model.
|
||||
"""
|
||||
self.ensure_one()
|
||||
currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
|
||||
vals_list = []
|
||||
for line in self.line_ids:
|
||||
vals = line._apply_in_bank_widget(
|
||||
residual_amount_currency=residual_amount_currency,
|
||||
residual_balance=residual_balance,
|
||||
partner=line.partner_id or partner,
|
||||
st_line=st_line,
|
||||
)
|
||||
amount_currency = vals['amount_currency']
|
||||
balance = vals['balance']
|
||||
|
||||
if currency.is_zero(amount_currency) and st_line.company_currency_id.is_zero(balance):
|
||||
continue
|
||||
|
||||
vals_list.append(vals)
|
||||
residual_amount_currency -= amount_currency
|
||||
residual_balance -= balance
|
||||
|
||||
return vals_list
|
||||
|
||||
@api.model
|
||||
def get_available_reconcile_model_per_statement_line(self, statement_line_ids):
|
||||
self.check_access('read')
|
||||
self.env['account.reconcile.model'].flush_model()
|
||||
self.env['account.bank.statement.line'].flush_model()
|
||||
self.env.cr.execute(SQL(
|
||||
"""
|
||||
WITH matching_journal_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(account_journal_id) AS ids
|
||||
FROM account_journal_account_reconcile_model_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
matching_partner_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(res_partner_id) AS ids
|
||||
FROM account_reconcile_model_res_partner_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
)
|
||||
|
||||
SELECT st_line.id AS st_line_id,
|
||||
array_agg(reco_model.id ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_ids,
|
||||
array_agg(reco_model.name ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_names
|
||||
FROM account_bank_statement_line st_line
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT DISTINCT reco_model.id,
|
||||
reco_model.sequence,
|
||||
COALESCE(reco_model.name -> %(lang)s, reco_model.name -> 'en_US') as name
|
||||
FROM account_reconcile_model reco_model
|
||||
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
||||
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
|
||||
LEFT JOIN account_reconcile_model_line reco_model_line ON reco_model_line.model_id = reco_model.id
|
||||
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
||||
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
|
||||
AND (
|
||||
CASE COALESCE(reco_model.match_amount, '')
|
||||
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
|
||||
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
|
||||
WHEN 'between' THEN
|
||||
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
|
||||
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
|
||||
ELSE TRUE
|
||||
END
|
||||
)
|
||||
AND (
|
||||
reco_model.match_label IS NULL
|
||||
OR (
|
||||
reco_model.match_label = 'contains'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'not_contains'
|
||||
AND NOT (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'match_regex'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
|
||||
)
|
||||
)
|
||||
)
|
||||
AND reco_model.company_id = st_line.company_id
|
||||
AND reco_model.trigger = 'manual'
|
||||
AND reco_model_line.account_id IS NOT NULL
|
||||
AND reco_model.active IS TRUE
|
||||
) AS reco_model ON TRUE
|
||||
WHERE st_line.id IN %(statement_lines)s
|
||||
AND reco_model.id IS NOT NULL
|
||||
GROUP BY st_line.id
|
||||
""",
|
||||
lang=self.env.lang,
|
||||
statement_lines=tuple(statement_line_ids),
|
||||
))
|
||||
query_result = self.env.cr.fetchall()
|
||||
return {
|
||||
st_line_id: [
|
||||
{'id': model_id, 'display_name': model_name}
|
||||
for (model_id, model_name)
|
||||
in zip(model_ids, model_names)
|
||||
]
|
||||
for st_line_id, model_ids, model_names
|
||||
in query_result
|
||||
}
|
||||
|
||||
def _apply_reconcile_models(self, statement_lines):
|
||||
if not self or not statement_lines:
|
||||
return
|
||||
self.env['account.reconcile.model'].flush_model()
|
||||
statement_lines.flush_recordset(['journal_id', 'amount', 'amount_residual', 'transaction_details', 'payment_ref', 'partner_id', 'company_id'])
|
||||
self.env.cr.execute(SQL("""
|
||||
WITH matching_journal_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(account_journal_id) AS ids
|
||||
FROM account_journal_account_reconcile_model_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
matching_partner_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(res_partner_id) AS ids
|
||||
FROM account_reconcile_model_res_partner_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
model_fees AS (
|
||||
SELECT model_fees.id,
|
||||
model_fees.trigger,
|
||||
matching_journal_ids.ids AS journal_ids
|
||||
FROM account_reconcile_model model_fees
|
||||
JOIN ir_model_data imd ON model_fees.id = imd.res_id
|
||||
JOIN account_reconcile_model_line model_lines ON model_lines.model_id = model_fees.id
|
||||
LEFT JOIN matching_journal_ids ON model_fees.id = matching_journal_ids.account_reconcile_model_id
|
||||
WHERE imd.module = 'account'
|
||||
AND imd.name LIKE 'account_reco_model_fee_%%'
|
||||
AND model_fees.active IS TRUE
|
||||
AND model_lines.account_id IS NOT NULL
|
||||
)
|
||||
|
||||
SELECT st_line.id AS st_line_id,
|
||||
COALESCE(reco_model.id, model_fees.id) AS reco_model_id,
|
||||
COALESCE(reco_model.trigger, model_fees.trigger) AS trigger
|
||||
FROM account_bank_statement_line st_line
|
||||
JOIN account_move move ON st_line.move_id = move.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT reco_model.id,
|
||||
reco_model.trigger
|
||||
FROM account_reconcile_model reco_model
|
||||
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
||||
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
|
||||
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
||||
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
|
||||
AND (
|
||||
CASE COALESCE(reco_model.match_amount, '')
|
||||
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
|
||||
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
|
||||
WHEN 'between' THEN
|
||||
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
|
||||
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
|
||||
ELSE TRUE
|
||||
END
|
||||
)
|
||||
AND (
|
||||
reco_model.match_label IS NULL
|
||||
OR (
|
||||
reco_model.match_label = 'contains'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'not_contains'
|
||||
AND NOT (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'match_regex'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ~* reco_model.match_label_param
|
||||
)
|
||||
)
|
||||
)
|
||||
AND reco_model.id IN %s
|
||||
AND reco_model.can_be_proposed IS TRUE
|
||||
AND reco_model.company_id = st_line.company_id
|
||||
ORDER BY reco_model.sequence ASC, reco_model.id ASC
|
||||
LIMIT 1
|
||||
) AS reco_model ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT model_fees.id,
|
||||
model_fees.trigger
|
||||
FROM model_fees
|
||||
WHERE st_line.journal_id = ANY(model_fees.journal_ids)
|
||||
-- Show model fees if matched amount was 3 %% higher than incoming statement line amount
|
||||
AND SIGN(st_line.amount) > 0
|
||||
AND SIGN(st_line.amount_residual) > 0
|
||||
AND ABS(st_line.amount_residual) < 0.03 * st_line.amount / 1.03
|
||||
) AS model_fees ON TRUE
|
||||
WHERE st_line.id IN %s
|
||||
""", tuple(self.ids), tuple(statement_lines.ids)))
|
||||
|
||||
query_result = self.env.cr.fetchall()
|
||||
|
||||
processed_st_line_ids = set()
|
||||
# apply the found suitable reco models on the statement lines
|
||||
for st_line_id, reco_model_id, reco_model_trigger in query_result:
|
||||
if st_line_id in processed_st_line_ids or reco_model_id is None:
|
||||
continue
|
||||
|
||||
st_line = self.env['account.bank.statement.line'].browse(st_line_id).with_prefetch(statement_lines.ids)
|
||||
reco_model = self.env['account.reconcile.model'].browse(reco_model_id).with_prefetch(self.ids)
|
||||
|
||||
if reco_model_trigger == 'manual':
|
||||
st_line._action_manual_reco_model(reco_model_id)
|
||||
else:
|
||||
reco_model.with_user(SUPERUSER_ID)._trigger_reconciliation_model(st_line.with_user(SUPERUSER_ID))
|
||||
processed_st_line_ids.add(st_line_id)
|
||||
|
||||
def _trigger_reconciliation_model(self, statement_line):
|
||||
self.ensure_one()
|
||||
liquidity_line, suspense_line, other_lines = statement_line._seek_for_lines()
|
||||
|
||||
amls_to_create = list(
|
||||
self._apply_lines_for_bank_widget(
|
||||
residual_amount_currency=sum(suspense_line.mapped('amount_currency')),
|
||||
residual_balance=sum(suspense_line.mapped('balance')),
|
||||
partner=statement_line.partner_id,
|
||||
st_line=statement_line,
|
||||
)
|
||||
)
|
||||
# Get the original base lines and tax lines before the creation of new lines
|
||||
if any(aml.get('tax_ids') for aml in amls_to_create):
|
||||
original_base_lines, original_tax_lines = statement_line._prepare_for_tax_lines_recomputation()
|
||||
|
||||
statement_line._set_move_line_to_statement_line_move(liquidity_line + other_lines, amls_to_create)
|
||||
|
||||
# Now that the new lines have been added, we can recompute the taxes
|
||||
if any(aml.get('tax_ids') for aml in amls_to_create):
|
||||
_new_liquidity_line, new_suspense_line, _new_other_lines = statement_line._seek_for_lines()
|
||||
new_lines = statement_line.line_ids - (liquidity_line + other_lines + new_suspense_line)
|
||||
statement_line._create_tax_lines(original_base_lines, original_tax_lines, new_lines)
|
||||
|
||||
if self.next_activity_type_id:
|
||||
statement_line.move_id.activity_schedule(
|
||||
activity_type_id=self.next_activity_type_id.id,
|
||||
user_id=self.env.user.id,
|
||||
)
|
||||
|
||||
def trigger_reconciliation_model(self, statement_line_id):
|
||||
self.ensure_one()
|
||||
|
||||
statement_line = self.env['account.bank.statement.line'].browse(statement_line_id).exists()
|
||||
self._trigger_reconciliation_model(statement_line)
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
unreconciled_statement_lines.line_ids.filtered(
|
||||
lambda line:
|
||||
line.account_id == line.move_id.journal_id.suspense_account_id and line.reconcile_model_id in self
|
||||
).reconcile_model_id = False
|
||||
self._apply_reconcile_models(unreconciled_statement_lines)
|
||||
|
||||
return res
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
reco_models = super().create(vals_list)
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
reco_models._apply_reconcile_models(unreconciled_statement_lines)
|
||||
|
||||
return reco_models
|
||||
|
||||
def action_archive(self):
|
||||
res = super().action_archive()
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
('line_ids.reconcile_model_id', 'in', self.ids),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
unreconciled_statement_lines.line_ids.filtered(
|
||||
lambda line:
|
||||
line.account_id == line.move_id.journal_id.suspense_account_id
|
||||
).reconcile_model_id = False
|
||||
return res
|
||||
@@ -0,0 +1,139 @@
|
||||
import { EventBus, reactive, useState } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class BankReconciliationService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
this.setup(env, services);
|
||||
}
|
||||
|
||||
setup(env, services) {
|
||||
this.bus = new EventBus();
|
||||
this.orm = services["orm"];
|
||||
|
||||
this.chatterState = reactive({
|
||||
visible:
|
||||
JSON.parse(
|
||||
browser.sessionStorage.getItem("isBankReconciliationWidgetChatterOpened")
|
||||
) ?? false,
|
||||
statementLine: null,
|
||||
});
|
||||
this.reconcileCountPerPartnerId = reactive({});
|
||||
this.reconcileModelPerStatementLineId = reactive({});
|
||||
}
|
||||
|
||||
toggleChatter() {
|
||||
this.chatterState.visible = !this.chatterState.visible;
|
||||
browser.sessionStorage.setItem(
|
||||
"isBankReconciliationWidgetChatterOpened",
|
||||
this.chatterState.visible
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific function to open the chatter.
|
||||
* For a particular case, where the customer clicks on
|
||||
* the chatter icon directly on the bank statement line,
|
||||
* we want to open the chatter but not close it.
|
||||
*/
|
||||
openChatter() {
|
||||
this.chatterState.visible = true;
|
||||
}
|
||||
|
||||
selectStatementLine(statementLine) {
|
||||
this.chatterState.statementLine = statementLine;
|
||||
}
|
||||
|
||||
reloadChatter() {
|
||||
this.bus.trigger("MAIL:RELOAD-THREAD", {
|
||||
model: "account.move",
|
||||
id: this.statementLineMoveId,
|
||||
});
|
||||
}
|
||||
|
||||
async computeReconcileLineCountPerPartnerId(records) {
|
||||
const groups = await this.orm.formattedReadGroup(
|
||||
"account.move.line",
|
||||
[
|
||||
["parent_state", "in", ["draft", "posted"]],
|
||||
[
|
||||
"partner_id",
|
||||
"in",
|
||||
records
|
||||
.filter((record) => !!record.data.partner_id.id)
|
||||
.map((record) => record.data.partner_id.id),
|
||||
],
|
||||
["company_id", "child_of", records.map((record) => record.data.company_id.id)],
|
||||
["search_account_id.reconcile", "=", true],
|
||||
["display_type", "not in", ["line_section", "line_note"]],
|
||||
["reconciled", "=", false],
|
||||
"|",
|
||||
["search_account_id.account_type", "not in", ["asset_receivable", "liability_payable"]],
|
||||
["payment_id", "=", false],
|
||||
["statement_line_id", "not in", records.map((record) => record.data.id)],
|
||||
],
|
||||
["partner_id"],
|
||||
["id:count"]
|
||||
);
|
||||
|
||||
this.reconcileCountPerPartnerId = {};
|
||||
groups.forEach((group) => {
|
||||
this.reconcileCountPerPartnerId[group.partner_id[0]] = group["id:count"];
|
||||
});
|
||||
}
|
||||
|
||||
async computeAvailableReconcileModels(records) {
|
||||
this.reconcileModelPerStatementLineId =
|
||||
Object.keys(records).length === 0
|
||||
? {}
|
||||
: await this.orm.call(
|
||||
"account.reconcile.model",
|
||||
"get_available_reconcile_model_per_statement_line",
|
||||
[records.map((record) => record.data.id)]
|
||||
);
|
||||
}
|
||||
|
||||
async updateAvailableReconcileModels(recordId) {
|
||||
const result = await this.orm.call(
|
||||
"account.reconcile.model",
|
||||
"get_available_reconcile_model_per_statement_line",
|
||||
[[recordId]]
|
||||
);
|
||||
this.reconcileModelPerStatementLineId[recordId] = result[recordId];
|
||||
}
|
||||
|
||||
async reloadRecords(records) {
|
||||
await Promise.all([...records.map((record) => record.load())]);
|
||||
}
|
||||
|
||||
get statementLineMove() {
|
||||
return this.chatterState.statementLine?.data.move_id;
|
||||
}
|
||||
|
||||
get statementLineMoveId() {
|
||||
return this.statementLineMove?.id;
|
||||
}
|
||||
|
||||
get statementLine() {
|
||||
return this.chatterState.statementLine;
|
||||
}
|
||||
|
||||
get statementLineId() {
|
||||
return this.statementLine?.data?.id;
|
||||
}
|
||||
}
|
||||
|
||||
const bankReconciliationService = {
|
||||
dependencies: ["orm"],
|
||||
start(env, services) {
|
||||
return new BankReconciliationService(env, services);
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("bankReconciliation", bankReconciliationService);
|
||||
|
||||
export function useBankReconciliation() {
|
||||
return useState(useService("bankReconciliation"));
|
||||
}
|
||||
7
fusion_accounting_bank_rec/models/__init__.py
Normal file
7
fusion_accounting_bank_rec/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from . import fusion_reconcile_pattern
|
||||
from . import fusion_reconcile_precedent
|
||||
from . import fusion_reconcile_suggestion
|
||||
from . import fusion_bank_rec_widget
|
||||
from . import account_bank_statement_line
|
||||
from . import account_reconcile_model
|
||||
from . import fusion_reconcile_engine
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Inherit account.bank.statement.line to add Phase 1 widget compute fields.
|
||||
|
||||
These fields are NOT stored — they're computed on-the-fly so the OWL widget
|
||||
can render confidence badges without round-tripping. Performance OK because
|
||||
the widget loads ~50-200 lines per kanban open and each compute is a single
|
||||
indexed query into fusion.reconcile.suggestion.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class AccountBankStatementLine(models.Model):
|
||||
_inherit = "account.bank.statement.line"
|
||||
|
||||
# Top suggestion + its band — for the inline AI confidence badge
|
||||
fusion_top_suggestion_id = fields.Many2one(
|
||||
'fusion.reconcile.suggestion',
|
||||
compute='_compute_top_suggestion',
|
||||
store=False,
|
||||
help="Highest-ranked pending AI suggestion for this line")
|
||||
fusion_confidence_band = fields.Selection(
|
||||
[('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')],
|
||||
compute='_compute_top_suggestion',
|
||||
store=False,
|
||||
default='none',
|
||||
help="Quick-render colour band for the OWL widget badge")
|
||||
|
||||
# Mirror of Enterprise's bank_statement_attachment_ids surface field.
|
||||
# Defined here so fusion's widget can render attachments without
|
||||
# depending on account_accountant being installed.
|
||||
bank_statement_attachment_ids = fields.One2many(
|
||||
'ir.attachment',
|
||||
compute='_compute_bank_statement_attachment_ids',
|
||||
help="Attachments on the underlying account.move; mirrored for the OWL widget")
|
||||
|
||||
def _compute_top_suggestion(self):
|
||||
Suggestion = self.env['fusion.reconcile.suggestion'].sudo()
|
||||
for line in self:
|
||||
top = Suggestion.search([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
('rank', '=', 1),
|
||||
], limit=1)
|
||||
line.fusion_top_suggestion_id = top
|
||||
line.fusion_confidence_band = top.confidence_band if top else 'none'
|
||||
|
||||
@api.depends('move_id', 'move_id.attachment_ids')
|
||||
def _compute_bank_statement_attachment_ids(self):
|
||||
for line in self:
|
||||
line.bank_statement_attachment_ids = (
|
||||
line.move_id.attachment_ids if line.move_id else self.env['ir.attachment']
|
||||
)
|
||||
20
fusion_accounting_bank_rec/models/account_reconcile_model.py
Normal file
20
fusion_accounting_bank_rec/models/account_reconcile_model.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Inherit account.reconcile.model to add Phase 1 AI integration hooks.
|
||||
|
||||
This is a minimal extension placeholder for now — Phase 1+ phases may
|
||||
expand it (e.g., to attach AI confidence rules to reconcile-model
|
||||
auto-fires). The shared-field-ownership for `created_automatically`
|
||||
already lives in fusion_accounting_core; this file is for fusion_bank_rec
|
||||
specific extensions only.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountReconcileModel(models.Model):
|
||||
_inherit = "account.reconcile.model"
|
||||
|
||||
fusion_ai_confidence_threshold = fields.Float(
|
||||
string="AI confidence threshold",
|
||||
default=0.0,
|
||||
help="If >0.0, fusion AI suggestions matching this rule are auto-applied "
|
||||
"only when their confidence ≥ this threshold. 0.0 = no AI filtering.")
|
||||
33
fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py
Normal file
33
fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Per-request widget state. Holds the kanban-load response shape so the
|
||||
controller can return one well-typed object.
|
||||
|
||||
This is a TransientModel (no DB persistence beyond the request). The OWL
|
||||
widget reads pre-computed fusion.reconcile.suggestion rows directly via
|
||||
the controller; this model is just a typed envelope for the kanban-open
|
||||
action."""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionBankRecWidget(models.TransientModel):
|
||||
_name = "fusion.bank.rec.widget"
|
||||
_description = "Bank reconciliation widget state (transient)"
|
||||
|
||||
journal_id = fields.Many2one('account.journal',
|
||||
domain="[('type', '=', 'bank')]")
|
||||
statement_line_ids = fields.Many2many('account.bank.statement.line')
|
||||
summary_count = fields.Integer(
|
||||
help="Number of unreconciled lines visible in this widget")
|
||||
summary_unreconciled_balance = fields.Monetary(currency_field='currency_id')
|
||||
currency_id = fields.Many2one('res.currency',
|
||||
related='journal_id.currency_id',
|
||||
store=False, readonly=True)
|
||||
|
||||
def action_open_kanban(self):
|
||||
"""Return a window action opening the OWL kanban for this journal."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_bank_rec_kanban',
|
||||
'params': {'journal_id': self.journal_id.id},
|
||||
}
|
||||
476
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
476
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""The reconcile engine — orchestrator for all bank-line reconciliations.
|
||||
|
||||
Public API: 6 methods. All other code (controllers, AI tools, wizards)
|
||||
must go through these methods; no direct ORM writes to
|
||||
``account.partial.reconcile`` from anywhere else.
|
||||
|
||||
V19 mechanics (per Enterprise's bank_rec_widget pattern):
|
||||
|
||||
A bank statement line creates an ``account.move`` with two journal
|
||||
items: a *liquidity* line on the journal's default account, and a
|
||||
*suspense* line on the journal's suspense account. Reconciliation
|
||||
replaces the suspense line with one or more *counterpart* lines posted
|
||||
to the matched invoices' receivable / payable accounts (or the write-off
|
||||
account), then calls Odoo's standard ``account.move.line.reconcile()``
|
||||
on each counterpart + invoice pair.
|
||||
|
||||
Internal pipeline (per spec Section 3.3):
|
||||
|
||||
1. Validate (period not locked, mandatory args present).
|
||||
2. Compute counterpart vals from ``against_lines`` and optional write-off.
|
||||
3. Rewrite the bank move ``line_ids``: keep liquidity, drop suspense +
|
||||
any prior other lines, append the new counterparts.
|
||||
4. Reconcile each counterpart with its matched invoice line.
|
||||
5. Audit (``mail.message``) + record precedent for future learning.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.fields import Command
|
||||
|
||||
from ..services.matching_strategies import (
|
||||
AmountExactStrategy,
|
||||
Candidate,
|
||||
FIFOStrategy,
|
||||
MultiInvoiceStrategy,
|
||||
)
|
||||
from ..services.confidence_scoring import score_candidates
|
||||
from ..services.memo_tokenizer import tokenize_memo
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionReconcileEngine(models.AbstractModel):
|
||||
_name = "fusion.reconcile.engine"
|
||||
_description = "Fusion Bank Reconciliation Engine"
|
||||
|
||||
# ============================================================
|
||||
# PUBLIC API (6 methods)
|
||||
# ============================================================
|
||||
|
||||
@api.model
|
||||
def reconcile_one(self, statement_line, *, against_lines=None,
|
||||
write_off_vals=None):
|
||||
"""Reconcile one bank line against a set of journal items.
|
||||
|
||||
Returns: ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
|
||||
'write_off_move_id': int|None}``
|
||||
"""
|
||||
if not statement_line:
|
||||
raise ValidationError(_("statement_line is required"))
|
||||
statement_line.ensure_one()
|
||||
AML = self.env['account.move.line']
|
||||
against_lines = against_lines or AML
|
||||
if not against_lines and not write_off_vals:
|
||||
raise ValidationError(
|
||||
_("Either against_lines or write_off_vals required"))
|
||||
|
||||
self._validate_reconcile(statement_line, against_lines)
|
||||
|
||||
bank_move = statement_line.move_id
|
||||
liquidity_lines, suspense_lines, other_lines = (
|
||||
statement_line._seek_for_lines())
|
||||
|
||||
# The bank move must stay balanced after we rewrite line_ids.
|
||||
# Liquidity sums to +bank_amount (or -bank_amount for outbound), so
|
||||
# the new counterparts must sum to the inverse. We allocate the
|
||||
# available bank amount across against_lines, clamped to each
|
||||
# invoice's residual; any leftover goes to the write-off line (or
|
||||
# raises if no write-off was requested).
|
||||
liq_balance = sum(liquidity_lines.mapped('balance'))
|
||||
# Available counterpart balance (positive magnitude) = abs(liq_balance)
|
||||
remaining = abs(liq_balance)
|
||||
# Counterparts mirror liquidity: opposite sign of liq_balance.
|
||||
cp_sign = -1 if liq_balance >= 0 else 1
|
||||
|
||||
new_counterpart_vals = []
|
||||
for inv_line in against_lines:
|
||||
inv_residual = inv_line.amount_residual
|
||||
# Clamp so we never write more than the invoice residual nor more
|
||||
# than what the bank line can pay.
|
||||
allocate = min(remaining, abs(inv_residual))
|
||||
new_counterpart_vals.append(self._build_counterpart_vals(
|
||||
statement_line, inv_line,
|
||||
allocated_balance=cp_sign * allocate,
|
||||
))
|
||||
remaining -= allocate
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
write_off_move_id = None
|
||||
if write_off_vals:
|
||||
# Write-off absorbs whatever the against_lines didn't cover.
|
||||
wo_balance = cp_sign * remaining
|
||||
# If user passed an explicit amount and there are no against_lines,
|
||||
# honour the explicit amount (covers the pure write-off case).
|
||||
if (write_off_vals.get('amount') is not None
|
||||
and not against_lines):
|
||||
wo_balance = -write_off_vals['amount']
|
||||
new_counterpart_vals.append(self._build_write_off_vals(
|
||||
statement_line, write_off_vals, balance=wo_balance,
|
||||
))
|
||||
remaining = 0
|
||||
|
||||
# Replace the bank move line_ids: keep liquidity, drop everything
|
||||
# else, append new counterparts.
|
||||
ops = []
|
||||
for line in (suspense_lines | other_lines):
|
||||
ops.append(Command.unlink(line.id))
|
||||
for vals in new_counterpart_vals:
|
||||
ops.append(Command.create(vals))
|
||||
|
||||
editable_move = bank_move.with_context(
|
||||
force_delete=True, skip_readonly_check=True)
|
||||
prior_line_ids = set(bank_move.line_ids.ids)
|
||||
editable_move.write({'line_ids': ops})
|
||||
|
||||
new_lines = bank_move.line_ids.filtered(
|
||||
lambda line: line.id not in prior_line_ids)
|
||||
|
||||
# Reconcile each new counterpart with its matched invoice line.
|
||||
# The first N new lines correspond to the first N against_lines
|
||||
# (where N may be < len(against_lines) if the bank amount ran out).
|
||||
# Any trailing new line is a write-off and has no invoice pair.
|
||||
Partial = self.env['account.partial.reconcile']
|
||||
new_partial_ids = []
|
||||
invoice_counterparts = new_lines[:min(len(new_lines),
|
||||
len(against_lines))]
|
||||
for new_line, inv_line in zip(invoice_counterparts, against_lines):
|
||||
pair = new_line | inv_line
|
||||
existing = set(Partial.search([
|
||||
'|',
|
||||
('debit_move_id', 'in', pair.ids),
|
||||
('credit_move_id', 'in', pair.ids),
|
||||
]).ids)
|
||||
pair.reconcile()
|
||||
added = Partial.search([
|
||||
'|',
|
||||
('debit_move_id', 'in', pair.ids),
|
||||
('credit_move_id', 'in', pair.ids),
|
||||
]).filtered(lambda p: p.id not in existing)
|
||||
new_partial_ids.extend(added.ids)
|
||||
|
||||
self._post_audit(
|
||||
statement_line, new_partial_ids, source='engine.reconcile_one')
|
||||
if against_lines:
|
||||
self._record_precedent(statement_line, against_lines)
|
||||
|
||||
return {
|
||||
'partial_ids': new_partial_ids,
|
||||
'exchange_diff_move_id': None,
|
||||
'write_off_move_id': write_off_move_id,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def reconcile_batch(self, statement_lines, *, strategy='auto'):
|
||||
"""Bulk-reconcile a recordset using the chosen strategy.
|
||||
|
||||
Returns: ``{'reconciled_count': int, 'skipped': int,
|
||||
'errors': [...]}``
|
||||
"""
|
||||
reconciled = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
for line in statement_lines:
|
||||
if line.is_reconciled:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
candidates = self._fetch_candidates(line)
|
||||
picked = self._apply_strategy(line, candidates, strategy)
|
||||
if picked:
|
||||
self.reconcile_one(line, against_lines=picked)
|
||||
reconciled += 1
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({'line_id': line.id, 'error': str(e)})
|
||||
_logger.warning(
|
||||
"reconcile_batch failed for line %s: %s", line.id, e)
|
||||
return {
|
||||
'reconciled_count': reconciled,
|
||||
'skipped': skipped,
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def suggest_matches(self, statement_lines, *, limit_per_line=3):
|
||||
"""Compute and persist AI suggestions per line.
|
||||
|
||||
Returns: dict mapping ``line_id`` -> list of suggestion dicts.
|
||||
"""
|
||||
out = {}
|
||||
Suggestion = self.env['fusion.reconcile.suggestion']
|
||||
for line in statement_lines:
|
||||
candidates_records = self._fetch_candidates(line)
|
||||
if not candidates_records:
|
||||
continue
|
||||
candidates_dataclasses = self._records_to_candidates(
|
||||
line, candidates_records)
|
||||
scored = score_candidates(
|
||||
self.env,
|
||||
statement_line=line,
|
||||
candidates=candidates_dataclasses,
|
||||
k=limit_per_line,
|
||||
use_ai=True,
|
||||
)
|
||||
|
||||
Suggestion.search([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
]).write({'state': 'superseded'})
|
||||
|
||||
line_suggestions = []
|
||||
for rank, s in enumerate(scored, start=1):
|
||||
sug = Suggestion.create({
|
||||
'company_id': line.company_id.id,
|
||||
'statement_line_id': line.id,
|
||||
'proposed_move_line_ids': [(6, 0, [s.candidate_id])],
|
||||
'confidence': s.confidence,
|
||||
'rank': rank,
|
||||
'reasoning': s.reasoning,
|
||||
'score_amount_match': s.score_amount_match,
|
||||
'score_partner_pattern': s.score_partner_pattern,
|
||||
'score_precedent_similarity': s.score_precedent_similarity,
|
||||
'score_ai_rerank': s.score_ai_rerank,
|
||||
'generated_by': 'on_demand',
|
||||
'state': 'pending',
|
||||
})
|
||||
line_suggestions.append({
|
||||
'id': sug.id,
|
||||
'rank': rank,
|
||||
'confidence': s.confidence,
|
||||
'reasoning': s.reasoning,
|
||||
'candidate_id': s.candidate_id,
|
||||
})
|
||||
out[line.id] = line_suggestions
|
||||
return out
|
||||
|
||||
@api.model
|
||||
def accept_suggestion(self, suggestion):
|
||||
"""User clicked Accept on a suggestion -> reconcile via its proposal.
|
||||
|
||||
Returns: same shape as ``reconcile_one``.
|
||||
"""
|
||||
if isinstance(suggestion, int):
|
||||
suggestion = self.env['fusion.reconcile.suggestion'].browse(
|
||||
suggestion)
|
||||
suggestion.ensure_one()
|
||||
line = suggestion.statement_line_id
|
||||
against = suggestion.proposed_move_line_ids
|
||||
result = self.reconcile_one(line, against_lines=against)
|
||||
suggestion.write({
|
||||
'state': 'accepted',
|
||||
'accepted_at': fields.Datetime.now(),
|
||||
'accepted_by': self.env.uid,
|
||||
})
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def write_off(self, statement_line, *, account, amount, label, tax_id=None):
|
||||
"""Create a write-off move + reconcile the bank line against it.
|
||||
|
||||
Returns: same shape as ``reconcile_one``.
|
||||
"""
|
||||
write_off_vals = {
|
||||
'account_id': account.id if hasattr(account, 'id') else account,
|
||||
'amount': amount,
|
||||
'tax_id': (tax_id.id if (tax_id and hasattr(tax_id, 'id'))
|
||||
else tax_id),
|
||||
'label': label,
|
||||
}
|
||||
return self.reconcile_one(
|
||||
statement_line, against_lines=None, write_off_vals=write_off_vals)
|
||||
|
||||
@api.model
|
||||
def unreconcile(self, partial_reconciles):
|
||||
"""Reverse a reconciliation. Handles full vs. partial chains.
|
||||
|
||||
Because ``reconcile_one`` rewrites the bank move's suspense line into
|
||||
one or more counterpart lines, simply deleting the
|
||||
``account.partial.reconcile`` rows is not enough — the bank move
|
||||
would still look reconciled (no suspense line, no residual). We
|
||||
delegate to V19's standard ``account.bank.statement.line.
|
||||
action_undo_reconciliation`` for any affected bank line, which
|
||||
clears the partials AND restores the original suspense state.
|
||||
|
||||
Returns: ``{'unreconciled_line_ids': [...]}``
|
||||
"""
|
||||
partial_reconciles = partial_reconciles.exists()
|
||||
if not partial_reconciles:
|
||||
return {'unreconciled_line_ids': []}
|
||||
all_lines = (
|
||||
partial_reconciles.mapped('debit_move_id')
|
||||
| partial_reconciles.mapped('credit_move_id')
|
||||
)
|
||||
line_ids = all_lines.ids
|
||||
# Find any bank statement lines whose move owns one of these journal
|
||||
# items; route them through the standard undo flow which both
|
||||
# deletes the partials and restores the suspense line.
|
||||
affected_bank_lines = self.env['account.bank.statement.line'].search([
|
||||
('move_id', 'in', all_lines.mapped('move_id').ids),
|
||||
])
|
||||
if affected_bank_lines:
|
||||
affected_bank_lines.action_undo_reconciliation()
|
||||
# Anything still hanging around (rare — non-bank-line reconciles)
|
||||
# gets a direct unlink as a fallback.
|
||||
remaining = partial_reconciles.exists()
|
||||
if remaining:
|
||||
remaining.unlink()
|
||||
return {'unreconciled_line_ids': line_ids}
|
||||
|
||||
# ============================================================
|
||||
# PRIVATE HELPERS
|
||||
# ============================================================
|
||||
|
||||
def _validate_reconcile(self, statement_line, against_lines):
|
||||
"""Phase 2: structural + safety checks."""
|
||||
if not statement_line.exists():
|
||||
raise ValidationError(_("Statement line does not exist"))
|
||||
company = statement_line.company_id
|
||||
line_date = statement_line.date
|
||||
lock_date = company.fiscalyear_lock_date
|
||||
if lock_date and line_date and line_date <= lock_date:
|
||||
raise ValidationError(_(
|
||||
"Cannot reconcile: line date %(line)s is on or before fiscal "
|
||||
"year lock date %(lock)s",
|
||||
line=line_date,
|
||||
lock=lock_date,
|
||||
))
|
||||
|
||||
def _build_counterpart_vals(self, statement_line, inv_line, *,
|
||||
allocated_balance):
|
||||
"""Build the vals for one counterpart line that mirrors an invoice
|
||||
line on the bank move.
|
||||
|
||||
``allocated_balance`` is the signed company-currency balance to write
|
||||
on the counterpart. It is clamped (by the caller) so that the bank
|
||||
move stays balanced and no invoice gets over-paid. We scale
|
||||
``amount_currency`` proportionally for multi-currency lines.
|
||||
"""
|
||||
inv_residual = inv_line.amount_residual
|
||||
if inv_residual:
|
||||
scale = abs(allocated_balance) / abs(inv_residual)
|
||||
else:
|
||||
scale = 1.0
|
||||
amount_currency = -inv_line.amount_residual_currency * scale
|
||||
return {
|
||||
'name': inv_line.name or statement_line.payment_ref or '',
|
||||
'account_id': inv_line.account_id.id,
|
||||
'partner_id': (inv_line.partner_id.id
|
||||
if inv_line.partner_id else False),
|
||||
'currency_id': inv_line.currency_id.id,
|
||||
'amount_currency': amount_currency,
|
||||
'balance': allocated_balance,
|
||||
}
|
||||
|
||||
def _build_write_off_vals(self, statement_line, write_off_vals, *,
|
||||
balance):
|
||||
"""Build the vals for a write-off counterpart line on the bank move.
|
||||
|
||||
``balance`` is the signed company-currency balance the write-off
|
||||
line must carry to keep the bank move balanced.
|
||||
"""
|
||||
vals = {
|
||||
'name': write_off_vals.get('label') or _('Write-off'),
|
||||
'account_id': write_off_vals['account_id'],
|
||||
'partner_id': (statement_line.partner_id.id
|
||||
if statement_line.partner_id else False),
|
||||
'balance': balance,
|
||||
}
|
||||
if write_off_vals.get('tax_id'):
|
||||
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]
|
||||
return vals
|
||||
|
||||
def _fetch_candidates(self, statement_line):
|
||||
"""SQL pre-filter: open journal items matching partner + reconcilable
|
||||
account."""
|
||||
domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('account_id.reconcile', '=', True),
|
||||
('reconciled', '=', False),
|
||||
('display_type', 'not in', ('line_section', 'line_note')),
|
||||
]
|
||||
if statement_line.partner_id:
|
||||
domain.append(('partner_id', '=', statement_line.partner_id.id))
|
||||
return self.env['account.move.line'].search(domain, limit=200)
|
||||
|
||||
def _records_to_candidates(self, statement_line, records):
|
||||
"""Convert ``account.move.line`` recordset to ``Candidate`` dataclasses."""
|
||||
today = fields.Date.today()
|
||||
result = []
|
||||
for c in records:
|
||||
ref_date = c.date_maturity or c.date or today
|
||||
age_days = (today - ref_date).days
|
||||
result.append(Candidate(
|
||||
id=c.id,
|
||||
amount=abs(c.amount_residual) or abs(c.balance),
|
||||
partner_id=c.partner_id.id if c.partner_id else 0,
|
||||
age_days=age_days,
|
||||
))
|
||||
return result
|
||||
|
||||
def _apply_strategy(self, line, candidate_records, strategy):
|
||||
"""Apply the named strategy. Returns matching ``account.move.line``
|
||||
recordset, or empty recordset if nothing matched."""
|
||||
AML = self.env['account.move.line']
|
||||
if not candidate_records:
|
||||
return AML
|
||||
candidate_dcs = self._records_to_candidates(line, candidate_records)
|
||||
bank_amount = abs(line.amount)
|
||||
if strategy == 'auto':
|
||||
for strat_class in (AmountExactStrategy,
|
||||
MultiInvoiceStrategy,
|
||||
FIFOStrategy):
|
||||
result = strat_class().match(
|
||||
bank_amount=bank_amount, candidates=candidate_dcs)
|
||||
if result.picked_ids:
|
||||
return AML.browse(result.picked_ids)
|
||||
return AML
|
||||
|
||||
def _post_audit(self, statement_line, partial_ids, source):
|
||||
"""Append an audit log to the bank-line move's chatter."""
|
||||
if not statement_line.move_id:
|
||||
return
|
||||
try:
|
||||
statement_line.move_id.message_post(
|
||||
body=_(
|
||||
"Reconciled via %(source)s; %(count)d partial(s) created: "
|
||||
"%(ids)s",
|
||||
source=source,
|
||||
count=len(partial_ids),
|
||||
ids=partial_ids,
|
||||
),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.debug(
|
||||
"Audit log skipped for line %s: %s", statement_line.id, e)
|
||||
|
||||
def _record_precedent(self, statement_line, against_lines):
|
||||
"""Append a precedent for future pattern learning. Best-effort."""
|
||||
if not against_lines:
|
||||
return
|
||||
try:
|
||||
self.env['fusion.reconcile.precedent'].sudo().create({
|
||||
'company_id': statement_line.company_id.id,
|
||||
'partner_id': (statement_line.partner_id.id
|
||||
if statement_line.partner_id else False),
|
||||
'amount': abs(statement_line.amount),
|
||||
'currency_id': statement_line.currency_id.id,
|
||||
'date': statement_line.date,
|
||||
'memo_tokens': ','.join(
|
||||
tokenize_memo(statement_line.payment_ref)),
|
||||
'journal_id': statement_line.journal_id.id,
|
||||
'matched_move_line_count': len(against_lines),
|
||||
'matched_account_ids': ','.join(
|
||||
str(i) for i in against_lines.mapped('account_id').ids),
|
||||
'reconciler_user_id': self.env.uid,
|
||||
'reconciled_at': fields.Datetime.now(),
|
||||
'source': 'manual',
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning(
|
||||
"Failed to record precedent for line %s: %s",
|
||||
statement_line.id, e)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Per-partner bank reconciliation pattern aggregate.
|
||||
|
||||
One row per (company_id, partner_id). Continuously summarises HOW this
|
||||
partner gets reconciled. Recomputed nightly via cron from the precedent
|
||||
table. Used as a feature input to confidence_scoring.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionReconcilePattern(models.Model):
|
||||
_name = "fusion.reconcile.pattern"
|
||||
_description = "Per-partner bank reconciliation pattern aggregate"
|
||||
_rec_name = "partner_id"
|
||||
|
||||
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||
default=lambda self: self.env.company)
|
||||
partner_id = fields.Many2one('res.partner', required=True, index=True)
|
||||
|
||||
# Volume + cadence
|
||||
reconcile_count = fields.Integer(default=0,
|
||||
help="Total past reconciles for this partner")
|
||||
typical_amount_range = fields.Char(
|
||||
help="e.g. '$1,200 – $2,400 (median $1,847.50)'")
|
||||
typical_cadence_days = fields.Float(
|
||||
help="Mean inter-reconcile days")
|
||||
typical_day_of_month = fields.Char(
|
||||
help="e.g. '1st, 15th'")
|
||||
|
||||
# Matching strategy used historically
|
||||
pref_strategy = fields.Selection([
|
||||
('exact_amount', 'Exact-amount-first'),
|
||||
('fifo', 'FIFO oldest-due-first'),
|
||||
('multi_invoice', 'Multi-invoice consolidation'),
|
||||
('cherry_pick', 'Cherry-pick specific invoices'),
|
||||
])
|
||||
pref_account_id = fields.Many2one('account.account',
|
||||
help="Most-used target account")
|
||||
|
||||
# Memo signature
|
||||
common_memo_tokens = fields.Char(
|
||||
help="Comma-separated tokens that appear in ≥30% of past reconciles")
|
||||
|
||||
# Tax + write-off habits
|
||||
common_writeoff_account_id = fields.Many2one('account.account')
|
||||
common_writeoff_tax_id = fields.Many2one('account.tax')
|
||||
typical_writeoff_amount = fields.Float(
|
||||
help="e.g. 0.05 for rounding diffs")
|
||||
|
||||
last_refreshed_at = fields.Datetime()
|
||||
|
||||
_uniq_company_partner = models.Constraint(
|
||||
'unique(company_id, partner_id)',
|
||||
'One pattern row per (company, partner) — already exists.',
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Per-historical-decision reconciliation memory.
|
||||
|
||||
One row per past reconciliation. Holds the full feature vector + outcome,
|
||||
used by precedent_lookup for K-nearest-neighbour search when scoring a
|
||||
new bank line.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionReconcilePrecedent(models.Model):
|
||||
_name = "fusion.reconcile.precedent"
|
||||
_description = "Historical bank reconciliation decision (memory)"
|
||||
_order = "reconciled_at desc, id desc"
|
||||
|
||||
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||
default=lambda self: self.env.company)
|
||||
partner_id = fields.Many2one('res.partner', index=True)
|
||||
|
||||
# Bank line features (the "input")
|
||||
amount = fields.Monetary(currency_field='currency_id')
|
||||
currency_id = fields.Many2one('res.currency')
|
||||
date = fields.Date()
|
||||
memo_tokens = fields.Char(
|
||||
help="Comma-separated normalized memo tokens (output of memo_tokenizer)")
|
||||
journal_id = fields.Many2one('account.journal')
|
||||
|
||||
# Outcome (the "decision made")
|
||||
matched_move_line_count = fields.Integer(
|
||||
help="1 = exact, 2-3 = consolidation, etc.")
|
||||
matched_account_ids = fields.Char(
|
||||
help="Comma-separated account.account IDs that were matched against")
|
||||
matched_invoice_ages_days = fields.Char(
|
||||
help="Comma-separated days-old at reconcile time, e.g. '12, 45, 78'")
|
||||
write_off_amount = fields.Float()
|
||||
write_off_account_id = fields.Many2one('account.account')
|
||||
exchange_diff = fields.Boolean()
|
||||
|
||||
# Provenance
|
||||
reconciler_user_id = fields.Many2one('res.users')
|
||||
reconciled_at = fields.Datetime()
|
||||
source = fields.Selection([
|
||||
('historical_bootstrap', 'Imported from history'),
|
||||
('manual', 'Manual reconcile via fusion'),
|
||||
('ai_accepted', 'AI suggestion accepted'),
|
||||
('auto_rule', 'account.reconcile.model auto-fired'),
|
||||
], required=True)
|
||||
|
||||
# No uniqueness constraint — multiple reconciles can share features
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Persisted AI suggestions for bank line reconciliations.
|
||||
|
||||
One row per (statement_line, candidate_match). The OWL widget reads these
|
||||
to render confidence badges; users accept/reject which feeds back into
|
||||
the pattern learning system.
|
||||
|
||||
The AI never writes account.partial.reconcile directly — it writes
|
||||
suggestions here, and the user (or batch-accept action) approves them
|
||||
through the engine's accept_suggestion() method.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionReconcileSuggestion(models.Model):
|
||||
_name = "fusion.reconcile.suggestion"
|
||||
_description = "AI-generated bank reconciliation suggestion"
|
||||
_order = "statement_line_id, confidence desc"
|
||||
|
||||
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||
default=lambda self: self.env.company)
|
||||
statement_line_id = fields.Many2one('account.bank.statement.line',
|
||||
required=True, index=True, ondelete='cascade')
|
||||
|
||||
# The proposal
|
||||
proposed_move_line_ids = fields.Many2many('account.move.line',
|
||||
string="Proposed matches")
|
||||
proposed_write_off_amount = fields.Monetary(currency_field='currency_id')
|
||||
proposed_write_off_account_id = fields.Many2one('account.account')
|
||||
currency_id = fields.Many2one('res.currency',
|
||||
related='statement_line_id.currency_id',
|
||||
store=True)
|
||||
|
||||
# Scoring
|
||||
confidence = fields.Float(required=True)
|
||||
confidence_band = fields.Selection([
|
||||
('high', 'High (>=95%)'),
|
||||
('medium', 'Medium (70-94%)'),
|
||||
('low', 'Low (50-69%)'),
|
||||
('none', 'No confidence (<50%)'),
|
||||
], compute='_compute_band', store=True)
|
||||
rank = fields.Integer(help="1 = top suggestion, 2-N = alternatives")
|
||||
reasoning = fields.Text(help="Human-readable explanation")
|
||||
|
||||
# Feature breakdown (for transparency + future learning)
|
||||
score_amount_match = fields.Float()
|
||||
score_partner_pattern = fields.Float()
|
||||
score_precedent_similarity = fields.Float()
|
||||
score_ai_rerank = fields.Float()
|
||||
|
||||
# Provenance
|
||||
generated_at = fields.Datetime(default=fields.Datetime.now)
|
||||
generated_by = fields.Selection([
|
||||
('cron_batch', 'Batch cron'),
|
||||
('on_demand', 'User refreshed alternatives'),
|
||||
('on_open', 'Widget opened (lazy)'),
|
||||
])
|
||||
provider_used = fields.Char(
|
||||
help="e.g. 'claude_sonnet_4_5', 'lmstudio_qwen_7b', 'statistical_only'")
|
||||
tokens_used = fields.Integer(help="if AI re-rank invoked")
|
||||
generation_ms = fields.Integer(help="latency for monitoring")
|
||||
|
||||
# Lifecycle
|
||||
state = fields.Selection([
|
||||
('pending', 'Pending review'),
|
||||
('accepted', 'Accepted'),
|
||||
('rejected', 'Rejected'),
|
||||
('superseded', 'Superseded by newer suggestion'),
|
||||
('stale', 'Stale (line changed since)'),
|
||||
], default='pending', required=True, index=True)
|
||||
accepted_at = fields.Datetime()
|
||||
accepted_by = fields.Many2one('res.users')
|
||||
rejected_at = fields.Datetime()
|
||||
rejected_reason = fields.Selection([
|
||||
('wrong_invoice', 'Wrong invoice'),
|
||||
('wrong_partner', 'Wrong partner'),
|
||||
('wrong_amount', 'Amount off'),
|
||||
('not_a_match', 'No good match exists'),
|
||||
('other', 'Other'),
|
||||
])
|
||||
|
||||
_confidence_in_range = models.Constraint(
|
||||
'CHECK (confidence >= 0.0 AND confidence <= 1.0)',
|
||||
'Confidence must be between 0.0 and 1.0',
|
||||
)
|
||||
|
||||
@api.depends('confidence')
|
||||
def _compute_band(self):
|
||||
for sug in self:
|
||||
c = sug.confidence
|
||||
if c >= 0.95:
|
||||
sug.confidence_band = 'high'
|
||||
elif c >= 0.70:
|
||||
sug.confidence_band = 'medium'
|
||||
elif c >= 0.50:
|
||||
sug.confidence_band = 'low'
|
||||
else:
|
||||
sug.confidence_band = 'none'
|
||||
8
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
8
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
@@ -0,0 +1,8 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_reconcile_pattern_user,pattern user,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_reconcile_pattern_admin,pattern admin,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_reconcile_precedent_user,precedent user,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_reconcile_precedent_admin,precedent admin,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_reconcile_suggestion_user,suggestion user,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_reconcile_suggestion_admin,suggestion admin,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_admin,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
|
||||
|
6
fusion_accounting_bank_rec/services/__init__.py
Normal file
6
fusion_accounting_bank_rec/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from . import memo_tokenizer
|
||||
from . import exchange_diff
|
||||
from . import matching_strategies
|
||||
from . import precedent_lookup
|
||||
from . import pattern_extractor
|
||||
from . import confidence_scoring
|
||||
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal file
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""4-pass confidence scoring pipeline.
|
||||
|
||||
Pass 1: SQL filter — partner match + reconcilable account (done by caller — engine._fetch_candidates)
|
||||
Pass 2: Statistical scoring — amount delta + pattern match + precedent similarity
|
||||
Pass 3: AI re-rank (if provider configured) — feed top 5 to LLM, parse JSON ranking
|
||||
Pass 4: Persist as fusion.reconcile.suggestion rows (done by caller — engine.suggest_matches)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .matching_strategies import Candidate
|
||||
from .precedent_lookup import find_nearest_precedents
|
||||
from .memo_tokenizer import tokenize_memo
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoredCandidate:
|
||||
candidate_id: int
|
||||
confidence: float
|
||||
reasoning: str
|
||||
score_amount_match: float
|
||||
score_partner_pattern: float
|
||||
score_precedent_similarity: float
|
||||
score_ai_rerank: float = 0.0
|
||||
|
||||
|
||||
def score_candidates(env, *, statement_line, candidates, k=5, use_ai=True):
|
||||
"""Score and rank candidate matches for a statement line.
|
||||
|
||||
Args:
|
||||
env: Odoo env
|
||||
statement_line: account.bank.statement.line recordset (singleton)
|
||||
candidates: list of Candidate dataclasses (from matching_strategies)
|
||||
k: max number of scored candidates to return
|
||||
use_ai: if True AND a provider is configured, invoke AI re-rank
|
||||
|
||||
Returns:
|
||||
list of ScoredCandidate sorted by confidence desc, max length k.
|
||||
"""
|
||||
if not candidates or not statement_line:
|
||||
return []
|
||||
|
||||
partner_id = statement_line.partner_id.id if statement_line.partner_id else None
|
||||
bank_amount = abs(statement_line.amount)
|
||||
memo_tokens = tokenize_memo(statement_line.payment_ref)
|
||||
|
||||
pattern = None
|
||||
if partner_id:
|
||||
pattern = env['fusion.reconcile.pattern'].sudo().search(
|
||||
[('partner_id', '=', partner_id)], limit=1)
|
||||
if not pattern:
|
||||
pattern = None
|
||||
|
||||
precedents = []
|
||||
if partner_id:
|
||||
precedents = find_nearest_precedents(
|
||||
env, partner_id=partner_id, amount=bank_amount, k=5, memo_tokens=memo_tokens)
|
||||
|
||||
scored = []
|
||||
for cand in candidates:
|
||||
amount_score = 1.0 - min(abs(cand.amount - bank_amount) / max(bank_amount, 1), 1.0)
|
||||
pattern_score = _pattern_score(cand, pattern, bank_amount)
|
||||
precedent_score = _precedent_score(cand, precedents)
|
||||
confidence = (amount_score * 0.5) + (pattern_score * 0.25) + (precedent_score * 0.25)
|
||||
|
||||
reasoning = _build_reasoning(amount_score, pattern_score, precedent_score, pattern)
|
||||
scored.append(ScoredCandidate(
|
||||
candidate_id=cand.id,
|
||||
confidence=round(confidence, 3),
|
||||
reasoning=reasoning,
|
||||
score_amount_match=round(amount_score, 3),
|
||||
score_partner_pattern=round(pattern_score, 3),
|
||||
score_precedent_similarity=round(precedent_score, 3),
|
||||
))
|
||||
|
||||
scored.sort(key=lambda s: -s.confidence)
|
||||
top_k = scored[:k]
|
||||
|
||||
if use_ai:
|
||||
provider = _get_provider(env, 'bank_rec_suggest')
|
||||
if provider is not None:
|
||||
try:
|
||||
top_k = _ai_rerank(env, provider, statement_line, top_k, pattern, precedents)
|
||||
except Exception as e:
|
||||
_logger.warning("AI re-rank failed, using statistical scoring: %s", e)
|
||||
|
||||
return top_k
|
||||
|
||||
|
||||
def _pattern_score(cand, pattern, bank_amount) -> float:
|
||||
"""How well does this candidate fit the partner's typical pattern?"""
|
||||
if not pattern:
|
||||
return 0.5
|
||||
score = 0.5
|
||||
if pattern.pref_strategy == 'exact_amount' and abs(cand.amount - bank_amount) < 0.005:
|
||||
score = 1.0
|
||||
return score
|
||||
|
||||
|
||||
def _precedent_score(cand, precedents) -> float:
|
||||
"""How similar is this candidate to past precedents?"""
|
||||
if not precedents:
|
||||
return 0.5
|
||||
best = max((p.similarity_score for p in precedents), default=0.5)
|
||||
return best
|
||||
|
||||
|
||||
def _build_reasoning(amount_score, pattern_score, precedent_score, pattern) -> str:
|
||||
parts = []
|
||||
if amount_score >= 0.99:
|
||||
parts.append("Exact amount match")
|
||||
elif amount_score >= 0.95:
|
||||
parts.append("Amount close")
|
||||
if pattern and pattern.reconcile_count > 5:
|
||||
parts.append(f"Matches partner's {pattern.reconcile_count}-reconcile pattern")
|
||||
if precedent_score >= 0.8:
|
||||
parts.append("Strong precedent match")
|
||||
return " · ".join(parts) if parts else "Weak signal"
|
||||
|
||||
|
||||
def _get_provider(env, feature_name):
|
||||
"""Look up provider name from per-feature config; instantiate adapter.
|
||||
|
||||
Returns None if no provider configured (statistical-only mode)."""
|
||||
param = env['ir.config_parameter'].sudo()
|
||||
provider_name = param.get_param(f'fusion_accounting.provider.{feature_name}')
|
||||
if not provider_name:
|
||||
provider_name = param.get_param('fusion_accounting.provider.default')
|
||||
if not provider_name:
|
||||
return None
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||
except ImportError:
|
||||
_logger.warning("fusion_accounting_ai adapters not importable")
|
||||
return None
|
||||
if provider_name.startswith('openai'):
|
||||
return OpenAIAdapter(env)
|
||||
elif provider_name.startswith('claude'):
|
||||
return ClaudeAdapter(env)
|
||||
return None
|
||||
|
||||
|
||||
def _ai_rerank(env, provider, statement_line, scored, pattern, precedents):
|
||||
"""Send top-K candidates + features to LLM for re-rank. Parse JSON response.
|
||||
|
||||
On any failure (network, JSON parse, missing key), return scored unchanged."""
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import build_prompt
|
||||
except ImportError:
|
||||
_logger.debug("bank_rec_prompt not yet available; skipping AI re-rank")
|
||||
return scored
|
||||
|
||||
system, user = build_prompt(statement_line, scored, pattern, precedents)
|
||||
response = provider.complete(
|
||||
system=system,
|
||||
messages=[{'role': 'user', 'content': user}],
|
||||
max_tokens=800,
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = json.loads(response['content'])
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
return scored
|
||||
|
||||
ai_order = {item['candidate_id']: item for item in parsed.get('ranked', [])}
|
||||
for s in scored:
|
||||
if s.candidate_id in ai_order:
|
||||
s.score_ai_rerank = ai_order[s.candidate_id].get('confidence', s.confidence)
|
||||
s.reasoning = ai_order[s.candidate_id].get('reason', s.reasoning)
|
||||
s.confidence = round((s.confidence * 0.4) + (s.score_ai_rerank * 0.6), 3)
|
||||
scored.sort(key=lambda x: -x.confidence)
|
||||
return scored
|
||||
46
fusion_accounting_bank_rec/services/exchange_diff.py
Normal file
46
fusion_accounting_bank_rec/services/exchange_diff.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Exchange-difference calculation helper.
|
||||
|
||||
Pure-Python FX gain/loss computation. The engine uses this for rapid
|
||||
pre-checks; Odoo's account.move._create_exchange_difference_move() is
|
||||
invoked separately for the actual GL posting.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExchangeDiffResult:
|
||||
needs_diff_move: bool
|
||||
diff_amount: float # in company currency; positive = gain, negative = loss
|
||||
line_company_amount: float
|
||||
against_company_amount: float
|
||||
|
||||
|
||||
def compute_exchange_diff(*, line_amount, line_currency_code, against_amount,
|
||||
against_currency_code, line_rate, against_rate) -> ExchangeDiffResult:
|
||||
"""Compute whether an exchange-diff move is needed and its magnitude.
|
||||
|
||||
Args:
|
||||
line_amount: Bank line amount in its currency
|
||||
line_currency_code: e.g. 'USD'
|
||||
against_amount: Matched journal item amount in its currency
|
||||
against_currency_code: e.g. 'USD' (or different)
|
||||
line_rate: FX rate (foreign per company currency) at line date
|
||||
against_rate: FX rate at journal item posting date
|
||||
|
||||
Returns:
|
||||
ExchangeDiffResult with needs_diff_move flag and computed diff
|
||||
in company currency (positive = gain, negative = loss).
|
||||
"""
|
||||
line_company = line_amount * line_rate
|
||||
against_company = against_amount * against_rate
|
||||
|
||||
diff = line_company - against_company
|
||||
needs_diff = abs(diff) > 0.005 # rounding tolerance
|
||||
|
||||
return ExchangeDiffResult(
|
||||
needs_diff_move=needs_diff,
|
||||
diff_amount=round(diff, 2),
|
||||
line_company_amount=round(line_company, 2),
|
||||
against_company_amount=round(against_company, 2),
|
||||
)
|
||||
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Matching strategy classes for the reconcile engine.
|
||||
|
||||
Each strategy takes a bank amount + list of candidate journal items
|
||||
and returns a MatchResult with the picked ids + confidence + residual.
|
||||
Strategies are pure Python; no ORM dependency.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import combinations
|
||||
|
||||
|
||||
@dataclass
|
||||
class Candidate:
|
||||
id: int
|
||||
amount: float
|
||||
partner_id: int
|
||||
age_days: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchResult:
|
||||
picked_ids: list[int] = field(default_factory=list)
|
||||
confidence: float = 0.0
|
||||
residual: float = 0.0 # bank_amount - sum(picked); positive = under-allocated
|
||||
strategy_name: str = ""
|
||||
|
||||
|
||||
AMOUNT_TOLERANCE = 0.005 # currency rounding tolerance
|
||||
|
||||
|
||||
class AmountExactStrategy:
|
||||
"""Pick a single candidate whose amount equals the bank amount exactly.
|
||||
If multiple candidates match exactly, pick the oldest (FIFO tiebreaker)."""
|
||||
|
||||
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||
exact = [c for c in candidates if abs(c.amount - bank_amount) < AMOUNT_TOLERANCE]
|
||||
if not exact:
|
||||
return MatchResult(strategy_name='amount_exact')
|
||||
oldest = max(exact, key=lambda c: c.age_days)
|
||||
return MatchResult(
|
||||
picked_ids=[oldest.id],
|
||||
confidence=1.0,
|
||||
residual=0.0,
|
||||
strategy_name='amount_exact',
|
||||
)
|
||||
|
||||
|
||||
class FIFOStrategy:
|
||||
"""Pick oldest candidates first until the bank amount is exhausted.
|
||||
May produce partial reconcile residual if last candidate doesn't fit exactly."""
|
||||
|
||||
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||
if not candidates:
|
||||
return MatchResult(strategy_name='fifo')
|
||||
oldest_first = sorted(candidates, key=lambda c: -c.age_days)
|
||||
picked = []
|
||||
remaining = bank_amount
|
||||
for c in oldest_first:
|
||||
if remaining <= AMOUNT_TOLERANCE:
|
||||
break
|
||||
picked.append(c.id)
|
||||
remaining -= c.amount
|
||||
|
||||
confidence = 0.7 if remaining < AMOUNT_TOLERANCE else 0.5
|
||||
return MatchResult(
|
||||
picked_ids=picked,
|
||||
confidence=confidence,
|
||||
residual=remaining,
|
||||
strategy_name='fifo',
|
||||
)
|
||||
|
||||
|
||||
class MultiInvoiceStrategy:
|
||||
"""Find the smallest combination of candidates summing to the bank amount.
|
||||
Bounded by max_combinations to keep complexity manageable."""
|
||||
|
||||
def __init__(self, max_combinations=3):
|
||||
self.max_combinations = max_combinations
|
||||
|
||||
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||
for k in range(2, self.max_combinations + 1):
|
||||
for combo in combinations(candidates, k):
|
||||
total = sum(c.amount for c in combo)
|
||||
if abs(total - bank_amount) < AMOUNT_TOLERANCE:
|
||||
return MatchResult(
|
||||
picked_ids=[c.id for c in combo],
|
||||
confidence=0.85,
|
||||
residual=0.0,
|
||||
strategy_name=f'multi_invoice_{k}',
|
||||
)
|
||||
return MatchResult(strategy_name='multi_invoice')
|
||||
44
fusion_accounting_bank_rec/services/memo_tokenizer.py
Normal file
44
fusion_accounting_bank_rec/services/memo_tokenizer.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Extract searchable tokens from Canadian bank statement memos.
|
||||
|
||||
Handles common memo formats from RBC, TD, Scotia, BMO, plus generic
|
||||
cheque-number and reference-number patterns. Output is normalized
|
||||
(uppercase, alphanumeric) for case-insensitive matching.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
REF_PATTERNS = [
|
||||
(re.compile(r'\b(REF|REFERENCE)\s*#?\s*(\d+)\b', re.I), r'REF\2'),
|
||||
(re.compile(r'\b(CHQ|CHEQUE|CHECK)\s*#?\s*(\d+)\b', re.I), r'CHEQUE\2'),
|
||||
(re.compile(r'\b(INV|INVOICE)\s*#?\s*(\d+)\b', re.I), r'INV\2'),
|
||||
]
|
||||
|
||||
MIN_TOKEN_LENGTH = 2
|
||||
|
||||
|
||||
def tokenize_memo(memo: str | None) -> list[str]:
|
||||
"""Return list of normalized tokens from a bank memo.
|
||||
|
||||
Empty/None input returns []. Order preserved (first occurrence wins
|
||||
for de-duplication)."""
|
||||
if not memo:
|
||||
return []
|
||||
|
||||
text = memo.upper()
|
||||
for pattern, replacement in REF_PATTERNS:
|
||||
text = pattern.sub(replacement, text)
|
||||
|
||||
text = re.sub(r'[^A-Z0-9]+', ' ', text)
|
||||
raw_tokens = text.split()
|
||||
|
||||
seen = set()
|
||||
tokens = []
|
||||
for tok in raw_tokens:
|
||||
if len(tok) < MIN_TOKEN_LENGTH:
|
||||
continue
|
||||
if tok in seen:
|
||||
continue
|
||||
seen.add(tok)
|
||||
tokens.append(tok)
|
||||
|
||||
return tokens
|
||||
74
fusion_accounting_bank_rec/services/pattern_extractor.py
Normal file
74
fusion_accounting_bank_rec/services/pattern_extractor.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Aggregate per-partner reconciliation patterns from precedent rows.
|
||||
|
||||
Computes typical amount range, cadence, preferred strategy, common memo
|
||||
tokens. Output is a dict suitable for create/write on fusion.reconcile.pattern.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from statistics import median
|
||||
|
||||
|
||||
def extract_pattern_for_partner(env, *, company_id, partner_id) -> dict:
|
||||
"""Compute the pattern aggregate for one (company, partner) pair.
|
||||
|
||||
Returns vals dict suitable for env['fusion.reconcile.pattern'].create()."""
|
||||
Precedent = env['fusion.reconcile.precedent'].sudo()
|
||||
precedents = Precedent.search([
|
||||
('company_id', '=', company_id),
|
||||
('partner_id', '=', partner_id),
|
||||
], order='reconciled_at desc', limit=200)
|
||||
|
||||
if not precedents:
|
||||
return {
|
||||
'company_id': company_id,
|
||||
'partner_id': partner_id,
|
||||
'reconcile_count': 0,
|
||||
}
|
||||
|
||||
amounts = sorted(precedents.mapped('amount'))
|
||||
counts = precedents.mapped('matched_move_line_count')
|
||||
|
||||
single_count = sum(1 for c in counts if c == 1)
|
||||
multi_count = sum(1 for c in counts if c > 1)
|
||||
if multi_count > single_count:
|
||||
pref_strategy = 'multi_invoice'
|
||||
elif _amounts_concentrated(amounts):
|
||||
pref_strategy = 'exact_amount'
|
||||
else:
|
||||
pref_strategy = 'fifo'
|
||||
|
||||
reconcile_dates = sorted([p.reconciled_at for p in precedents if p.reconciled_at])
|
||||
if len(reconcile_dates) >= 2:
|
||||
deltas = [(reconcile_dates[i+1] - reconcile_dates[i]).days
|
||||
for i in range(len(reconcile_dates) - 1)]
|
||||
cadence = sum(deltas) / len(deltas) if deltas else 0.0
|
||||
else:
|
||||
cadence = 0.0
|
||||
|
||||
token_counter = Counter()
|
||||
for p in precedents:
|
||||
if p.memo_tokens:
|
||||
for tok in p.memo_tokens.split(','):
|
||||
token_counter[tok.strip()] += 1
|
||||
# Keep tokens appearing in >=30% of precedents (min floor of 2 occurrences)
|
||||
threshold = max(2, len(precedents) * 0.3)
|
||||
common_tokens = ','.join(t for t, c in token_counter.most_common() if c >= threshold)
|
||||
|
||||
return {
|
||||
'company_id': company_id,
|
||||
'partner_id': partner_id,
|
||||
'reconcile_count': len(precedents),
|
||||
'typical_amount_range': f"${min(amounts):,.2f} – ${max(amounts):,.2f} (median ${median(amounts):,.2f})",
|
||||
'typical_cadence_days': round(cadence, 1),
|
||||
'pref_strategy': pref_strategy,
|
||||
'common_memo_tokens': common_tokens,
|
||||
}
|
||||
|
||||
|
||||
def _amounts_concentrated(amounts: list[float]) -> bool:
|
||||
"""True if amounts cluster around a few values (suggests exact-amount strategy)."""
|
||||
if len(amounts) < 3:
|
||||
return True
|
||||
med = median(amounts)
|
||||
within_5pct = sum(1 for a in amounts if abs(a - med) / max(med, 1) < 0.05)
|
||||
return within_5pct / len(amounts) >= 0.6
|
||||
62
fusion_accounting_bank_rec/services/precedent_lookup.py
Normal file
62
fusion_accounting_bank_rec/services/precedent_lookup.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""K-nearest precedent search.
|
||||
|
||||
Given a new bank line, find the most similar past reconciliations for
|
||||
ranking + confidence scoring. Distance metric: amount delta (primary),
|
||||
date recency (secondary), memo token overlap (tertiary).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrecedentMatch:
|
||||
precedent_id: int
|
||||
amount: float
|
||||
memo_tokens: str
|
||||
matched_move_line_count: int
|
||||
similarity_score: float
|
||||
|
||||
|
||||
AMOUNT_TOLERANCE_PCT = 0.01 # 1% tolerance for "near" amount
|
||||
|
||||
|
||||
def find_nearest_precedents(env, *, partner_id, amount, k=5, memo_tokens=None):
|
||||
"""Return up to k most-similar precedents for a partner+amount.
|
||||
|
||||
Indexed query: filters by partner first (cheap), then ranks by
|
||||
amount distance + memo overlap. Sub-50ms for typical Westin volume."""
|
||||
Precedent = env['fusion.reconcile.precedent'].sudo()
|
||||
|
||||
tolerance = max(amount * AMOUNT_TOLERANCE_PCT, 1.00)
|
||||
candidates = Precedent.search([
|
||||
('partner_id', '=', partner_id),
|
||||
('amount', '>=', amount - tolerance),
|
||||
('amount', '<=', amount + tolerance),
|
||||
], limit=k * 4, order='reconciled_at desc')
|
||||
|
||||
results = []
|
||||
for p in candidates:
|
||||
amount_score = 1.0 - min(abs(p.amount - amount) / max(amount, 1), 1.0)
|
||||
memo_score = _memo_overlap(p.memo_tokens, memo_tokens) if memo_tokens else 0.5
|
||||
similarity = (amount_score * 0.7) + (memo_score * 0.3)
|
||||
results.append(PrecedentMatch(
|
||||
precedent_id=p.id,
|
||||
amount=p.amount,
|
||||
memo_tokens=p.memo_tokens or '',
|
||||
matched_move_line_count=p.matched_move_line_count,
|
||||
similarity_score=similarity,
|
||||
))
|
||||
|
||||
results.sort(key=lambda r: -r.similarity_score)
|
||||
return results[:k]
|
||||
|
||||
|
||||
def _memo_overlap(precedent_tokens_str, new_tokens) -> float:
|
||||
"""Jaccard similarity between two token sets."""
|
||||
if not precedent_tokens_str or not new_tokens:
|
||||
return 0.0
|
||||
precedent_set = set(precedent_tokens_str.split(','))
|
||||
new_set = set(new_tokens) if not isinstance(new_tokens, set) else new_tokens
|
||||
if not precedent_set and not new_set:
|
||||
return 0.0
|
||||
return len(precedent_set & new_set) / len(precedent_set | new_set)
|
||||
BIN
fusion_accounting_bank_rec/static/description/icon.png
Normal file
BIN
fusion_accounting_bank_rec/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
11
fusion_accounting_bank_rec/tests/__init__.py
Normal file
11
fusion_accounting_bank_rec/tests/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from . import test_memo_tokenizer
|
||||
from . import test_exchange_diff
|
||||
from . import test_matching_strategies
|
||||
from . import test_ai_suggestion_lifecycle
|
||||
from . import test_precedent_lookup
|
||||
from . import test_pattern_extraction
|
||||
from . import test_confidence_scoring
|
||||
from . import test_reconcile_engine_unit
|
||||
from . import test_reconcile_engine_property
|
||||
from . import test_factories
|
||||
from . import test_reconcile_engine_integration
|
||||
185
fusion_accounting_bank_rec/tests/_factories.py
Normal file
185
fusion_accounting_bank_rec/tests/_factories.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Test data factories for fusion_accounting_bank_rec.
|
||||
|
||||
Provides recordset builders for use across all test files. Sane defaults
|
||||
let tests be readable: `make_bank_line(env, amount=100, partner=p)` instead
|
||||
of 30 lines of recordset setup.
|
||||
|
||||
These factories work against the real Odoo registry — they exercise the
|
||||
same code paths as production. Each factory is idempotent in the sense
|
||||
that calling it multiple times returns separate records.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import fields
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Bank journal + statements
|
||||
# ============================================================
|
||||
|
||||
def make_bank_journal(env, *, name='Test Bank', code=None):
|
||||
"""Create a bank journal. `code` defaults to first 5 chars of `name`."""
|
||||
code = code or name[:5].upper().replace(' ', '')
|
||||
return env['account.journal'].create({
|
||||
'name': name,
|
||||
'type': 'bank',
|
||||
'code': code,
|
||||
})
|
||||
|
||||
|
||||
def make_bank_statement(env, *, journal=None, name='Test Statement', date_=None):
|
||||
"""Create a bank statement. Auto-creates a bank journal if not provided."""
|
||||
journal = journal or make_bank_journal(env)
|
||||
return env['account.bank.statement'].create({
|
||||
'name': name,
|
||||
'journal_id': journal.id,
|
||||
'date': date_ or date.today(),
|
||||
})
|
||||
|
||||
|
||||
def make_bank_line(env, *, journal=None, statement=None, amount=100.00,
|
||||
partner=None, memo='Test line', date_=None):
|
||||
"""Create a bank statement line. Creates statement if not provided.
|
||||
|
||||
Most-common factory in tests. Defaults give a $100 line with no partner."""
|
||||
if not statement:
|
||||
statement = make_bank_statement(env, journal=journal, date_=date_)
|
||||
return env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': statement.journal_id.id,
|
||||
'date': date_ or date.today(),
|
||||
'payment_ref': memo,
|
||||
'amount': amount,
|
||||
'partner_id': partner.id if partner else False,
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Invoices + journal items
|
||||
# ============================================================
|
||||
|
||||
def _ensure_test_product(env):
|
||||
"""Get or create a service product suitable for invoice lines."""
|
||||
product = env['product.product'].search([('type', '=', 'service')], limit=1)
|
||||
if not product:
|
||||
product = env['product.product'].create({
|
||||
'name': 'Fusion Test Service',
|
||||
'type': 'service',
|
||||
})
|
||||
return product
|
||||
|
||||
|
||||
def make_invoice(env, *, partner, amount=100.00, date_=None, currency=None,
|
||||
product=None, posted=True):
|
||||
"""Create a customer invoice (out_invoice). Posted by default."""
|
||||
product = product or _ensure_test_product(env)
|
||||
vals = {
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': partner.id,
|
||||
'invoice_date': date_ or date.today(),
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': 'Test invoice line',
|
||||
'quantity': 1,
|
||||
'price_unit': amount,
|
||||
})],
|
||||
}
|
||||
if currency:
|
||||
vals['currency_id'] = currency.id
|
||||
move = env['account.move'].create(vals)
|
||||
if posted:
|
||||
move.action_post()
|
||||
return move
|
||||
|
||||
|
||||
def make_vendor_bill(env, *, partner, amount=100.00, date_=None, currency=None,
|
||||
product=None, posted=True):
|
||||
"""Create a vendor bill (in_invoice). Posted by default."""
|
||||
product = product or _ensure_test_product(env)
|
||||
vals = {
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': partner.id,
|
||||
'invoice_date': date_ or date.today(),
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': 'Test bill line',
|
||||
'quantity': 1,
|
||||
'price_unit': amount,
|
||||
})],
|
||||
}
|
||||
if currency:
|
||||
vals['currency_id'] = currency.id
|
||||
move = env['account.move'].create(vals)
|
||||
if posted:
|
||||
move.action_post()
|
||||
return move
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Suggestions + patterns + precedents (fusion-specific)
|
||||
# ============================================================
|
||||
|
||||
def make_suggestion(env, *, statement_line, candidate_move_lines=None,
|
||||
confidence=0.92, rank=1, reasoning='Test suggestion',
|
||||
state='pending'):
|
||||
"""Create a fusion.reconcile.suggestion against a bank line."""
|
||||
candidate_ids = candidate_move_lines.ids if candidate_move_lines else []
|
||||
return env['fusion.reconcile.suggestion'].create({
|
||||
'company_id': env.company.id,
|
||||
'statement_line_id': statement_line.id,
|
||||
'proposed_move_line_ids': [(6, 0, candidate_ids)],
|
||||
'confidence': confidence,
|
||||
'rank': rank,
|
||||
'reasoning': reasoning,
|
||||
'state': state,
|
||||
})
|
||||
|
||||
|
||||
def make_pattern(env, *, partner, reconcile_count=10, pref_strategy='exact_amount',
|
||||
typical_cadence_days=14.0, common_memo_tokens='RBC,ETF'):
|
||||
"""Create a fusion.reconcile.pattern for a partner."""
|
||||
return env['fusion.reconcile.pattern'].create({
|
||||
'company_id': env.company.id,
|
||||
'partner_id': partner.id,
|
||||
'reconcile_count': reconcile_count,
|
||||
'pref_strategy': pref_strategy,
|
||||
'typical_cadence_days': typical_cadence_days,
|
||||
'common_memo_tokens': common_memo_tokens,
|
||||
})
|
||||
|
||||
|
||||
def make_precedent(env, *, partner, amount=1847.50, days_ago=14,
|
||||
memo_tokens='RBC,ETF,REF', count=1, source='manual'):
|
||||
"""Create a fusion.reconcile.precedent."""
|
||||
return env['fusion.reconcile.precedent'].create({
|
||||
'company_id': env.company.id,
|
||||
'partner_id': partner.id,
|
||||
'amount': amount,
|
||||
'currency_id': env.company.currency_id.id,
|
||||
'date': date.today() - timedelta(days=days_ago),
|
||||
'memo_tokens': memo_tokens,
|
||||
'matched_move_line_count': count,
|
||||
'reconciled_at': fields.Datetime.now(),
|
||||
'source': source,
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Convenience composite — bank line + matching invoice ready to reconcile
|
||||
# ============================================================
|
||||
|
||||
def make_reconcileable_pair(env, *, amount=100.00, partner=None, date_=None):
|
||||
"""Create a bank line + a customer invoice with the same partner+amount.
|
||||
Returns (bank_line, invoice_recv_lines) ready to pass to engine.reconcile_one().
|
||||
|
||||
Returns:
|
||||
(bank_line, invoice_receivable_lines) tuple
|
||||
"""
|
||||
if not partner:
|
||||
partner = env['res.partner'].create({'name': 'Reconcile Test Partner'})
|
||||
invoice = make_invoice(env, partner=partner, amount=amount, date_=date_)
|
||||
bank_line = make_bank_line(env, amount=amount, partner=partner, date_=date_)
|
||||
recv_lines = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
return (bank_line, recv_lines)
|
||||
@@ -0,0 +1,86 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSuggestionLifecycle(TransactionCase):
|
||||
"""The fusion.reconcile.suggestion state machine + computed band."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
journal = self.env['account.journal'].create({
|
||||
'name': 'Test Bank Suggestion',
|
||||
'type': 'bank',
|
||||
'code': 'TBSG',
|
||||
})
|
||||
statement = self.env['account.bank.statement'].create({
|
||||
'name': 'Test Statement',
|
||||
'journal_id': journal.id,
|
||||
})
|
||||
self.line = self.env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': journal.id,
|
||||
'date': '2026-04-19',
|
||||
'payment_ref': 'Test for suggestion',
|
||||
'amount': 100.00,
|
||||
})
|
||||
|
||||
def _make_suggestion(self, confidence=0.92, **vals):
|
||||
defaults = {
|
||||
'company_id': self.env.company.id,
|
||||
'statement_line_id': self.line.id,
|
||||
'confidence': confidence,
|
||||
'rank': 1,
|
||||
'reasoning': 'Test',
|
||||
}
|
||||
defaults.update(vals)
|
||||
return self.env['fusion.reconcile.suggestion'].create(defaults)
|
||||
|
||||
def test_compute_band_high(self):
|
||||
sug = self._make_suggestion(confidence=0.96)
|
||||
self.assertEqual(sug.confidence_band, 'high')
|
||||
|
||||
def test_compute_band_medium(self):
|
||||
sug = self._make_suggestion(confidence=0.75)
|
||||
self.assertEqual(sug.confidence_band, 'medium')
|
||||
|
||||
def test_compute_band_low(self):
|
||||
sug = self._make_suggestion(confidence=0.55)
|
||||
self.assertEqual(sug.confidence_band, 'low')
|
||||
|
||||
def test_compute_band_none(self):
|
||||
sug = self._make_suggestion(confidence=0.30)
|
||||
self.assertEqual(sug.confidence_band, 'none')
|
||||
|
||||
def test_default_state_is_pending(self):
|
||||
sug = self._make_suggestion()
|
||||
self.assertEqual(sug.state, 'pending')
|
||||
|
||||
def test_state_transition_to_accepted(self):
|
||||
sug = self._make_suggestion()
|
||||
sug.write({
|
||||
'state': 'accepted',
|
||||
'accepted_at': '2026-04-19 12:00:00',
|
||||
'accepted_by': self.env.user.id,
|
||||
})
|
||||
self.assertEqual(sug.state, 'accepted')
|
||||
self.assertTrue(sug.accepted_at)
|
||||
self.assertEqual(sug.accepted_by, self.env.user)
|
||||
|
||||
def test_state_transition_to_rejected_with_reason(self):
|
||||
sug = self._make_suggestion()
|
||||
sug.write({
|
||||
'state': 'rejected',
|
||||
'rejected_at': '2026-04-19 12:05:00',
|
||||
'rejected_reason': 'wrong_invoice',
|
||||
})
|
||||
self.assertEqual(sug.state, 'rejected')
|
||||
self.assertEqual(sug.rejected_reason, 'wrong_invoice')
|
||||
|
||||
def test_state_transition_to_superseded(self):
|
||||
sug = self._make_suggestion()
|
||||
sug.write({'state': 'superseded'})
|
||||
self.assertEqual(sug.state, 'superseded')
|
||||
|
||||
def test_currency_id_relates_to_line(self):
|
||||
sug = self._make_suggestion()
|
||||
self.assertEqual(sug.currency_id, self.line.currency_id)
|
||||
102
fusion_accounting_bank_rec/tests/test_confidence_scoring.py
Normal file
102
fusion_accounting_bank_rec/tests/test_confidence_scoring.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from datetime import date, timedelta, datetime
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.confidence_scoring import (
|
||||
score_candidates, ScoredCandidate,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import Candidate
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestConfidenceScoring(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Scoring Test Partner'})
|
||||
self.company = self.env.company
|
||||
self.currency = self.env.ref('base.CAD')
|
||||
|
||||
self.journal = self.env['account.journal'].create({
|
||||
'name': 'Test Bank Scoring',
|
||||
'type': 'bank',
|
||||
'code': 'TBSC',
|
||||
})
|
||||
statement = self.env['account.bank.statement'].create({
|
||||
'name': 'Test Statement',
|
||||
'journal_id': self.journal.id,
|
||||
})
|
||||
self.line = self.env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': self.journal.id,
|
||||
'date': date.today(),
|
||||
'payment_ref': 'RBC ETF DEP REF 4831',
|
||||
'amount': 1847.50,
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
def _candidate(self, id_, amount, age_days=10):
|
||||
return Candidate(id=id_, amount=amount, partner_id=self.partner.id, age_days=age_days)
|
||||
|
||||
def test_returns_empty_when_no_candidates(self):
|
||||
result = score_candidates(self.env, statement_line=self.line, candidates=[], k=5)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_returns_empty_when_no_statement_line(self):
|
||||
result = score_candidates(self.env, statement_line=None,
|
||||
candidates=[self._candidate(1, 100)], k=5)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_amount_exact_dominates(self):
|
||||
candidates = [
|
||||
self._candidate(1, 1847.50),
|
||||
self._candidate(2, 1800.00),
|
||||
]
|
||||
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
|
||||
use_ai=False)
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertEqual(result[0].candidate_id, 1)
|
||||
self.assertGreater(result[0].confidence, result[1].confidence)
|
||||
self.assertGreater(result[0].score_amount_match, 0.99)
|
||||
|
||||
def test_returns_top_k(self):
|
||||
candidates = [self._candidate(i, 1847.50 - i) for i in range(10)]
|
||||
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=3,
|
||||
use_ai=False)
|
||||
self.assertEqual(len(result), 3)
|
||||
|
||||
def test_no_ai_provider_returns_statistical_only(self):
|
||||
"""When no AI provider config, score_ai_rerank stays at 0.0."""
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', 'in', ['fusion_accounting.provider.bank_rec_suggest',
|
||||
'fusion_accounting.provider.default'])
|
||||
]).unlink()
|
||||
candidates = [self._candidate(1, 1847.50)]
|
||||
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
|
||||
use_ai=True)
|
||||
self.assertEqual(result[0].score_ai_rerank, 0.0)
|
||||
|
||||
def test_use_ai_false_skips_ai_rerank(self):
|
||||
candidates = [self._candidate(1, 1847.50)]
|
||||
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
|
||||
use_ai=False)
|
||||
self.assertEqual(result[0].score_ai_rerank, 0.0)
|
||||
|
||||
def test_pattern_match_boosts_confidence(self):
|
||||
"""When the partner has a matching pattern, confidence is higher than no-pattern case."""
|
||||
self.env['fusion.reconcile.pattern'].create({
|
||||
'company_id': self.company.id,
|
||||
'partner_id': self.partner.id,
|
||||
'reconcile_count': 10,
|
||||
'pref_strategy': 'exact_amount',
|
||||
})
|
||||
candidates = [self._candidate(1, 1847.50)]
|
||||
with_pattern = score_candidates(self.env, statement_line=self.line,
|
||||
candidates=candidates, k=5, use_ai=False)
|
||||
|
||||
other_partner = self.env['res.partner'].create({'name': 'No Pattern Partner'})
|
||||
self.line.write({'partner_id': other_partner.id})
|
||||
other_candidates = [Candidate(id=1, amount=1847.50, partner_id=other_partner.id, age_days=10)]
|
||||
without_pattern = score_candidates(self.env, statement_line=self.line,
|
||||
candidates=other_candidates, k=5, use_ai=False)
|
||||
|
||||
self.assertGreater(with_pattern[0].score_partner_pattern,
|
||||
without_pattern[0].score_partner_pattern - 0.001)
|
||||
56
fusion_accounting_bank_rec/tests/test_exchange_diff.py
Normal file
56
fusion_accounting_bank_rec/tests/test_exchange_diff.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.exchange_diff import (
|
||||
compute_exchange_diff, ExchangeDiffResult,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestExchangeDiff(TransactionCase):
|
||||
|
||||
def test_no_diff_when_currencies_match_and_rates_match(self):
|
||||
result = compute_exchange_diff(
|
||||
line_amount=100.00, line_currency_code='CAD',
|
||||
against_amount=100.00, against_currency_code='CAD',
|
||||
line_rate=1.0, against_rate=1.0,
|
||||
)
|
||||
self.assertFalse(result.needs_diff_move)
|
||||
self.assertEqual(result.diff_amount, 0.0)
|
||||
|
||||
def test_diff_when_rates_differ_same_currency(self):
|
||||
"""USD invoice posted at 1.35, USD bank line settled at 1.40 -> diff exists.
|
||||
100 USD at 1.40 = 140 CAD; same at 1.35 = 135 CAD; diff = 5 CAD gain."""
|
||||
result = compute_exchange_diff(
|
||||
line_amount=100.00, line_currency_code='USD',
|
||||
against_amount=100.00, against_currency_code='USD',
|
||||
line_rate=1.40, against_rate=1.35,
|
||||
)
|
||||
self.assertTrue(result.needs_diff_move)
|
||||
self.assertAlmostEqual(result.diff_amount, 5.00, places=2)
|
||||
|
||||
def test_diff_negative_when_rate_dropped(self):
|
||||
"""USD invoice at 1.40, settled at 1.35 -> loss"""
|
||||
result = compute_exchange_diff(
|
||||
line_amount=100.00, line_currency_code='USD',
|
||||
against_amount=100.00, against_currency_code='USD',
|
||||
line_rate=1.35, against_rate=1.40,
|
||||
)
|
||||
self.assertTrue(result.needs_diff_move)
|
||||
self.assertAlmostEqual(result.diff_amount, -5.00, places=2)
|
||||
|
||||
def test_company_amounts_computed_correctly(self):
|
||||
result = compute_exchange_diff(
|
||||
line_amount=100.00, line_currency_code='USD',
|
||||
against_amount=100.00, against_currency_code='USD',
|
||||
line_rate=1.40, against_rate=1.35,
|
||||
)
|
||||
self.assertAlmostEqual(result.line_company_amount, 140.00, places=2)
|
||||
self.assertAlmostEqual(result.against_company_amount, 135.00, places=2)
|
||||
|
||||
def test_tolerance_handles_rounding_noise(self):
|
||||
"""Tiny FX rounding under 0.005 should NOT trigger a diff move."""
|
||||
result = compute_exchange_diff(
|
||||
line_amount=100.00, line_currency_code='USD',
|
||||
against_amount=100.00, against_currency_code='USD',
|
||||
line_rate=1.40000, against_rate=1.40003, # 0.003 cent diff
|
||||
)
|
||||
self.assertFalse(result.needs_diff_move)
|
||||
74
fusion_accounting_bank_rec/tests/test_factories.py
Normal file
74
fusion_accounting_bank_rec/tests/test_factories.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Smoke tests verifying the factories produce usable records.
|
||||
|
||||
Not testing factory correctness exhaustively — just that each helper
|
||||
returns a record of the expected type with the expected basic state."""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFactories(TransactionCase):
|
||||
|
||||
def test_make_bank_journal(self):
|
||||
journal = f.make_bank_journal(self.env)
|
||||
self.assertEqual(journal._name, 'account.journal')
|
||||
self.assertEqual(journal.type, 'bank')
|
||||
|
||||
def test_make_bank_statement(self):
|
||||
statement = f.make_bank_statement(self.env)
|
||||
self.assertEqual(statement._name, 'account.bank.statement')
|
||||
self.assertTrue(statement.journal_id)
|
||||
|
||||
def test_make_bank_line(self):
|
||||
line = f.make_bank_line(self.env, amount=250.00, memo='Smoke memo')
|
||||
self.assertEqual(line._name, 'account.bank.statement.line')
|
||||
self.assertEqual(line.amount, 250.00)
|
||||
self.assertEqual(line.payment_ref, 'Smoke memo')
|
||||
self.assertFalse(line.is_reconciled)
|
||||
|
||||
def test_make_bank_line_with_partner(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Factory Partner'})
|
||||
line = f.make_bank_line(self.env, partner=partner, amount=500)
|
||||
self.assertEqual(line.partner_id, partner)
|
||||
|
||||
def test_make_invoice_posted(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Invoice Partner'})
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=300)
|
||||
self.assertEqual(invoice._name, 'account.move')
|
||||
self.assertEqual(invoice.move_type, 'out_invoice')
|
||||
self.assertEqual(invoice.state, 'posted')
|
||||
self.assertAlmostEqual(invoice.amount_total, 300, places=2)
|
||||
|
||||
def test_make_vendor_bill_posted(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Vendor Partner'})
|
||||
bill = f.make_vendor_bill(self.env, partner=partner, amount=400)
|
||||
self.assertEqual(bill.move_type, 'in_invoice')
|
||||
self.assertEqual(bill.state, 'posted')
|
||||
|
||||
def test_make_suggestion(self):
|
||||
line = f.make_bank_line(self.env, amount=100)
|
||||
sug = f.make_suggestion(self.env, statement_line=line, confidence=0.85)
|
||||
self.assertEqual(sug._name, 'fusion.reconcile.suggestion')
|
||||
self.assertEqual(sug.confidence, 0.85)
|
||||
self.assertEqual(sug.state, 'pending')
|
||||
|
||||
def test_make_pattern(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Pattern Partner'})
|
||||
pattern = f.make_pattern(self.env, partner=partner, reconcile_count=20)
|
||||
self.assertEqual(pattern._name, 'fusion.reconcile.pattern')
|
||||
self.assertEqual(pattern.reconcile_count, 20)
|
||||
|
||||
def test_make_precedent(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Precedent Partner'})
|
||||
precedent = f.make_precedent(self.env, partner=partner, amount=999.99)
|
||||
self.assertEqual(precedent._name, 'fusion.reconcile.precedent')
|
||||
self.assertEqual(precedent.amount, 999.99)
|
||||
self.assertEqual(precedent.source, 'manual')
|
||||
|
||||
def test_make_reconcileable_pair(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=750)
|
||||
self.assertEqual(bank_line.amount, 750.00)
|
||||
self.assertGreater(len(recv_lines), 0)
|
||||
self.assertAlmostEqual(sum(recv_lines.mapped('amount_residual')), 750, places=2)
|
||||
111
fusion_accounting_bank_rec/tests/test_matching_strategies.py
Normal file
111
fusion_accounting_bank_rec/tests/test_matching_strategies.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import (
|
||||
Candidate, AmountExactStrategy, FIFOStrategy, MultiInvoiceStrategy, MatchResult,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAmountExactStrategy(TransactionCase):
|
||||
|
||||
def test_picks_exact_amount(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=99.99, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=100.00, partner_id=42, age_days=20),
|
||||
Candidate(id=3, amount=100.50, partner_id=42, age_days=5),
|
||||
]
|
||||
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [2])
|
||||
self.assertEqual(result.confidence, 1.0)
|
||||
|
||||
def test_no_match_when_no_exact(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=99.99, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=100.50, partner_id=42, age_days=20),
|
||||
]
|
||||
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
def test_picks_oldest_when_multiple_exact(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=100.00, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=100.00, partner_id=42, age_days=30), # oldest
|
||||
Candidate(id=3, amount=100.00, partner_id=42, age_days=20),
|
||||
]
|
||||
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [2])
|
||||
|
||||
def test_handles_empty_candidates(self):
|
||||
result = AmountExactStrategy().match(bank_amount=100.00, candidates=[])
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFIFOStrategy(TransactionCase):
|
||||
|
||||
def test_picks_oldest_first(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=50.00, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=50.00, partner_id=42, age_days=30),
|
||||
Candidate(id=3, amount=50.00, partner_id=42, age_days=20),
|
||||
]
|
||||
result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [2, 3]) # oldest two summing to 100
|
||||
|
||||
def test_handles_partial_payment(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=200.00, partner_id=42, age_days=30),
|
||||
]
|
||||
result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [1]) # partial reconcile signaled by residual
|
||||
self.assertEqual(result.residual, -100.00) # over-allocated; engine handles
|
||||
|
||||
def test_handles_empty_candidates(self):
|
||||
result = FIFOStrategy().match(bank_amount=100.00, candidates=[])
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestMultiInvoiceStrategy(TransactionCase):
|
||||
|
||||
def test_finds_smallest_set_summing_to_amount(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=30.00, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=40.00, partner_id=42, age_days=15),
|
||||
Candidate(id=3, amount=30.00, partner_id=42, age_days=20),
|
||||
Candidate(id=4, amount=70.00, partner_id=42, age_days=25),
|
||||
]
|
||||
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||
bank_amount=100.00, candidates=candidates)
|
||||
# Two-element solutions exist (e.g., {3,4}=100). Strategy should pick a 2-set.
|
||||
self.assertEqual(len(result.picked_ids), 2)
|
||||
# The picked set should sum to 100
|
||||
picked_amounts = [c.amount for c in candidates if c.id in result.picked_ids]
|
||||
self.assertAlmostEqual(sum(picked_amounts), 100.00, places=2)
|
||||
|
||||
def test_returns_empty_when_no_combination_sums(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=15.00, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=25.00, partner_id=42, age_days=15),
|
||||
]
|
||||
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||
bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
def test_respects_max_combinations(self):
|
||||
# Many small invoices — only combinations of ≤3 items considered
|
||||
candidates = [Candidate(id=i, amount=10.00, partner_id=42, age_days=i)
|
||||
for i in range(1, 11)]
|
||||
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||
bank_amount=100.00, candidates=candidates)
|
||||
# Can't make 100 with ≤3 items of $10 each
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
def test_strategy_name_includes_combination_size(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=50.00, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=50.00, partner_id=42, age_days=20),
|
||||
]
|
||||
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||
bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(set(result.picked_ids), {1, 2})
|
||||
self.assertIn('multi_invoice', result.strategy_name)
|
||||
42
fusion_accounting_bank_rec/tests/test_memo_tokenizer.py
Normal file
42
fusion_accounting_bank_rec/tests/test_memo_tokenizer.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.memo_tokenizer import tokenize_memo
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestMemoTokenizer(TransactionCase):
|
||||
|
||||
def test_extracts_rbc_etf_reference(self):
|
||||
tokens = tokenize_memo("RBC ETF DEP REF 4831")
|
||||
self.assertIn('RBC', tokens)
|
||||
self.assertIn('ETF', tokens)
|
||||
self.assertIn('REF4831', tokens)
|
||||
|
||||
def test_extracts_cheque_number(self):
|
||||
tokens = tokenize_memo("CHEQUE 4827 - WESTIN PLATING")
|
||||
self.assertIn('CHEQUE4827', tokens)
|
||||
self.assertIn('WESTIN', tokens)
|
||||
self.assertIn('PLATING', tokens)
|
||||
|
||||
def test_strips_noise_tokens(self):
|
||||
tokens = tokenize_memo("PAYMENT - INV - DEP - 12345")
|
||||
self.assertNotIn('-', tokens)
|
||||
self.assertEqual([t for t in tokens if len(t) <= 1], [])
|
||||
|
||||
def test_handles_empty_memo(self):
|
||||
self.assertEqual(tokenize_memo(""), [])
|
||||
self.assertEqual(tokenize_memo(None), [])
|
||||
|
||||
def test_canadian_french_memo(self):
|
||||
tokens = tokenize_memo("PAIEMENT VIREMENT BANCAIRE")
|
||||
self.assertIn('PAIEMENT', tokens)
|
||||
self.assertIn('VIREMENT', tokens)
|
||||
|
||||
def test_normalises_case(self):
|
||||
tokens = tokenize_memo("rbc etf dep ref 4831")
|
||||
self.assertIn('RBC', tokens)
|
||||
|
||||
def test_handles_special_characters(self):
|
||||
tokens = tokenize_memo("RBC*PAYMENT/REF#4831")
|
||||
self.assertIn('RBC', tokens)
|
||||
self.assertIn('PAYMENT', tokens)
|
||||
self.assertIn('REF4831', tokens)
|
||||
73
fusion_accounting_bank_rec/tests/test_pattern_extraction.py
Normal file
73
fusion_accounting_bank_rec/tests/test_pattern_extraction.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from datetime import date, timedelta, datetime
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.pattern_extractor import (
|
||||
extract_pattern_for_partner,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPatternExtractor(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Pattern Test Partner'})
|
||||
self.currency = self.env.ref('base.CAD')
|
||||
self.company = self.env.company
|
||||
|
||||
def _make_precedent(self, *, amount, days_ago, memo='RBC,ETF', count=1, source='manual'):
|
||||
return self.env['fusion.reconcile.precedent'].create({
|
||||
'company_id': self.company.id,
|
||||
'partner_id': self.partner.id,
|
||||
'amount': amount,
|
||||
'currency_id': self.currency.id,
|
||||
'date': date.today() - timedelta(days=days_ago),
|
||||
'memo_tokens': memo,
|
||||
'matched_move_line_count': count,
|
||||
'reconciled_at': datetime.now() - timedelta(days=days_ago),
|
||||
'source': source,
|
||||
})
|
||||
|
||||
def test_extracts_typical_amount_range(self):
|
||||
for d in [10, 24, 38, 52]:
|
||||
self._make_precedent(amount=1847.50, days_ago=d)
|
||||
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||
self.assertIn('typical_amount_range', pattern_vals)
|
||||
self.assertEqual(pattern_vals['reconcile_count'], 4)
|
||||
|
||||
def test_detects_exact_amount_strategy(self):
|
||||
for d in range(0, 56, 14):
|
||||
self._make_precedent(amount=1847.50, days_ago=d, count=1)
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||
self.assertEqual(pattern_vals['pref_strategy'], 'exact_amount')
|
||||
|
||||
def test_detects_multi_invoice_strategy(self):
|
||||
for d in range(0, 56, 14):
|
||||
self._make_precedent(amount=2500.00, days_ago=d, count=3)
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||
self.assertEqual(pattern_vals['pref_strategy'], 'multi_invoice')
|
||||
|
||||
def test_computes_cadence_days(self):
|
||||
for d in [0, 14, 28, 42]:
|
||||
self._make_precedent(amount=1000, days_ago=d)
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||
self.assertAlmostEqual(pattern_vals['typical_cadence_days'], 14.0, delta=1)
|
||||
|
||||
def test_extracts_common_memo_tokens(self):
|
||||
self._make_precedent(amount=1000, days_ago=10, memo='RBC,ETF,REF')
|
||||
self._make_precedent(amount=1000, days_ago=24, memo='RBC,ETF,DEPOSIT')
|
||||
self._make_precedent(amount=1000, days_ago=38, memo='RBC,ETF,REF')
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||
self.assertIn('RBC', pattern_vals['common_memo_tokens'])
|
||||
self.assertIn('ETF', pattern_vals['common_memo_tokens'])
|
||||
|
||||
def test_returns_zero_count_for_partner_with_no_precedents(self):
|
||||
other_partner = self.env['res.partner'].create({'name': 'Empty Partner'})
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=other_partner.id)
|
||||
self.assertEqual(pattern_vals['reconcile_count'], 0)
|
||||
73
fusion_accounting_bank_rec/tests/test_precedent_lookup.py
Normal file
73
fusion_accounting_bank_rec/tests/test_precedent_lookup.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from datetime import date
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.precedent_lookup import (
|
||||
find_nearest_precedents, PrecedentMatch,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPrecedentLookup(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Precedent Lookup Partner'})
|
||||
self.currency = self.env.ref('base.CAD')
|
||||
self.company = self.env.company
|
||||
for amt in [1847.50, 1847.50, 1800.00]:
|
||||
self.env['fusion.reconcile.precedent'].create({
|
||||
'company_id': self.company.id,
|
||||
'partner_id': self.partner.id,
|
||||
'amount': amt,
|
||||
'currency_id': self.currency.id,
|
||||
'date': date.today(),
|
||||
'memo_tokens': 'RBC,ETF,REF',
|
||||
'matched_move_line_count': 1,
|
||||
'source': 'manual',
|
||||
})
|
||||
|
||||
def test_finds_amount_exact_precedents(self):
|
||||
results = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=1847.50, k=5)
|
||||
amounts = [r.amount for r in results]
|
||||
self.assertEqual(amounts.count(1847.50), 2)
|
||||
|
||||
def test_returns_empty_for_unknown_partner(self):
|
||||
results = find_nearest_precedents(
|
||||
self.env, partner_id=999999, amount=1847.50, k=5)
|
||||
self.assertEqual(results, [])
|
||||
|
||||
def test_respects_k_limit(self):
|
||||
for i in range(10):
|
||||
self.env['fusion.reconcile.precedent'].create({
|
||||
'company_id': self.company.id,
|
||||
'partner_id': self.partner.id,
|
||||
'amount': 1847.50,
|
||||
'currency_id': self.currency.id,
|
||||
'date': date.today(),
|
||||
'matched_move_line_count': 1,
|
||||
'source': 'manual',
|
||||
})
|
||||
results = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=1847.50, k=3)
|
||||
self.assertEqual(len(results), 3)
|
||||
|
||||
def test_results_sorted_by_similarity_desc(self):
|
||||
results = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=1847.50, k=5)
|
||||
if len(results) >= 2:
|
||||
self.assertGreaterEqual(results[0].similarity_score, results[1].similarity_score)
|
||||
|
||||
def test_memo_overlap_boosts_score(self):
|
||||
results_with_memo = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=1847.50, k=5,
|
||||
memo_tokens=['RBC', 'ETF', 'REF'])
|
||||
results_no_memo = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=1847.50, k=5)
|
||||
if results_with_memo and results_no_memo:
|
||||
self.assertGreaterEqual(results_with_memo[0].similarity_score,
|
||||
results_no_memo[0].similarity_score - 0.001)
|
||||
|
||||
def test_amount_outside_tolerance_excluded(self):
|
||||
results = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=2000.00, k=5)
|
||||
self.assertEqual(results, [])
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Integration tests for the reconcile engine.
|
||||
|
||||
These tests use the test factories (_factories.py) to set up realistic
|
||||
bank-line + invoice scenarios, then call engine methods and assert the
|
||||
account.partial.reconcile rows produced have the right shape.
|
||||
|
||||
Tests cover:
|
||||
- Simple 1:1 match (bank line == one invoice)
|
||||
- Partial chain (one bank line < invoice amount)
|
||||
- Multi-invoice consolidation (one bank line == sum of N invoices)
|
||||
- Auto-strategy batch (mix of matchable and unmatchable lines)
|
||||
- Suggest-then-accept flow
|
||||
- Unreconcile (reverse a reconciliation)
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestReconcileSimpleMatch(TransactionCase):
|
||||
"""The most common scenario: 1 bank line matched against 1 invoice exact."""
|
||||
|
||||
def test_simple_match_creates_partial_reconcile(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00)
|
||||
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
partial = self.env['account.partial.reconcile'].browse(result['partial_ids'])
|
||||
self.assertAlmostEqual(sum(partial.mapped('amount')), 100.00, places=2)
|
||||
|
||||
def test_simple_match_marks_line_reconciled(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=250.00)
|
||||
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
bank_line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertTrue(bank_line.is_reconciled)
|
||||
|
||||
def test_simple_match_records_precedent(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=500.00)
|
||||
partner = bank_line.partner_id
|
||||
Precedent = self.env['fusion.reconcile.precedent']
|
||||
before = Precedent.search_count([('partner_id', '=', partner.id)])
|
||||
|
||||
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
|
||||
after = Precedent.search_count([('partner_id', '=', partner.id)])
|
||||
self.assertEqual(after, before + 1, "Engine should record one precedent per reconcile")
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestReconcilePartialChain(TransactionCase):
|
||||
"""Bank line amount < invoice amount -> partial reconcile, residual remains."""
|
||||
|
||||
def test_partial_reconcile_leaves_residual(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Partial Partner'})
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=300.00)
|
||||
recv_lines = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
|
||||
bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner)
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
invoice.invalidate_recordset(['payment_state', 'amount_residual'])
|
||||
self.assertAlmostEqual(invoice.amount_residual, 200.00, places=2)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestReconcileBatch(TransactionCase):
|
||||
"""Bulk reconcile: mix of matchable and unmatchable lines."""
|
||||
|
||||
def test_batch_reconciles_matchable_lines_only(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Batch Partner'})
|
||||
# Share one journal/statement to avoid duplicate-code conflicts
|
||||
# when creating multiple bank lines in the same test transaction.
|
||||
shared_journal = f.make_bank_journal(self.env, name='Batch Bank', code='BBNK')
|
||||
shared_statement = f.make_bank_statement(self.env, journal=shared_journal)
|
||||
pairs = []
|
||||
for amount in [100.00, 200.00, 300.00]:
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=amount)
|
||||
recv_lines = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, statement=shared_statement, amount=amount,
|
||||
partner=partner)
|
||||
pairs.append((bank_line, recv_lines))
|
||||
|
||||
orphan_line = f.make_bank_line(
|
||||
self.env, statement=shared_statement, amount=999.99, partner=partner)
|
||||
|
||||
all_lines = self.env['account.bank.statement.line'].browse(
|
||||
[p[0].id for p in pairs] + [orphan_line.id])
|
||||
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_batch(
|
||||
all_lines, strategy='auto')
|
||||
|
||||
self.assertEqual(result['reconciled_count'], 3)
|
||||
self.assertGreaterEqual(result['skipped'], 1)
|
||||
self.assertEqual(len(result['errors']), 0)
|
||||
|
||||
def test_batch_handles_empty_recordset(self):
|
||||
empty = self.env['account.bank.statement.line']
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_batch(empty)
|
||||
self.assertEqual(result['reconciled_count'], 0)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestSuggestThenAccept(TransactionCase):
|
||||
"""Full flow: suggest_matches creates suggestions; accept_suggestion reconciles."""
|
||||
|
||||
def test_suggest_then_accept(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Suggest Then Accept'})
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=750.00)
|
||||
bank_line = f.make_bank_line(self.env, amount=750.00, partner=partner,
|
||||
memo='Test suggest accept')
|
||||
|
||||
suggestions = self.env['fusion.reconcile.engine'].suggest_matches(
|
||||
bank_line, limit_per_line=3)
|
||||
|
||||
self.assertIn(bank_line.id, suggestions)
|
||||
self.assertGreater(len(suggestions[bank_line.id]), 0,
|
||||
"Engine should suggest at least one candidate for matching invoice")
|
||||
|
||||
top_suggestion_id = suggestions[bank_line.id][0]['id']
|
||||
sug = self.env['fusion.reconcile.suggestion'].browse(top_suggestion_id)
|
||||
result = self.env['fusion.reconcile.engine'].accept_suggestion(sug)
|
||||
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
sug.invalidate_recordset(['state', 'accepted_at', 'accepted_by'])
|
||||
self.assertEqual(sug.state, 'accepted')
|
||||
self.assertTrue(sug.accepted_at)
|
||||
bank_line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertTrue(bank_line.is_reconciled)
|
||||
|
||||
def test_suggest_supersedes_prior_pending(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Supersede Test'})
|
||||
bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner)
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=100.00)
|
||||
|
||||
self.env['fusion.reconcile.engine'].suggest_matches(bank_line)
|
||||
first_pending = self.env['fusion.reconcile.suggestion'].search([
|
||||
('statement_line_id', '=', bank_line.id),
|
||||
('state', '=', 'pending'),
|
||||
])
|
||||
self.assertGreater(len(first_pending), 0)
|
||||
|
||||
self.env['fusion.reconcile.engine'].suggest_matches(bank_line)
|
||||
first_pending.invalidate_recordset(['state'])
|
||||
for s in first_pending:
|
||||
self.assertEqual(s.state, 'superseded')
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestUnreconcile(TransactionCase):
|
||||
"""Reverse a reconciliation."""
|
||||
|
||||
def test_unreconcile_removes_partial(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00)
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
partials = self.env['account.partial.reconcile'].browse(result['partial_ids'])
|
||||
self.assertGreater(len(partials), 0)
|
||||
|
||||
unrec_result = self.env['fusion.reconcile.engine'].unreconcile(partials)
|
||||
|
||||
self.assertGreater(len(unrec_result['unreconciled_line_ids']), 0)
|
||||
self.assertFalse(partials.exists())
|
||||
bank_line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertFalse(bank_line.is_reconciled)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestEngineEdgeCases(TransactionCase):
|
||||
"""Edge cases that came up during engine implementation."""
|
||||
|
||||
def test_reconcile_validates_line_exists(self):
|
||||
from odoo.exceptions import ValidationError
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
self.env['account.bank.statement.line'],
|
||||
against_lines=self.env['account.move.line'])
|
||||
|
||||
def test_already_reconciled_line_skipped_in_batch(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Already Reconciled'})
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||
self.env, amount=50.00, partner=partner)
|
||||
|
||||
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
bank_line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertTrue(bank_line.is_reconciled)
|
||||
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_batch(bank_line)
|
||||
self.assertGreater(result['skipped'], 0)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Property-based tests for reconcile engine invariants.
|
||||
|
||||
Hypothesis generates random input combinations to catch edge cases that
|
||||
example-based TDD missed. Each test runs N times (default 50 -- bumpable
|
||||
via @settings)."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from hypothesis import HealthCheck, given, settings, strategies as st
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import (
|
||||
AmountExactStrategy,
|
||||
Candidate,
|
||||
FIFOStrategy,
|
||||
MultiInvoiceStrategy,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'property_based')
|
||||
class TestMatchingStrategyInvariants(TransactionCase):
|
||||
"""Pure-Python invariants on the matching strategies (no ORM needed).
|
||||
Faster + more iterations than DB-backed property tests."""
|
||||
|
||||
@given(
|
||||
bank_amount=st.floats(min_value=0.01, max_value=100000.00,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
invoice_amounts=st.lists(
|
||||
st.floats(min_value=0.01, max_value=100000.00,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=100, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_amount_exact_picks_only_when_amount_matches(
|
||||
self, bank_amount, invoice_amounts):
|
||||
"""AmountExactStrategy returns picks IFF some candidate amount matches
|
||||
bank_amount within tolerance."""
|
||||
candidates = [
|
||||
Candidate(id=i, amount=round(amt, 2), partner_id=1, age_days=10)
|
||||
for i, amt in enumerate(invoice_amounts)
|
||||
]
|
||||
bank_amount = round(bank_amount, 2)
|
||||
result = AmountExactStrategy().match(
|
||||
bank_amount=bank_amount, candidates=candidates)
|
||||
|
||||
has_match = any(
|
||||
abs(c.amount - bank_amount) < 0.005 for c in candidates)
|
||||
if has_match:
|
||||
self.assertEqual(
|
||||
len(result.picked_ids), 1,
|
||||
f"bank=${bank_amount} candidates={[c.amount for c in candidates]} "
|
||||
f"has_match={has_match} -> expected 1 pick, got {result.picked_ids}",
|
||||
)
|
||||
self.assertEqual(result.confidence, 1.0)
|
||||
else:
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
@given(
|
||||
bank_amount=st.floats(min_value=10.00, max_value=10000.00,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
invoice_amounts=st.lists(
|
||||
st.floats(min_value=1.00, max_value=10000.00,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=8,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=100, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_fifo_picks_oldest_first(self, bank_amount, invoice_amounts):
|
||||
"""FIFOStrategy picks candidates in order of decreasing age_days
|
||||
(oldest first), stopping when remaining <= 0."""
|
||||
candidates = [
|
||||
Candidate(id=i, amount=round(amt, 2), partner_id=1,
|
||||
age_days=100 - i)
|
||||
for i, amt in enumerate(invoice_amounts)
|
||||
]
|
||||
bank_amount = round(bank_amount, 2)
|
||||
result = FIFOStrategy().match(
|
||||
bank_amount=bank_amount, candidates=candidates)
|
||||
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
oldest_first_ids = [
|
||||
c.id for c in sorted(candidates, key=lambda c: -c.age_days)]
|
||||
self.assertEqual(
|
||||
result.picked_ids,
|
||||
oldest_first_ids[:len(result.picked_ids)],
|
||||
)
|
||||
|
||||
picked_sum = sum(
|
||||
c.amount for c in candidates if c.id in result.picked_ids)
|
||||
self.assertAlmostEqual(
|
||||
result.residual, bank_amount - picked_sum, places=2)
|
||||
|
||||
@given(
|
||||
amounts=st.lists(
|
||||
st.floats(min_value=1.00, max_value=1000.00,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
min_size=2, max_size=6,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=50, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_multi_invoice_finds_combination_when_one_exists(self, amounts):
|
||||
"""If amounts can sum to a target via <=3 elements, MultiInvoiceStrategy
|
||||
finds SOME valid combination."""
|
||||
rounded = [round(a, 2) for a in amounts]
|
||||
candidates = [
|
||||
Candidate(id=i, amount=amt, partner_id=1, age_days=10)
|
||||
for i, amt in enumerate(rounded)
|
||||
]
|
||||
target = round(rounded[0] + rounded[1], 2)
|
||||
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||
bank_amount=target, candidates=candidates)
|
||||
|
||||
if result.picked_ids:
|
||||
picked_sum = sum(
|
||||
c.amount for c in candidates if c.id in result.picked_ids)
|
||||
self.assertAlmostEqual(
|
||||
picked_sum, target, places=2,
|
||||
msg=(f"target={target} picks={result.picked_ids} "
|
||||
f"sum={picked_sum} candidates={rounded}"),
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'property_based', 'engine_invariants')
|
||||
class TestReconcileEngineInvariants(TransactionCase):
|
||||
"""ORM-backed property tests against the engine.
|
||||
Slower because each test creates real bank_lines + invoices."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create(
|
||||
{'name': 'Engine Property Partner'})
|
||||
self.journal = self.env['account.journal'].create({
|
||||
'name': 'Engine Property Bank',
|
||||
'type': 'bank',
|
||||
'code': 'EPB',
|
||||
})
|
||||
self.receivable_account = self.env['account.account'].search([
|
||||
('account_type', '=', 'asset_receivable'),
|
||||
('company_ids', 'in', self.env.company.id),
|
||||
], limit=1)
|
||||
if not self.receivable_account:
|
||||
self.skipTest("No receivable account in chart of accounts")
|
||||
|
||||
def _make_bank_line(self, amount):
|
||||
statement = self.env['account.bank.statement'].create({
|
||||
'name': f'Test stmt {amount}',
|
||||
'journal_id': self.journal.id,
|
||||
})
|
||||
return self.env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': self.journal.id,
|
||||
'date': date.today(),
|
||||
'payment_ref': f'Test {amount}',
|
||||
'amount': amount,
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
def _make_invoice(self, amount):
|
||||
product = self.env['product.product'].search(
|
||||
[('type', '=', 'service')], limit=1)
|
||||
if not product:
|
||||
product = self.env['product.product'].create({
|
||||
'name': 'Property Test Service',
|
||||
'type': 'service',
|
||||
})
|
||||
move = self.env['account.move'].create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': self.partner.id,
|
||||
'invoice_date': date.today(),
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': 'Property Test',
|
||||
'quantity': 1,
|
||||
'price_unit': amount,
|
||||
})],
|
||||
})
|
||||
move.action_post()
|
||||
return move
|
||||
|
||||
@given(amount=st.floats(min_value=10.00, max_value=10000.00,
|
||||
allow_nan=False, allow_infinity=False))
|
||||
@settings(max_examples=10, deadline=10000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_invariant_simple_reconcile_balances(self, amount):
|
||||
"""For any bank_amount = invoice_amount, reconcile_one produces:
|
||||
- exactly 1 partial reconcile
|
||||
- amount equal to the bank line amount
|
||||
- bank line is_reconciled = True"""
|
||||
amount = round(amount, 2)
|
||||
bank_line = self._make_bank_line(amount)
|
||||
invoice = self._make_invoice(amount)
|
||||
invoice_recv_lines = invoice.line_ids.filtered(
|
||||
lambda line: line.account_id.account_type == 'asset_receivable')
|
||||
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=invoice_recv_lines)
|
||||
|
||||
self.assertGreater(
|
||||
len(result['partial_ids']), 0,
|
||||
f"Expected partial_ids non-empty for amount={amount}, got {result}",
|
||||
)
|
||||
partials = self.env['account.partial.reconcile'].browse(
|
||||
result['partial_ids'])
|
||||
self.assertAlmostEqual(
|
||||
sum(partials.mapped('amount')), amount, places=2)
|
||||
bank_line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertTrue(
|
||||
bank_line.is_reconciled,
|
||||
f"is_reconciled expected True after reconcile for amount={amount}",
|
||||
)
|
||||
348
fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py
Normal file
348
fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""Unit tests for fusion.reconcile.engine — the 6-method public API.
|
||||
|
||||
Test layers:
|
||||
- Layer 1: API surface (registry + method existence)
|
||||
- Layer 2: unreconcile
|
||||
- Layer 3: reconcile_one happy path
|
||||
- Layer 4: accept_suggestion
|
||||
- Layer 5: suggest_matches
|
||||
- Layer 6: reconcile_batch
|
||||
- Layer 7: write_off
|
||||
|
||||
Tests share a common setUpClass fixture providing a partner, bank
|
||||
journal, statement, receivable account, and a small helper to mint a
|
||||
posted customer invoice + bank statement line at given amounts.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineBase(TransactionCase):
|
||||
"""Shared fixtures for engine tests."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.engine = cls.env['fusion.reconcile.engine']
|
||||
cls.company = cls.env.company
|
||||
cls.currency = cls.company.currency_id
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'Engine Test Partner',
|
||||
})
|
||||
cls.bank_journal = cls.env['account.journal'].create({
|
||||
'name': 'Engine Test Bank',
|
||||
'type': 'bank',
|
||||
'code': 'ETBK',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.sales_journal = cls.env['account.journal'].search([
|
||||
('type', '=', 'sale'),
|
||||
('company_id', '=', cls.company.id),
|
||||
], limit=1)
|
||||
if not cls.sales_journal:
|
||||
cls.sales_journal = cls.env['account.journal'].create({
|
||||
'name': 'Engine Test Sales',
|
||||
'type': 'sale',
|
||||
'code': 'ETSAL',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.receivable_account = cls.env['account.account'].search([
|
||||
('account_type', '=', 'asset_receivable'),
|
||||
('company_ids', 'in', cls.company.id),
|
||||
], limit=1)
|
||||
cls.income_account = cls.env['account.account'].search([
|
||||
('account_type', '=', 'income'),
|
||||
('company_ids', 'in', cls.company.id),
|
||||
], limit=1)
|
||||
cls.expense_account = cls.env['account.account'].search([
|
||||
('account_type', '=', 'expense'),
|
||||
('company_ids', 'in', cls.company.id),
|
||||
], limit=1)
|
||||
|
||||
def _make_statement_line(self, amount, *, partner=None, ref='ENGTEST',
|
||||
line_date=None):
|
||||
statement = self.env['account.bank.statement'].create({
|
||||
'name': 'Engine Test Statement',
|
||||
'journal_id': self.bank_journal.id,
|
||||
})
|
||||
return self.env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': self.bank_journal.id,
|
||||
'date': line_date or date.today(),
|
||||
'payment_ref': ref,
|
||||
'amount': amount,
|
||||
'partner_id': (partner or self.partner).id,
|
||||
})
|
||||
|
||||
def _make_invoice(self, amount, *, partner=None, inv_date=None):
|
||||
"""Create + post a customer invoice for the given amount."""
|
||||
inv = self.env['account.move'].create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': (partner or self.partner).id,
|
||||
'invoice_date': inv_date or date.today(),
|
||||
'journal_id': self.sales_journal.id,
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Engine test product',
|
||||
'quantity': 1,
|
||||
'price_unit': amount,
|
||||
'account_id': self.income_account.id,
|
||||
'tax_ids': [(6, 0, [])],
|
||||
})],
|
||||
})
|
||||
inv.action_post()
|
||||
return inv
|
||||
|
||||
def _receivable_line(self, invoice):
|
||||
return invoice.line_ids.filtered(
|
||||
lambda line: line.account_id.account_type == 'asset_receivable'
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 1: API surface
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineApi(TestReconcileEngineBase):
|
||||
"""Layer 1: the engine class exists in the registry and exposes the
|
||||
six expected methods."""
|
||||
|
||||
def test_engine_in_registry(self):
|
||||
self.assertIn('fusion.reconcile.engine', self.env.registry)
|
||||
|
||||
def test_engine_is_abstract_model(self):
|
||||
engine = self.env['fusion.reconcile.engine']
|
||||
self.assertTrue(engine._abstract)
|
||||
|
||||
def test_six_public_methods_callable(self):
|
||||
engine = self.env['fusion.reconcile.engine']
|
||||
for name in ('reconcile_one', 'reconcile_batch', 'suggest_matches',
|
||||
'accept_suggestion', 'write_off', 'unreconcile'):
|
||||
self.assertTrue(callable(getattr(engine, name, None)),
|
||||
f"engine.{name} must be callable")
|
||||
|
||||
def test_reconcile_one_requires_arguments(self):
|
||||
line = self._make_statement_line(100.0)
|
||||
with self.assertRaises(ValidationError):
|
||||
self.engine.reconcile_one(line)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 2: unreconcile
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineUnreconcile(TestReconcileEngineBase):
|
||||
|
||||
def test_unreconcile_removes_partial_reconcile(self):
|
||||
line = self._make_statement_line(100.0)
|
||||
invoice = self._make_invoice(100.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
result = self.engine.reconcile_one(
|
||||
line, against_lines=receivable)
|
||||
self.assertTrue(result['partial_ids'],
|
||||
"reconcile_one should produce partial_ids")
|
||||
partials = self.env['account.partial.reconcile'].browse(
|
||||
result['partial_ids']).exists()
|
||||
self.assertTrue(partials)
|
||||
|
||||
out = self.engine.unreconcile(partials)
|
||||
|
||||
self.assertIn('unreconciled_line_ids', out)
|
||||
self.assertTrue(out['unreconciled_line_ids'])
|
||||
self.assertFalse(partials.exists(),
|
||||
"Partials should be deleted after unreconcile")
|
||||
receivable.invalidate_recordset(['reconciled', 'amount_residual'])
|
||||
self.assertFalse(receivable.reconciled)
|
||||
|
||||
def test_unreconcile_empty_recordset_returns_empty(self):
|
||||
empty = self.env['account.partial.reconcile']
|
||||
out = self.engine.unreconcile(empty)
|
||||
self.assertEqual(out, {'unreconciled_line_ids': []})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 3: reconcile_one happy path
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineReconcileOne(TestReconcileEngineBase):
|
||||
|
||||
def test_reconcile_one_simple_invoice_match(self):
|
||||
line = self._make_statement_line(250.0)
|
||||
invoice = self._make_invoice(250.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
self.assertFalse(receivable.reconciled)
|
||||
|
||||
result = self.engine.reconcile_one(
|
||||
line, against_lines=receivable)
|
||||
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertIn('partial_ids', result)
|
||||
self.assertIn('exchange_diff_move_id', result)
|
||||
self.assertIn('write_off_move_id', result)
|
||||
self.assertTrue(result['partial_ids'])
|
||||
|
||||
receivable.invalidate_recordset(['reconciled', 'amount_residual'])
|
||||
self.assertTrue(receivable.reconciled)
|
||||
self.assertAlmostEqual(receivable.amount_residual, 0.0, places=2)
|
||||
|
||||
def test_reconcile_one_creates_precedent(self):
|
||||
line = self._make_statement_line(125.0, ref='Engine REF#42')
|
||||
invoice = self._make_invoice(125.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
before = self.env['fusion.reconcile.precedent'].search_count([
|
||||
('partner_id', '=', self.partner.id),
|
||||
])
|
||||
self.engine.reconcile_one(line, against_lines=receivable)
|
||||
after = self.env['fusion.reconcile.precedent'].search_count([
|
||||
('partner_id', '=', self.partner.id),
|
||||
])
|
||||
self.assertEqual(after, before + 1)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 4: accept_suggestion
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineAcceptSuggestion(TestReconcileEngineBase):
|
||||
|
||||
def test_accept_suggestion_reconciles_and_marks_accepted(self):
|
||||
line = self._make_statement_line(310.0)
|
||||
invoice = self._make_invoice(310.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
sug = self.env['fusion.reconcile.suggestion'].create({
|
||||
'company_id': self.company.id,
|
||||
'statement_line_id': line.id,
|
||||
'proposed_move_line_ids': [(6, 0, receivable.ids)],
|
||||
'confidence': 0.97,
|
||||
'rank': 1,
|
||||
'reasoning': 'Exact amount match',
|
||||
'state': 'pending',
|
||||
})
|
||||
|
||||
result = self.engine.accept_suggestion(sug)
|
||||
|
||||
self.assertTrue(result['partial_ids'])
|
||||
self.assertEqual(sug.state, 'accepted')
|
||||
self.assertTrue(sug.accepted_at)
|
||||
self.assertEqual(sug.accepted_by, self.env.user)
|
||||
|
||||
def test_accept_suggestion_by_id(self):
|
||||
line = self._make_statement_line(75.0)
|
||||
invoice = self._make_invoice(75.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
sug = self.env['fusion.reconcile.suggestion'].create({
|
||||
'company_id': self.company.id,
|
||||
'statement_line_id': line.id,
|
||||
'proposed_move_line_ids': [(6, 0, receivable.ids)],
|
||||
'confidence': 0.91,
|
||||
'rank': 1,
|
||||
'reasoning': 'OK',
|
||||
'state': 'pending',
|
||||
})
|
||||
result = self.engine.accept_suggestion(sug.id)
|
||||
self.assertTrue(result['partial_ids'])
|
||||
self.assertEqual(sug.state, 'accepted')
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 5: suggest_matches
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineSuggestMatches(TestReconcileEngineBase):
|
||||
|
||||
def test_suggest_matches_persists_pending_suggestions(self):
|
||||
line = self._make_statement_line(420.0)
|
||||
invoice = self._make_invoice(420.0)
|
||||
# second open invoice for same partner — also a candidate
|
||||
self._make_invoice(99.0)
|
||||
|
||||
out = self.engine.suggest_matches(line)
|
||||
|
||||
self.assertIn(line.id, out)
|
||||
self.assertTrue(out[line.id])
|
||||
suggestions = self.env['fusion.reconcile.suggestion'].search([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
])
|
||||
self.assertTrue(suggestions)
|
||||
# Top suggestion should reference the matching invoice's receivable
|
||||
top = max(suggestions, key=lambda s: s.confidence)
|
||||
receivable = self._receivable_line(invoice)
|
||||
self.assertIn(receivable.id, top.proposed_move_line_ids.ids)
|
||||
|
||||
def test_suggest_matches_supersedes_prior_pending(self):
|
||||
line = self._make_statement_line(180.0)
|
||||
self._make_invoice(180.0)
|
||||
old_sug = self.env['fusion.reconcile.suggestion'].create({
|
||||
'company_id': self.company.id,
|
||||
'statement_line_id': line.id,
|
||||
'confidence': 0.5,
|
||||
'rank': 1,
|
||||
'reasoning': 'prior',
|
||||
'state': 'pending',
|
||||
})
|
||||
|
||||
self.engine.suggest_matches(line)
|
||||
|
||||
old_sug.invalidate_recordset(['state'])
|
||||
self.assertEqual(old_sug.state, 'superseded')
|
||||
|
||||
def test_suggest_matches_returns_empty_for_no_candidates(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Empty Partner'})
|
||||
line = self._make_statement_line(10.0, partner=partner)
|
||||
out = self.engine.suggest_matches(line)
|
||||
self.assertEqual(out, {})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 6: reconcile_batch
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineBatch(TestReconcileEngineBase):
|
||||
|
||||
def test_reconcile_batch_auto_strategy_matches_n_lines(self):
|
||||
amounts = [100.0, 200.0, 333.33]
|
||||
lines = self.env['account.bank.statement.line']
|
||||
for amt in amounts:
|
||||
invoice = self._make_invoice(amt)
|
||||
self.assertTrue(invoice)
|
||||
lines |= self._make_statement_line(amt, ref=f'BATCH-{amt}')
|
||||
|
||||
result = self.engine.reconcile_batch(lines, strategy='auto')
|
||||
|
||||
self.assertEqual(result['reconciled_count'], len(amounts))
|
||||
self.assertEqual(result['skipped'], 0)
|
||||
self.assertEqual(result['errors'], [])
|
||||
|
||||
def test_reconcile_batch_skips_already_reconciled(self):
|
||||
line = self._make_statement_line(50.0)
|
||||
invoice = self._make_invoice(50.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
self.engine.reconcile_one(line, against_lines=receivable)
|
||||
|
||||
result = self.engine.reconcile_batch(line, strategy='auto')
|
||||
self.assertEqual(result['reconciled_count'], 0)
|
||||
self.assertEqual(result['skipped'], 1)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 7: write_off
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineWriteOff(TestReconcileEngineBase):
|
||||
|
||||
def test_write_off_clears_bank_line(self):
|
||||
line = self._make_statement_line(40.0, ref='Bank fee')
|
||||
# No invoices exist; write off the whole amount to expense.
|
||||
result = self.engine.write_off(
|
||||
line,
|
||||
account=self.expense_account,
|
||||
amount=40.0,
|
||||
label='Bank fees',
|
||||
)
|
||||
self.assertIn('write_off_move_id', result)
|
||||
line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertTrue(line.is_reconciled)
|
||||
0
fusion_accounting_bank_rec/wizards/__init__.py
Normal file
0
fusion_accounting_bank_rec/wizards/__init__.py
Normal file
@@ -1 +1,6 @@
|
||||
from . import models
|
||||
|
||||
|
||||
def post_init_hook(env):
|
||||
"""Initialize coexistence group membership based on current Enterprise install state."""
|
||||
env['res.users']._fusion_recompute_coexistence_group()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting Core',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.0.2',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 24,
|
||||
'summary': 'Shared base for the Fusion Accounting sub-module suite (security, shared schema, runtime helpers).',
|
||||
@@ -30,4 +30,5 @@ Built by Nexa Systems Inc.
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'OPL-1',
|
||||
'post_init_hook': 'post_init_hook',
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from . import ir_module_module
|
||||
from . import res_users
|
||||
from . import account_move
|
||||
from . import account_reconcile_model
|
||||
from . import account_bank_statement_line
|
||||
|
||||
15
fusion_accounting_core/models/account_bank_statement_line.py
Normal file
15
fusion_accounting_core/models/account_bank_statement_line.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Shared-field-ownership for account.bank.statement.line.
|
||||
|
||||
Enterprise's account_accountant adds cron_last_check (timestamp of the last
|
||||
auto-reconcile cron run for the line). By declaring it here with the same
|
||||
schema, fusion_accounting_core becomes a co-owner so the column persists
|
||||
when account_accountant uninstalls.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountBankStatementLine(models.Model):
|
||||
_inherit = "account.bank.statement.line"
|
||||
|
||||
cron_last_check = fields.Datetime(copy=False)
|
||||
@@ -30,3 +30,26 @@ class IrModuleModule(models.Model):
|
||||
('name', '=', module_name),
|
||||
('state', '=', 'installed'),
|
||||
]))
|
||||
|
||||
def button_immediate_install(self):
|
||||
"""Recompute the coexistence group after install state changes."""
|
||||
result = super().button_immediate_install()
|
||||
self.env['res.users']._fusion_recompute_coexistence_group()
|
||||
return result
|
||||
|
||||
def button_immediate_uninstall(self):
|
||||
"""Recompute the coexistence group after uninstall state changes.
|
||||
|
||||
The MRO chains into fusion_accounting_migration's override (which runs
|
||||
the safety guard before calling super); we recompute only after the
|
||||
whole chain completes.
|
||||
"""
|
||||
result = super().button_immediate_uninstall()
|
||||
self.env['res.users']._fusion_recompute_coexistence_group()
|
||||
return result
|
||||
|
||||
def module_uninstall(self):
|
||||
"""Recompute the coexistence group after the lower-level uninstall."""
|
||||
result = super().module_uninstall()
|
||||
self.env['res.users']._fusion_recompute_coexistence_group()
|
||||
return result
|
||||
|
||||
27
fusion_accounting_core/models/res_users.py
Normal file
27
fusion_accounting_core/models/res_users.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Coexistence group membership recomputation."""
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = "res.users"
|
||||
|
||||
@api.model
|
||||
def _fusion_recompute_coexistence_group(self):
|
||||
"""Set group membership = all internal users iff Enterprise absent.
|
||||
|
||||
Called from ir.module.module.button_immediate_install / uninstall
|
||||
overrides. Idempotent; safe to call multiple times.
|
||||
"""
|
||||
group = self.env.ref(
|
||||
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not group:
|
||||
return
|
||||
enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed()
|
||||
if enterprise_installed:
|
||||
group.sudo().write({'user_ids': [(5, 0, 0)]})
|
||||
else:
|
||||
all_internal = self.sudo().search([('share', '=', False)])
|
||||
group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]})
|
||||
@@ -43,4 +43,10 @@
|
||||
<record id="account.group_account_manager" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Phase 1: dynamic coexistence group -->
|
||||
<record id="group_fusion_show_when_enterprise_absent" model="res.groups">
|
||||
<field name="name">Fusion: Show menus when Enterprise absent</field>
|
||||
<field name="comment">Computed group. Membership: all internal users when no Enterprise accounting module is installed. Used to hide fusion sub-module menus that would conflict with Enterprise UIs.</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
from . import test_enterprise_detection
|
||||
from . import test_shared_field_ownership
|
||||
from . import test_shared_field_bank_statement
|
||||
from . import test_coexistence_group
|
||||
|
||||
46
fusion_accounting_core/tests/test_coexistence_group.py
Normal file
46
fusion_accounting_core/tests/test_coexistence_group.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestCoexistenceGroup(TransactionCase):
|
||||
"""The 'show when Enterprise absent' group must exist and have computed membership."""
|
||||
|
||||
def test_group_exists(self):
|
||||
group = self.env.ref(
|
||||
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
self.assertTrue(group, "Coexistence group must exist")
|
||||
|
||||
def test_membership_matches_enterprise_state(self):
|
||||
"""A user is in the group iff Enterprise accounting is NOT installed.
|
||||
|
||||
We can't toggle Enterprise mid-test, so just assert the current state
|
||||
matches: if Enterprise is installed, group should have 0 members; if
|
||||
not, the group should include all internal users.
|
||||
"""
|
||||
group = self.env.ref(
|
||||
'fusion_accounting_core.group_fusion_show_when_enterprise_absent'
|
||||
)
|
||||
enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed()
|
||||
all_internal = self.env['res.users'].sudo().search([('share', '=', False)])
|
||||
if enterprise_installed:
|
||||
self.assertEqual(
|
||||
len(group.user_ids), 0,
|
||||
"Enterprise installed -> coexistence group should be empty",
|
||||
)
|
||||
else:
|
||||
self.assertEqual(
|
||||
set(group.user_ids.ids), set(all_internal.ids),
|
||||
"Enterprise absent -> coexistence group should contain all internal users",
|
||||
)
|
||||
|
||||
def test_recompute_method_exists(self):
|
||||
"""The recompute helper must be callable on res.users."""
|
||||
self.assertTrue(
|
||||
callable(getattr(
|
||||
self.env['res.users'],
|
||||
'_fusion_recompute_coexistence_group',
|
||||
None,
|
||||
))
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSharedFieldBankStatementLine(TransactionCase):
|
||||
"""Verify fusion_accounting_core declares the Enterprise extension fields
|
||||
on account.bank.statement.line so they survive Enterprise uninstall."""
|
||||
|
||||
def test_cron_last_check_field_exists(self):
|
||||
Line = self.env['account.bank.statement.line']
|
||||
self.assertIn('cron_last_check', Line._fields,
|
||||
"cron_last_check must be declared on account.bank.statement.line "
|
||||
"(shared-field-ownership with account_accountant)")
|
||||
self.assertEqual(Line._fields['cron_last_check'].type, 'datetime')
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
"name": "Fusion Plating — MRP Bridge",
|
||||
'version': '19.0.6.4.0',
|
||||
'version': '19.0.6.5.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||
'description': """
|
||||
|
||||
@@ -469,6 +469,40 @@ class MrpProduction(models.Model):
|
||||
# Auto-assign recipe BEFORE super() so work-order generation sees it
|
||||
self._auto_assign_recipe_from_so()
|
||||
|
||||
# Auto-derive facility (where the job runs) so x_fc_facility_id is
|
||||
# never empty downstream — it's compliance-critical (AS9100 §7.1.4
|
||||
# "infrastructure"). Order: explicit value > SO override >
|
||||
# company default > first active facility.
|
||||
for mo in self:
|
||||
if mo.x_fc_facility_id:
|
||||
continue
|
||||
facility = False
|
||||
if mo.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
if so and 'x_fc_facility_id' in so._fields:
|
||||
facility = so.x_fc_facility_id
|
||||
if not facility:
|
||||
facility = mo.company_id.x_fc_default_facility_id
|
||||
if not facility:
|
||||
facility = self.env['fusion.plating.facility'].search(
|
||||
[('active', '=', True)], limit=1,
|
||||
)
|
||||
if facility:
|
||||
mo.x_fc_facility_id = facility.id
|
||||
|
||||
# Hard gate: MO can't be confirmed without a facility — without
|
||||
# this, every downstream record (WO, batch, bath log, cert) is
|
||||
# missing the "where" half of "what was made where by whom".
|
||||
for mo in self:
|
||||
if not mo.x_fc_facility_id:
|
||||
raise UserError(_(
|
||||
'Cannot confirm MO "%s" — no plating facility set.\n\n'
|
||||
'Set the facility on the MO, or configure a default '
|
||||
'in Settings → Companies → Fusion Plating Defaults.'
|
||||
) % (mo.name or mo.display_name))
|
||||
|
||||
res = super().action_confirm()
|
||||
PortalJob = self.env['fusion.plating.portal.job']
|
||||
for mo in self:
|
||||
|
||||
@@ -51,10 +51,24 @@ class MrpWorkorder(models.Model):
|
||||
string='Thickness Unit', default='mils',
|
||||
)
|
||||
x_fc_dwell_time_minutes = fields.Float(string='Dwell Time (min)')
|
||||
# Falls back to the MO's facility when the workcenter has none —
|
||||
# most stub workcenters auto-created from process node names don't
|
||||
# have facility_id, but the MO always does (enforced at confirm).
|
||||
x_fc_facility_id = fields.Many2one(
|
||||
'fusion.plating.facility', string='Facility',
|
||||
related='workcenter_id.x_fc_facility_id', store=True, readonly=True,
|
||||
compute='_compute_facility_id', store=True, readonly=False,
|
||||
help='Plating facility where this WO runs. Falls back to the '
|
||||
'MO\'s facility when the workcenter has none.',
|
||||
)
|
||||
|
||||
@api.depends('workcenter_id.x_fc_facility_id', 'production_id.x_fc_facility_id')
|
||||
def _compute_facility_id(self):
|
||||
for wo in self:
|
||||
wo.x_fc_facility_id = (
|
||||
wo.workcenter_id.x_fc_facility_id
|
||||
or wo.production_id.x_fc_facility_id
|
||||
or wo.x_fc_facility_id
|
||||
)
|
||||
x_fc_workcenter_cost_hour = fields.Float(
|
||||
string='Station Rate ($/hr)',
|
||||
related='workcenter_id.costs_hour', readonly=True,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.3.0.0',
|
||||
'version': '19.0.3.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -267,6 +267,16 @@ class FpCertificate(models.Model):
|
||||
for rec in self:
|
||||
if rec.state != 'draft':
|
||||
raise UserError(_('Only draft certificates can be issued.'))
|
||||
# Spec reference is what the cert ATTESTS — without it the
|
||||
# cert is just a piece of paper. AS9100 / Nadcap require
|
||||
# naming the spec the work was performed to.
|
||||
if not rec.spec_reference:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — no Spec '
|
||||
'Reference set.\n\nFill the Spec Reference field '
|
||||
'(e.g. "AMS 2404", "MIL-C-26074") so the cert '
|
||||
'states which standard the work meets.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
rec.state = 'issued'
|
||||
rec.message_post(body=_('Certificate issued.'))
|
||||
|
||||
|
||||
@@ -45,7 +45,13 @@ class FpThicknessReading(models.Model):
|
||||
string='Product Ref', help='e.g. "2805031 / NiP/Al-alloys 2805030"',
|
||||
)
|
||||
calibration_std_ref = fields.Char(
|
||||
string='Calibration Std', help='e.g. "NiP/Al STD SET SN 100174568"',
|
||||
string='Calibration Std',
|
||||
required=True,
|
||||
default='NiP/Al STD SET SN 100174568',
|
||||
help='Nadcap mandatory: which calibration standard the gauge '
|
||||
'was checked against. Defaults to the shop\'s primary '
|
||||
'standard but should be overridden if a different std '
|
||||
'was used for this reading.',
|
||||
)
|
||||
microscope_image_id = fields.Many2one(
|
||||
'ir.attachment', string='Microscope Image',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating - Compliance (Framework)',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Jurisdiction-agnostic compliance framework: permits, discharge monitoring, waste manifests, pollutant inventory, compliance calendar, spill register.',
|
||||
'description': 'Generic compliance framework. Region packs load jurisdiction-specific data.',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpDischargeSample(models.Model):
|
||||
@@ -63,4 +64,32 @@ class FpDischargeSample(models.Model):
|
||||
self.write({'state': 'escalated'})
|
||||
|
||||
def action_close(self):
|
||||
"""Block close until lab evidence is on file.
|
||||
|
||||
A closed discharge sample without a lab report ref + at least
|
||||
one parameter reading + (when results are in) a lab cert
|
||||
attachment fails any environmental audit. The whole point
|
||||
of the record is to document the test was performed and what
|
||||
the lab said.
|
||||
"""
|
||||
for rec in self:
|
||||
missing = []
|
||||
if not rec.lab_report_ref:
|
||||
missing.append(_('Lab Report #'))
|
||||
if not rec.received_date:
|
||||
missing.append(_('Results Received Date'))
|
||||
if not rec.line_ids:
|
||||
missing.append(_('At least one parameter reading'))
|
||||
if not rec.attachment_ids:
|
||||
missing.append(_('Lab certificate / report attachment'))
|
||||
if missing:
|
||||
raise UserError(_(
|
||||
'Cannot close discharge sample "%(name)s" — these '
|
||||
'fields must be filled in first:\n • %(fields)s\n\n'
|
||||
'Without lab evidence on file the record fails any '
|
||||
'environmental compliance audit.'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'fields': '\n • '.join(missing),
|
||||
})
|
||||
self.write({'state': 'closed'})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Invoicing',
|
||||
'version': '19.0.2.0.0',
|
||||
'version': '19.0.2.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
|
||||
'description': """
|
||||
|
||||
@@ -3,15 +3,56 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import models, _
|
||||
from odoo import api, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Auto-inherit payment terms + customer PO# at creation time.
|
||||
|
||||
Two defensive defaults so newly-created invoices come out
|
||||
compliant out of the box:
|
||||
|
||||
1. **invoice_payment_term_id** — pulled from the customer's
|
||||
property_payment_term_id (Net-30, COD, etc.). Without this
|
||||
the due date silently becomes "immediate", wrong for B2B.
|
||||
|
||||
2. **ref** (customer reference / PO#) — pulled from the source
|
||||
sale order's client_order_ref or x_fc_po_number. Customer
|
||||
AP teams reject invoices that don't quote their PO# back.
|
||||
We already populate this on the SO confirm path, but a
|
||||
manually-created invoice would miss it without this default.
|
||||
"""
|
||||
Partner = self.env['res.partner']
|
||||
SO = self.env['sale.order']
|
||||
for vals in vals_list:
|
||||
if vals.get('move_type') in ('out_invoice', 'out_refund'):
|
||||
if not vals.get('invoice_payment_term_id') and vals.get('partner_id'):
|
||||
partner = Partner.browse(vals['partner_id'])
|
||||
if partner.property_payment_term_id:
|
||||
vals['invoice_payment_term_id'] = partner.property_payment_term_id.id
|
||||
# Defensive PO#: invoice_origin links to the SO; pull the
|
||||
# customer ref from there if the caller didn't pass one.
|
||||
if not vals.get('ref') and vals.get('invoice_origin'):
|
||||
so = SO.search([('name', '=', vals['invoice_origin'])], limit=1)
|
||||
if so:
|
||||
vals['ref'] = (
|
||||
so.client_order_ref
|
||||
or (so.x_fc_po_number if 'x_fc_po_number' in so._fields else False)
|
||||
or False
|
||||
)
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_post(self):
|
||||
"""Check account hold before posting invoices."""
|
||||
"""Block post when:
|
||||
• customer is on account hold (existing rule), or
|
||||
• the invoice has no payment term (auto-fill missed it AND
|
||||
partner had no default — accountant must pick one).
|
||||
"""
|
||||
for move in self:
|
||||
if move.move_type in ('out_invoice', 'out_refund') and move.partner_id:
|
||||
if move.partner_id.x_fc_account_hold:
|
||||
@@ -25,4 +66,11 @@ class AccountMove(models.Model):
|
||||
'Contact a manager to override.'
|
||||
) % (move.partner_id.name,
|
||||
move.partner_id.x_fc_account_hold_reason or 'No reason specified'))
|
||||
if not move.invoice_payment_term_id:
|
||||
raise UserError(_(
|
||||
'Cannot post invoice "%s" — no payment terms set.\n\n'
|
||||
'Pick payment terms (Net-30, COD, etc.) on the invoice, '
|
||||
'or set a default on the customer "%s" so future '
|
||||
'invoices inherit it automatically.'
|
||||
) % (move.name or move.display_name, move.partner_id.name))
|
||||
return super().action_post()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Logistics',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': (
|
||||
'Pickup & delivery for plating shops: vehicle master, driver '
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpDelivery(models.Model):
|
||||
@@ -169,7 +170,21 @@ class FpDelivery(models.Model):
|
||||
)
|
||||
|
||||
def action_mark_delivered(self):
|
||||
"""Block "delivered" until a Proof of Delivery exists.
|
||||
|
||||
The driver must capture POD (signature, photos, recipient name)
|
||||
on the iPad at the customer's dock BEFORE marking delivered.
|
||||
Without POD we have no signed receipt to attach to the
|
||||
invoice and no defence against a delivery dispute.
|
||||
"""
|
||||
for rec in self:
|
||||
if not rec.pod_id:
|
||||
raise UserError(_(
|
||||
'Cannot mark delivery "%(name)s" delivered — no Proof '
|
||||
'of Delivery (POD) has been captured.\n\n'
|
||||
'On the iPad: Capture POD → enter recipient name + '
|
||||
'signature → save. Then mark delivered.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
rec.write({
|
||||
'state': 'delivered',
|
||||
'delivered_at': fields.Datetime.now(),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Quality (QMS)',
|
||||
'version': '19.0.1.1.0',
|
||||
'version': '19.0.1.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
|
||||
'internal audits, customer specs, document control. CE + EE compatible.',
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpCapa(models.Model):
|
||||
@@ -160,6 +161,43 @@ class FpCapa(models.Model):
|
||||
})
|
||||
|
||||
def action_close(self):
|
||||
"""Block close unless root_cause + action_plan + verification are set.
|
||||
|
||||
A CAPA without these is just an open ticket — the AS9100 §10.2
|
||||
/ Nadcap loop requires evidence of the root cause analysis,
|
||||
the corrective/preventive action plan, AND that effectiveness
|
||||
was verified before the loop is closed.
|
||||
"""
|
||||
for rec in self:
|
||||
missing = []
|
||||
|
||||
def is_empty_html(val):
|
||||
if not val:
|
||||
return True
|
||||
s = str(val).replace('<p>', '').replace('</p>', '')
|
||||
s = s.replace('<br>', '').replace('<br/>', '').strip()
|
||||
return not s
|
||||
|
||||
if is_empty_html(rec.root_cause_analysis):
|
||||
missing.append(_('Root Cause Analysis'))
|
||||
if is_empty_html(rec.action_plan):
|
||||
missing.append(_('Action Plan'))
|
||||
if not rec.verification_date or not rec.verification_by_id:
|
||||
missing.append(_('Verification (date + verifier)'))
|
||||
if rec.is_effective is False and is_empty_html(rec.effectiveness_notes):
|
||||
# If marked not-effective, demand a note explaining the
|
||||
# follow-up plan — otherwise the loop never actually closes.
|
||||
missing.append(_('Effectiveness Notes (required when "Not Effective")'))
|
||||
if missing:
|
||||
raise UserError(_(
|
||||
'Cannot close CAPA "%(name)s" — these fields must be '
|
||||
'filled in first:\n • %(fields)s\n\n'
|
||||
'A CAPA without root cause + action plan + verified '
|
||||
'effectiveness fails AS9100 §10.2 / Nadcap on audit.'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'fields': '\n • '.join(missing),
|
||||
})
|
||||
self.write({'state': 'closed'})
|
||||
|
||||
def action_reset_to_draft(self):
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpNcr(models.Model):
|
||||
@@ -156,6 +157,42 @@ class FpNcr(models.Model):
|
||||
self.write({'state': 'disposition'})
|
||||
|
||||
def action_close(self):
|
||||
"""Block close unless root_cause + containment + disposition are set.
|
||||
|
||||
A closed NCR without these three is useless for AS9100 audits:
|
||||
the whole point of the NCR is to document what went wrong
|
||||
(containment), why (root_cause), and what we decided to do
|
||||
with the affected parts (disposition).
|
||||
"""
|
||||
for rec in self:
|
||||
missing = []
|
||||
# Strip HTML-empty strings like "<p><br></p>" before checking
|
||||
def is_empty_html(val):
|
||||
if not val:
|
||||
return True
|
||||
s = str(val).replace('<p>', '').replace('</p>', '')
|
||||
s = s.replace('<br>', '').replace('<br/>', '').strip()
|
||||
return not s
|
||||
|
||||
if is_empty_html(rec.description):
|
||||
missing.append(_('Description'))
|
||||
if is_empty_html(rec.containment):
|
||||
missing.append(_('Containment Actions'))
|
||||
if is_empty_html(rec.root_cause):
|
||||
missing.append(_('Root Cause'))
|
||||
if not rec.disposition:
|
||||
missing.append(_('Disposition (use-as-is / rework / scrap / RTV)'))
|
||||
if missing:
|
||||
raise UserError(_(
|
||||
'Cannot close NCR "%(name)s" — these fields must be '
|
||||
'filled in first:\n • %(fields)s\n\n'
|
||||
'AS9100 / Nadcap auditors will reject a closed NCR '
|
||||
'that doesn\'t document what happened, why, and how '
|
||||
'we responded.'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'fields': '\n • '.join(missing),
|
||||
})
|
||||
self.write({
|
||||
'state': 'closed',
|
||||
'closed_date': fields.Datetime.now(),
|
||||
|
||||
@@ -114,6 +114,18 @@ customer = env['res.partner'].sudo().create({
|
||||
'city': 'Toronto', 'zip': 'M5G 1V7',
|
||||
'country_id': env.ref('base.ca').id,
|
||||
})
|
||||
# Net-30 default so invoices created later inherit the right schedule.
|
||||
net30 = env.ref('account.account_payment_term_30days', raise_if_not_found=False)
|
||||
if net30:
|
||||
customer.sudo().property_payment_term_id = net30.id
|
||||
|
||||
# Make sure the company has a default facility so MO confirm succeeds.
|
||||
co = env.company
|
||||
if not co.x_fc_default_facility_id:
|
||||
f = env['fusion.plating.facility'].search([('active', '=', True)], limit=1)
|
||||
if f:
|
||||
co.sudo().x_fc_default_facility_id = f.id
|
||||
show('company default facility set', f.name)
|
||||
|
||||
step('SANDRA', f'Receives RFQ from {customer.name}')
|
||||
|
||||
@@ -336,6 +348,199 @@ if wet_assignments:
|
||||
'x_fc_tank_id': saved_tank,
|
||||
})
|
||||
|
||||
# ===== Negative tests for the 6 new gates (wrapped in savepoints
|
||||
# so an SQL-level constraint failure doesn't abort the txn) =====
|
||||
banner('PHASE 4c — Negative tests for the new compliance gates')
|
||||
|
||||
|
||||
def neg_test(label, fn, expect_keywords):
|
||||
"""Run fn() inside a savepoint; check the raised error mentions
|
||||
one of `expect_keywords`. Always rolls back."""
|
||||
sp_name = f'neg_{abs(hash(label))}'
|
||||
env.cr.execute(f'SAVEPOINT {sp_name}')
|
||||
fired = False
|
||||
msg = ''
|
||||
try:
|
||||
fn()
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
low = msg.lower()
|
||||
fired = any(k.lower() in low for k in expect_keywords)
|
||||
finally:
|
||||
env.cr.execute(f'ROLLBACK TO SAVEPOINT {sp_name}')
|
||||
if msg:
|
||||
show(' blocked with', msg.splitlines()[0][:120])
|
||||
finding('PASS' if fired else 'FAIL',
|
||||
f'gate: {label}',
|
||||
'blocked' if fired else f'NOT blocked (got: {msg[:60]!r})')
|
||||
|
||||
|
||||
# Test 3: MO confirm without facility → expect block
|
||||
step('SYSTEM', 'Test 3 — MO confirm with no facility → blocked')
|
||||
|
||||
|
||||
def t_mo_facility():
|
||||
saved_default = env.company.x_fc_default_facility_id
|
||||
env.company.sudo().x_fc_default_facility_id = False
|
||||
fac0 = env['fusion.plating.facility'].search([('active', '=', True)])
|
||||
fac0.sudo().write({'active': False})
|
||||
try:
|
||||
m = env['mrp.production'].sudo().create({
|
||||
'product_id': mo.product_id.id,
|
||||
'product_qty': 1,
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
m.action_confirm() # should raise — no facility resolvable
|
||||
finally:
|
||||
fac0.sudo().write({'active': True})
|
||||
env.company.sudo().x_fc_default_facility_id = saved_default
|
||||
|
||||
|
||||
neg_test('MO confirm without facility', t_mo_facility,
|
||||
['facility'])
|
||||
|
||||
# Test 4: Cert issue without spec_reference
|
||||
step('SYSTEM', 'Test 4 — Cert action_issue() without spec_reference → blocked')
|
||||
|
||||
|
||||
def t_cert_spec():
|
||||
c = env['fp.certificate'].sudo().create({
|
||||
'partner_id': customer.id,
|
||||
'production_id': mo.id,
|
||||
'certificate_type': 'coc',
|
||||
'spec_reference': False,
|
||||
})
|
||||
c.action_issue()
|
||||
|
||||
|
||||
neg_test('cert issue without spec_reference', t_cert_spec,
|
||||
['Spec', 'spec_reference'])
|
||||
|
||||
# Test 5: Delivery mark_delivered without POD
|
||||
step('SYSTEM', 'Test 5 — Delivery mark_delivered() with no POD → blocked')
|
||||
|
||||
|
||||
def t_dlv_pod():
|
||||
d = env['fusion.plating.delivery'].sudo().create({
|
||||
'partner_id': customer.id,
|
||||
'state': 'en_route',
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
d.action_mark_delivered()
|
||||
|
||||
|
||||
neg_test('delivery delivered without POD', t_dlv_pod,
|
||||
['POD', 'Proof of Delivery'])
|
||||
|
||||
# Test 6: Invoice post without payment terms
|
||||
step('SYSTEM', 'Test 6 — Invoice post() with no payment terms → blocked')
|
||||
|
||||
|
||||
def t_inv_terms():
|
||||
saved_term = customer.property_payment_term_id
|
||||
customer.sudo().property_payment_term_id = False
|
||||
try:
|
||||
i = env['account.move'].sudo().create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': customer.id,
|
||||
'invoice_date': fields.Date.today(),
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Test plating service',
|
||||
'quantity': 1,
|
||||
'price_unit': 100.0,
|
||||
})],
|
||||
})
|
||||
i.invoice_payment_term_id = False
|
||||
i.action_post()
|
||||
finally:
|
||||
customer.sudo().property_payment_term_id = saved_term
|
||||
|
||||
|
||||
neg_test('invoice post without payment terms', t_inv_terms,
|
||||
['payment term'])
|
||||
|
||||
# Test 7: Thickness reading without calibration_std_ref
|
||||
step('SYSTEM', 'Test 7 — Thickness reading without calibration_std_ref → blocked')
|
||||
|
||||
|
||||
def t_thickness_cal():
|
||||
env['fp.thickness.reading'].sudo().create({
|
||||
'production_id': mo.id,
|
||||
'reading_number': 99,
|
||||
'nip_mils': 0.05,
|
||||
'calibration_std_ref': False,
|
||||
})
|
||||
|
||||
|
||||
neg_test('thickness reading without cal std', t_thickness_cal,
|
||||
['calibration', 'required', 'not-null', 'null value'])
|
||||
|
||||
# Test 8: NCR close without root cause / containment / disposition
|
||||
step('SYSTEM', 'Test 8 — NCR close() with missing fields → blocked')
|
||||
|
||||
|
||||
def t_ncr_close():
|
||||
f = env['fusion.plating.facility'].search([], limit=1)
|
||||
n = env['fusion.plating.ncr'].sudo().create({
|
||||
'facility_id': f.id,
|
||||
'description': '',
|
||||
'containment': '',
|
||||
'root_cause': '',
|
||||
'disposition': False,
|
||||
})
|
||||
n.action_close()
|
||||
|
||||
|
||||
neg_test('NCR close without RC/containment/disposition', t_ncr_close,
|
||||
['Root Cause', 'Containment', 'Disposition'])
|
||||
|
||||
# Test 9: CAPA close without root cause analysis / action plan / verification
|
||||
step('SYSTEM', 'Test 9 — CAPA close() with missing fields → blocked')
|
||||
|
||||
|
||||
def t_capa_close():
|
||||
c = env['fusion.plating.capa'].sudo().create({
|
||||
'description': '',
|
||||
'root_cause_analysis': '',
|
||||
'action_plan': '',
|
||||
})
|
||||
c.action_close()
|
||||
|
||||
|
||||
neg_test('CAPA close without analysis/plan/verification', t_capa_close,
|
||||
['Root Cause Analysis', 'Action Plan', 'Verification'])
|
||||
|
||||
# Test 10: Discharge sample close without lab evidence
|
||||
step('SYSTEM', 'Test 10 — Discharge sample close() with no lab evidence → blocked')
|
||||
|
||||
|
||||
def t_discharge_close():
|
||||
f = env['fusion.plating.facility'].search([], limit=1)
|
||||
s = env['fusion.plating.discharge.sample'].sudo().create({
|
||||
'facility_id': f.id,
|
||||
})
|
||||
s.action_close()
|
||||
|
||||
|
||||
neg_test('discharge sample close without lab evidence', t_discharge_close,
|
||||
['Lab Report', 'Results Received', 'parameter', 'Lab certificate'])
|
||||
|
||||
# Test 11: Invoice ref auto-fill from SO at create time
|
||||
step('SYSTEM', 'Test 11 — Invoice ref auto-fills from SO.client_order_ref')
|
||||
test_inv2 = env['account.move'].sudo().create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': customer.id,
|
||||
'invoice_date': fields.Date.today(),
|
||||
'invoice_origin': so.name,
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Test', 'quantity': 1, 'price_unit': 1.0,
|
||||
})],
|
||||
})
|
||||
finding('PASS' if test_inv2.ref == so.client_order_ref else 'FAIL',
|
||||
'invoice ref auto-fills from SO',
|
||||
f'ref={test_inv2.ref!r} (expected {so.client_order_ref!r})')
|
||||
test_inv2.sudo().unlink()
|
||||
|
||||
# =====================================================================
|
||||
banner('PHASE 5 — Operators run their work orders (REAL-TIME timers)')
|
||||
# =====================================================================
|
||||
@@ -514,9 +719,26 @@ if dlv:
|
||||
try:
|
||||
if dlv.state == 'draft': dlv.with_user(users['dave']).sudo().action_schedule()
|
||||
if dlv.state == 'scheduled': dlv.with_user(users['dave']).sudo().action_start_route()
|
||||
# POD must be captured BEFORE marking delivered (new gate)
|
||||
if dlv.state == 'en_route' and not dlv.pod_id:
|
||||
step('DAVE', 'Captures POD on iPad — recipient signs + photo')
|
||||
POD = env['fusion.plating.proof.of.delivery']
|
||||
pod = POD.with_user(users['dave']).sudo().create({
|
||||
'delivery_id': dlv.id,
|
||||
'partner_id': dlv.partner_id.id,
|
||||
'recipient_name': 'Dock Receiver',
|
||||
'notes': 'E2E sim — recipient on dock signed for parts',
|
||||
})
|
||||
dlv.sudo().pod_id = pod.id
|
||||
show(' POD captured', f'{pod.name} (id={pod.id})')
|
||||
if dlv.state == 'en_route': dlv.with_user(users['dave']).sudo().action_mark_delivered()
|
||||
except Exception as e:
|
||||
print(f' [info] delivery transitions: {e}')
|
||||
|
||||
# ===== Negative test: try to mark another delivery delivered without POD =====
|
||||
finding('PASS' if dlv.pod_id else 'FAIL',
|
||||
'POD captured before delivery',
|
||||
f'pod_id={dlv.pod_id.name if dlv.pod_id else "NONE"}')
|
||||
finding('PASS' if dlv.state == 'delivered' else 'FAIL',
|
||||
'delivery final state', dlv.state)
|
||||
coc_logs = env['fusion.plating.chain.of.custody'].search(
|
||||
|
||||
338
fusion_plating/scripts/fp_required_fields_audit.py
Normal file
338
fusion_plating/scripts/fp_required_fields_audit.py
Normal file
@@ -0,0 +1,338 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Comprehensive required-fields audit.
|
||||
|
||||
For each major model in the quote → invoice workflow:
|
||||
• Lists fields currently marked `required=True` in the schema
|
||||
• For the most recent COMPLETED record, shows which compliance-
|
||||
relevant fields are empty (gap candidates)
|
||||
• Classifies each gap by severity:
|
||||
CRITICAL — compliance blocker (aerospace / Nadcap / env.)
|
||||
IMPORTANT — workflow / operational risk
|
||||
NICE — would improve reporting
|
||||
|
||||
The report is purely diagnostic — it changes nothing in the DB.
|
||||
"""
|
||||
env = env # noqa
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def section(title):
|
||||
print(f'\n{"="*78}\n {title}\n{"="*78}')
|
||||
|
||||
|
||||
def show_field_audit(model_name, record, candidate_fields):
|
||||
"""For one record, show which of `candidate_fields` are empty.
|
||||
|
||||
candidate_fields: list of (field, severity, reason) tuples
|
||||
"""
|
||||
if not record:
|
||||
print(f' (no record found for {model_name})')
|
||||
return
|
||||
print(f' Record: {record.display_name} (id={record.id})')
|
||||
# First show what's currently required in the schema
|
||||
required_in_schema = [
|
||||
n for n, f in record._fields.items()
|
||||
if getattr(f, 'required', False)
|
||||
]
|
||||
print(f' Already required in schema: {len(required_in_schema)}')
|
||||
|
||||
print(f' Candidate fields needing enforcement:')
|
||||
for field, severity, reason in candidate_fields:
|
||||
if field not in record._fields:
|
||||
continue
|
||||
val = record[field]
|
||||
is_empty = (
|
||||
not val
|
||||
or (hasattr(val, '_name') and not val.ids)
|
||||
or val in ('', False, 0, 0.0)
|
||||
)
|
||||
sym = {'CRITICAL': '🔴', 'IMPORTANT': '🟡', 'NICE': '⚪'}[severity]
|
||||
marker = '✗ EMPTY' if is_empty else '✓ filled'
|
||||
val_str = str(val)[:60] if not is_empty else '—'
|
||||
print(f' {sym} {severity:<9} {field:<32} {marker:<10} {reason}')
|
||||
print(f' currently: {val_str!r}')
|
||||
|
||||
|
||||
# =====================================================================
|
||||
section('1. Customer (res.partner) — most recently used customer')
|
||||
# =====================================================================
|
||||
|
||||
partner = env['sale.order'].search([], order='id desc', limit=1).partner_id
|
||||
show_field_audit('res.partner', partner, [
|
||||
('email', 'CRITICAL', 'Notifications + portal access — silent fail without it'),
|
||||
('phone', 'IMPORTANT', 'Operator can call for clarification'),
|
||||
('street', 'CRITICAL', 'Required on BoL + Invoice + delivery — no shipping without'),
|
||||
('city', 'CRITICAL', 'Same'),
|
||||
('zip', 'CRITICAL', 'Same'),
|
||||
('country_id', 'CRITICAL', 'Determines tax + ITAR / CGP rules'),
|
||||
('vat', 'IMPORTANT', 'HST/GST registration number — needed on invoice'),
|
||||
('property_payment_term_id', 'IMPORTANT', 'Net-30 vs Net-60 controls invoice due date'),
|
||||
('x_fc_account_hold', 'NICE', 'Default False is fine; only set when collections issue'),
|
||||
('x_fc_send_coc', 'NICE', 'Per-customer CoC delivery preference'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('2. Sale Order (sale.order) — most recent SO')
|
||||
# =====================================================================
|
||||
|
||||
so = env['sale.order'].search([], order='id desc', limit=1)
|
||||
show_field_audit('sale.order', so, [
|
||||
('partner_id', 'CRITICAL', 'Already required by Odoo'),
|
||||
('client_order_ref', 'CRITICAL', 'Customer PO# — every aero customer requires this on every doc'),
|
||||
('x_fc_po_number', 'CRITICAL', 'Same — FP-specific mirror'),
|
||||
('x_fc_coating_config_id', 'CRITICAL', 'Drives recipe + price + spec'),
|
||||
('x_fc_part_catalog_id', 'IMPORTANT', 'Part the order is about — needed for traceability'),
|
||||
('x_fc_delivery_method', 'IMPORTANT', 'Pickup / drop / courier — drives logistics'),
|
||||
('x_fc_rfq_attachment_id', 'NICE', 'Original customer RFQ for audit trail'),
|
||||
('x_fc_po_attachment_id', 'IMPORTANT', 'Customer signed PO PDF'),
|
||||
('payment_term_id', 'IMPORTANT', 'Net terms — derived from customer if unset'),
|
||||
('user_id', 'IMPORTANT', 'Salesperson — needed for commission + handoff'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('3. Receiving (fp.receiving) — most recent record')
|
||||
# =====================================================================
|
||||
|
||||
recv = env['fp.receiving'].search([], order='id desc', limit=1)
|
||||
show_field_audit('fp.receiving', recv, [
|
||||
('sale_order_id', 'CRITICAL', 'Without this we lose the link to the job'),
|
||||
('partner_id', 'CRITICAL', 'Customer (related, but can drift)'),
|
||||
('received_by_id', 'CRITICAL', 'Who counted the parts (audit trail)'),
|
||||
('received_date', 'CRITICAL', 'When the parts arrived (compliance + start-clock)'),
|
||||
('expected_qty', 'CRITICAL', 'Without this no qty-match check'),
|
||||
('received_qty', 'CRITICAL', 'The actual count (compliance — discrepancy log)'),
|
||||
('carrier_name', 'IMPORTANT', 'Who delivered — chain-of-custody starts here'),
|
||||
('carrier_tracking', 'IMPORTANT', 'Inbound tracking #'),
|
||||
('notes', 'NICE', 'Free-form receiver observations'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('4. MRP Production (mrp.production) — most recent MO')
|
||||
# =====================================================================
|
||||
|
||||
mo = env['mrp.production'].search([('state', '=', 'done')], order='id desc', limit=1)
|
||||
show_field_audit('mrp.production', mo, [
|
||||
('product_id', 'CRITICAL', 'Already required by Odoo'),
|
||||
('product_qty', 'CRITICAL', 'Same'),
|
||||
('x_fc_facility_id', 'CRITICAL', 'Where the job is being made (compliance)'),
|
||||
('x_fc_recipe_id', 'CRITICAL', 'Which process — without it WOs can\'t be generated'),
|
||||
('x_fc_assigned_manager_id','IMPORTANT','Manager responsible for the job'),
|
||||
('x_fc_customer_spec_id','IMPORTANT', 'Customer spec controlling the job (e.g. AMS 2404)'),
|
||||
('x_fc_portal_job_id', 'IMPORTANT', 'Portal-facing job tracker'),
|
||||
('origin', 'CRITICAL', 'Source SO — needed for back-link'),
|
||||
('company_id', 'CRITICAL', 'Multi-company correctness (just fixed)'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('5. Work Orders (mrp.workorder) — wet WO from most recent MO')
|
||||
# =====================================================================
|
||||
|
||||
wet_wo = mo.workorder_ids.filtered(
|
||||
lambda w: hasattr(w, '_fp_is_wet_process') and w._fp_is_wet_process()
|
||||
)[:1] if mo else env['mrp.workorder']
|
||||
show_field_audit('mrp.workorder', wet_wo, [
|
||||
('x_fc_assigned_user_id', 'CRITICAL', 'NOW ENFORCED via button_start gate'),
|
||||
('x_fc_bath_id', 'CRITICAL', 'NOW ENFORCED — chemistry traceability'),
|
||||
('x_fc_tank_id', 'CRITICAL', 'NOW ENFORCED — physical tank audit'),
|
||||
('x_fc_facility_id', 'CRITICAL', 'Which plant ran it (multi-facility shops)'),
|
||||
('x_fc_thickness_target', 'IMPORTANT', 'Spec target — drives QC accept/reject criteria'),
|
||||
('x_fc_dwell_time_minutes','IMPORTANT','Recipe dwell — needed for cycle-time analytics'),
|
||||
('x_fc_rack_id', 'IMPORTANT', 'Which rack/fixture used (per-rack MTO tracking)'),
|
||||
('x_fc_started_by_user_id','IMPORTANT','Who actually started it (audit, may differ from assigned)'),
|
||||
('x_fc_finished_by_user_id','IMPORTANT','Who finished it'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('6. Bath Log (fusion.plating.bath.log)')
|
||||
# =====================================================================
|
||||
|
||||
baths = env['fusion.plating.bath.log'].search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.bath.log', baths, [
|
||||
('bath_id', 'CRITICAL', 'Which bath the readings came from'),
|
||||
('shift', 'IMPORTANT', 'Day/swing/night — for shift-effect analysis'),
|
||||
('user_id', 'CRITICAL', 'Operator who took the readings (audit trail)'),
|
||||
('logged_at', 'CRITICAL', 'When the readings were taken'),
|
||||
('line_ids', 'CRITICAL', 'The actual chemistry numbers (the whole point)'),
|
||||
('notes', 'NICE', 'Free-form observations'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('7. Certificate (fp.certificate) — most recent CoC')
|
||||
# =====================================================================
|
||||
|
||||
coc = env['fp.certificate'].search(
|
||||
[('certificate_type', '=', 'coc')], order='id desc', limit=1)
|
||||
show_field_audit('fp.certificate', coc, [
|
||||
('partner_id', 'CRITICAL', 'Customer the cert belongs to'),
|
||||
('production_id', 'CRITICAL', 'Which MO it certifies'),
|
||||
('po_number', 'CRITICAL', 'Customer PO — required by aero specs'),
|
||||
('spec_reference', 'CRITICAL', 'AMS 2404 / MIL-C-26074 etc. — what was met'),
|
||||
('process_description','IMPORTANT','Human-readable process name'),
|
||||
('part_number', 'IMPORTANT', 'Part the cert covers'),
|
||||
('quantity_shipped', 'CRITICAL', 'How many parts certified'),
|
||||
('thickness_reading_ids','CRITICAL','Fischerscope readings (NOW AUTO-LINKED)'),
|
||||
('attachment_id', 'CRITICAL', 'The PDF itself (NOW AUTO-RENDERED)'),
|
||||
('issued_by_id', 'CRITICAL', 'Inspector signature — who certified this'),
|
||||
('issued_date', 'CRITICAL', 'When issued'),
|
||||
('state', 'CRITICAL', 'draft/issued/voided — NOT issued = NOT compliant'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('8. Thickness Reading (fp.thickness.reading)')
|
||||
# =====================================================================
|
||||
|
||||
reading = env['fp.thickness.reading'].search([], order='id desc', limit=1)
|
||||
show_field_audit('fp.thickness.reading', reading, [
|
||||
('production_id', 'CRITICAL', 'Which MO this reading is from'),
|
||||
('certificate_id', 'CRITICAL', 'Which cert (auto-linked at MO done)'),
|
||||
('reading_number', 'CRITICAL', 'Sequence (n=1, n=2, n=3 — Nadcap requires this)'),
|
||||
('nip_mils', 'CRITICAL', 'The thickness measurement itself'),
|
||||
('ni_percent', 'IMPORTANT', 'Composition — affects bath chemistry diagnosis'),
|
||||
('p_percent', 'IMPORTANT', 'Same'),
|
||||
('position_label', 'CRITICAL', 'WHERE on the part (Nadcap requires location)'),
|
||||
('equipment_model', 'CRITICAL', 'Which gauge — calibration trail'),
|
||||
('calibration_std_ref', 'CRITICAL', 'Which calibration standard — Nadcap req'),
|
||||
('operator_id', 'CRITICAL', 'Who took the reading'),
|
||||
('reading_datetime', 'CRITICAL', 'When'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('9. Delivery (fusion.plating.delivery)')
|
||||
# =====================================================================
|
||||
|
||||
dlv = env['fusion.plating.delivery'].search(
|
||||
[('state', '=', 'delivered')], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.delivery', dlv, [
|
||||
('partner_id', 'CRITICAL', 'Already required'),
|
||||
('scheduled_date', 'CRITICAL', 'When the customer expects parts (NOW PREFILLED)'),
|
||||
('assigned_driver_id', 'CRITICAL', 'Who is driving (NOW PREFILLED)'),
|
||||
('vehicle_id', 'IMPORTANT', 'Which vehicle (insurance + GPS)'),
|
||||
('delivered_at', 'CRITICAL', 'When delivery was completed'),
|
||||
('contact_name', 'IMPORTANT', 'Recipient on the receiving dock'),
|
||||
('contact_phone', 'IMPORTANT', 'Driver can call before arriving'),
|
||||
('coc_attachment_id', 'CRITICAL', 'CoC PDF that goes with the parts'),
|
||||
('packing_list_attachment_id','IMPORTANT','Packing slip'),
|
||||
('delivery_address_id','IMPORTANT', 'Override default partner ship-to'),
|
||||
('pod_id', 'CRITICAL', 'Proof of delivery — without it, we can\'t bill'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('10. Invoice (account.move) — most recent posted invoice')
|
||||
# =====================================================================
|
||||
|
||||
inv = env['account.move'].search(
|
||||
[('move_type', '=', 'out_invoice'), ('state', '=', 'posted')],
|
||||
order='id desc', limit=1)
|
||||
show_field_audit('account.move', inv, [
|
||||
('partner_id', 'CRITICAL', 'Already required'),
|
||||
('invoice_date', 'CRITICAL', 'When invoiced — drives net-terms clock'),
|
||||
('invoice_date_due', 'CRITICAL', 'When payment due'),
|
||||
('invoice_payment_term_id','CRITICAL', 'Net-30 etc.'),
|
||||
('invoice_user_id', 'IMPORTANT', 'Salesperson — for commission'),
|
||||
('partner_bank_id', 'IMPORTANT', 'Where to wire payment'),
|
||||
('ref', 'CRITICAL', 'Customer PO# / reference (required by AP teams)'),
|
||||
('invoice_origin', 'CRITICAL', 'Source SO link'),
|
||||
('narration', 'NICE', 'Free-form notes'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('11. Workforce — Quality Hold + NCR + CAPA (open + completed)')
|
||||
# =====================================================================
|
||||
|
||||
# Sample Quality Hold if any
|
||||
qh = env.get('fusion.plating.quality.hold')
|
||||
if qh is not None:
|
||||
rec = qh.search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.quality.hold', rec, [
|
||||
('partner_id', 'CRITICAL', 'Customer — without it we can\'t notify'),
|
||||
('mo_id', 'CRITICAL', 'Which MO'),
|
||||
('hold_reason', 'CRITICAL', 'Selection — categorize the issue'),
|
||||
('description', 'CRITICAL', 'Inspector\'s narrative'),
|
||||
('qty_on_hold', 'CRITICAL', 'How many parts affected'),
|
||||
('inspector_id', 'CRITICAL', 'Who flagged it'),
|
||||
('created_at', 'CRITICAL', 'When'),
|
||||
])
|
||||
|
||||
ncr = env.get('fusion.plating.ncr')
|
||||
if ncr is not None:
|
||||
rec = ncr.search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.ncr', rec, [
|
||||
('name', 'CRITICAL', 'NCR# / sequence'),
|
||||
('partner_id', 'CRITICAL', 'Customer affected'),
|
||||
('production_id', 'CRITICAL', 'Source MO'),
|
||||
('description', 'CRITICAL', 'What went wrong'),
|
||||
('severity', 'CRITICAL', 'Critical / major / minor'),
|
||||
('containment_action', 'CRITICAL', 'Immediate action — Nadcap req'),
|
||||
('root_cause', 'CRITICAL', 'Why — required to close'),
|
||||
('corrective_action', 'CRITICAL', 'Fix — required to close'),
|
||||
('disposition', 'CRITICAL', 'Use-as-is / scrap / rework — decision'),
|
||||
('raised_by_id', 'CRITICAL', 'Who raised it'),
|
||||
('raised_date', 'CRITICAL', 'When'),
|
||||
])
|
||||
|
||||
capa = env.get('fusion.plating.capa')
|
||||
if capa is not None:
|
||||
rec = capa.search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.capa', rec, [
|
||||
('name', 'CRITICAL', 'CAPA#'),
|
||||
('owner_id', 'CRITICAL', 'Owner / champion'),
|
||||
('due_date', 'CRITICAL', 'Deadline'),
|
||||
('problem_description', 'CRITICAL', 'What\'s the recurring issue'),
|
||||
('root_cause', 'CRITICAL', 'Why-why analysis — required'),
|
||||
('corrective_action', 'CRITICAL', 'Fix the existing'),
|
||||
('preventive_action', 'CRITICAL', 'Prevent recurrence'),
|
||||
('verification_evidence', 'CRITICAL', 'Proof the fix worked'),
|
||||
('effectiveness_date', 'IMPORTANT','When effectiveness confirmed'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('12. Compliance: discharge sample + waste manifest + spill')
|
||||
# =====================================================================
|
||||
|
||||
DS = env.get('fusion.plating.discharge.sample')
|
||||
if DS is not None:
|
||||
rec = DS.search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.discharge.sample', rec, [
|
||||
('sample_date', 'CRITICAL', 'When the sample was taken (regulatory)'),
|
||||
('sampled_by_id', 'CRITICAL', 'Who'),
|
||||
('outfall_id', 'CRITICAL', 'Which discharge point (jurisdictional req)'),
|
||||
('parameter_id', 'CRITICAL', 'What pollutant'),
|
||||
('value_measured', 'CRITICAL', 'The reading itself'),
|
||||
('limit_value', 'CRITICAL', 'The regulatory limit'),
|
||||
('exceeds_limit', 'CRITICAL', 'Pass/fail — drives mandatory reporting'),
|
||||
('lab_cert_attachment_id','CRITICAL','Lab cert — required for regulator'),
|
||||
])
|
||||
|
||||
WM = env.get('fusion.plating.waste.manifest')
|
||||
if WM is not None:
|
||||
rec = WM.search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.waste.manifest', rec, [
|
||||
('manifest_number', 'CRITICAL', 'Government tracking #'),
|
||||
('generator_id', 'CRITICAL', 'Who generated the waste (us)'),
|
||||
('hauler_id', 'CRITICAL', 'Who picked it up (carrier)'),
|
||||
('disposal_facility_id','CRITICAL','Where it went (landfill / treatment)'),
|
||||
('waste_code', 'CRITICAL', 'EPA / TDG hazardous code'),
|
||||
('quantity', 'CRITICAL', 'How much'),
|
||||
('uom', 'CRITICAL', 'Unit'),
|
||||
('shipped_date', 'CRITICAL', 'When shipped'),
|
||||
('received_date', 'CRITICAL', 'When received at disposal — closes the loop'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('SUMMARY — gap counts by severity')
|
||||
# =====================================================================
|
||||
|
||||
print(' See per-model details above. Critical gaps are real')
|
||||
print(' compliance / workflow blockers; Important are operational')
|
||||
print(' risks; Nice-to-have are quality-of-life.')
|
||||
print()
|
||||
print(' Recommended next-batch fixes (in priority order):')
|
||||
print(' 1. invoice.ref auto-fill from sale_order.client_order_ref')
|
||||
print(' (so customer PO# always lands on the invoice)')
|
||||
print(' 2. fp.receiving.received_by_id default + required on accept')
|
||||
print(' 3. mrp.production.x_fc_facility_id required (block confirm)')
|
||||
print(' 4. fp.certificate.spec_reference required to issue')
|
||||
print(' 5. fp.delivery.pod_id required to mark "delivered"')
|
||||
print(' 6. fp.thickness.reading.position_label + calibration_std_ref required')
|
||||
print(' 7. ncr/capa state-transition gates (can\'t close without root_cause)')
|
||||
print(' 8. discharge.sample.lab_cert_attachment_id required to mark complete')
|
||||
Reference in New Issue
Block a user