Compare commits
12 Commits
phase6_2-l
...
1691ee1ab6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1691ee1ab6 | ||
|
|
45710ea890 | ||
|
|
267c8ee165 | ||
|
|
14ebcb2996 | ||
|
|
1df230029d | ||
|
|
f4d6a4f577 | ||
|
|
560838e66c | ||
|
|
469a9d0732 | ||
|
|
60bf2adfa8 | ||
|
|
78a481f3f4 | ||
|
|
3f4fdeffce | ||
|
|
a9e27828d1 |
@@ -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.4',
|
||||
'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"));
|
||||
}
|
||||
6
fusion_accounting_bank_rec/models/__init__.py
Normal file
6
fusion_accounting_bank_rec/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
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
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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
|
||||
|
3
fusion_accounting_bank_rec/services/__init__.py
Normal file
3
fusion_accounting_bank_rec/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import memo_tokenizer
|
||||
from . import exchange_diff
|
||||
from . import matching_strategies
|
||||
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
|
||||
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 |
4
fusion_accounting_bank_rec/tests/__init__.py
Normal file
4
fusion_accounting_bank_rec/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import test_memo_tokenizer
|
||||
from . import test_exchange_diff
|
||||
from . import test_matching_strategies
|
||||
from . import test_ai_suggestion_lifecycle
|
||||
@@ -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)
|
||||
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)
|
||||
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)
|
||||
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',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Invoicing',
|
||||
'version': '19.0.2.0.0',
|
||||
'version': '19.0.2.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
|
||||
'description': """
|
||||
|
||||
@@ -3,15 +3,38 @@
|
||||
# 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 from the customer when missing.
|
||||
|
||||
Customers usually have a default `property_payment_term_id`
|
||||
(Net-30, Net-60, COD…). When an invoice is created without
|
||||
terms, the due date silently defaults to "immediate" — wrong
|
||||
for almost every B2B customer. Pull the partner's terms in
|
||||
before super so the invoice is born with the right schedule.
|
||||
"""
|
||||
Partner = self.env['res.partner']
|
||||
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
|
||||
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 +48,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(),
|
||||
|
||||
@@ -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,133 @@ 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'])
|
||||
|
||||
# =====================================================================
|
||||
banner('PHASE 5 — Operators run their work orders (REAL-TIME timers)')
|
||||
# =====================================================================
|
||||
@@ -514,9 +653,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