Merge Phase 1: AI-assisted bank reconciliation
51 tasks shipped on fusion_accounting/phase-1-bank-rec: - fusion.reconcile.engine (6-method API, single write surface) - 4-pass AI confidence scoring pipeline - 14 mirrored Enterprise OWL components + 8 fusion-only - 10 JSON-RPC controller endpoints + reactive frontend service - Materialized view + 3 cron jobs - 2 wizards + migration audit PDF - 157 tests passing (engine, integration, property-based, controller, MV, wizards, coexistence, perf, LLM compat) - All 4 P95 perf metrics within 1x of budget # Conflicts: # fusion_plating/fusion_plating_bridge_mrp/__manifest__.py # fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py # fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting',
|
'name': 'Fusion Accounting',
|
||||||
'version': '19.0.1.0.0',
|
'version': '19.0.1.0.1',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 25,
|
'sequence': 25,
|
||||||
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
|
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
|
||||||
@@ -13,9 +13,9 @@ Currently installs:
|
|||||||
- fusion_accounting_core Shared schema, security, runtime helpers
|
- fusion_accounting_core Shared schema, security, runtime helpers
|
||||||
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
|
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
|
||||||
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
|
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
|
||||||
|
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
|
||||||
|
|
||||||
Future sub-modules (added per the roadmap as each Phase ships):
|
Future sub-modules (added per the roadmap as each Phase ships):
|
||||||
- fusion_accounting_bank_rec (Phase 1)
|
|
||||||
- fusion_accounting_reports (Phase 2)
|
- fusion_accounting_reports (Phase 2)
|
||||||
- fusion_accounting_dashboard (Phase 3)
|
- fusion_accounting_dashboard (Phase 3)
|
||||||
- fusion_accounting_followup (Phase 5)
|
- fusion_accounting_followup (Phase 5)
|
||||||
@@ -33,6 +33,7 @@ Built by Nexa Systems Inc.
|
|||||||
'fusion_accounting_core',
|
'fusion_accounting_core',
|
||||||
'fusion_accounting_ai',
|
'fusion_accounting_ai',
|
||||||
'fusion_accounting_migration',
|
'fusion_accounting_migration',
|
||||||
|
'fusion_accounting_bank_rec',
|
||||||
],
|
],
|
||||||
'data': [],
|
'data': [],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting AI',
|
'name': 'Fusion Accounting AI',
|
||||||
'version': '19.0.1.0.0',
|
'version': '19.0.1.0.1',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 26,
|
'sequence': 26,
|
||||||
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',
|
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from . import claude
|
from . import claude
|
||||||
from . import openai_adapter
|
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 import models, api, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
from ._base import LLMProvider
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -12,6 +14,64 @@ except ImportError:
|
|||||||
anthropic_sdk = None
|
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):
|
class FusionAccountingAdapterClaude(models.AbstractModel):
|
||||||
_name = 'fusion.accounting.adapter.claude'
|
_name = 'fusion.accounting.adapter.claude'
|
||||||
_description = 'Claude AI Adapter'
|
_description = 'Claude AI Adapter'
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import logging
|
|||||||
from odoo import models, api, _
|
from odoo import models, api, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
from ._base import LLMProvider
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -12,6 +14,71 @@ except ImportError:
|
|||||||
OpenAI = None
|
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):
|
class FusionAccountingAdapterOpenAI(models.AbstractModel):
|
||||||
_name = 'fusion.accounting.adapter.openai'
|
_name = 'fusion.accounting.adapter.openai'
|
||||||
_description = 'OpenAI AI Adapter'
|
_description = 'OpenAI AI Adapter'
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ Routes bank-rec data lookups across:
|
|||||||
- FUSION: fusion.bank.rec.widget (added by fusion_accounting_bank_rec, Phase 1)
|
- FUSION: fusion.bank.rec.widget (added by fusion_accounting_bank_rec, Phase 1)
|
||||||
- ENTERPRISE: account_accountant's bank_rec_widget JS service
|
- ENTERPRISE: account_accountant's bank_rec_widget JS service
|
||||||
- COMMUNITY: pure search on account.bank.statement.line
|
- COMMUNITY: pure search on account.bank.statement.line
|
||||||
|
|
||||||
|
In addition to ``list_unreconciled``, the adapter exposes thin wrappers
|
||||||
|
around the engine's public API: ``suggest_matches``, ``accept_suggestion``,
|
||||||
|
``unreconcile``. AI tools and the OWL controller go through these wrappers
|
||||||
|
instead of touching the engine directly so install-mode routing stays in
|
||||||
|
one place.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .base import DataAdapter
|
from .base import DataAdapter
|
||||||
@@ -14,6 +20,10 @@ class BankRecAdapter(DataAdapter):
|
|||||||
FUSION_MODEL = 'fusion.bank.rec.widget'
|
FUSION_MODEL = 'fusion.bank.rec.widget'
|
||||||
ENTERPRISE_MODULE = 'account_accountant'
|
ENTERPRISE_MODULE = 'account_accountant'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# list_unreconciled
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
def list_unreconciled(self, journal_id=None, limit=100, date_from=None,
|
def list_unreconciled(self, journal_id=None, limit=100, date_from=None,
|
||||||
date_to=None, min_amount=None, company_id=None):
|
date_to=None, min_amount=None, company_id=None):
|
||||||
"""Return unreconciled bank statement lines.
|
"""Return unreconciled bank statement lines.
|
||||||
@@ -31,13 +41,29 @@ class BankRecAdapter(DataAdapter):
|
|||||||
def list_unreconciled_via_fusion(self, journal_id=None, limit=100,
|
def list_unreconciled_via_fusion(self, journal_id=None, limit=100,
|
||||||
date_from=None, date_to=None,
|
date_from=None, date_to=None,
|
||||||
min_amount=None, company_id=None):
|
min_amount=None, company_id=None):
|
||||||
# Phase 1 will add fusion.bank.rec.widget; this method becomes the primary path.
|
"""Community shape + fusion AI fields (top suggestion, band, attachments)."""
|
||||||
# For now: even when the model exists, delegate to community read shape.
|
base = self.list_unreconciled_via_community(
|
||||||
return self.list_unreconciled_via_community(
|
|
||||||
journal_id=journal_id, limit=limit,
|
journal_id=journal_id, limit=limit,
|
||||||
date_from=date_from, date_to=date_to,
|
date_from=date_from, date_to=date_to,
|
||||||
min_amount=min_amount, company_id=company_id,
|
min_amount=min_amount, company_id=company_id,
|
||||||
)
|
)
|
||||||
|
if not base:
|
||||||
|
return base
|
||||||
|
Line = self.env['account.bank.statement.line'].sudo()
|
||||||
|
ids = [row['id'] for row in base]
|
||||||
|
lines_by_id = {line.id: line for line in Line.browse(ids)}
|
||||||
|
for row in base:
|
||||||
|
line = lines_by_id.get(row['id'])
|
||||||
|
if not line:
|
||||||
|
row['fusion_top_suggestion_id'] = None
|
||||||
|
row['fusion_confidence_band'] = 'none'
|
||||||
|
row['attachment_count'] = 0
|
||||||
|
continue
|
||||||
|
top = line.fusion_top_suggestion_id
|
||||||
|
row['fusion_top_suggestion_id'] = top.id if top else None
|
||||||
|
row['fusion_confidence_band'] = line.fusion_confidence_band or 'none'
|
||||||
|
row['attachment_count'] = len(line.bank_statement_attachment_ids)
|
||||||
|
return base
|
||||||
|
|
||||||
def list_unreconciled_via_enterprise(self, journal_id=None, limit=100,
|
def list_unreconciled_via_enterprise(self, journal_id=None, limit=100,
|
||||||
date_from=None, date_to=None,
|
date_from=None, date_to=None,
|
||||||
@@ -83,5 +109,121 @@ class BankRecAdapter(DataAdapter):
|
|||||||
for r in records
|
for r in records
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# suggest_matches
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def suggest_matches(self, statement_line_ids, *, limit_per_line=3,
|
||||||
|
company_id=None):
|
||||||
|
"""Return AI suggestions per bank line.
|
||||||
|
|
||||||
|
Shape: ``{line_id: [{'id', 'rank', 'confidence', 'reasoning',
|
||||||
|
'candidate_id'}, ...]}``. Empty dict when AI suggestions are not
|
||||||
|
available (Enterprise / Community).
|
||||||
|
"""
|
||||||
|
return self._dispatch(
|
||||||
|
'suggest_matches',
|
||||||
|
statement_line_ids=statement_line_ids,
|
||||||
|
limit_per_line=limit_per_line,
|
||||||
|
company_id=company_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def suggest_matches_via_fusion(self, statement_line_ids, *,
|
||||||
|
limit_per_line=3, company_id=None):
|
||||||
|
Line = self.env['account.bank.statement.line'].sudo()
|
||||||
|
lines = Line.browse(list(statement_line_ids or [])).exists()
|
||||||
|
if not lines:
|
||||||
|
return {}
|
||||||
|
return self.env['fusion.reconcile.engine'].suggest_matches(
|
||||||
|
lines, limit_per_line=limit_per_line)
|
||||||
|
|
||||||
|
def suggest_matches_via_enterprise(self, statement_line_ids, *,
|
||||||
|
limit_per_line=3, company_id=None):
|
||||||
|
# Enterprise has its own suggest mechanism inside bank_rec_widget;
|
||||||
|
# we don't proxy it from Python.
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def suggest_matches_via_community(self, statement_line_ids, *,
|
||||||
|
limit_per_line=3, company_id=None):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# accept_suggestion
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def accept_suggestion(self, suggestion_id):
|
||||||
|
"""Accept a fusion AI suggestion and reconcile against its proposal.
|
||||||
|
|
||||||
|
Returns ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
|
||||||
|
'write_off_move_id': int|None}``. Fusion-only.
|
||||||
|
"""
|
||||||
|
return self._dispatch(
|
||||||
|
'accept_suggestion', suggestion_id=suggestion_id)
|
||||||
|
|
||||||
|
def accept_suggestion_via_fusion(self, suggestion_id):
|
||||||
|
return self.env['fusion.reconcile.engine'].accept_suggestion(
|
||||||
|
int(suggestion_id))
|
||||||
|
|
||||||
|
def accept_suggestion_via_enterprise(self, suggestion_id):
|
||||||
|
raise NotImplementedError("accept_suggestion is fusion-only")
|
||||||
|
|
||||||
|
def accept_suggestion_via_community(self, suggestion_id):
|
||||||
|
raise NotImplementedError("accept_suggestion is fusion-only")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# unreconcile
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def unreconcile(self, partial_reconcile_ids):
|
||||||
|
"""Reverse a reconciliation by partial IDs.
|
||||||
|
|
||||||
|
Returns ``{'unreconciled_line_ids': [...]}``. Available in all modes
|
||||||
|
(the engine delegates to V19's standard
|
||||||
|
``account.bank.statement.line.action_undo_reconciliation``).
|
||||||
|
"""
|
||||||
|
return self._dispatch(
|
||||||
|
'unreconcile', partial_reconcile_ids=partial_reconcile_ids)
|
||||||
|
|
||||||
|
def unreconcile_via_fusion(self, partial_reconcile_ids):
|
||||||
|
Partial = self.env['account.partial.reconcile'].sudo()
|
||||||
|
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
|
||||||
|
return self.env['fusion.reconcile.engine'].unreconcile(partials)
|
||||||
|
|
||||||
|
def unreconcile_via_enterprise(self, partial_reconcile_ids):
|
||||||
|
# Enterprise/community paths can't depend on fusion.reconcile.engine
|
||||||
|
# being loaded (fusion_accounting_ai does NOT depend on
|
||||||
|
# fusion_accounting_bank_rec). Mirror the engine's behaviour using
|
||||||
|
# only Community-available helpers.
|
||||||
|
return self._unreconcile_standalone(partial_reconcile_ids)
|
||||||
|
|
||||||
|
def unreconcile_via_community(self, partial_reconcile_ids):
|
||||||
|
return self._unreconcile_standalone(partial_reconcile_ids)
|
||||||
|
|
||||||
|
def _unreconcile_standalone(self, partial_reconcile_ids):
|
||||||
|
"""Engine-free unreconcile for installs without fusion_accounting_bank_rec.
|
||||||
|
|
||||||
|
Mirrors ``fusion.reconcile.engine.unreconcile``: finds bank lines whose
|
||||||
|
moves own any of the partials' journal items, runs the standard undo
|
||||||
|
on them, then unlinks any leftovers.
|
||||||
|
"""
|
||||||
|
Partial = self.env['account.partial.reconcile'].sudo()
|
||||||
|
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
|
||||||
|
if not partials:
|
||||||
|
return {'unreconciled_line_ids': []}
|
||||||
|
all_lines = (
|
||||||
|
partials.mapped('debit_move_id')
|
||||||
|
| partials.mapped('credit_move_id')
|
||||||
|
)
|
||||||
|
line_ids = all_lines.ids
|
||||||
|
affected = self.env['account.bank.statement.line'].sudo().search([
|
||||||
|
('move_id', 'in', all_lines.mapped('move_id').ids),
|
||||||
|
])
|
||||||
|
if affected:
|
||||||
|
affected.action_undo_reconciliation()
|
||||||
|
remaining = partials.exists()
|
||||||
|
if remaining:
|
||||||
|
remaining.unlink()
|
||||||
|
return {'unreconciled_line_ids': line_ids}
|
||||||
|
|
||||||
|
|
||||||
register_adapter('bank_rec', BankRecAdapter)
|
register_adapter('bank_rec', BankRecAdapter)
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from . import system_prompt
|
from . import system_prompt
|
||||||
from . import domain_prompts
|
from . import domain_prompts
|
||||||
|
from . import bank_rec_prompt
|
||||||
|
|||||||
107
fusion_accounting_ai/services/prompts/bank_rec_prompt.py
Normal file
107
fusion_accounting_ai/services/prompts/bank_rec_prompt.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Bank reconciliation AI re-rank prompt.
|
||||||
|
|
||||||
|
Used by fusion_accounting_bank_rec/services/confidence_scoring.py to ask
|
||||||
|
an LLM to refine the statistical ranking of candidate matches.
|
||||||
|
|
||||||
|
Output contract: the LLM MUST respond with valid JSON of shape:
|
||||||
|
{"ranked": [{"candidate_id": int, "confidence": float, "reason": str}, ...]}
|
||||||
|
|
||||||
|
System prompt is provider-agnostic - works with OpenAI Chat Completions,
|
||||||
|
Claude Messages, and local OpenAI-compatible servers (LM Studio, Ollama).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You are an expert accountant assisting with bank reconciliation.
|
||||||
|
|
||||||
|
Your job: given a bank statement line and a list of candidate journal items
|
||||||
|
that statistically scored well as potential matches, re-rank them based on
|
||||||
|
domain expertise. Consider:
|
||||||
|
|
||||||
|
1. **Amount-exact matches** are almost always correct unless the partner is wrong.
|
||||||
|
2. **Memo / reference clues** - bank memos often contain invoice numbers, partner
|
||||||
|
names, or transaction references that disambiguate matches.
|
||||||
|
3. **Date proximity** - invoices are typically reconciled within 30 days of issue.
|
||||||
|
4. **Pattern conformance** - if the partner has a learned pattern (e.g. "always
|
||||||
|
pays exact amount, weekly cadence"), favor candidates that fit that pattern.
|
||||||
|
5. **Precedent similarity** - if a near-identical reconcile happened before,
|
||||||
|
it's likely the right one.
|
||||||
|
|
||||||
|
Return ONLY valid JSON of this exact shape:
|
||||||
|
{
|
||||||
|
"ranked": [
|
||||||
|
{"candidate_id": <int>, "confidence": <float 0-1>, "reason": "<short string>"},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Do NOT include any prose before or after the JSON. Do NOT use markdown code fences.
|
||||||
|
The "ranked" array MUST contain every candidate_id from the input, in your
|
||||||
|
preferred order (highest confidence first).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(statement_line, scored_candidates, pattern=None, precedents=None):
|
||||||
|
"""Build (system_prompt, user_prompt) for AI re-rank.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
statement_line: account.bank.statement.line recordset (singleton)
|
||||||
|
scored_candidates: list of ScoredCandidate dataclasses (from confidence_scoring)
|
||||||
|
pattern: fusion.reconcile.pattern recordset for the partner, or None
|
||||||
|
precedents: list of PrecedentMatch dataclasses, or None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(system_prompt: str, user_prompt: str) tuple
|
||||||
|
"""
|
||||||
|
user_parts = []
|
||||||
|
|
||||||
|
user_parts.append("BANK LINE:")
|
||||||
|
user_parts.append(f" Date: {statement_line.date}")
|
||||||
|
user_parts.append(
|
||||||
|
f" Amount: {statement_line.amount} {statement_line.currency_id.name or ''}"
|
||||||
|
)
|
||||||
|
user_parts.append(
|
||||||
|
f" Memo / payment ref: {statement_line.payment_ref or '(none)'}"
|
||||||
|
)
|
||||||
|
if statement_line.partner_id:
|
||||||
|
user_parts.append(f" Partner: {statement_line.partner_id.name}")
|
||||||
|
|
||||||
|
if pattern:
|
||||||
|
user_parts.append("")
|
||||||
|
user_parts.append("PARTNER PATTERN (learned from past reconciles):")
|
||||||
|
user_parts.append(f" Reconcile count: {pattern.reconcile_count}")
|
||||||
|
user_parts.append(f" Preferred strategy: {pattern.pref_strategy}")
|
||||||
|
user_parts.append(
|
||||||
|
f" Typical cadence: ~{pattern.typical_cadence_days} days between reconciles"
|
||||||
|
)
|
||||||
|
if pattern.typical_amount_range:
|
||||||
|
user_parts.append(f" Typical amount range: {pattern.typical_amount_range}")
|
||||||
|
if pattern.common_memo_tokens:
|
||||||
|
user_parts.append(f" Common memo tokens: {pattern.common_memo_tokens}")
|
||||||
|
|
||||||
|
if precedents:
|
||||||
|
user_parts.append("")
|
||||||
|
user_parts.append("RECENT PRECEDENTS (most-similar past reconciles for this partner):")
|
||||||
|
# Cap at 3 precedents to keep prompt small and reduce token cost.
|
||||||
|
for p in precedents[:3]:
|
||||||
|
user_parts.append(
|
||||||
|
f" - amount={p.amount}, similarity={p.similarity_score:.2f}, "
|
||||||
|
f"matched {p.matched_move_line_count} line(s), tokens={p.memo_tokens}"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_parts.append("")
|
||||||
|
user_parts.append("CANDIDATES (scored by statistical pipeline):")
|
||||||
|
for s in scored_candidates:
|
||||||
|
user_parts.append(
|
||||||
|
f" - candidate_id={s.candidate_id}, statistical_confidence={s.confidence}, "
|
||||||
|
f"amount_match={s.score_amount_match}, pattern_fit={s.score_partner_pattern}, "
|
||||||
|
f"precedent_sim={s.score_precedent_similarity}, "
|
||||||
|
f"reason=\"{s.reasoning}\""
|
||||||
|
)
|
||||||
|
|
||||||
|
user_parts.append("")
|
||||||
|
user_parts.append("Re-rank these candidates and return JSON per the system prompt.")
|
||||||
|
|
||||||
|
user_prompt = "\n".join(user_parts)
|
||||||
|
return (SYSTEM_PROMPT, user_prompt)
|
||||||
@@ -67,7 +67,16 @@ def match_bank_line_to_payments(env, params):
|
|||||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||||
if not st_line.exists():
|
if not st_line.exists():
|
||||||
return {'error': 'Statement line not found'}
|
return {'error': 'Statement line not found'}
|
||||||
st_line.set_line_bank_statement_line(move_line_ids)
|
# Phase 1 Task 23: route through engine when available
|
||||||
|
if 'fusion.reconcile.engine' in env.registry:
|
||||||
|
cands = env['account.move.line'].browse(move_line_ids).exists()
|
||||||
|
if not cands:
|
||||||
|
return {'error': 'No valid move_line_ids'}
|
||||||
|
env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
st_line, against_lines=cands)
|
||||||
|
st_line.invalidate_recordset(['is_reconciled'])
|
||||||
|
else:
|
||||||
|
st_line.set_line_bank_statement_line(move_line_ids)
|
||||||
return {
|
return {
|
||||||
'status': 'matched',
|
'status': 'matched',
|
||||||
'statement_line_id': st_line_id,
|
'statement_line_id': st_line_id,
|
||||||
@@ -83,7 +92,12 @@ def auto_reconcile_bank_lines(env, params):
|
|||||||
('company_id', '=', int(company_id)),
|
('company_id', '=', int(company_id)),
|
||||||
])
|
])
|
||||||
before_count = len(lines)
|
before_count = len(lines)
|
||||||
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
|
# Phase 1 Task 23: route through engine when available
|
||||||
|
if 'fusion.reconcile.engine' in env.registry:
|
||||||
|
env['fusion.reconcile.engine'].reconcile_batch(
|
||||||
|
lines, strategy='auto')
|
||||||
|
else:
|
||||||
|
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
|
||||||
still_unreconciled = env['account.bank.statement.line'].search([
|
still_unreconciled = env['account.bank.statement.line'].search([
|
||||||
('is_reconciled', '=', False),
|
('is_reconciled', '=', False),
|
||||||
('company_id', '=', int(company_id)),
|
('company_id', '=', int(company_id)),
|
||||||
@@ -946,6 +960,171 @@ def _format_aml_candidates(amls):
|
|||||||
} for aml in amls]
|
} for aml in amls]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Phase 1 Bank Reconciliation: engine-backed tools
|
||||||
|
#
|
||||||
|
# These five tools wrap the fusion.reconcile.engine 6-method API via the
|
||||||
|
# bank_rec data adapter (or the engine directly when the adapter does not
|
||||||
|
# expose a wrapper). They give the AI chat the same reconciliation surface
|
||||||
|
# a human gets in the OWL bank-rec UI.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_suggest_matches(env, params):
|
||||||
|
"""Compute and persist AI suggestions for one or more bank statement lines.
|
||||||
|
|
||||||
|
Wraps ``BankRecAdapter.suggest_matches`` -> ``fusion.reconcile.engine``.
|
||||||
|
"""
|
||||||
|
raw_ids = params.get('statement_line_ids')
|
||||||
|
if not raw_ids:
|
||||||
|
return {'error': 'statement_line_ids is required'}
|
||||||
|
statement_line_ids = [int(x) for x in raw_ids]
|
||||||
|
limit_per_line = int(params.get('limit_per_line', 3))
|
||||||
|
|
||||||
|
from ..data_adapters import get_adapter
|
||||||
|
adapter = get_adapter(env, 'bank_rec')
|
||||||
|
raw = adapter.suggest_matches(
|
||||||
|
statement_line_ids=statement_line_ids,
|
||||||
|
limit_per_line=limit_per_line,
|
||||||
|
company_id=env.company.id,
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
suggestions = {}
|
||||||
|
total = 0
|
||||||
|
for line_id, sug_list in raw.items():
|
||||||
|
out = []
|
||||||
|
for s in sug_list:
|
||||||
|
out.append({
|
||||||
|
'suggestion_id': s.get('id'),
|
||||||
|
'candidate_id': s.get('candidate_id'),
|
||||||
|
'confidence': s.get('confidence'),
|
||||||
|
'reasoning': s.get('reasoning') or '',
|
||||||
|
'rank': s.get('rank'),
|
||||||
|
})
|
||||||
|
total += 1
|
||||||
|
suggestions[line_id] = out
|
||||||
|
return {'suggestions': suggestions, 'count': total}
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_accept_suggestion(env, params):
|
||||||
|
"""Accept a fusion.reconcile.suggestion: reconciles the bank line against
|
||||||
|
the suggestion's proposed move lines and marks the suggestion accepted.
|
||||||
|
|
||||||
|
Wraps ``BankRecAdapter.accept_suggestion``.
|
||||||
|
"""
|
||||||
|
if not params.get('suggestion_id'):
|
||||||
|
return {'error': 'suggestion_id is required'}
|
||||||
|
suggestion_id = int(params['suggestion_id'])
|
||||||
|
suggestion = env['fusion.reconcile.suggestion'].browse(suggestion_id)
|
||||||
|
if not suggestion.exists():
|
||||||
|
return {'error': 'Suggestion not found'}
|
||||||
|
|
||||||
|
from ..data_adapters import get_adapter
|
||||||
|
adapter = get_adapter(env, 'bank_rec')
|
||||||
|
result = adapter.accept_suggestion(suggestion_id) or {}
|
||||||
|
statement_line = suggestion.statement_line_id
|
||||||
|
return {
|
||||||
|
'status': 'accepted',
|
||||||
|
'suggestion_id': suggestion_id,
|
||||||
|
'partial_ids': list(result.get('partial_ids') or []),
|
||||||
|
'is_reconciled': bool(statement_line.is_reconciled),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_reconcile_bank_line(env, params):
|
||||||
|
"""Manually reconcile a bank statement line against a set of journal items.
|
||||||
|
|
||||||
|
Routes through ``fusion.reconcile.engine.reconcile_one`` so behaviour
|
||||||
|
matches the OWL widget and ``fusion_accept_suggestion``. Use this for
|
||||||
|
direct AI-initiated matches that did not come from an AI suggestion.
|
||||||
|
"""
|
||||||
|
if not params.get('statement_line_id'):
|
||||||
|
return {'error': 'statement_line_id is required'}
|
||||||
|
raw_against = params.get('against_move_line_ids')
|
||||||
|
if not raw_against:
|
||||||
|
return {'error': 'against_move_line_ids is required'}
|
||||||
|
|
||||||
|
st_line_id = int(params['statement_line_id'])
|
||||||
|
aml_ids = [int(x) for x in raw_against]
|
||||||
|
statement_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||||
|
if not statement_line.exists():
|
||||||
|
return {'error': 'Statement line not found'}
|
||||||
|
against_lines = env['account.move.line'].browse(aml_ids).exists()
|
||||||
|
if not against_lines:
|
||||||
|
return {'error': 'No valid against_move_line_ids'}
|
||||||
|
|
||||||
|
result = env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
statement_line, against_lines=against_lines)
|
||||||
|
return {
|
||||||
|
'status': 'reconciled',
|
||||||
|
'statement_line_id': st_line_id,
|
||||||
|
'partial_ids': list(result.get('partial_ids') or []),
|
||||||
|
'is_reconciled': bool(statement_line.is_reconciled),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_unreconcile(env, params):
|
||||||
|
"""Reverse a reconciliation by partial_reconcile_ids.
|
||||||
|
|
||||||
|
Wraps ``BankRecAdapter.unreconcile``. Works in fusion, Enterprise, and
|
||||||
|
Community installs (the adapter falls back to a standalone path when
|
||||||
|
fusion_accounting_bank_rec is not loaded).
|
||||||
|
"""
|
||||||
|
raw_ids = params.get('partial_reconcile_ids')
|
||||||
|
if not raw_ids:
|
||||||
|
return {'error': 'partial_reconcile_ids is required'}
|
||||||
|
partial_ids = [int(x) for x in raw_ids]
|
||||||
|
|
||||||
|
from ..data_adapters import get_adapter
|
||||||
|
adapter = get_adapter(env, 'bank_rec')
|
||||||
|
result = adapter.unreconcile(partial_ids) or {}
|
||||||
|
unreconciled_line_ids = list(result.get('unreconciled_line_ids') or [])
|
||||||
|
return {
|
||||||
|
'status': 'unreconciled',
|
||||||
|
'unreconciled_line_ids': unreconciled_line_ids,
|
||||||
|
'count': len(unreconciled_line_ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fusion_get_pending_suggestions(env, params):
|
||||||
|
"""List pending fusion.reconcile.suggestion rows.
|
||||||
|
|
||||||
|
Optional filters: ``statement_line_id``, ``min_confidence`` (default 0.0),
|
||||||
|
``limit`` (default 50). Only returns suggestions in the ``pending`` state
|
||||||
|
for the current company.
|
||||||
|
"""
|
||||||
|
domain = [
|
||||||
|
('company_id', '=', env.company.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
]
|
||||||
|
if params.get('statement_line_id'):
|
||||||
|
domain.append(
|
||||||
|
('statement_line_id', '=', int(params['statement_line_id'])))
|
||||||
|
min_confidence = float(params.get('min_confidence') or 0.0)
|
||||||
|
if min_confidence > 0.0:
|
||||||
|
domain.append(('confidence', '>=', min_confidence))
|
||||||
|
limit = int(params.get('limit', 50))
|
||||||
|
|
||||||
|
Suggestion = env['fusion.reconcile.suggestion'].sudo()
|
||||||
|
records = Suggestion.search(
|
||||||
|
domain, limit=limit, order='confidence desc, id desc')
|
||||||
|
rows = []
|
||||||
|
for s in records:
|
||||||
|
st_line = s.statement_line_id
|
||||||
|
rows.append({
|
||||||
|
'id': s.id,
|
||||||
|
'statement_line_id': st_line.id if st_line else None,
|
||||||
|
'statement_line_ref': (
|
||||||
|
st_line.payment_ref or '' if st_line else ''),
|
||||||
|
'candidate_ids': s.proposed_move_line_ids.ids,
|
||||||
|
'confidence': s.confidence,
|
||||||
|
'rank': s.rank,
|
||||||
|
'reasoning': s.reasoning or '',
|
||||||
|
'state': s.state,
|
||||||
|
})
|
||||||
|
return {'count': len(rows), 'suggestions': rows}
|
||||||
|
|
||||||
|
|
||||||
TOOLS = {
|
TOOLS = {
|
||||||
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
|
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
|
||||||
'get_unreconciled_receipts': get_unreconciled_receipts,
|
'get_unreconciled_receipts': get_unreconciled_receipts,
|
||||||
@@ -962,4 +1141,10 @@ TOOLS = {
|
|||||||
'reconcile_payroll_cheques': reconcile_payroll_cheques,
|
'reconcile_payroll_cheques': reconcile_payroll_cheques,
|
||||||
'suggest_bank_line_matches': suggest_bank_line_matches,
|
'suggest_bank_line_matches': suggest_bank_line_matches,
|
||||||
'search_matching_entries': search_matching_entries,
|
'search_matching_entries': search_matching_entries,
|
||||||
|
# Phase 1 engine-backed tools
|
||||||
|
'fusion_suggest_matches': fusion_suggest_matches,
|
||||||
|
'fusion_accept_suggestion': fusion_accept_suggestion,
|
||||||
|
'fusion_reconcile_bank_line': fusion_reconcile_bank_line,
|
||||||
|
'fusion_unreconcile': fusion_unreconcile,
|
||||||
|
'fusion_get_pending_suggestions': fusion_get_pending_suggestions,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from . import test_post_migration
|
from . import test_post_migration
|
||||||
from . import test_data_adapters
|
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))
|
||||||
103
fusion_accounting_bank_rec/CLAUDE.md
Normal file
103
fusion_accounting_bank_rec/CLAUDE.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# fusion_accounting_bank_rec — Cursor / Claude Context
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Replaces (or augments — coexists with) Odoo Enterprise's `account_accountant`
|
||||||
|
bank reconciliation widget with a Fusion-native, AI-assistive implementation.
|
||||||
|
Ships in Phase 1 of the fusion_accounting roadmap.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Hybrid: the engine (`fusion.reconcile.engine`, AbstractModel) is the SINGLE
|
||||||
|
write surface for reconciliations. Everything else (controller, OWL widget,
|
||||||
|
AI tools, wizards, cron) routes through the engine's 6-method API:
|
||||||
|
|
||||||
|
- `reconcile_one(line, against_lines, write_off_vals=None)`
|
||||||
|
- `reconcile_batch(lines, strategy='auto')`
|
||||||
|
- `suggest_matches(lines, limit_per_line=3)`
|
||||||
|
- `accept_suggestion(suggestion)`
|
||||||
|
- `write_off(line, account, amount, label, tax_id=None)`
|
||||||
|
- `unreconcile(partial_reconciles)`
|
||||||
|
|
||||||
|
Pure-Python services live in `services/`:
|
||||||
|
- `memo_tokenizer` — Canadian bank memo regex
|
||||||
|
- `exchange_diff` — FX gain/loss pre-compute
|
||||||
|
- `matching_strategies` — AmountExact, FIFO, MultiInvoice
|
||||||
|
- `precedent_lookup` — K-nearest search
|
||||||
|
- `pattern_extractor` — per-partner aggregate
|
||||||
|
- `confidence_scoring` — 4-pass pipeline (statistical → AI re-rank)
|
||||||
|
- `precedent_backfill` — migration helper
|
||||||
|
|
||||||
|
Persistent models in `models/`:
|
||||||
|
- `fusion.reconcile.pattern` — per-(company, partner) learned profile
|
||||||
|
- `fusion.reconcile.precedent` — per-decision history
|
||||||
|
- `fusion.reconcile.suggestion` — AI suggestions with state lifecycle
|
||||||
|
- `fusion.bank.rec.widget` — TransientModel for OWL round-trip
|
||||||
|
- `fusion.unreconciled.bank.line.mv` — pre-aggregated MV for fast UI listing
|
||||||
|
- `fusion.bank.rec.cron` — cron handler (suggest, pattern refresh, MV refresh)
|
||||||
|
- `fusion.auto.reconcile.wizard` / `fusion.bulk.reconcile.wizard` — TransientModel wizards
|
||||||
|
- `fusion.migration.wizard` (inherits) — adds `_bank_rec_bootstrap_step`
|
||||||
|
- `account.bank.statement.line` (inherits) — adds fusion_top_suggestion_id, fusion_confidence_band, etc.
|
||||||
|
- `account.reconcile.model` (inherits) — adds fusion_ai_confidence_threshold
|
||||||
|
|
||||||
|
Controller: `controllers/bank_rec_controller.py` exposes 10 JSON-RPC endpoints
|
||||||
|
under `/fusion/bank_rec/*`. All calls route through the engine.
|
||||||
|
|
||||||
|
OWL frontend: `static/src/`
|
||||||
|
- `services/bank_reconciliation_service.js` — central reactive state + RPC wrappers
|
||||||
|
- `views/kanban/bank_rec_kanban_*.js` — top-level controller + renderer
|
||||||
|
- `components/bank_reconciliation/<...>` — 14 mirrored Enterprise components + 8 fusion-only components (ai_suggestion folder, batch_action_bar, reconcile_model_picker, attachment_strip, partner_history_panel)
|
||||||
|
- `tours/bank_rec_tours.js` — 5 OWL tour smoke tests
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **V19 deprecations to avoid:** `_sql_constraints` (use `models.Constraint`),
|
||||||
|
`@api.depends('id')` (raises `NotImplementedError`), `@route(type='json')`
|
||||||
|
(use `type='jsonrpc'`), `numbercall` field on `ir.cron` (removed),
|
||||||
|
`groups_id` on `res.users` (use `all_group_ids` for searching),
|
||||||
|
`users` field on `res.groups` (use `user_ids`), `groups_id` on
|
||||||
|
`ir.ui.menu` (use `group_ids`).
|
||||||
|
|
||||||
|
- **Coexistence:** When `account_accountant` is installed, the fusion menu
|
||||||
|
is hidden via `fusion_accounting_core.group_fusion_show_when_enterprise_absent`
|
||||||
|
(a computed group). Engine model is always available.
|
||||||
|
|
||||||
|
- **Materialized view refresh:** Triggered on `fusion.reconcile.suggestion`
|
||||||
|
create/write (best-effort, non-blocking). Cron refreshes every 5 min via
|
||||||
|
a dedicated autocommit cursor (REFRESH CONCURRENTLY can't run inside
|
||||||
|
Odoo's regular transaction).
|
||||||
|
|
||||||
|
- **Test factories:** `tests/_factories.py` provides `make_bank_journal`,
|
||||||
|
`make_bank_line`, `make_invoice`, `make_reconcileable_pair`, `make_suggestion`,
|
||||||
|
`make_pattern`, `make_precedent`. NOTE: `make_bank_journal` defaults to
|
||||||
|
code `'TEST'` so multiple calls in one test will collide; pass an explicit
|
||||||
|
unique code or share a journal across calls.
|
||||||
|
|
||||||
|
- **Hypothesis property tests:** Use `@settings(suppress_health_check=[...])`
|
||||||
|
to silence function_scoped_fixture warnings in TransactionCase.
|
||||||
|
|
||||||
|
## Test counts (as of Phase 1 complete)
|
||||||
|
|
||||||
|
- 157 logical tests total in fusion_accounting_bank_rec
|
||||||
|
- 0 failures, 0 errors
|
||||||
|
- Includes: 4 benchmark tests (tagged 'benchmark'), 1 local LLM smoke (tagged 'local_llm', skips when no LLM), 5 OWL tour tests (tagged 'tour')
|
||||||
|
|
||||||
|
## Performance baseline
|
||||||
|
|
||||||
|
| Operation | P95 | Budget |
|
||||||
|
|---|---|---|
|
||||||
|
| `engine.suggest_matches` (1 line) | 234ms | <500ms |
|
||||||
|
| `engine.reconcile_batch` (50 lines) | 3318ms | <5000ms |
|
||||||
|
| `controller.list_unreconciled` (50 lines) | 77ms | <200ms |
|
||||||
|
| MV refresh | 60ms | <2000ms |
|
||||||
|
|
||||||
|
All within 1x of budget at Phase 1 ship.
|
||||||
|
|
||||||
|
## Known concerns / Phase 1.5 backlog
|
||||||
|
|
||||||
|
- `accept_suggestion` returns `partial_ids` but not `is_reconciled` — UI reads it post-call
|
||||||
|
- `engine.write_off` mixed mode (write-off + against_lines) implemented but untested
|
||||||
|
- `engine.reconcile_one` returns `exchange_diff_move_id: None` (Odoo's reconcile() handles FX inline; surfacing the move_id needs an extra query)
|
||||||
|
- `against_lines` early-break in `reconcile_one` silently drops excess; auto strategy avoids this but manual callers should pre-validate
|
||||||
|
- Reconcile-model bulk wizard `_apply_lines_for_bank_statement_line` is Enterprise-only (Community falls back to per-line error)
|
||||||
|
- OWL tour tests skip-mode when websocket-client absent
|
||||||
41
fusion_accounting_bank_rec/README.md
Normal file
41
fusion_accounting_bank_rec/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# fusion_accounting_bank_rec
|
||||||
|
|
||||||
|
AI-assisted bank reconciliation for Odoo 19 Community — a Fusion-native
|
||||||
|
replacement for Enterprise's `account_accountant` bank reconciliation widget.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Side-by-side parity with Enterprise's bank reconciliation UI (kanban + side
|
||||||
|
panel, multi-currency, write-offs, attachments, chatter)
|
||||||
|
- AI-assistive: confidence-scored suggestions per bank line via the
|
||||||
|
`fusion.reconcile.engine` 4-pass scoring pipeline (statistical + optional
|
||||||
|
LLM re-rank)
|
||||||
|
- Coexists with `account_accountant` (Enterprise wins by default; Fusion menu
|
||||||
|
appears only when Enterprise is uninstalled)
|
||||||
|
- Migration-aware: bootstrap step backfills `fusion.reconcile.precedent` from
|
||||||
|
existing `account.partial.reconcile` rows so the AI has memory from day 1
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
odoo --addons-path=... -i fusion_accounting_bank_rec
|
||||||
|
|
||||||
|
# Open the widget (when Enterprise's account_accountant is NOT installed)
|
||||||
|
# Apps → Bank Reconciliation → Reconcile Bank Lines
|
||||||
|
|
||||||
|
# When Enterprise IS installed: use Enterprise's UI; the engine + AI tools
|
||||||
|
# are still available via the AI chat.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- Local LLM (LM Studio, Ollama):
|
||||||
|
- `fusion_accounting.openai_base_url` = `http://host.docker.internal:1234/v1`
|
||||||
|
- `fusion_accounting.openai_model` = your local model name
|
||||||
|
- `fusion_accounting.provider.bank_rec_suggest` = `openai`
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- `CLAUDE.md` — agent context
|
||||||
|
- `UPGRADE_NOTES.md` — Odoo version anchoring
|
||||||
34
fusion_accounting_bank_rec/UPGRADE_NOTES.md
Normal file
34
fusion_accounting_bank_rec/UPGRADE_NOTES.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# fusion_accounting_bank_rec — Upgrade Notes
|
||||||
|
|
||||||
|
## Odoo Version Anchor
|
||||||
|
|
||||||
|
This module targets **Odoo 19.0** (community-base).
|
||||||
|
|
||||||
|
Reference snapshot of Enterprise code mirrored from:
|
||||||
|
- `account_accountant` (Odoo 19.0.x)
|
||||||
|
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/`
|
||||||
|
|
||||||
|
## Cross-Version Diff Strategy
|
||||||
|
|
||||||
|
When a new Odoo version ships:
|
||||||
|
|
||||||
|
1. Run `check_odoo_diff.sh` (in repo root) against the new Enterprise version
|
||||||
|
2. Note any breaking changes in `account.bank.statement.line` API
|
||||||
|
3. For mirrored OWL components, diff Enterprise's new versions against ours and
|
||||||
|
port material changes (signature renames, new behaviour we want to inherit)
|
||||||
|
4. Re-run the full test suite + tour tests against the new Odoo version
|
||||||
|
5. Update this file with the new version anchor + any deviations
|
||||||
|
|
||||||
|
## V19 Migration Notes (already applied)
|
||||||
|
|
||||||
|
- `_sql_constraints` → `models.Constraint` (Tasks 14, 15)
|
||||||
|
- `@api.depends('id')` → removed (Task 17)
|
||||||
|
- `@route(type='json')` → `type='jsonrpc'` (Task 26)
|
||||||
|
- `numbercall` removed from `ir.cron` (Task 25)
|
||||||
|
- `res.groups.users` → `user_ids` (Task 43)
|
||||||
|
- `ir.ui.menu.groups_id` → `group_ids` (Tasks 42, 43)
|
||||||
|
|
||||||
|
## Phase 1 → Phase 1.5 Migration
|
||||||
|
|
||||||
|
If we ship Phase 1.5 (UI polish, deferred features), changes will go in
|
||||||
|
incremental commits. No DB migration needed (Phase 1 schema is forward-compatible).
|
||||||
5
fusion_accounting_bank_rec/__init__.py
Normal file
5
fusion_accounting_bank_rec/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
from . import services
|
||||||
|
from . import wizards
|
||||||
|
from . import reports
|
||||||
113
fusion_accounting_bank_rec/__manifest__.py
Normal file
113
fusion_accounting_bank_rec/__manifest__.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||||
|
'version': '19.0.1.0.26',
|
||||||
|
'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', 'fusion_accounting_migration'],
|
||||||
|
'external_dependencies': {
|
||||||
|
'python': ['hypothesis'],
|
||||||
|
},
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/cron.xml',
|
||||||
|
'wizards/auto_reconcile_wizard_views.xml',
|
||||||
|
'wizards/bulk_reconcile_wizard_views.xml',
|
||||||
|
'reports/migration_audit_report_views.xml',
|
||||||
|
'reports/migration_audit_report_action.xml',
|
||||||
|
'views/menu_views.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'fusion_accounting_bank_rec/static/src/scss/_variables.scss',
|
||||||
|
'fusion_accounting_bank_rec/static/src/scss/bank_reconciliation.scss',
|
||||||
|
'fusion_accounting_bank_rec/static/src/scss/ai_suggestion.scss',
|
||||||
|
'fusion_accounting_bank_rec/static/src/scss/dark_mode.scss',
|
||||||
|
'fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_controller.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_renderer.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_view.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban.xml',
|
||||||
|
# OWL component mirror — Enterprise account_accountant bank-rec.
|
||||||
|
# Re-export shim so mirrored components can use the relative
|
||||||
|
# `../bank_reconciliation_service` import unchanged.
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_reconciliation_service.js',
|
||||||
|
# Batch 1 (Task 30) — display components
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_line/statement_line.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_line/statement_line.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_summary/statement_summary.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_summary/statement_summary.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_info_pop_over/line_info_pop_over.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_info_pop_over/line_info_pop_over.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconciled_line_name/reconciled_line_name.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconciled_line_name/reconciled_line_name.xml',
|
||||||
|
# Batch 2 (Task 31) — action + edit components
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.xml',
|
||||||
|
# Batch 3 (Task 32) — dialog components
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog_list.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog_list.xml',
|
||||||
|
# Batch 4 (Task 33) — auxiliary components
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/quick_create/quick_create.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/quick_create/quick_create.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/chatter/chatter.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/file_uploader/file_uploader.js',
|
||||||
|
# Fusion-only (Task 34) — AI suggestion UI
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.xml',
|
||||||
|
# Fusion-only (Task 35) — batch action bar + reconcile model picker
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconcile_model_picker/reconcile_model_picker.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconcile_model_picker/reconcile_model_picker.xml',
|
||||||
|
# Fusion-only (Task 36) — attachment strip + partner history panel
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.xml',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/partner_history_panel/partner_history_panel.js',
|
||||||
|
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/partner_history_panel/partner_history_panel.xml',
|
||||||
|
],
|
||||||
|
'web.assets_tests': [
|
||||||
|
'fusion_accounting_bank_rec/static/src/tours/bank_rec_tours.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'OPL-1',
|
||||||
|
}
|
||||||
1
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
1
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import bank_rec_controller
|
||||||
325
fusion_accounting_bank_rec/controllers/bank_rec_controller.py
Normal file
325
fusion_accounting_bank_rec/controllers/bank_rec_controller.py
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"""HTTP controller: 10 JSON-RPC endpoints for the OWL bank-rec widget.
|
||||||
|
|
||||||
|
All endpoints route through ``BankRecAdapter`` (which lives in
|
||||||
|
``fusion_accounting_ai`` and already encapsulates fusion / enterprise /
|
||||||
|
community routing) or directly through ``fusion.reconcile.engine`` for
|
||||||
|
methods the adapter does not yet expose. The controller never touches
|
||||||
|
``account.partial.reconcile`` directly.
|
||||||
|
|
||||||
|
V19: uses ``@route(type='jsonrpc')``, the V19-blessed replacement for the
|
||||||
|
deprecated ``type='json'`` (Odoo 19 logs a deprecation warning if you
|
||||||
|
still use ``json``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import _, http
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _adapter():
|
||||||
|
"""Resolve the bank-rec data adapter from fusion_accounting_ai."""
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.data_adapters import (
|
||||||
|
get_adapter,
|
||||||
|
)
|
||||||
|
return get_adapter(request.env, 'bank_rec')
|
||||||
|
|
||||||
|
|
||||||
|
class FusionBankRecController(http.Controller):
|
||||||
|
"""JSON-RPC surface consumed by the OWL bank-reconciliation widget.
|
||||||
|
|
||||||
|
All routes are ``auth='user'`` -- anonymous traffic is rejected by
|
||||||
|
Odoo's HTTP layer before reaching the handler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. get_state -- initial widget bootstrap
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/get_state', type='jsonrpc', auth='user')
|
||||||
|
def get_state(self, journal_id, company_id):
|
||||||
|
"""Return the journal summary that seeds the kanban widget."""
|
||||||
|
Journal = request.env['account.journal']
|
||||||
|
Line = request.env['account.bank.statement.line']
|
||||||
|
journal = Journal.browse(int(journal_id))
|
||||||
|
if not journal.exists():
|
||||||
|
raise ValidationError(_("Journal %s not found") % journal_id)
|
||||||
|
company_id = int(company_id) if company_id else request.env.company.id
|
||||||
|
unreconciled_lines = Line.search([
|
||||||
|
('journal_id', '=', journal.id),
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
])
|
||||||
|
total_amount = sum(abs(l.amount) for l in unreconciled_lines)
|
||||||
|
last_stmt = request.env['account.bank.statement'].search(
|
||||||
|
[('journal_id', '=', journal.id)],
|
||||||
|
order='date desc', limit=1)
|
||||||
|
currency = journal.currency_id or journal.company_id.currency_id
|
||||||
|
return {
|
||||||
|
'journal': {
|
||||||
|
'id': journal.id,
|
||||||
|
'name': journal.name,
|
||||||
|
'currency_code': currency.name,
|
||||||
|
},
|
||||||
|
'unreconciled_count': len(unreconciled_lines),
|
||||||
|
'total_pending_amount': total_amount,
|
||||||
|
'last_statement_date': str(last_stmt.date) if last_stmt and last_stmt.date else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. list_unreconciled -- paginated, fusion-enriched
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/list_unreconciled', type='jsonrpc', auth='user')
|
||||||
|
def list_unreconciled(self, journal_id, limit=50, offset=0,
|
||||||
|
company_id=None, date_from=None, date_to=None,
|
||||||
|
min_amount=None):
|
||||||
|
"""Return enriched, paginated unreconciled bank lines."""
|
||||||
|
limit = int(limit)
|
||||||
|
offset = int(offset)
|
||||||
|
company_id = (int(company_id) if company_id
|
||||||
|
else request.env.company.id)
|
||||||
|
# The adapter doesn't take an offset; over-fetch and slice.
|
||||||
|
rows = _adapter().list_unreconciled(
|
||||||
|
journal_id=int(journal_id),
|
||||||
|
limit=limit + offset,
|
||||||
|
company_id=company_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
min_amount=min_amount,
|
||||||
|
)
|
||||||
|
sliced = rows[offset:offset + limit]
|
||||||
|
Line = request.env['account.bank.statement.line']
|
||||||
|
domain = [
|
||||||
|
('journal_id', '=', int(journal_id)),
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
]
|
||||||
|
if date_from:
|
||||||
|
domain.append(('date', '>=', date_from))
|
||||||
|
if date_to:
|
||||||
|
domain.append(('date', '<=', date_to))
|
||||||
|
if min_amount is not None:
|
||||||
|
domain.append(('amount', '>=', float(min_amount)))
|
||||||
|
total = Line.search_count(domain)
|
||||||
|
return {
|
||||||
|
'count': len(sliced),
|
||||||
|
'total': total,
|
||||||
|
'lines': sliced,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. get_line_detail -- one line + suggestions + attachments
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/get_line_detail', type='jsonrpc', auth='user')
|
||||||
|
def get_line_detail(self, statement_line_id):
|
||||||
|
"""Return full detail for one line including pending suggestions."""
|
||||||
|
Line = request.env['account.bank.statement.line']
|
||||||
|
line = Line.browse(int(statement_line_id))
|
||||||
|
if not line.exists():
|
||||||
|
raise ValidationError(
|
||||||
|
_("Statement line %s not found") % statement_line_id)
|
||||||
|
Sug = request.env['fusion.reconcile.suggestion']
|
||||||
|
suggestions = Sug.search([
|
||||||
|
('statement_line_id', '=', line.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
], order='confidence desc, rank asc')
|
||||||
|
Att = request.env['ir.attachment']
|
||||||
|
attachments = Att.search([
|
||||||
|
('res_model', '=', 'account.move'),
|
||||||
|
('res_id', '=', line.move_id.id),
|
||||||
|
]) if line.move_id else Att.browse()
|
||||||
|
currency = line.currency_id or line.company_id.currency_id
|
||||||
|
return {
|
||||||
|
'line': {
|
||||||
|
'id': line.id,
|
||||||
|
'date': str(line.date) if line.date else None,
|
||||||
|
'payment_ref': line.payment_ref or '',
|
||||||
|
'amount': line.amount,
|
||||||
|
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||||
|
'partner_name': line.partner_id.name if line.partner_id else None,
|
||||||
|
'currency_id': currency.id,
|
||||||
|
'currency_code': currency.name,
|
||||||
|
'journal_id': line.journal_id.id,
|
||||||
|
'journal_name': line.journal_id.name,
|
||||||
|
'is_reconciled': line.is_reconciled,
|
||||||
|
},
|
||||||
|
'suggestions': [{
|
||||||
|
'id': s.id,
|
||||||
|
'candidate_ids': s.proposed_move_line_ids.ids,
|
||||||
|
'confidence': s.confidence,
|
||||||
|
'rank': s.rank,
|
||||||
|
'reasoning': s.reasoning or '',
|
||||||
|
'scores': {
|
||||||
|
'amount_match': s.score_amount_match,
|
||||||
|
'partner_pattern': s.score_partner_pattern,
|
||||||
|
'precedent_similarity': s.score_precedent_similarity,
|
||||||
|
'ai_rerank': s.score_ai_rerank,
|
||||||
|
},
|
||||||
|
} for s in suggestions],
|
||||||
|
'attachments': [{
|
||||||
|
'id': a.id,
|
||||||
|
'name': a.name,
|
||||||
|
'mimetype': a.mimetype,
|
||||||
|
} for a in attachments],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 4. suggest_matches -- lazy AI suggest for a line
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/suggest_matches', type='jsonrpc', auth='user')
|
||||||
|
def suggest_matches(self, statement_line_ids, limit_per_line=3):
|
||||||
|
"""Trigger AI suggest for one or more statement lines."""
|
||||||
|
ids = [int(i) for i in (statement_line_ids or [])]
|
||||||
|
result = _adapter().suggest_matches(
|
||||||
|
statement_line_ids=ids,
|
||||||
|
limit_per_line=int(limit_per_line),
|
||||||
|
)
|
||||||
|
return {'suggestions': result}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 5. accept_suggestion -- promote AI suggestion to real reconcile
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/accept_suggestion', type='jsonrpc', auth='user')
|
||||||
|
def accept_suggestion(self, suggestion_id):
|
||||||
|
"""Accept a fusion suggestion. Returns the partial IDs created."""
|
||||||
|
sug = request.env['fusion.reconcile.suggestion'].browse(
|
||||||
|
int(suggestion_id))
|
||||||
|
if not sug.exists():
|
||||||
|
raise ValidationError(
|
||||||
|
_("Suggestion %s not found") % suggestion_id)
|
||||||
|
# Capture the journal/company before reconcile (the sug may go stale).
|
||||||
|
journal_id = sug.statement_line_id.journal_id.id
|
||||||
|
company_id = sug.company_id.id
|
||||||
|
result = _adapter().accept_suggestion(suggestion_id=int(suggestion_id))
|
||||||
|
unreconciled_count_after = request.env[
|
||||||
|
'account.bank.statement.line'].search_count([
|
||||||
|
('journal_id', '=', journal_id),
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
'status': 'accepted',
|
||||||
|
'partial_ids': result.get('partial_ids', []),
|
||||||
|
'unreconciled_count_after': unreconciled_count_after,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 6. reconcile_manual -- user picked candidates manually
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/reconcile_manual', type='jsonrpc', auth='user')
|
||||||
|
def reconcile_manual(self, statement_line_id, against_move_line_ids):
|
||||||
|
"""Reconcile a line against an explicit set of journal items."""
|
||||||
|
line = request.env['account.bank.statement.line'].browse(
|
||||||
|
int(statement_line_id))
|
||||||
|
if not line.exists():
|
||||||
|
raise ValidationError(
|
||||||
|
_("Statement line %s not found") % statement_line_id)
|
||||||
|
cands = request.env['account.move.line'].browse(
|
||||||
|
[int(i) for i in (against_move_line_ids or [])])
|
||||||
|
result = request.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
line, against_lines=cands)
|
||||||
|
return {
|
||||||
|
'status': 'reconciled',
|
||||||
|
'partial_ids': result.get('partial_ids', []),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 7. unreconcile -- reverse a prior reconcile
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/unreconcile', type='jsonrpc', auth='user')
|
||||||
|
def unreconcile(self, partial_reconcile_ids):
|
||||||
|
"""Reverse one or more partial reconciles."""
|
||||||
|
ids = [int(i) for i in (partial_reconcile_ids or [])]
|
||||||
|
result = _adapter().unreconcile(partial_reconcile_ids=ids)
|
||||||
|
return {
|
||||||
|
'status': 'unreconciled',
|
||||||
|
'unreconciled_line_ids': result.get('unreconciled_line_ids', []),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 8. write_off -- absorb residual into a write-off account
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/write_off', type='jsonrpc', auth='user')
|
||||||
|
def write_off(self, statement_line_id, account_id, amount, label,
|
||||||
|
tax_id=None):
|
||||||
|
"""Apply a write-off against a bank statement line."""
|
||||||
|
line = request.env['account.bank.statement.line'].browse(
|
||||||
|
int(statement_line_id))
|
||||||
|
if not line.exists():
|
||||||
|
raise ValidationError(
|
||||||
|
_("Statement line %s not found") % statement_line_id)
|
||||||
|
account = request.env['account.account'].browse(int(account_id))
|
||||||
|
tax = (request.env['account.tax'].browse(int(tax_id))
|
||||||
|
if tax_id else None)
|
||||||
|
result = request.env['fusion.reconcile.engine'].write_off(
|
||||||
|
line, account=account, amount=float(amount),
|
||||||
|
tax_id=tax, label=label)
|
||||||
|
return {
|
||||||
|
'status': 'written_off',
|
||||||
|
'partial_ids': result.get('partial_ids', []),
|
||||||
|
'write_off_move_id': result.get('write_off_move_id'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 9. bulk_reconcile -- batch auto-reconcile a recordset
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/bulk_reconcile', type='jsonrpc', auth='user')
|
||||||
|
def bulk_reconcile(self, statement_line_ids, strategy='auto'):
|
||||||
|
"""Batch auto-reconcile. Returns counts + per-line errors."""
|
||||||
|
ids = [int(i) for i in (statement_line_ids or [])]
|
||||||
|
lines = request.env['account.bank.statement.line'].browse(ids)
|
||||||
|
result = request.env['fusion.reconcile.engine'].reconcile_batch(
|
||||||
|
lines, strategy=strategy)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 10. get_partner_history -- partner reconcile history panel
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@http.route('/fusion/bank_rec/get_partner_history', type='jsonrpc', auth='user')
|
||||||
|
def get_partner_history(self, partner_id, limit=20):
|
||||||
|
"""Return a partner's reconcile history + learned pattern."""
|
||||||
|
Partner = request.env['res.partner']
|
||||||
|
partner = Partner.browse(int(partner_id))
|
||||||
|
if not partner.exists():
|
||||||
|
raise ValidationError(_("Partner %s not found") % partner_id)
|
||||||
|
Precedent = request.env['fusion.reconcile.precedent']
|
||||||
|
recent = Precedent.search(
|
||||||
|
[('partner_id', '=', partner.id)],
|
||||||
|
order='reconciled_at desc, id desc',
|
||||||
|
limit=int(limit),
|
||||||
|
)
|
||||||
|
Pattern = request.env['fusion.reconcile.pattern']
|
||||||
|
pattern = Pattern.search(
|
||||||
|
[('partner_id', '=', partner.id)], limit=1)
|
||||||
|
return {
|
||||||
|
'partner': {
|
||||||
|
'id': partner.id,
|
||||||
|
'name': partner.name,
|
||||||
|
},
|
||||||
|
'recent_reconciles': [{
|
||||||
|
'precedent_id': p.id,
|
||||||
|
'date': str(p.date) if p.date else None,
|
||||||
|
'amount': p.amount,
|
||||||
|
'memo_tokens': p.memo_tokens or '',
|
||||||
|
'matched_count': p.matched_move_line_count,
|
||||||
|
'source': p.source,
|
||||||
|
} for p in recent],
|
||||||
|
'pattern': ({
|
||||||
|
'reconcile_count': pattern.reconcile_count,
|
||||||
|
'pref_strategy': pattern.pref_strategy or None,
|
||||||
|
'common_memo_tokens': pattern.common_memo_tokens or None,
|
||||||
|
'typical_cadence_days': pattern.typical_cadence_days,
|
||||||
|
} if pattern else None),
|
||||||
|
}
|
||||||
35
fusion_accounting_bank_rec/data/cron.xml
Normal file
35
fusion_accounting_bank_rec/data/cron.xml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="cron_fusion_bank_rec_suggest" model="ir.cron">
|
||||||
|
<field name="name">Fusion Bank Rec — Warm AI Suggestions</field>
|
||||||
|
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_suggest_pending()</field>
|
||||||
|
<field name="interval_number">30</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="cron_fusion_bank_rec_pattern_refresh" model="ir.cron">
|
||||||
|
<field name="name">Fusion Bank Rec — Refresh Partner Patterns</field>
|
||||||
|
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_refresh_patterns()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="nextcall" eval="(DateTime.now().replace(hour=2, minute=0, second=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="cron_fusion_bank_rec_mv_refresh" model="ir.cron">
|
||||||
|
<field name="name">Fusion Bank Rec — Refresh Unreconciled MV</field>
|
||||||
|
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_refresh_mv()</field>
|
||||||
|
<field name="interval_number">5</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Materialized view: pre-aggregated data for the OWL bank reconciliation widget.
|
||||||
|
-- Refreshed on cron (Task 25) and on suggestion writes.
|
||||||
|
-- Indexed on (company_id, journal_id, date) for fast UI queries.
|
||||||
|
|
||||||
|
-- NOTE: account_bank_statement_line does not store `date` directly in V19;
|
||||||
|
-- it is a related field through move_id -> account_move.date. We JOIN on
|
||||||
|
-- account_move to get it.
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_unreconciled_bank_line_mv AS
|
||||||
|
SELECT
|
||||||
|
bsl.id AS id,
|
||||||
|
bsl.company_id AS company_id,
|
||||||
|
bsl.journal_id AS journal_id,
|
||||||
|
am.date AS date,
|
||||||
|
bsl.amount AS amount,
|
||||||
|
bsl.payment_ref AS payment_ref,
|
||||||
|
bsl.currency_id AS currency_id,
|
||||||
|
bsl.partner_id AS partner_id,
|
||||||
|
bsl.create_date AS create_date,
|
||||||
|
-- Top suggestion (highest confidence pending one)
|
||||||
|
(SELECT s.id FROM fusion_reconcile_suggestion s
|
||||||
|
WHERE s.statement_line_id = bsl.id AND s.state = 'pending'
|
||||||
|
ORDER BY s.confidence DESC, s.rank ASC LIMIT 1) AS top_suggestion_id,
|
||||||
|
(SELECT s.confidence FROM fusion_reconcile_suggestion s
|
||||||
|
WHERE s.statement_line_id = bsl.id AND s.state = 'pending'
|
||||||
|
ORDER BY s.confidence DESC, s.rank ASC LIMIT 1) AS top_confidence,
|
||||||
|
CASE
|
||||||
|
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
|
||||||
|
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') >= 0.85
|
||||||
|
THEN 'high'
|
||||||
|
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
|
||||||
|
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') >= 0.60
|
||||||
|
THEN 'medium'
|
||||||
|
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
|
||||||
|
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') > 0
|
||||||
|
THEN 'low'
|
||||||
|
ELSE 'none'
|
||||||
|
END AS confidence_band,
|
||||||
|
-- Attachment count (assumes ir_attachment.res_model='account.bank.statement.line')
|
||||||
|
(SELECT COUNT(*) FROM ir_attachment att
|
||||||
|
WHERE att.res_model = 'account.bank.statement.line' AND att.res_id = bsl.id)
|
||||||
|
AS attachment_count,
|
||||||
|
-- Partner reconcile pattern hint
|
||||||
|
COALESCE((SELECT p.reconcile_count FROM fusion_reconcile_pattern p
|
||||||
|
WHERE p.partner_id = bsl.partner_id AND p.company_id = bsl.company_id LIMIT 1), 0)
|
||||||
|
AS partner_reconcile_count
|
||||||
|
FROM account_bank_statement_line bsl
|
||||||
|
JOIN account_move am ON am.id = bsl.move_id
|
||||||
|
WHERE bsl.is_reconciled IS NOT TRUE;
|
||||||
|
|
||||||
|
-- Indexes for the common UI queries: filter by company + journal, sort by date desc.
|
||||||
|
CREATE INDEX IF NOT EXISTS fusion_mv_unrec_company_journal_date_idx
|
||||||
|
ON fusion_unreconciled_bank_line_mv (company_id, journal_id, date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS fusion_mv_unrec_partner_idx
|
||||||
|
ON fusion_unreconciled_bank_line_mv (partner_id) WHERE partner_id IS NOT NULL;
|
||||||
|
-- UNIQUE index required for CONCURRENTLY refresh
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS fusion_mv_unrec_id_idx
|
||||||
|
ON fusion_unreconciled_bank_line_mv (id);
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
10
fusion_accounting_bank_rec/models/__init__.py
Normal file
10
fusion_accounting_bank_rec/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from . import fusion_reconcile_pattern
|
||||||
|
from . import fusion_reconcile_precedent
|
||||||
|
from . import fusion_reconcile_suggestion
|
||||||
|
from . import fusion_bank_rec_widget
|
||||||
|
from . import account_bank_statement_line
|
||||||
|
from . import account_reconcile_model
|
||||||
|
from . import fusion_reconcile_engine
|
||||||
|
from . import fusion_unreconciled_bank_line_mv
|
||||||
|
from . import fusion_bank_rec_cron
|
||||||
|
from . import fusion_migration_wizard
|
||||||
@@ -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.")
|
||||||
119
fusion_accounting_bank_rec/models/fusion_bank_rec_cron.py
Normal file
119
fusion_accounting_bank_rec/models/fusion_bank_rec_cron.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Cron handler model for fusion_accounting_bank_rec.
|
||||||
|
|
||||||
|
Three scheduled jobs:
|
||||||
|
- _cron_suggest_pending: warm AI suggestions for unreconciled lines (30 min)
|
||||||
|
- _cron_refresh_patterns: recompute fusion.reconcile.pattern aggregates (daily 02:00)
|
||||||
|
- _cron_refresh_mv: REFRESH MATERIALIZED VIEW CONCURRENTLY (5 min)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import odoo
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
from ..services.pattern_extractor import extract_pattern_for_partner
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionBankRecCron(models.AbstractModel):
|
||||||
|
_name = "fusion.bank.rec.cron"
|
||||||
|
_description = "Fusion Bank Reconciliation Cron Handlers"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_suggest_pending(self, batch_size=50):
|
||||||
|
"""For each unreconciled bank line that doesn't have a recent pending
|
||||||
|
suggestion, run engine.suggest_matches.
|
||||||
|
|
||||||
|
Recent = a pending suggestion created within the last 24 hours."""
|
||||||
|
cutoff = fields.Datetime.now() - timedelta(hours=24)
|
||||||
|
Line = self.env['account.bank.statement.line']
|
||||||
|
lines_to_consider = Line.search([
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
('partner_id', '!=', False),
|
||||||
|
], limit=batch_size * 5)
|
||||||
|
|
||||||
|
Suggestion = self.env['fusion.reconcile.suggestion']
|
||||||
|
lines_needing_suggestions = self.env['account.bank.statement.line']
|
||||||
|
for line in lines_to_consider:
|
||||||
|
recent = Suggestion.search_count([
|
||||||
|
('statement_line_id', '=', line.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
('create_date', '>=', cutoff),
|
||||||
|
])
|
||||||
|
if recent == 0:
|
||||||
|
lines_needing_suggestions |= line
|
||||||
|
if len(lines_needing_suggestions) >= batch_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not lines_needing_suggestions:
|
||||||
|
_logger.debug("Cron: no bank lines need suggestion warming")
|
||||||
|
return
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
"Cron: warming suggestions for %d bank lines",
|
||||||
|
len(lines_needing_suggestions))
|
||||||
|
try:
|
||||||
|
self.env['fusion.reconcile.engine'].suggest_matches(
|
||||||
|
lines_needing_suggestions, limit_per_line=3)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("Cron suggest_pending failed: %s", e)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_refresh_patterns(self):
|
||||||
|
"""For each (company, partner) pair with precedents, recompute and
|
||||||
|
upsert the fusion.reconcile.pattern row."""
|
||||||
|
Pattern = self.env['fusion.reconcile.pattern']
|
||||||
|
self.env.cr.execute("""
|
||||||
|
SELECT DISTINCT company_id, partner_id
|
||||||
|
FROM fusion_reconcile_precedent
|
||||||
|
WHERE partner_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
pairs = self.env.cr.fetchall()
|
||||||
|
_logger.info(
|
||||||
|
"Cron: refreshing patterns for %d (company, partner) pairs",
|
||||||
|
len(pairs))
|
||||||
|
for company_id, partner_id in pairs:
|
||||||
|
try:
|
||||||
|
vals = extract_pattern_for_partner(
|
||||||
|
self.env, company_id=company_id, partner_id=partner_id)
|
||||||
|
existing = Pattern.search([
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
('partner_id', '=', partner_id),
|
||||||
|
], limit=1)
|
||||||
|
if existing:
|
||||||
|
existing.write(vals)
|
||||||
|
else:
|
||||||
|
Pattern.create(vals)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(
|
||||||
|
"Pattern refresh failed for company=%s partner=%s: %s",
|
||||||
|
company_id, partner_id, e)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_refresh_mv(self):
|
||||||
|
"""Refresh the materialized view CONCURRENTLY using an autocommit cursor.
|
||||||
|
|
||||||
|
REFRESH CONCURRENTLY can't run inside a transaction, so we open a
|
||||||
|
fresh connection in autocommit mode (per Task 24's note). On any
|
||||||
|
failure, we fall back to the model's blocking refresh."""
|
||||||
|
try:
|
||||||
|
db_name = self.env.cr.dbname
|
||||||
|
db = odoo.sql_db.db_connect(db_name)
|
||||||
|
with db.cursor() as cron_cr:
|
||||||
|
cron_cr._cnx.set_session(autocommit=True)
|
||||||
|
cron_cr.execute(
|
||||||
|
"REFRESH MATERIALIZED VIEW CONCURRENTLY "
|
||||||
|
"fusion_unreconciled_bank_line_mv")
|
||||||
|
_logger.debug("Cron: MV refresh CONCURRENTLY succeeded")
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(
|
||||||
|
"Cron MV refresh CONCURRENTLY failed (%s); falling back to "
|
||||||
|
"blocking refresh", e)
|
||||||
|
try:
|
||||||
|
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||||
|
concurrently=False)
|
||||||
|
except Exception as e2:
|
||||||
|
_logger.exception(
|
||||||
|
"Cron MV refresh fallback also failed: %s", e2)
|
||||||
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},
|
||||||
|
}
|
||||||
97
fusion_accounting_bank_rec/models/fusion_migration_wizard.py
Normal file
97
fusion_accounting_bank_rec/models/fusion_migration_wizard.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Bank-rec specific migration step.
|
||||||
|
|
||||||
|
Hooks into fusion.migration.wizard (defined by fusion_accounting_migration)
|
||||||
|
to bootstrap fusion.reconcile.precedent from existing
|
||||||
|
account.partial.reconcile rows. This gives the AI immediate "memory" from
|
||||||
|
past Enterprise reconciles so suggestions can be ranked by precedent
|
||||||
|
similarity from day one.
|
||||||
|
|
||||||
|
The bootstrap step is exposed as a public method (_bank_rec_bootstrap_step)
|
||||||
|
so tests and the audit report can invoke it directly. action_run_migration
|
||||||
|
is overridden to call super() then run the bootstrap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import _, models
|
||||||
|
|
||||||
|
from ..services.precedent_backfill import backfill_precedents
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionMigrationWizard(models.TransientModel):
|
||||||
|
_inherit = "fusion.migration.wizard"
|
||||||
|
|
||||||
|
def _bank_rec_bootstrap_step(self):
|
||||||
|
"""Migration step: backfill precedents + refresh patterns + refresh MV.
|
||||||
|
|
||||||
|
Returns a dict describing what happened, suitable for surfacing to
|
||||||
|
the user via notification or PDF audit report.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
_logger.info(
|
||||||
|
"fusion_accounting_bank_rec migration step: bootstrap starting")
|
||||||
|
|
||||||
|
company_id = None
|
||||||
|
if 'company_id' in self._fields and self.company_id:
|
||||||
|
company_id = self.company_id.id
|
||||||
|
|
||||||
|
precedent_result = backfill_precedents(
|
||||||
|
self.env, company_id=company_id, limit=10000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.env['fusion.bank.rec.cron']._cron_refresh_patterns()
|
||||||
|
patterns_ok = True
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
_logger.warning(
|
||||||
|
"Pattern refresh during migration failed: %s", e)
|
||||||
|
patterns_ok = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||||
|
concurrently=False)
|
||||||
|
mv_ok = True
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
_logger.warning("MV refresh during migration failed: %s", e)
|
||||||
|
mv_ok = False
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'step': 'bank_rec_bootstrap',
|
||||||
|
'precedents_created': precedent_result['created'],
|
||||||
|
'precedents_skipped': precedent_result['skipped'],
|
||||||
|
'patterns_refreshed': patterns_ok,
|
||||||
|
'mv_refreshed': mv_ok,
|
||||||
|
}
|
||||||
|
_logger.info(
|
||||||
|
"fusion_accounting_bank_rec bootstrap complete: %s", result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def action_run_migration(self):
|
||||||
|
"""Override the migration entry-point to add the bank-rec step.
|
||||||
|
|
||||||
|
Calls super() (which currently returns a notification stub from
|
||||||
|
Phase 0) and then runs the bank-rec bootstrap. Returns a
|
||||||
|
notification summarizing both.
|
||||||
|
"""
|
||||||
|
_ = super().action_run_migration()
|
||||||
|
result = self._bank_rec_bootstrap_step()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'type': 'success',
|
||||||
|
'title': _("Bank-Rec Migration Complete"),
|
||||||
|
'message': _(
|
||||||
|
"Backfilled %(created)d precedents "
|
||||||
|
"(skipped %(skipped)d). "
|
||||||
|
"Patterns refreshed: %(p)s. MV refreshed: %(m)s."
|
||||||
|
) % {
|
||||||
|
'created': result['precedents_created'],
|
||||||
|
'skipped': result['precedents_skipped'],
|
||||||
|
'p': 'yes' if result['patterns_refreshed'] else 'no',
|
||||||
|
'm': 'yes' if result['mv_refreshed'] else 'no',
|
||||||
|
},
|
||||||
|
'sticky': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
481
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
481
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
"""The reconcile engine — orchestrator for all bank-line reconciliations.
|
||||||
|
|
||||||
|
Public API: 6 methods. All other code (controllers, AI tools, wizards)
|
||||||
|
must go through these methods; no direct ORM writes to
|
||||||
|
``account.partial.reconcile`` from anywhere else.
|
||||||
|
|
||||||
|
V19 mechanics (per Enterprise's bank_rec_widget pattern):
|
||||||
|
|
||||||
|
A bank statement line creates an ``account.move`` with two journal
|
||||||
|
items: a *liquidity* line on the journal's default account, and a
|
||||||
|
*suspense* line on the journal's suspense account. Reconciliation
|
||||||
|
replaces the suspense line with one or more *counterpart* lines posted
|
||||||
|
to the matched invoices' receivable / payable accounts (or the write-off
|
||||||
|
account), then calls Odoo's standard ``account.move.line.reconcile()``
|
||||||
|
on each counterpart + invoice pair.
|
||||||
|
|
||||||
|
Internal pipeline (per spec Section 3.3):
|
||||||
|
|
||||||
|
1. Validate (period not locked, mandatory args present).
|
||||||
|
2. Compute counterpart vals from ``against_lines`` and optional write-off.
|
||||||
|
3. Rewrite the bank move ``line_ids``: keep liquidity, drop suspense +
|
||||||
|
any prior other lines, append the new counterparts.
|
||||||
|
4. Reconcile each counterpart with its matched invoice line.
|
||||||
|
5. Audit (``mail.message``) + record precedent for future learning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.fields import Command
|
||||||
|
|
||||||
|
from ..services.matching_strategies import (
|
||||||
|
AmountExactStrategy,
|
||||||
|
Candidate,
|
||||||
|
FIFOStrategy,
|
||||||
|
MultiInvoiceStrategy,
|
||||||
|
)
|
||||||
|
from ..services.confidence_scoring import score_candidates
|
||||||
|
from ..services.memo_tokenizer import tokenize_memo
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionReconcileEngine(models.AbstractModel):
|
||||||
|
_name = "fusion.reconcile.engine"
|
||||||
|
_description = "Fusion Bank Reconciliation Engine"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PUBLIC API (6 methods)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def reconcile_one(self, statement_line, *, against_lines=None,
|
||||||
|
write_off_vals=None):
|
||||||
|
"""Reconcile one bank line against a set of journal items.
|
||||||
|
|
||||||
|
Returns: ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
|
||||||
|
'write_off_move_id': int|None}``
|
||||||
|
"""
|
||||||
|
if not statement_line:
|
||||||
|
raise ValidationError(_("statement_line is required"))
|
||||||
|
statement_line.ensure_one()
|
||||||
|
AML = self.env['account.move.line']
|
||||||
|
against_lines = against_lines or AML
|
||||||
|
if not against_lines and not write_off_vals:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Either against_lines or write_off_vals required"))
|
||||||
|
|
||||||
|
self._validate_reconcile(statement_line, against_lines)
|
||||||
|
|
||||||
|
bank_move = statement_line.move_id
|
||||||
|
liquidity_lines, suspense_lines, other_lines = (
|
||||||
|
statement_line._seek_for_lines())
|
||||||
|
|
||||||
|
# The bank move must stay balanced after we rewrite line_ids.
|
||||||
|
# Liquidity sums to +bank_amount (or -bank_amount for outbound), so
|
||||||
|
# the new counterparts must sum to the inverse. We allocate the
|
||||||
|
# available bank amount across against_lines, clamped to each
|
||||||
|
# invoice's residual; any leftover goes to the write-off line (or
|
||||||
|
# raises if no write-off was requested).
|
||||||
|
liq_balance = sum(liquidity_lines.mapped('balance'))
|
||||||
|
# Available counterpart balance (positive magnitude) = abs(liq_balance)
|
||||||
|
remaining = abs(liq_balance)
|
||||||
|
# Counterparts mirror liquidity: opposite sign of liq_balance.
|
||||||
|
cp_sign = -1 if liq_balance >= 0 else 1
|
||||||
|
|
||||||
|
new_counterpart_vals = []
|
||||||
|
for inv_line in against_lines:
|
||||||
|
inv_residual = inv_line.amount_residual
|
||||||
|
# Clamp so we never write more than the invoice residual nor more
|
||||||
|
# than what the bank line can pay.
|
||||||
|
allocate = min(remaining, abs(inv_residual))
|
||||||
|
new_counterpart_vals.append(self._build_counterpart_vals(
|
||||||
|
statement_line, inv_line,
|
||||||
|
allocated_balance=cp_sign * allocate,
|
||||||
|
))
|
||||||
|
remaining -= allocate
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
write_off_move_id = None
|
||||||
|
if write_off_vals:
|
||||||
|
# Write-off absorbs whatever the against_lines didn't cover.
|
||||||
|
wo_balance = cp_sign * remaining
|
||||||
|
# If user passed an explicit amount and there are no against_lines,
|
||||||
|
# honour the explicit amount (covers the pure write-off case).
|
||||||
|
if (write_off_vals.get('amount') is not None
|
||||||
|
and not against_lines):
|
||||||
|
wo_balance = -write_off_vals['amount']
|
||||||
|
new_counterpart_vals.append(self._build_write_off_vals(
|
||||||
|
statement_line, write_off_vals, balance=wo_balance,
|
||||||
|
))
|
||||||
|
remaining = 0
|
||||||
|
|
||||||
|
# Replace the bank move line_ids: keep liquidity, drop everything
|
||||||
|
# else, append new counterparts.
|
||||||
|
ops = []
|
||||||
|
for line in (suspense_lines | other_lines):
|
||||||
|
ops.append(Command.unlink(line.id))
|
||||||
|
for vals in new_counterpart_vals:
|
||||||
|
ops.append(Command.create(vals))
|
||||||
|
|
||||||
|
editable_move = bank_move.with_context(
|
||||||
|
force_delete=True, skip_readonly_check=True)
|
||||||
|
prior_line_ids = set(bank_move.line_ids.ids)
|
||||||
|
editable_move.write({'line_ids': ops})
|
||||||
|
|
||||||
|
new_lines = bank_move.line_ids.filtered(
|
||||||
|
lambda line: line.id not in prior_line_ids)
|
||||||
|
|
||||||
|
# Reconcile each new counterpart with its matched invoice line.
|
||||||
|
# The first N new lines correspond to the first N against_lines
|
||||||
|
# (where N may be < len(against_lines) if the bank amount ran out).
|
||||||
|
# Any trailing new line is a write-off and has no invoice pair.
|
||||||
|
Partial = self.env['account.partial.reconcile']
|
||||||
|
new_partial_ids = []
|
||||||
|
invoice_counterparts = new_lines[:min(len(new_lines),
|
||||||
|
len(against_lines))]
|
||||||
|
for new_line, inv_line in zip(invoice_counterparts, against_lines):
|
||||||
|
pair = new_line | inv_line
|
||||||
|
existing = set(Partial.search([
|
||||||
|
'|',
|
||||||
|
('debit_move_id', 'in', pair.ids),
|
||||||
|
('credit_move_id', 'in', pair.ids),
|
||||||
|
]).ids)
|
||||||
|
pair.reconcile()
|
||||||
|
added = Partial.search([
|
||||||
|
'|',
|
||||||
|
('debit_move_id', 'in', pair.ids),
|
||||||
|
('credit_move_id', 'in', pair.ids),
|
||||||
|
]).filtered(lambda p: p.id not in existing)
|
||||||
|
new_partial_ids.extend(added.ids)
|
||||||
|
|
||||||
|
self._post_audit(
|
||||||
|
statement_line, new_partial_ids, source='engine.reconcile_one')
|
||||||
|
if against_lines:
|
||||||
|
self._record_precedent(statement_line, against_lines)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'partial_ids': new_partial_ids,
|
||||||
|
'exchange_diff_move_id': None,
|
||||||
|
'write_off_move_id': write_off_move_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def reconcile_batch(self, statement_lines, *, strategy='auto'):
|
||||||
|
"""Bulk-reconcile a recordset using the chosen strategy.
|
||||||
|
|
||||||
|
Returns: ``{'reconciled_count': int, 'skipped': int,
|
||||||
|
'errors': [...]}``
|
||||||
|
"""
|
||||||
|
reconciled = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = []
|
||||||
|
for line in statement_lines:
|
||||||
|
if line.is_reconciled:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
# Per-line savepoint so a single DB-level failure (e.g. a
|
||||||
|
# check-constraint violation on one bad line) doesn't poison
|
||||||
|
# the whole batch's transaction.
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
candidates = self._fetch_candidates(line)
|
||||||
|
picked = self._apply_strategy(
|
||||||
|
line, candidates, strategy)
|
||||||
|
if picked:
|
||||||
|
self.reconcile_one(line, against_lines=picked)
|
||||||
|
reconciled += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
errors.append({'line_id': line.id, 'error': str(e)})
|
||||||
|
_logger.warning(
|
||||||
|
"reconcile_batch failed for line %s: %s", line.id, e)
|
||||||
|
return {
|
||||||
|
'reconciled_count': reconciled,
|
||||||
|
'skipped': skipped,
|
||||||
|
'errors': errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def suggest_matches(self, statement_lines, *, limit_per_line=3):
|
||||||
|
"""Compute and persist AI suggestions per line.
|
||||||
|
|
||||||
|
Returns: dict mapping ``line_id`` -> list of suggestion dicts.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
Suggestion = self.env['fusion.reconcile.suggestion']
|
||||||
|
for line in statement_lines:
|
||||||
|
candidates_records = self._fetch_candidates(line)
|
||||||
|
if not candidates_records:
|
||||||
|
continue
|
||||||
|
candidates_dataclasses = self._records_to_candidates(
|
||||||
|
line, candidates_records)
|
||||||
|
scored = score_candidates(
|
||||||
|
self.env,
|
||||||
|
statement_line=line,
|
||||||
|
candidates=candidates_dataclasses,
|
||||||
|
k=limit_per_line,
|
||||||
|
use_ai=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
Suggestion.search([
|
||||||
|
('statement_line_id', '=', line.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
]).write({'state': 'superseded'})
|
||||||
|
|
||||||
|
line_suggestions = []
|
||||||
|
for rank, s in enumerate(scored, start=1):
|
||||||
|
sug = Suggestion.create({
|
||||||
|
'company_id': line.company_id.id,
|
||||||
|
'statement_line_id': line.id,
|
||||||
|
'proposed_move_line_ids': [(6, 0, [s.candidate_id])],
|
||||||
|
'confidence': s.confidence,
|
||||||
|
'rank': rank,
|
||||||
|
'reasoning': s.reasoning,
|
||||||
|
'score_amount_match': s.score_amount_match,
|
||||||
|
'score_partner_pattern': s.score_partner_pattern,
|
||||||
|
'score_precedent_similarity': s.score_precedent_similarity,
|
||||||
|
'score_ai_rerank': s.score_ai_rerank,
|
||||||
|
'generated_by': 'on_demand',
|
||||||
|
'state': 'pending',
|
||||||
|
})
|
||||||
|
line_suggestions.append({
|
||||||
|
'id': sug.id,
|
||||||
|
'rank': rank,
|
||||||
|
'confidence': s.confidence,
|
||||||
|
'reasoning': s.reasoning,
|
||||||
|
'candidate_id': s.candidate_id,
|
||||||
|
})
|
||||||
|
out[line.id] = line_suggestions
|
||||||
|
return out
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def accept_suggestion(self, suggestion):
|
||||||
|
"""User clicked Accept on a suggestion -> reconcile via its proposal.
|
||||||
|
|
||||||
|
Returns: same shape as ``reconcile_one``.
|
||||||
|
"""
|
||||||
|
if isinstance(suggestion, int):
|
||||||
|
suggestion = self.env['fusion.reconcile.suggestion'].browse(
|
||||||
|
suggestion)
|
||||||
|
suggestion.ensure_one()
|
||||||
|
line = suggestion.statement_line_id
|
||||||
|
against = suggestion.proposed_move_line_ids
|
||||||
|
result = self.reconcile_one(line, against_lines=against)
|
||||||
|
suggestion.write({
|
||||||
|
'state': 'accepted',
|
||||||
|
'accepted_at': fields.Datetime.now(),
|
||||||
|
'accepted_by': self.env.uid,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def write_off(self, statement_line, *, account, amount, label, tax_id=None):
|
||||||
|
"""Create a write-off move + reconcile the bank line against it.
|
||||||
|
|
||||||
|
Returns: same shape as ``reconcile_one``.
|
||||||
|
"""
|
||||||
|
write_off_vals = {
|
||||||
|
'account_id': account.id if hasattr(account, 'id') else account,
|
||||||
|
'amount': amount,
|
||||||
|
'tax_id': (tax_id.id if (tax_id and hasattr(tax_id, 'id'))
|
||||||
|
else tax_id),
|
||||||
|
'label': label,
|
||||||
|
}
|
||||||
|
return self.reconcile_one(
|
||||||
|
statement_line, against_lines=None, write_off_vals=write_off_vals)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def unreconcile(self, partial_reconciles):
|
||||||
|
"""Reverse a reconciliation. Handles full vs. partial chains.
|
||||||
|
|
||||||
|
Because ``reconcile_one`` rewrites the bank move's suspense line into
|
||||||
|
one or more counterpart lines, simply deleting the
|
||||||
|
``account.partial.reconcile`` rows is not enough — the bank move
|
||||||
|
would still look reconciled (no suspense line, no residual). We
|
||||||
|
delegate to V19's standard ``account.bank.statement.line.
|
||||||
|
action_undo_reconciliation`` for any affected bank line, which
|
||||||
|
clears the partials AND restores the original suspense state.
|
||||||
|
|
||||||
|
Returns: ``{'unreconciled_line_ids': [...]}``
|
||||||
|
"""
|
||||||
|
partial_reconciles = partial_reconciles.exists()
|
||||||
|
if not partial_reconciles:
|
||||||
|
return {'unreconciled_line_ids': []}
|
||||||
|
all_lines = (
|
||||||
|
partial_reconciles.mapped('debit_move_id')
|
||||||
|
| partial_reconciles.mapped('credit_move_id')
|
||||||
|
)
|
||||||
|
line_ids = all_lines.ids
|
||||||
|
# Find any bank statement lines whose move owns one of these journal
|
||||||
|
# items; route them through the standard undo flow which both
|
||||||
|
# deletes the partials and restores the suspense line.
|
||||||
|
affected_bank_lines = self.env['account.bank.statement.line'].search([
|
||||||
|
('move_id', 'in', all_lines.mapped('move_id').ids),
|
||||||
|
])
|
||||||
|
if affected_bank_lines:
|
||||||
|
affected_bank_lines.action_undo_reconciliation()
|
||||||
|
# Anything still hanging around (rare — non-bank-line reconciles)
|
||||||
|
# gets a direct unlink as a fallback.
|
||||||
|
remaining = partial_reconciles.exists()
|
||||||
|
if remaining:
|
||||||
|
remaining.unlink()
|
||||||
|
return {'unreconciled_line_ids': line_ids}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PRIVATE HELPERS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _validate_reconcile(self, statement_line, against_lines):
|
||||||
|
"""Phase 2: structural + safety checks."""
|
||||||
|
if not statement_line.exists():
|
||||||
|
raise ValidationError(_("Statement line does not exist"))
|
||||||
|
company = statement_line.company_id
|
||||||
|
line_date = statement_line.date
|
||||||
|
lock_date = company.fiscalyear_lock_date
|
||||||
|
if lock_date and line_date and line_date <= lock_date:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"Cannot reconcile: line date %(line)s is on or before fiscal "
|
||||||
|
"year lock date %(lock)s",
|
||||||
|
line=line_date,
|
||||||
|
lock=lock_date,
|
||||||
|
))
|
||||||
|
|
||||||
|
def _build_counterpart_vals(self, statement_line, inv_line, *,
|
||||||
|
allocated_balance):
|
||||||
|
"""Build the vals for one counterpart line that mirrors an invoice
|
||||||
|
line on the bank move.
|
||||||
|
|
||||||
|
``allocated_balance`` is the signed company-currency balance to write
|
||||||
|
on the counterpart. It is clamped (by the caller) so that the bank
|
||||||
|
move stays balanced and no invoice gets over-paid. We scale
|
||||||
|
``amount_currency`` proportionally for multi-currency lines.
|
||||||
|
"""
|
||||||
|
inv_residual = inv_line.amount_residual
|
||||||
|
if inv_residual:
|
||||||
|
scale = abs(allocated_balance) / abs(inv_residual)
|
||||||
|
else:
|
||||||
|
scale = 1.0
|
||||||
|
amount_currency = -inv_line.amount_residual_currency * scale
|
||||||
|
return {
|
||||||
|
'name': inv_line.name or statement_line.payment_ref or '',
|
||||||
|
'account_id': inv_line.account_id.id,
|
||||||
|
'partner_id': (inv_line.partner_id.id
|
||||||
|
if inv_line.partner_id else False),
|
||||||
|
'currency_id': inv_line.currency_id.id,
|
||||||
|
'amount_currency': amount_currency,
|
||||||
|
'balance': allocated_balance,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_write_off_vals(self, statement_line, write_off_vals, *,
|
||||||
|
balance):
|
||||||
|
"""Build the vals for a write-off counterpart line on the bank move.
|
||||||
|
|
||||||
|
``balance`` is the signed company-currency balance the write-off
|
||||||
|
line must carry to keep the bank move balanced.
|
||||||
|
"""
|
||||||
|
vals = {
|
||||||
|
'name': write_off_vals.get('label') or _('Write-off'),
|
||||||
|
'account_id': write_off_vals['account_id'],
|
||||||
|
'partner_id': (statement_line.partner_id.id
|
||||||
|
if statement_line.partner_id else False),
|
||||||
|
'balance': balance,
|
||||||
|
}
|
||||||
|
if write_off_vals.get('tax_id'):
|
||||||
|
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]
|
||||||
|
return vals
|
||||||
|
|
||||||
|
def _fetch_candidates(self, statement_line):
|
||||||
|
"""SQL pre-filter: open journal items matching partner + reconcilable
|
||||||
|
account."""
|
||||||
|
domain = [
|
||||||
|
('parent_state', '=', 'posted'),
|
||||||
|
('account_id.reconcile', '=', True),
|
||||||
|
('reconciled', '=', False),
|
||||||
|
('display_type', 'not in', ('line_section', 'line_note')),
|
||||||
|
]
|
||||||
|
if statement_line.partner_id:
|
||||||
|
domain.append(('partner_id', '=', statement_line.partner_id.id))
|
||||||
|
return self.env['account.move.line'].search(domain, limit=200)
|
||||||
|
|
||||||
|
def _records_to_candidates(self, statement_line, records):
|
||||||
|
"""Convert ``account.move.line`` recordset to ``Candidate`` dataclasses."""
|
||||||
|
today = fields.Date.today()
|
||||||
|
result = []
|
||||||
|
for c in records:
|
||||||
|
ref_date = c.date_maturity or c.date or today
|
||||||
|
age_days = (today - ref_date).days
|
||||||
|
result.append(Candidate(
|
||||||
|
id=c.id,
|
||||||
|
amount=abs(c.amount_residual) or abs(c.balance),
|
||||||
|
partner_id=c.partner_id.id if c.partner_id else 0,
|
||||||
|
age_days=age_days,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _apply_strategy(self, line, candidate_records, strategy):
|
||||||
|
"""Apply the named strategy. Returns matching ``account.move.line``
|
||||||
|
recordset, or empty recordset if nothing matched."""
|
||||||
|
AML = self.env['account.move.line']
|
||||||
|
if not candidate_records:
|
||||||
|
return AML
|
||||||
|
candidate_dcs = self._records_to_candidates(line, candidate_records)
|
||||||
|
bank_amount = abs(line.amount)
|
||||||
|
if strategy == 'auto':
|
||||||
|
for strat_class in (AmountExactStrategy,
|
||||||
|
MultiInvoiceStrategy,
|
||||||
|
FIFOStrategy):
|
||||||
|
result = strat_class().match(
|
||||||
|
bank_amount=bank_amount, candidates=candidate_dcs)
|
||||||
|
if result.picked_ids:
|
||||||
|
return AML.browse(result.picked_ids)
|
||||||
|
return AML
|
||||||
|
|
||||||
|
def _post_audit(self, statement_line, partial_ids, source):
|
||||||
|
"""Append an audit log to the bank-line move's chatter."""
|
||||||
|
if not statement_line.move_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
statement_line.move_id.message_post(
|
||||||
|
body=_(
|
||||||
|
"Reconciled via %(source)s; %(count)d partial(s) created: "
|
||||||
|
"%(ids)s",
|
||||||
|
source=source,
|
||||||
|
count=len(partial_ids),
|
||||||
|
ids=partial_ids,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
_logger.debug(
|
||||||
|
"Audit log skipped for line %s: %s", statement_line.id, e)
|
||||||
|
|
||||||
|
def _record_precedent(self, statement_line, against_lines):
|
||||||
|
"""Append a precedent for future pattern learning. Best-effort."""
|
||||||
|
if not against_lines:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.env['fusion.reconcile.precedent'].sudo().create({
|
||||||
|
'company_id': statement_line.company_id.id,
|
||||||
|
'partner_id': (statement_line.partner_id.id
|
||||||
|
if statement_line.partner_id else False),
|
||||||
|
'amount': abs(statement_line.amount),
|
||||||
|
'currency_id': statement_line.currency_id.id,
|
||||||
|
'date': statement_line.date,
|
||||||
|
'memo_tokens': ','.join(
|
||||||
|
tokenize_memo(statement_line.payment_ref)),
|
||||||
|
'journal_id': statement_line.journal_id.id,
|
||||||
|
'matched_move_line_count': len(against_lines),
|
||||||
|
'matched_account_ids': ','.join(
|
||||||
|
str(i) for i in against_lines.mapped('account_id').ids),
|
||||||
|
'reconciler_user_id': self.env.uid,
|
||||||
|
'reconciled_at': fields.Datetime.now(),
|
||||||
|
'source': 'manual',
|
||||||
|
})
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
_logger.warning(
|
||||||
|
"Failed to record precedent for line %s: %s",
|
||||||
|
statement_line.id, e)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Per-partner bank reconciliation pattern aggregate.
|
||||||
|
|
||||||
|
One row per (company_id, partner_id). Continuously summarises HOW this
|
||||||
|
partner gets reconciled. Recomputed nightly via cron from the precedent
|
||||||
|
table. Used as a feature input to confidence_scoring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FusionReconcilePattern(models.Model):
|
||||||
|
_name = "fusion.reconcile.pattern"
|
||||||
|
_description = "Per-partner bank reconciliation pattern aggregate"
|
||||||
|
_rec_name = "partner_id"
|
||||||
|
|
||||||
|
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||||
|
default=lambda self: self.env.company)
|
||||||
|
partner_id = fields.Many2one('res.partner', required=True, index=True)
|
||||||
|
|
||||||
|
# Volume + cadence
|
||||||
|
reconcile_count = fields.Integer(default=0,
|
||||||
|
help="Total past reconciles for this partner")
|
||||||
|
typical_amount_range = fields.Char(
|
||||||
|
help="e.g. '$1,200 – $2,400 (median $1,847.50)'")
|
||||||
|
typical_cadence_days = fields.Float(
|
||||||
|
help="Mean inter-reconcile days")
|
||||||
|
typical_day_of_month = fields.Char(
|
||||||
|
help="e.g. '1st, 15th'")
|
||||||
|
|
||||||
|
# Matching strategy used historically
|
||||||
|
pref_strategy = fields.Selection([
|
||||||
|
('exact_amount', 'Exact-amount-first'),
|
||||||
|
('fifo', 'FIFO oldest-due-first'),
|
||||||
|
('multi_invoice', 'Multi-invoice consolidation'),
|
||||||
|
('cherry_pick', 'Cherry-pick specific invoices'),
|
||||||
|
])
|
||||||
|
pref_account_id = fields.Many2one('account.account',
|
||||||
|
help="Most-used target account")
|
||||||
|
|
||||||
|
# Memo signature
|
||||||
|
common_memo_tokens = fields.Char(
|
||||||
|
help="Comma-separated tokens that appear in ≥30% of past reconciles")
|
||||||
|
|
||||||
|
# Tax + write-off habits
|
||||||
|
common_writeoff_account_id = fields.Many2one('account.account')
|
||||||
|
common_writeoff_tax_id = fields.Many2one('account.tax')
|
||||||
|
typical_writeoff_amount = fields.Float(
|
||||||
|
help="e.g. 0.05 for rounding diffs")
|
||||||
|
|
||||||
|
last_refreshed_at = fields.Datetime()
|
||||||
|
|
||||||
|
_uniq_company_partner = models.Constraint(
|
||||||
|
'unique(company_id, partner_id)',
|
||||||
|
'One pattern row per (company, partner) — already exists.',
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""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'),
|
||||||
|
('backfill', 'Backfilled from account.partial.reconcile (migration)'),
|
||||||
|
('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
|
||||||
137
fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py
Normal file
137
fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CRUD overrides — trigger MV refresh so the OWL widget sees fresh
|
||||||
|
# confidence bands / top suggestion ids without waiting for cron.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
records = super().create(vals_list)
|
||||||
|
self._trigger_mv_refresh()
|
||||||
|
return records
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super().write(vals)
|
||||||
|
# Only refresh on changes that affect the MV's projected columns.
|
||||||
|
if 'state' in vals or 'confidence' in vals or 'rank' in vals:
|
||||||
|
self._trigger_mv_refresh()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _trigger_mv_refresh(self):
|
||||||
|
"""Best-effort MV refresh; never poison the originating transaction.
|
||||||
|
|
||||||
|
Uses concurrently=False because Postgres forbids
|
||||||
|
REFRESH MATERIALIZED VIEW CONCURRENTLY inside a transaction block,
|
||||||
|
and Odoo's per-request cursor is always in a transaction. The cron
|
||||||
|
job (Task 25) opens a dedicated autocommit cursor for CONCURRENTLY
|
||||||
|
refreshes when the MV grows large enough that a brief blocking
|
||||||
|
refresh becomes objectionable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||||
|
concurrently=False)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
_logger.warning(
|
||||||
|
"MV refresh after suggestion write failed: %s", e)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Materialized view exposing pre-aggregated unreconciled-bank-line data.
|
||||||
|
|
||||||
|
The MV is created in the model's init() (called by Odoo on install/upgrade).
|
||||||
|
Refresh strategy:
|
||||||
|
- Cron (every 5 min) — see fusion_accounting_bank_rec/data/cron.xml (Task 25)
|
||||||
|
- Triggered refresh after suggestion writes (handled in fusion_reconcile_suggestion.py)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionUnreconciledBankLineMV(models.Model):
|
||||||
|
_name = "fusion.unreconciled.bank.line.mv"
|
||||||
|
_description = "Materialized view of unreconciled bank lines for OWL widget"
|
||||||
|
_auto = False # we manage the table ourselves
|
||||||
|
_table = "fusion_unreconciled_bank_line_mv"
|
||||||
|
_order = "date desc, id desc"
|
||||||
|
|
||||||
|
# Fields mirror the columns in the SQL view; required so Odoo can read them.
|
||||||
|
company_id = fields.Many2one('res.company', readonly=True)
|
||||||
|
journal_id = fields.Many2one('account.journal', readonly=True)
|
||||||
|
date = fields.Date(readonly=True)
|
||||||
|
amount = fields.Float(readonly=True)
|
||||||
|
payment_ref = fields.Char(readonly=True)
|
||||||
|
currency_id = fields.Many2one('res.currency', readonly=True)
|
||||||
|
partner_id = fields.Many2one('res.partner', readonly=True)
|
||||||
|
create_date = fields.Datetime(readonly=True)
|
||||||
|
top_suggestion_id = fields.Many2one('fusion.reconcile.suggestion', readonly=True)
|
||||||
|
top_confidence = fields.Float(readonly=True)
|
||||||
|
confidence_band = fields.Selection([
|
||||||
|
('high', 'High'),
|
||||||
|
('medium', 'Medium'),
|
||||||
|
('low', 'Low'),
|
||||||
|
('none', 'None'),
|
||||||
|
], readonly=True)
|
||||||
|
attachment_count = fields.Integer(readonly=True)
|
||||||
|
partner_reconcile_count = fields.Integer(readonly=True)
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
"""Create the MV if missing.
|
||||||
|
|
||||||
|
Reads create_mv_unreconciled_bank_line.sql and executes it. Idempotent
|
||||||
|
because the SQL uses CREATE MATERIALIZED VIEW IF NOT EXISTS."""
|
||||||
|
sql_path = os.path.join(
|
||||||
|
os.path.dirname(__file__), '..', 'data', 'sql',
|
||||||
|
'create_mv_unreconciled_bank_line.sql')
|
||||||
|
with open(sql_path, 'r') as f:
|
||||||
|
sql = f.read()
|
||||||
|
self.env.cr.execute(sql)
|
||||||
|
_logger.info(
|
||||||
|
"fusion_unreconciled_bank_line_mv: created/verified MV + indexes")
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _refresh(self, *, concurrently=True):
|
||||||
|
"""Refresh the MV.
|
||||||
|
|
||||||
|
If ``concurrently=True`` (default), uses
|
||||||
|
REFRESH MATERIALIZED VIEW CONCURRENTLY (requires the unique index).
|
||||||
|
Falls back to a blocking refresh on the first refresh after creation
|
||||||
|
(when CONCURRENTLY is not yet allowed because the MV has never been
|
||||||
|
populated).
|
||||||
|
|
||||||
|
Flushes the ORM cache first so the materialization sees the latest
|
||||||
|
committed-to-DB values for fields like ``is_reconciled`` (computed,
|
||||||
|
stored — sometimes still buffered in the cache mid-request)."""
|
||||||
|
self.env.flush_all()
|
||||||
|
keyword = "CONCURRENTLY" if concurrently else ""
|
||||||
|
try:
|
||||||
|
self.env.cr.execute(
|
||||||
|
f"REFRESH MATERIALIZED VIEW {keyword} fusion_unreconciled_bank_line_mv"
|
||||||
|
)
|
||||||
|
_logger.debug(
|
||||||
|
"fusion_unreconciled_bank_line_mv refreshed (%s)",
|
||||||
|
'concurrent' if concurrently else 'blocking')
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
# CONCURRENTLY fails on first refresh after creation if the MV is
|
||||||
|
# empty / has never been populated; fall back to non-concurrent.
|
||||||
|
if concurrently:
|
||||||
|
_logger.warning(
|
||||||
|
"Concurrent MV refresh failed (%s); falling back to "
|
||||||
|
"blocking refresh", e)
|
||||||
|
self.env.cr.execute(
|
||||||
|
"REFRESH MATERIALIZED VIEW fusion_unreconciled_bank_line_mv"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
1
fusion_accounting_bank_rec/reports/__init__.py
Normal file
1
fusion_accounting_bank_rec/reports/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import migration_audit_report
|
||||||
51
fusion_accounting_bank_rec/reports/migration_audit_report.py
Normal file
51
fusion_accounting_bank_rec/reports/migration_audit_report.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""QWeb PDF report: summary of bank-rec migration outcomes.
|
||||||
|
|
||||||
|
Triggered from the migration wizard's "Print" menu after the wizard
|
||||||
|
completes. For each company on the system, reports:
|
||||||
|
- Backfilled precedents (source='backfill')
|
||||||
|
- Fusion reconcile patterns
|
||||||
|
- Bank statement lines still unreconciled
|
||||||
|
|
||||||
|
Lets the operator confirm Phase 1 migration successfully bootstrapped
|
||||||
|
the AI's reconcile memory from past Enterprise reconciles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
|
||||||
|
class FusionMigrationAuditReport(models.AbstractModel):
|
||||||
|
_name = "report.fusion_accounting_bank_rec.migration_audit_template"
|
||||||
|
_description = "Bank-Rec Migration Audit Report"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_report_values(self, docids, data=None):
|
||||||
|
Wizard = self.env['fusion.migration.wizard']
|
||||||
|
wizards = Wizard.browse(docids) if docids else Wizard
|
||||||
|
|
||||||
|
Precedent = self.env['fusion.reconcile.precedent']
|
||||||
|
Pattern = self.env['fusion.reconcile.pattern']
|
||||||
|
Line = self.env['account.bank.statement.line']
|
||||||
|
|
||||||
|
company_stats = []
|
||||||
|
for company in self.env['res.company'].search([]):
|
||||||
|
company_stats.append({
|
||||||
|
'company': company,
|
||||||
|
'precedents_count': Precedent.search_count([
|
||||||
|
('company_id', '=', company.id),
|
||||||
|
('source', '=', 'backfill'),
|
||||||
|
]),
|
||||||
|
'patterns_count': Pattern.search_count([
|
||||||
|
('company_id', '=', company.id),
|
||||||
|
]),
|
||||||
|
'unreconciled_count': Line.search_count([
|
||||||
|
('company_id', '=', company.id),
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'doc_ids': docids,
|
||||||
|
'doc_model': 'fusion.migration.wizard',
|
||||||
|
'docs': wizards,
|
||||||
|
'company_stats': company_stats,
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="action_report_migration_audit" model="ir.actions.report">
|
||||||
|
<field name="name">Bank-Rec Migration Audit</field>
|
||||||
|
<field name="model">fusion.migration.wizard</field>
|
||||||
|
<field name="report_type">qweb-pdf</field>
|
||||||
|
<field name="report_name">fusion_accounting_bank_rec.migration_audit_template</field>
|
||||||
|
<field name="report_file">fusion_accounting_bank_rec.migration_audit_template</field>
|
||||||
|
<field name="binding_model_id" ref="fusion_accounting_migration.model_fusion_migration_wizard"/>
|
||||||
|
<field name="binding_type">report</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<template id="migration_audit_template">
|
||||||
|
<t t-call="web.html_container">
|
||||||
|
<t t-call="web.external_layout">
|
||||||
|
<div class="page">
|
||||||
|
<h2>Bank-Rec Migration Audit</h2>
|
||||||
|
<p>
|
||||||
|
Generated
|
||||||
|
<span t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Per-Company Summary</h3>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Company</th>
|
||||||
|
<th class="text-end">Backfilled Precedents</th>
|
||||||
|
<th class="text-end">Patterns</th>
|
||||||
|
<th class="text-end">Still Unreconciled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr t-foreach="company_stats" t-as="cs">
|
||||||
|
<td><span t-esc="cs['company'].name"/></td>
|
||||||
|
<td class="text-end"><span t-esc="cs['precedents_count']"/></td>
|
||||||
|
<td class="text-end"><span t-esc="cs['patterns_count']"/></td>
|
||||||
|
<td class="text-end"><span t-esc="cs['unreconciled_count']"/></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="text-muted">
|
||||||
|
This report verifies that Phase 1 migration successfully
|
||||||
|
bootstrapped the AI's reconcile memory from past Enterprise
|
||||||
|
reconciles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
12
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
12
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
|
access_fusion_unreconciled_bank_line_mv_user,unreconciled bank line mv user,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||||
|
access_fusion_unreconciled_bank_line_mv_admin,unreconciled bank line mv admin,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_admin,1,0,0,0
|
||||||
|
access_fusion_auto_reconcile_wizard_user,fusion.auto.reconcile.wizard.user,model_fusion_auto_reconcile_wizard,base.group_user,1,1,1,0
|
||||||
|
access_fusion_bulk_reconcile_wizard_user,fusion.bulk.reconcile.wizard.user,model_fusion_bulk_reconcile_wizard,base.group_user,1,1,1,0
|
||||||
|
7
fusion_accounting_bank_rec/services/__init__.py
Normal file
7
fusion_accounting_bank_rec/services/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from . import memo_tokenizer
|
||||||
|
from . import exchange_diff
|
||||||
|
from . import matching_strategies
|
||||||
|
from . import precedent_lookup
|
||||||
|
from . import pattern_extractor
|
||||||
|
from . import confidence_scoring
|
||||||
|
from . import precedent_backfill
|
||||||
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal file
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""4-pass confidence scoring pipeline.
|
||||||
|
|
||||||
|
Pass 1: SQL filter — partner match + reconcilable account (done by caller — engine._fetch_candidates)
|
||||||
|
Pass 2: Statistical scoring — amount delta + pattern match + precedent similarity
|
||||||
|
Pass 3: AI re-rank (if provider configured) — feed top 5 to LLM, parse JSON ranking
|
||||||
|
Pass 4: Persist as fusion.reconcile.suggestion rows (done by caller — engine.suggest_matches)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .matching_strategies import Candidate
|
||||||
|
from .precedent_lookup import find_nearest_precedents
|
||||||
|
from .memo_tokenizer import tokenize_memo
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScoredCandidate:
|
||||||
|
candidate_id: int
|
||||||
|
confidence: float
|
||||||
|
reasoning: str
|
||||||
|
score_amount_match: float
|
||||||
|
score_partner_pattern: float
|
||||||
|
score_precedent_similarity: float
|
||||||
|
score_ai_rerank: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def score_candidates(env, *, statement_line, candidates, k=5, use_ai=True):
|
||||||
|
"""Score and rank candidate matches for a statement line.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: Odoo env
|
||||||
|
statement_line: account.bank.statement.line recordset (singleton)
|
||||||
|
candidates: list of Candidate dataclasses (from matching_strategies)
|
||||||
|
k: max number of scored candidates to return
|
||||||
|
use_ai: if True AND a provider is configured, invoke AI re-rank
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of ScoredCandidate sorted by confidence desc, max length k.
|
||||||
|
"""
|
||||||
|
if not candidates or not statement_line:
|
||||||
|
return []
|
||||||
|
|
||||||
|
partner_id = statement_line.partner_id.id if statement_line.partner_id else None
|
||||||
|
bank_amount = abs(statement_line.amount)
|
||||||
|
memo_tokens = tokenize_memo(statement_line.payment_ref)
|
||||||
|
|
||||||
|
pattern = None
|
||||||
|
if partner_id:
|
||||||
|
pattern = env['fusion.reconcile.pattern'].sudo().search(
|
||||||
|
[('partner_id', '=', partner_id)], limit=1)
|
||||||
|
if not pattern:
|
||||||
|
pattern = None
|
||||||
|
|
||||||
|
precedents = []
|
||||||
|
if partner_id:
|
||||||
|
precedents = find_nearest_precedents(
|
||||||
|
env, partner_id=partner_id, amount=bank_amount, k=5, memo_tokens=memo_tokens)
|
||||||
|
|
||||||
|
scored = []
|
||||||
|
for cand in candidates:
|
||||||
|
amount_score = 1.0 - min(abs(cand.amount - bank_amount) / max(bank_amount, 1), 1.0)
|
||||||
|
pattern_score = _pattern_score(cand, pattern, bank_amount)
|
||||||
|
precedent_score = _precedent_score(cand, precedents)
|
||||||
|
confidence = (amount_score * 0.5) + (pattern_score * 0.25) + (precedent_score * 0.25)
|
||||||
|
|
||||||
|
reasoning = _build_reasoning(amount_score, pattern_score, precedent_score, pattern)
|
||||||
|
scored.append(ScoredCandidate(
|
||||||
|
candidate_id=cand.id,
|
||||||
|
confidence=round(confidence, 3),
|
||||||
|
reasoning=reasoning,
|
||||||
|
score_amount_match=round(amount_score, 3),
|
||||||
|
score_partner_pattern=round(pattern_score, 3),
|
||||||
|
score_precedent_similarity=round(precedent_score, 3),
|
||||||
|
))
|
||||||
|
|
||||||
|
scored.sort(key=lambda s: -s.confidence)
|
||||||
|
top_k = scored[:k]
|
||||||
|
|
||||||
|
if use_ai:
|
||||||
|
provider = _get_provider(env, 'bank_rec_suggest')
|
||||||
|
if provider is not None:
|
||||||
|
try:
|
||||||
|
top_k = _ai_rerank(env, provider, statement_line, top_k, pattern, precedents)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("AI re-rank failed, using statistical scoring: %s", e)
|
||||||
|
|
||||||
|
return top_k
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern_score(cand, pattern, bank_amount) -> float:
|
||||||
|
"""How well does this candidate fit the partner's typical pattern?"""
|
||||||
|
if not pattern:
|
||||||
|
return 0.5
|
||||||
|
score = 0.5
|
||||||
|
if pattern.pref_strategy == 'exact_amount' and abs(cand.amount - bank_amount) < 0.005:
|
||||||
|
score = 1.0
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def _precedent_score(cand, precedents) -> float:
|
||||||
|
"""How similar is this candidate to past precedents?"""
|
||||||
|
if not precedents:
|
||||||
|
return 0.5
|
||||||
|
best = max((p.similarity_score for p in precedents), default=0.5)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reasoning(amount_score, pattern_score, precedent_score, pattern) -> str:
|
||||||
|
parts = []
|
||||||
|
if amount_score >= 0.99:
|
||||||
|
parts.append("Exact amount match")
|
||||||
|
elif amount_score >= 0.95:
|
||||||
|
parts.append("Amount close")
|
||||||
|
if pattern and pattern.reconcile_count > 5:
|
||||||
|
parts.append(f"Matches partner's {pattern.reconcile_count}-reconcile pattern")
|
||||||
|
if precedent_score >= 0.8:
|
||||||
|
parts.append("Strong precedent match")
|
||||||
|
return " · ".join(parts) if parts else "Weak signal"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_provider(env, feature_name):
|
||||||
|
"""Look up provider name from per-feature config; instantiate adapter.
|
||||||
|
|
||||||
|
Returns None if no provider configured (statistical-only mode)."""
|
||||||
|
param = env['ir.config_parameter'].sudo()
|
||||||
|
provider_name = param.get_param(f'fusion_accounting.provider.{feature_name}')
|
||||||
|
if not provider_name:
|
||||||
|
provider_name = param.get_param('fusion_accounting.provider.default')
|
||||||
|
if not provider_name:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||||
|
except ImportError:
|
||||||
|
_logger.warning("fusion_accounting_ai adapters not importable")
|
||||||
|
return None
|
||||||
|
if provider_name.startswith('openai'):
|
||||||
|
return OpenAIAdapter(env)
|
||||||
|
elif provider_name.startswith('claude'):
|
||||||
|
return ClaudeAdapter(env)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_rerank(env, provider, statement_line, scored, pattern, precedents):
|
||||||
|
"""Send top-K candidates + features to LLM for re-rank. Parse JSON response.
|
||||||
|
|
||||||
|
On any failure (network, JSON parse, missing key), return scored unchanged."""
|
||||||
|
try:
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import build_prompt
|
||||||
|
except ImportError:
|
||||||
|
_logger.debug("bank_rec_prompt not yet available; skipping AI re-rank")
|
||||||
|
return scored
|
||||||
|
|
||||||
|
system, user = build_prompt(statement_line, scored, pattern, precedents)
|
||||||
|
response = provider.complete(
|
||||||
|
system=system,
|
||||||
|
messages=[{'role': 'user', 'content': user}],
|
||||||
|
max_tokens=800,
|
||||||
|
temperature=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(response['content'])
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError):
|
||||||
|
return scored
|
||||||
|
|
||||||
|
ai_order = {item['candidate_id']: item for item in parsed.get('ranked', [])}
|
||||||
|
for s in scored:
|
||||||
|
if s.candidate_id in ai_order:
|
||||||
|
s.score_ai_rerank = ai_order[s.candidate_id].get('confidence', s.confidence)
|
||||||
|
s.reasoning = ai_order[s.candidate_id].get('reason', s.reasoning)
|
||||||
|
s.confidence = round((s.confidence * 0.4) + (s.score_ai_rerank * 0.6), 3)
|
||||||
|
scored.sort(key=lambda x: -x.confidence)
|
||||||
|
return scored
|
||||||
46
fusion_accounting_bank_rec/services/exchange_diff.py
Normal file
46
fusion_accounting_bank_rec/services/exchange_diff.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Exchange-difference calculation helper.
|
||||||
|
|
||||||
|
Pure-Python FX gain/loss computation. The engine uses this for rapid
|
||||||
|
pre-checks; Odoo's account.move._create_exchange_difference_move() is
|
||||||
|
invoked separately for the actual GL posting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExchangeDiffResult:
|
||||||
|
needs_diff_move: bool
|
||||||
|
diff_amount: float # in company currency; positive = gain, negative = loss
|
||||||
|
line_company_amount: float
|
||||||
|
against_company_amount: float
|
||||||
|
|
||||||
|
|
||||||
|
def compute_exchange_diff(*, line_amount, line_currency_code, against_amount,
|
||||||
|
against_currency_code, line_rate, against_rate) -> ExchangeDiffResult:
|
||||||
|
"""Compute whether an exchange-diff move is needed and its magnitude.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line_amount: Bank line amount in its currency
|
||||||
|
line_currency_code: e.g. 'USD'
|
||||||
|
against_amount: Matched journal item amount in its currency
|
||||||
|
against_currency_code: e.g. 'USD' (or different)
|
||||||
|
line_rate: FX rate (foreign per company currency) at line date
|
||||||
|
against_rate: FX rate at journal item posting date
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ExchangeDiffResult with needs_diff_move flag and computed diff
|
||||||
|
in company currency (positive = gain, negative = loss).
|
||||||
|
"""
|
||||||
|
line_company = line_amount * line_rate
|
||||||
|
against_company = against_amount * against_rate
|
||||||
|
|
||||||
|
diff = line_company - against_company
|
||||||
|
needs_diff = abs(diff) > 0.005 # rounding tolerance
|
||||||
|
|
||||||
|
return ExchangeDiffResult(
|
||||||
|
needs_diff_move=needs_diff,
|
||||||
|
diff_amount=round(diff, 2),
|
||||||
|
line_company_amount=round(line_company, 2),
|
||||||
|
against_company_amount=round(against_company, 2),
|
||||||
|
)
|
||||||
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Matching strategy classes for the reconcile engine.
|
||||||
|
|
||||||
|
Each strategy takes a bank amount + list of candidate journal items
|
||||||
|
and returns a MatchResult with the picked ids + confidence + residual.
|
||||||
|
Strategies are pure Python; no ORM dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from itertools import combinations
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Candidate:
|
||||||
|
id: int
|
||||||
|
amount: float
|
||||||
|
partner_id: int
|
||||||
|
age_days: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchResult:
|
||||||
|
picked_ids: list[int] = field(default_factory=list)
|
||||||
|
confidence: float = 0.0
|
||||||
|
residual: float = 0.0 # bank_amount - sum(picked); positive = under-allocated
|
||||||
|
strategy_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
AMOUNT_TOLERANCE = 0.005 # currency rounding tolerance
|
||||||
|
|
||||||
|
|
||||||
|
class AmountExactStrategy:
|
||||||
|
"""Pick a single candidate whose amount equals the bank amount exactly.
|
||||||
|
If multiple candidates match exactly, pick the oldest (FIFO tiebreaker)."""
|
||||||
|
|
||||||
|
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||||
|
exact = [c for c in candidates if abs(c.amount - bank_amount) < AMOUNT_TOLERANCE]
|
||||||
|
if not exact:
|
||||||
|
return MatchResult(strategy_name='amount_exact')
|
||||||
|
oldest = max(exact, key=lambda c: c.age_days)
|
||||||
|
return MatchResult(
|
||||||
|
picked_ids=[oldest.id],
|
||||||
|
confidence=1.0,
|
||||||
|
residual=0.0,
|
||||||
|
strategy_name='amount_exact',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FIFOStrategy:
|
||||||
|
"""Pick oldest candidates first until the bank amount is exhausted.
|
||||||
|
May produce partial reconcile residual if last candidate doesn't fit exactly."""
|
||||||
|
|
||||||
|
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||||
|
if not candidates:
|
||||||
|
return MatchResult(strategy_name='fifo')
|
||||||
|
oldest_first = sorted(candidates, key=lambda c: -c.age_days)
|
||||||
|
picked = []
|
||||||
|
remaining = bank_amount
|
||||||
|
for c in oldest_first:
|
||||||
|
if remaining <= AMOUNT_TOLERANCE:
|
||||||
|
break
|
||||||
|
picked.append(c.id)
|
||||||
|
remaining -= c.amount
|
||||||
|
|
||||||
|
confidence = 0.7 if remaining < AMOUNT_TOLERANCE else 0.5
|
||||||
|
return MatchResult(
|
||||||
|
picked_ids=picked,
|
||||||
|
confidence=confidence,
|
||||||
|
residual=remaining,
|
||||||
|
strategy_name='fifo',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MultiInvoiceStrategy:
|
||||||
|
"""Find the smallest combination of candidates summing to the bank amount.
|
||||||
|
Bounded by max_combinations to keep complexity manageable."""
|
||||||
|
|
||||||
|
def __init__(self, max_combinations=3):
|
||||||
|
self.max_combinations = max_combinations
|
||||||
|
|
||||||
|
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||||
|
for k in range(2, self.max_combinations + 1):
|
||||||
|
for combo in combinations(candidates, k):
|
||||||
|
total = sum(c.amount for c in combo)
|
||||||
|
if abs(total - bank_amount) < AMOUNT_TOLERANCE:
|
||||||
|
return MatchResult(
|
||||||
|
picked_ids=[c.id for c in combo],
|
||||||
|
confidence=0.85,
|
||||||
|
residual=0.0,
|
||||||
|
strategy_name=f'multi_invoice_{k}',
|
||||||
|
)
|
||||||
|
return MatchResult(strategy_name='multi_invoice')
|
||||||
44
fusion_accounting_bank_rec/services/memo_tokenizer.py
Normal file
44
fusion_accounting_bank_rec/services/memo_tokenizer.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Extract searchable tokens from Canadian bank statement memos.
|
||||||
|
|
||||||
|
Handles common memo formats from RBC, TD, Scotia, BMO, plus generic
|
||||||
|
cheque-number and reference-number patterns. Output is normalized
|
||||||
|
(uppercase, alphanumeric) for case-insensitive matching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
REF_PATTERNS = [
|
||||||
|
(re.compile(r'\b(REF|REFERENCE)\s*#?\s*(\d+)\b', re.I), r'REF\2'),
|
||||||
|
(re.compile(r'\b(CHQ|CHEQUE|CHECK)\s*#?\s*(\d+)\b', re.I), r'CHEQUE\2'),
|
||||||
|
(re.compile(r'\b(INV|INVOICE)\s*#?\s*(\d+)\b', re.I), r'INV\2'),
|
||||||
|
]
|
||||||
|
|
||||||
|
MIN_TOKEN_LENGTH = 2
|
||||||
|
|
||||||
|
|
||||||
|
def tokenize_memo(memo: str | None) -> list[str]:
|
||||||
|
"""Return list of normalized tokens from a bank memo.
|
||||||
|
|
||||||
|
Empty/None input returns []. Order preserved (first occurrence wins
|
||||||
|
for de-duplication)."""
|
||||||
|
if not memo:
|
||||||
|
return []
|
||||||
|
|
||||||
|
text = memo.upper()
|
||||||
|
for pattern, replacement in REF_PATTERNS:
|
||||||
|
text = pattern.sub(replacement, text)
|
||||||
|
|
||||||
|
text = re.sub(r'[^A-Z0-9]+', ' ', text)
|
||||||
|
raw_tokens = text.split()
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
tokens = []
|
||||||
|
for tok in raw_tokens:
|
||||||
|
if len(tok) < MIN_TOKEN_LENGTH:
|
||||||
|
continue
|
||||||
|
if tok in seen:
|
||||||
|
continue
|
||||||
|
seen.add(tok)
|
||||||
|
tokens.append(tok)
|
||||||
|
|
||||||
|
return tokens
|
||||||
74
fusion_accounting_bank_rec/services/pattern_extractor.py
Normal file
74
fusion_accounting_bank_rec/services/pattern_extractor.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Aggregate per-partner reconciliation patterns from precedent rows.
|
||||||
|
|
||||||
|
Computes typical amount range, cadence, preferred strategy, common memo
|
||||||
|
tokens. Output is a dict suitable for create/write on fusion.reconcile.pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from statistics import median
|
||||||
|
|
||||||
|
|
||||||
|
def extract_pattern_for_partner(env, *, company_id, partner_id) -> dict:
|
||||||
|
"""Compute the pattern aggregate for one (company, partner) pair.
|
||||||
|
|
||||||
|
Returns vals dict suitable for env['fusion.reconcile.pattern'].create()."""
|
||||||
|
Precedent = env['fusion.reconcile.precedent'].sudo()
|
||||||
|
precedents = Precedent.search([
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
('partner_id', '=', partner_id),
|
||||||
|
], order='reconciled_at desc', limit=200)
|
||||||
|
|
||||||
|
if not precedents:
|
||||||
|
return {
|
||||||
|
'company_id': company_id,
|
||||||
|
'partner_id': partner_id,
|
||||||
|
'reconcile_count': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
amounts = sorted(precedents.mapped('amount'))
|
||||||
|
counts = precedents.mapped('matched_move_line_count')
|
||||||
|
|
||||||
|
single_count = sum(1 for c in counts if c == 1)
|
||||||
|
multi_count = sum(1 for c in counts if c > 1)
|
||||||
|
if multi_count > single_count:
|
||||||
|
pref_strategy = 'multi_invoice'
|
||||||
|
elif _amounts_concentrated(amounts):
|
||||||
|
pref_strategy = 'exact_amount'
|
||||||
|
else:
|
||||||
|
pref_strategy = 'fifo'
|
||||||
|
|
||||||
|
reconcile_dates = sorted([p.reconciled_at for p in precedents if p.reconciled_at])
|
||||||
|
if len(reconcile_dates) >= 2:
|
||||||
|
deltas = [(reconcile_dates[i+1] - reconcile_dates[i]).days
|
||||||
|
for i in range(len(reconcile_dates) - 1)]
|
||||||
|
cadence = sum(deltas) / len(deltas) if deltas else 0.0
|
||||||
|
else:
|
||||||
|
cadence = 0.0
|
||||||
|
|
||||||
|
token_counter = Counter()
|
||||||
|
for p in precedents:
|
||||||
|
if p.memo_tokens:
|
||||||
|
for tok in p.memo_tokens.split(','):
|
||||||
|
token_counter[tok.strip()] += 1
|
||||||
|
# Keep tokens appearing in >=30% of precedents (min floor of 2 occurrences)
|
||||||
|
threshold = max(2, len(precedents) * 0.3)
|
||||||
|
common_tokens = ','.join(t for t, c in token_counter.most_common() if c >= threshold)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'company_id': company_id,
|
||||||
|
'partner_id': partner_id,
|
||||||
|
'reconcile_count': len(precedents),
|
||||||
|
'typical_amount_range': f"${min(amounts):,.2f} – ${max(amounts):,.2f} (median ${median(amounts):,.2f})",
|
||||||
|
'typical_cadence_days': round(cadence, 1),
|
||||||
|
'pref_strategy': pref_strategy,
|
||||||
|
'common_memo_tokens': common_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _amounts_concentrated(amounts: list[float]) -> bool:
|
||||||
|
"""True if amounts cluster around a few values (suggests exact-amount strategy)."""
|
||||||
|
if len(amounts) < 3:
|
||||||
|
return True
|
||||||
|
med = median(amounts)
|
||||||
|
within_5pct = sum(1 for a in amounts if abs(a - med) / max(med, 1) < 0.05)
|
||||||
|
return within_5pct / len(amounts) >= 0.6
|
||||||
116
fusion_accounting_bank_rec/services/precedent_backfill.py
Normal file
116
fusion_accounting_bank_rec/services/precedent_backfill.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Pure-Python helpers for backfilling fusion.reconcile.precedent
|
||||||
|
from existing account.partial.reconcile rows during migration.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
- Each account.partial.reconcile that involves at least one
|
||||||
|
account.bank.statement.line's reconcile-account line is a candidate.
|
||||||
|
- One precedent per qualifying partial. The (statement_line.id, account_id,
|
||||||
|
amount) triple is encoded into matched_account_ids so a second run can
|
||||||
|
detect and skip already-backfilled rows (idempotency).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .memo_tokenizer import tokenize_memo
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _identify_bank_side(partial):
|
||||||
|
"""Return (bank_move_line, counterpart_move_line, statement_line_id)
|
||||||
|
or (None, None, None) if neither side is a bank statement line."""
|
||||||
|
debit_line = partial.debit_move_id
|
||||||
|
credit_line = partial.credit_move_id
|
||||||
|
|
||||||
|
if debit_line.move_id.statement_line_id:
|
||||||
|
return debit_line, credit_line, debit_line.move_id.statement_line_id.id
|
||||||
|
if credit_line.move_id.statement_line_id:
|
||||||
|
return credit_line, debit_line, credit_line.move_id.statement_line_id.id
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_precedents(env, *, company_id=None, batch_size=500, limit=10000):
|
||||||
|
"""Walk account.partial.reconcile and create fusion.reconcile.precedent
|
||||||
|
rows for any reconcile that involves a bank statement line.
|
||||||
|
|
||||||
|
Idempotent: skips partials whose (statement_line, account, amount)
|
||||||
|
signature is already present in fusion.reconcile.precedent (encoded
|
||||||
|
via matched_account_ids).
|
||||||
|
|
||||||
|
Returns dict with `created` and `skipped` counts.
|
||||||
|
"""
|
||||||
|
Precedent = env['fusion.reconcile.precedent'].sudo()
|
||||||
|
Partial = env['account.partial.reconcile'].sudo()
|
||||||
|
Line = env['account.bank.statement.line'].sudo()
|
||||||
|
|
||||||
|
in_test_mode = env.cr.__class__.__name__ == 'TestCursor'
|
||||||
|
|
||||||
|
# Pre-filter to partials that touch a bank statement line on either side.
|
||||||
|
# In a real DB we typically have 10x more invoice<->payment partials than
|
||||||
|
# bank-rec partials; filtering here keeps the loop bounded and makes the
|
||||||
|
# default limit reflect "real" candidates rather than every partial ever.
|
||||||
|
domain = [
|
||||||
|
'|',
|
||||||
|
('debit_move_id.move_id.statement_line_id', '!=', False),
|
||||||
|
('credit_move_id.move_id.statement_line_id', '!=', False),
|
||||||
|
]
|
||||||
|
if company_id:
|
||||||
|
domain.append(('company_id', '=', company_id))
|
||||||
|
partials = Partial.search(domain, limit=limit, order='id asc')
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
skipped = 0
|
||||||
|
for partial in partials:
|
||||||
|
bank_line, counterpart, bsl_id = _identify_bank_side(partial)
|
||||||
|
if not bsl_id:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
signature_account = str(counterpart.account_id.id)
|
||||||
|
|
||||||
|
existing = Precedent.search([
|
||||||
|
('partner_id', '=',
|
||||||
|
counterpart.partner_id.id if counterpart.partner_id else False),
|
||||||
|
('amount', '=', abs(partial.amount)),
|
||||||
|
('matched_account_ids', '=ilike', f'%{signature_account}%'),
|
||||||
|
('source', '=', 'backfill'),
|
||||||
|
], limit=1)
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
statement_line = Line.browse(bsl_id)
|
||||||
|
try:
|
||||||
|
currency = (partial.debit_currency_id
|
||||||
|
or partial.company_id.currency_id)
|
||||||
|
Precedent.create({
|
||||||
|
'company_id': partial.company_id.id,
|
||||||
|
'partner_id': (counterpart.partner_id.id
|
||||||
|
if counterpart.partner_id else False),
|
||||||
|
'amount': abs(partial.amount),
|
||||||
|
'currency_id': currency.id,
|
||||||
|
'date': statement_line.date or partial.create_date.date(),
|
||||||
|
'memo_tokens': ','.join(
|
||||||
|
tokenize_memo(statement_line.payment_ref or '')),
|
||||||
|
'journal_id': statement_line.journal_id.id,
|
||||||
|
'matched_move_line_count': 1,
|
||||||
|
'matched_account_ids': signature_account,
|
||||||
|
'reconciler_user_id': partial.create_uid.id,
|
||||||
|
'reconciled_at': partial.create_date,
|
||||||
|
'source': 'backfill',
|
||||||
|
})
|
||||||
|
created += 1
|
||||||
|
if created % batch_size == 0:
|
||||||
|
if not in_test_mode:
|
||||||
|
env.cr.commit()
|
||||||
|
_logger.info(
|
||||||
|
"Backfill progress: %d created, %d skipped",
|
||||||
|
created, skipped)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
_logger.warning("Backfill skip partial %s: %s", partial.id, e)
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
"precedent_backfill complete: %d created, %d skipped",
|
||||||
|
created, skipped)
|
||||||
|
return {'created': created, 'skipped': skipped}
|
||||||
62
fusion_accounting_bank_rec/services/precedent_lookup.py
Normal file
62
fusion_accounting_bank_rec/services/precedent_lookup.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""K-nearest precedent search.
|
||||||
|
|
||||||
|
Given a new bank line, find the most similar past reconciliations for
|
||||||
|
ranking + confidence scoring. Distance metric: amount delta (primary),
|
||||||
|
date recency (secondary), memo token overlap (tertiary).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PrecedentMatch:
|
||||||
|
precedent_id: int
|
||||||
|
amount: float
|
||||||
|
memo_tokens: str
|
||||||
|
matched_move_line_count: int
|
||||||
|
similarity_score: float
|
||||||
|
|
||||||
|
|
||||||
|
AMOUNT_TOLERANCE_PCT = 0.01 # 1% tolerance for "near" amount
|
||||||
|
|
||||||
|
|
||||||
|
def find_nearest_precedents(env, *, partner_id, amount, k=5, memo_tokens=None):
|
||||||
|
"""Return up to k most-similar precedents for a partner+amount.
|
||||||
|
|
||||||
|
Indexed query: filters by partner first (cheap), then ranks by
|
||||||
|
amount distance + memo overlap. Sub-50ms for typical Westin volume."""
|
||||||
|
Precedent = env['fusion.reconcile.precedent'].sudo()
|
||||||
|
|
||||||
|
tolerance = max(amount * AMOUNT_TOLERANCE_PCT, 1.00)
|
||||||
|
candidates = Precedent.search([
|
||||||
|
('partner_id', '=', partner_id),
|
||||||
|
('amount', '>=', amount - tolerance),
|
||||||
|
('amount', '<=', amount + tolerance),
|
||||||
|
], limit=k * 4, order='reconciled_at desc')
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for p in candidates:
|
||||||
|
amount_score = 1.0 - min(abs(p.amount - amount) / max(amount, 1), 1.0)
|
||||||
|
memo_score = _memo_overlap(p.memo_tokens, memo_tokens) if memo_tokens else 0.5
|
||||||
|
similarity = (amount_score * 0.7) + (memo_score * 0.3)
|
||||||
|
results.append(PrecedentMatch(
|
||||||
|
precedent_id=p.id,
|
||||||
|
amount=p.amount,
|
||||||
|
memo_tokens=p.memo_tokens or '',
|
||||||
|
matched_move_line_count=p.matched_move_line_count,
|
||||||
|
similarity_score=similarity,
|
||||||
|
))
|
||||||
|
|
||||||
|
results.sort(key=lambda r: -r.similarity_score)
|
||||||
|
return results[:k]
|
||||||
|
|
||||||
|
|
||||||
|
def _memo_overlap(precedent_tokens_str, new_tokens) -> float:
|
||||||
|
"""Jaccard similarity between two token sets."""
|
||||||
|
if not precedent_tokens_str or not new_tokens:
|
||||||
|
return 0.0
|
||||||
|
precedent_set = set(precedent_tokens_str.split(','))
|
||||||
|
new_set = set(new_tokens) if not isinstance(new_tokens, set) else new_tokens
|
||||||
|
if not precedent_set and not new_set:
|
||||||
|
return 0.0
|
||||||
|
return len(precedent_set & new_set) / len(precedent_set | new_set)
|
||||||
BIN
fusion_accounting_bank_rec/static/description/icon.png
Normal file
BIN
fusion_accounting_bank_rec/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class AiAlternativesPanel extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.AiAlternativesPanel";
|
||||||
|
static props = {
|
||||||
|
suggestions: { type: Array },
|
||||||
|
onClose: { type: Function, optional: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.bankRec = useService("fusion_bank_reconciliation");
|
||||||
|
}
|
||||||
|
|
||||||
|
bandFor(c) {
|
||||||
|
if (c >= 0.85) return "high";
|
||||||
|
if (c >= 0.6) return "medium";
|
||||||
|
if (c > 0) return "low";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
pctFor(c) {
|
||||||
|
return Math.round(c * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onAccept(suggestionId) {
|
||||||
|
await this.bankRec.acceptSuggestion(suggestionId);
|
||||||
|
if (this.props.onClose) {
|
||||||
|
this.props.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.AiAlternativesPanel">
|
||||||
|
<div class="o_fusion_alternatives_panel">
|
||||||
|
<h6>Other AI suggestions</h6>
|
||||||
|
<div t-foreach="props.suggestions" t-as="sug" t-key="sug.id"
|
||||||
|
class="o_fusion_alternative">
|
||||||
|
<div>
|
||||||
|
<span class="alt_confidence" t-att-class="'band-' + bandFor(sug.confidence)">
|
||||||
|
<t t-esc="pctFor(sug.confidence)"/>%
|
||||||
|
</span>
|
||||||
|
<t t-esc="sug.reasoning"/>
|
||||||
|
</div>
|
||||||
|
<button class="btn_fusion" t-on-click="() => onAccept(sug.id)">
|
||||||
|
Use this
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div t-if="props.onClose" class="text-end mt-2">
|
||||||
|
<button class="btn_fusion" t-on-click="props.onClose">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
|
||||||
|
export class AiReasoningTooltip extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.AiReasoningTooltip";
|
||||||
|
static props = {
|
||||||
|
scores: { type: Object },
|
||||||
|
reasoning: { type: String, optional: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
pctFor(value) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
return (value * 100).toFixed(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.AiReasoningTooltip">
|
||||||
|
<div class="o_fusion_reasoning_tooltip" style="font-size: 0.85em; padding: 0.5rem;">
|
||||||
|
<div t-if="props.reasoning" class="mb-2">
|
||||||
|
<em><t t-esc="props.reasoning"/></em>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">
|
||||||
|
<div>Amount match: <t t-esc="pctFor(props.scores.amount_match)"/>%</div>
|
||||||
|
<div>Partner pattern: <t t-esc="pctFor(props.scores.partner_pattern)"/>%</div>
|
||||||
|
<div>Precedent similarity: <t t-esc="pctFor(props.scores.precedent_similarity)"/>%</div>
|
||||||
|
<div t-if="props.scores.ai_rerank">
|
||||||
|
AI re-rank: <t t-esc="pctFor(props.scores.ai_rerank)"/>%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class AiSuggestionStrip extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.AiSuggestionStrip";
|
||||||
|
static props = {
|
||||||
|
suggestion: { type: Object },
|
||||||
|
showAlternatives: { type: Function, optional: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.bankRec = useService("fusion_bank_reconciliation");
|
||||||
|
}
|
||||||
|
|
||||||
|
get band() {
|
||||||
|
const c = this.props.suggestion.confidence;
|
||||||
|
if (c >= 0.85) return "high";
|
||||||
|
if (c >= 0.6) return "medium";
|
||||||
|
if (c > 0) return "low";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
get confidencePct() {
|
||||||
|
return Math.round(this.props.suggestion.confidence * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onAccept() {
|
||||||
|
await this.bankRec.acceptSuggestion(this.props.suggestion.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onShowAlternatives() {
|
||||||
|
if (this.props.showAlternatives) {
|
||||||
|
this.props.showAlternatives();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.AiSuggestionStrip">
|
||||||
|
<div class="o_fusion_ai_suggestion" t-att-data-band="band">
|
||||||
|
<div class="o_fusion_confidence_badge">
|
||||||
|
<t t-esc="confidencePct"/>%
|
||||||
|
</div>
|
||||||
|
<div class="o_fusion_suggestion_text">
|
||||||
|
<div class="o_fusion_reasoning">
|
||||||
|
<t t-esc="props.suggestion.reasoning || 'AI suggested match'"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_fusion_suggestion_actions">
|
||||||
|
<button class="btn_fusion btn_fusion_primary" t-on-click="onAccept">
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button t-if="props.showAlternatives" class="btn_fusion"
|
||||||
|
t-on-click="onShowAlternatives">
|
||||||
|
Other options
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from `account_accountant/.../apply_amount/apply_amount.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
class BankRecWidgetApplyAmountHtmlField extends Component {
|
||||||
|
static props = standardFieldProps;
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecWidgetApplyAmountHtmlField";
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.action = useService("action");
|
||||||
|
this.orm = useService("orm");
|
||||||
|
}
|
||||||
|
|
||||||
|
get value() {
|
||||||
|
return this.props.record.data[this.props.name];
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchApplyAmount(ev) {
|
||||||
|
const root = this.env.model.root;
|
||||||
|
const fetchReconciledLines = async (fields = []) => {
|
||||||
|
return await this.orm.searchRead(
|
||||||
|
"account.move.line",
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"id",
|
||||||
|
"in",
|
||||||
|
...root.data.reconciled_lines_excluding_exchange_diff_ids._currentIds,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
fields
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchStatementLines = async (fields = []) => {
|
||||||
|
return await this.orm.searchRead(
|
||||||
|
"account.move.line",
|
||||||
|
[["move_id", "=", root.data.move_id.id]],
|
||||||
|
fields
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ev.target.attributes.name?.value === "action_redirect_to_move") {
|
||||||
|
const [line] = await fetchReconciledLines(["amount_currency", "balance", "move_id"]);
|
||||||
|
await this.openMove(line.move_id[0]);
|
||||||
|
} else if (ev.target.attributes.name?.value === "apply_full_amount") {
|
||||||
|
const [line] = await fetchReconciledLines(["amount_currency", "balance"]);
|
||||||
|
await root.update({
|
||||||
|
balance: -line.balance,
|
||||||
|
amount_currency: -line.amount_currency,
|
||||||
|
});
|
||||||
|
} else if (ev.target.attributes.name?.value === "apply_partial_amount") {
|
||||||
|
const lines = await fetchStatementLines(["amount_currency", "balance"]);
|
||||||
|
// We have all the lines of the entry, we want the suspense line.
|
||||||
|
await root.update({
|
||||||
|
balance: lines.at(-1).balance,
|
||||||
|
amount_currency: lines.at(-1).amount_currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openMove(moveId) {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "account.move",
|
||||||
|
res_id: moveId,
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fusionBankRecWidgetApplyAmountHtmlField = { component: BankRecWidgetApplyAmountHtmlField };
|
||||||
|
|
||||||
|
registry.category("fields").add("fusion_apply_amount_html", fusionBankRecWidgetApplyAmountHtmlField);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecWidgetApplyAmountHtmlField">
|
||||||
|
<div t-out="value" t-on-click="switchApplyAmount"/>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
|
||||||
|
export class AttachmentStrip extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.AttachmentStrip";
|
||||||
|
static props = {
|
||||||
|
attachments: { type: Array },
|
||||||
|
};
|
||||||
|
|
||||||
|
iconFor(mimetype) {
|
||||||
|
if (!mimetype) {
|
||||||
|
return "fa-file";
|
||||||
|
}
|
||||||
|
if (mimetype.startsWith("image/")) {
|
||||||
|
return "fa-file-image-o";
|
||||||
|
}
|
||||||
|
if (mimetype === "application/pdf") {
|
||||||
|
return "fa-file-pdf-o";
|
||||||
|
}
|
||||||
|
return "fa-file-o";
|
||||||
|
}
|
||||||
|
|
||||||
|
urlFor(att) {
|
||||||
|
return `/web/content/${att.id}?download=true`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.AttachmentStrip">
|
||||||
|
<div class="o_fusion_attachment_strip d-flex flex-wrap"
|
||||||
|
style="gap: 0.5rem; padding: 0.5rem;">
|
||||||
|
<div t-if="props.attachments.length === 0" class="text-muted small">
|
||||||
|
No attachments
|
||||||
|
</div>
|
||||||
|
<a t-foreach="props.attachments" t-as="att" t-key="att.id"
|
||||||
|
t-att-href="urlFor(att)" target="_blank"
|
||||||
|
class="o_fusion_attachment_chip"
|
||||||
|
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.5rem; background: #f3f4f6; border-radius: 0.25rem; text-decoration: none; color: inherit; font-size: 0.85em;">
|
||||||
|
<i class="fa" t-att-class="iconFor(att.mimetype)"/>
|
||||||
|
<span><t t-esc="att.name"/></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-export shim so mirrored Enterprise components can use the relative
|
||||||
|
* import `../bank_reconciliation_service` unchanged. The real
|
||||||
|
* implementation lives in
|
||||||
|
* `@fusion_accounting_bank_rec/services/bank_reconciliation_service`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
BankReconciliationService,
|
||||||
|
bankReconciliationService,
|
||||||
|
useBankReconciliation,
|
||||||
|
} from "@fusion_accounting_bank_rec/services/bank_reconciliation_service";
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from
|
||||||
|
* `account_accountant/.../bankrec_form_dialog/bankrec_form_dialog.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FormController } from "@web/views/form/form_controller";
|
||||||
|
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
|
||||||
|
import { formView } from "@web/views/form/form_view";
|
||||||
|
import { onWillStart } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { user } from "@web/core/user";
|
||||||
|
|
||||||
|
export class BankRecFormDialog extends FormViewDialog {
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
Object.assign(this.viewProps, {
|
||||||
|
buttonTemplate: "fusion_accounting_bank_rec.BankRecFormDialog.buttons",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BankRecEditLineFormController extends FormController {
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.isReviewed = this.props.context.is_reviewed;
|
||||||
|
onWillStart(async () => {
|
||||||
|
this.userCanReview = await user.hasGroup("account.group_account_user");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async toReviewButtonClicked(params = {}) {
|
||||||
|
await this.orm.call("account.move", "set_moves_checked", [
|
||||||
|
this.model.root.data.move_id.id,
|
||||||
|
false,
|
||||||
|
]);
|
||||||
|
return this.saveButtonClicked(params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bankRecEditLineFormController = {
|
||||||
|
...formView,
|
||||||
|
Controller: BankRecEditLineFormController,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("views").add("fusion_bankrec_edit_line", bankRecEditLineFormController);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecFormDialog.buttons" t-inherit="web.FormViewDialog.ToOne.buttons" t-inherit-mode="primary">
|
||||||
|
<xpath expr="//button[hasclass('o_form_button_save')]" position="after">
|
||||||
|
<button
|
||||||
|
t-if="userCanReview and this.isReviewed"
|
||||||
|
class="btn btn-info"
|
||||||
|
t-on-click.stop="() => this.toReviewButtonClicked({closable: true})"
|
||||||
|
data-hotkey="q">
|
||||||
|
<span>To Review</span>
|
||||||
|
</button>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class BatchActionBar extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.BatchActionBar";
|
||||||
|
static props = {
|
||||||
|
selectedIds: { type: Array, optional: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.bankRec = useService("fusion_bank_reconciliation");
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasSelection() {
|
||||||
|
return this.props.selectedIds && this.props.selectedIds.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectionCount() {
|
||||||
|
return this.props.selectedIds ? this.props.selectedIds.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onAutoReconcile() {
|
||||||
|
if (!this.hasSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.bankRec.bulkReconcile(this.props.selectedIds, "auto");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSuggestForSelected() {
|
||||||
|
if (!this.hasSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.bankRec.suggestMatches(this.props.selectedIds, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BatchActionBar">
|
||||||
|
<div class="o_fusion_batch_action_bar d-flex"
|
||||||
|
style="gap: 0.5rem; padding: 0.75rem; background: #f3f4f6; border-radius: 0.375rem;">
|
||||||
|
<span class="text-muted">
|
||||||
|
<t t-esc="selectionCount"/> selected
|
||||||
|
</span>
|
||||||
|
<button class="btn_fusion" t-att-disabled="!hasSelection" t-on-click="onSuggestForSelected">
|
||||||
|
Suggest for selected
|
||||||
|
</button>
|
||||||
|
<button class="btn_fusion btn_fusion_primary" t-att-disabled="!hasSelection" t-on-click="onAutoReconcile">
|
||||||
|
Auto-reconcile selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from `account_accountant/.../button/button.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class BankRecButton extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecButton";
|
||||||
|
static props = {
|
||||||
|
label: { type: String, optional: true },
|
||||||
|
action: { type: Function, optional: true },
|
||||||
|
count: { type: [Number, { value: null }], optional: true },
|
||||||
|
primary: { type: Boolean, optional: true },
|
||||||
|
toReview: { type: Boolean, optional: true },
|
||||||
|
classes: { type: String, optional: true },
|
||||||
|
};
|
||||||
|
static defaultProps = {
|
||||||
|
primary: false,
|
||||||
|
classes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.ui = useService("ui");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecButton">
|
||||||
|
<button
|
||||||
|
t-attf-class="d-flex gap-1 btn text-nowrap {{ props.classes }}"
|
||||||
|
t-att-class="{'btn-sm': !ui.isSmall, 'btn-primary': props.primary, 'btn-info': props.toReview, 'btn-secondary': !props.primary}"
|
||||||
|
t-on-click.stop="() => props?.action()"
|
||||||
|
>
|
||||||
|
<span t-esc="props?.label" class="m-auto text-truncate"/>
|
||||||
|
<span class="rounded-pill px-2 o_bg-black-10" t-if="props?.count">
|
||||||
|
<t t-esc="props.count"/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from `account_accountant/.../button_list/button_list.js`.
|
||||||
|
* Phase 1 structural parity. Behaviour delegates to the
|
||||||
|
* Enterprise-compat surface in our `fusion_bank_reconciliation` service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BankRecButton } from "@fusion_accounting_bank_rec/components/bank_reconciliation/button/button";
|
||||||
|
import { BankRecFileUploader } from "@fusion_accounting_bank_rec/components/bank_reconciliation/file_uploader/file_uploader";
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||||
|
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||||
|
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||||
|
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
||||||
|
import { BankRecSelectCreateDialog } from "@fusion_accounting_bank_rec/components/bank_reconciliation/search_dialog/search_dialog";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { getCurrency } from "@web/core/currency";
|
||||||
|
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
|
||||||
|
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
|
||||||
|
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
|
||||||
|
|
||||||
|
export class BankRecButtonList extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecButtonList";
|
||||||
|
static components = {
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
BankRecButton,
|
||||||
|
BankRecFileUploader,
|
||||||
|
};
|
||||||
|
static props = {
|
||||||
|
statementLineRootRef: { type: Object },
|
||||||
|
statementLine: { type: Object },
|
||||||
|
suspenseAccountLine: { type: Object, optional: true },
|
||||||
|
reconcileLineCount: { type: [Number, { value: null }], optional: true },
|
||||||
|
reconcileModels: Array,
|
||||||
|
preSelectedReconciliationModel: { type: Object, optional: true },
|
||||||
|
};
|
||||||
|
static defaultProps = {
|
||||||
|
reconcileLineCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.action = useService("action");
|
||||||
|
this.ui = useService("ui");
|
||||||
|
this.orm = useService("orm");
|
||||||
|
|
||||||
|
this.addDialog = useOwnedDialogs();
|
||||||
|
this.currencyDigits = getCurrency(this.statementLineData.currency_id.id)?.digits || 2;
|
||||||
|
this.bankReconciliation = useBankReconciliation();
|
||||||
|
|
||||||
|
this.registerHotkeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreFocus() {
|
||||||
|
if (this.isLineSelected) {
|
||||||
|
this.props.statementLineRootRef.el.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a search dialog (no create option) for selecting a `res.partner` record.
|
||||||
|
*/
|
||||||
|
setPartnerOnReconcileLine() {
|
||||||
|
this.addDialog(
|
||||||
|
SelectCreateDialog,
|
||||||
|
{
|
||||||
|
title: _t("Search: Partner"),
|
||||||
|
noCreate: false,
|
||||||
|
multiSelect: false,
|
||||||
|
resModel: "res.partner",
|
||||||
|
context: { default_name: this.statementLineData.partner_name },
|
||||||
|
onSelected: async (partner) => {
|
||||||
|
await this.orm.call(
|
||||||
|
"account.bank.statement.line",
|
||||||
|
"set_partner_bank_statement_line",
|
||||||
|
[this.statementLineData.id, partner[0]]
|
||||||
|
);
|
||||||
|
const recordsToLoad = [];
|
||||||
|
if (this.statementLineData.partner_name) {
|
||||||
|
// Reload all impacted statement lines if we have a partner_name
|
||||||
|
recordsToLoad.push(
|
||||||
|
...this.env.model.root.records.filter(
|
||||||
|
(record) =>
|
||||||
|
record.data.partner_name === this.statementLineData.partner_name
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
recordsToLoad.push(this.props.statementLine);
|
||||||
|
}
|
||||||
|
await this.bankReconciliation.reloadRecords(recordsToLoad);
|
||||||
|
await this.bankReconciliation.computeReconcileLineCountPerPartnerId(
|
||||||
|
this.env.model.root.records
|
||||||
|
);
|
||||||
|
this.bankReconciliation.reloadChatter();
|
||||||
|
this.restoreFocus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onClose: () => {
|
||||||
|
this.restoreFocus();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a dialog to select an account and assigns it to the current reconcile line.
|
||||||
|
*/
|
||||||
|
setAccountOnReconcileLine() {
|
||||||
|
const context = {
|
||||||
|
list_view_ref: "account_accountant.view_account_list_bank_rec_widget",
|
||||||
|
search_view_ref: "account_accountant.view_account_search_bank_rec_widget",
|
||||||
|
...(this.statementLineData.amount > 0
|
||||||
|
? { preferred_account_type: "income" }
|
||||||
|
: { preferred_account_type: "expense" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addDialog(
|
||||||
|
SelectCreateDialog,
|
||||||
|
{
|
||||||
|
title: _t("Search: Account"),
|
||||||
|
noCreate: true,
|
||||||
|
multiSelect: false,
|
||||||
|
domain: [
|
||||||
|
[
|
||||||
|
"id",
|
||||||
|
"not in",
|
||||||
|
[
|
||||||
|
this.statementLineData.journal_id.suspense_account_id.id,
|
||||||
|
this.statementLineData.journal_id.default_account_id.id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
context: context,
|
||||||
|
resModel: "account.account",
|
||||||
|
onSelected: async (account) => {
|
||||||
|
const linesToLoad = await this._setAccountOnReconcileLine(
|
||||||
|
this.lastAccountMoveLine.data.id,
|
||||||
|
account[0],
|
||||||
|
{ context: { account_default_taxes: true } }
|
||||||
|
);
|
||||||
|
const recordsToLoad = [
|
||||||
|
...this.env.model.root.records.filter((record) =>
|
||||||
|
linesToLoad.includes(record.data.id)
|
||||||
|
),
|
||||||
|
this.props.statementLine,
|
||||||
|
];
|
||||||
|
await this.bankReconciliation.reloadRecords(recordsToLoad);
|
||||||
|
this.bankReconciliation.reloadChatter();
|
||||||
|
this.restoreFocus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onClose: () => {
|
||||||
|
this.restoreFocus();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _setAccountOnReconcileLine(amlId, accountId, context = {}) {
|
||||||
|
return await this.orm.call(
|
||||||
|
"account.bank.statement.line",
|
||||||
|
"set_account_bank_statement_line",
|
||||||
|
[this.statementLineData.id, amlId, accountId],
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAccountReceivableOnReconcileLine() {
|
||||||
|
let accountId;
|
||||||
|
if (this.statementLineData.partner_id.property_account_receivable_id.id) {
|
||||||
|
accountId = this.statementLineData.partner_id.property_account_receivable_id.id;
|
||||||
|
} else {
|
||||||
|
accountId = await this.orm.webSearchRead("account.account", [
|
||||||
|
["account_type", "=", "asset_receivable"],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await this._setAccountOnReconcileLine(this.lastAccountMoveLine.data.id, accountId);
|
||||||
|
this.props.statementLine.load();
|
||||||
|
this.bankReconciliation.reloadChatter();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAccountPayableOnReconcileLine() {
|
||||||
|
let accountId;
|
||||||
|
if (this.statementLineData.partner_id.property_account_payable_id.id) {
|
||||||
|
accountId = this.statementLineData.partner_id.property_account_payable_id.id;
|
||||||
|
} else {
|
||||||
|
accountId = await this.orm.webSearchRead("account.account", [
|
||||||
|
["account_type", "=", "liability_payable"],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await this._setAccountOnReconcileLine(this.lastAccountMoveLine.data.id, accountId);
|
||||||
|
this.props.statementLine.load();
|
||||||
|
this.bankReconciliation.reloadChatter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a dialog to search and select journal items to reconcile with the current bank statement line.
|
||||||
|
*/
|
||||||
|
reconcileOnReconcileLine() {
|
||||||
|
const context = {
|
||||||
|
list_view_ref: "account_accountant.view_account_move_line_list_bank_rec_widget",
|
||||||
|
search_view_ref: "account_accountant.view_account_move_line_search_bank_rec_widget",
|
||||||
|
preferred_aml_value: -this.props.suspenseAccountLine.amount_currency,
|
||||||
|
preferred_aml_currency_id: this.props.suspenseAccountLine.currency_id.id,
|
||||||
|
...(this.statementLineData.partner_id
|
||||||
|
? { search_default_partner_id: this.statementLineData.partner_id.id }
|
||||||
|
: { search_default_posted: 1 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addDialog(
|
||||||
|
BankRecSelectCreateDialog,
|
||||||
|
{
|
||||||
|
title: _t("Search: Journal Items to Match"),
|
||||||
|
noCreate: true,
|
||||||
|
domain: this.getReconcileButtonDomain(),
|
||||||
|
resModel: "account.move.line",
|
||||||
|
size: "xl",
|
||||||
|
context: context,
|
||||||
|
onSelected: async (moveLines) => {
|
||||||
|
await this.orm.call(
|
||||||
|
"account.bank.statement.line",
|
||||||
|
"set_line_bank_statement_line",
|
||||||
|
[this.statementLineData.id, moveLines]
|
||||||
|
);
|
||||||
|
await this.bankReconciliation.computeReconcileLineCountPerPartnerId(
|
||||||
|
this.env.model.root.records
|
||||||
|
);
|
||||||
|
this.props.statementLine.load();
|
||||||
|
this.bankReconciliation.reloadChatter();
|
||||||
|
this.restoreFocus();
|
||||||
|
},
|
||||||
|
suspenseAccountLine: this.props.suspenseAccountLine,
|
||||||
|
reference: this.statementLineData.payment_ref,
|
||||||
|
date: this.statementLineData.date,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onClose: () => {
|
||||||
|
this.restoreFocus();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getReconcileButtonDomain() {
|
||||||
|
return [
|
||||||
|
["parent_state", "in", ["draft", "posted"]],
|
||||||
|
["company_id", "child_of", this.statementLineData.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", "!=", this.statementLineData.id],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the current bank statement line.
|
||||||
|
*/
|
||||||
|
async deleteTransaction() {
|
||||||
|
this.addDialog(ConfirmationDialog, {
|
||||||
|
body: _t("Are you sure you want to delete this statement line?"),
|
||||||
|
confirm: async () => {
|
||||||
|
await this.orm.unlink("account.bank.statement.line", [this.statementLineData.id]);
|
||||||
|
this.env.model.load();
|
||||||
|
},
|
||||||
|
cancel: () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the move of the statement line as to check
|
||||||
|
*/
|
||||||
|
async setStatementLineAsReviewed() {
|
||||||
|
await this.orm.call("account.move", "set_moves_checked", [
|
||||||
|
this.statementLineData.move_id.id,
|
||||||
|
]);
|
||||||
|
this.props.statementLine.load();
|
||||||
|
this.bankReconciliation.reloadChatter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Reconciliation Model
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async triggerReconciliationModel(reconciliationModelId) {
|
||||||
|
await this.orm.call("account.reconcile.model", "trigger_reconciliation_model", [
|
||||||
|
reconciliationModelId,
|
||||||
|
this.statementLineData.id,
|
||||||
|
]);
|
||||||
|
await this.bankReconciliation.computeReconcileLineCountPerPartnerId(
|
||||||
|
this.env.model.root.records
|
||||||
|
);
|
||||||
|
this.props.statementLine.load();
|
||||||
|
this.bankReconciliation.reloadChatter();
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeyAction(key) {
|
||||||
|
const keyActions = {
|
||||||
|
1: {
|
||||||
|
condition:
|
||||||
|
this.props.statementLineRootRef.el.querySelector(".set-partner-btn") &&
|
||||||
|
this.isLineSelected,
|
||||||
|
action: async () => this.setPartnerOnReconcileLine(),
|
||||||
|
buttonElement: this.props.statementLineRootRef.el.querySelector(".set-partner-btn"),
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
condition:
|
||||||
|
this.props.statementLineRootRef.el.querySelector(".reconcile-btn") &&
|
||||||
|
this.isLineSelected,
|
||||||
|
action: async () => this.reconcileOnReconcileLine(),
|
||||||
|
buttonElement: this.props.statementLineRootRef.el.querySelector(".reconcile-btn"),
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
condition:
|
||||||
|
this.props.statementLineRootRef.el.querySelector(".set-account-btn") &&
|
||||||
|
this.isLineSelected,
|
||||||
|
action: () => this.setAccountOnReconcileLine(),
|
||||||
|
buttonElement: this.props.statementLineRootRef.el.querySelector(".set-account-btn"),
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
condition:
|
||||||
|
this.props.statementLineRootRef.el.querySelector(".set-payable-btn") &&
|
||||||
|
this.isLineSelected,
|
||||||
|
action: () => this.setAccountPayableOnReconcileLine(),
|
||||||
|
buttonElement: this.props.statementLineRootRef.el.querySelector(".set-payable-btn"),
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
condition:
|
||||||
|
this.props.statementLineRootRef.el.querySelector(".set-receivable-btn") &&
|
||||||
|
this.isLineSelected,
|
||||||
|
action: () => this.setAccountReceivableOnReconcileLine(),
|
||||||
|
buttonElement:
|
||||||
|
this.props.statementLineRootRef.el.querySelector(".set-receivable-btn"),
|
||||||
|
},
|
||||||
|
6: {
|
||||||
|
condition:
|
||||||
|
this.props.statementLineRootRef.el.querySelector(
|
||||||
|
".reconciliation-model-btn-0"
|
||||||
|
) && this.isLineSelected,
|
||||||
|
action: () => {
|
||||||
|
const buttonElement = this.props.statementLineRootRef.el.querySelector(
|
||||||
|
".reconciliation-model-btn-0"
|
||||||
|
);
|
||||||
|
if (buttonElement) {
|
||||||
|
buttonElement.click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buttonElement: this.props.statementLineRootRef.el.querySelector(
|
||||||
|
".reconciliation-model-btn-0"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
7: {
|
||||||
|
condition:
|
||||||
|
this.props.statementLineRootRef.el.querySelector(
|
||||||
|
".reconciliation-model-btn-1"
|
||||||
|
) && this.isLineSelected,
|
||||||
|
action: () => {
|
||||||
|
const buttonElement = this.props.statementLineRootRef.el.querySelector(
|
||||||
|
".reconciliation-model-btn-1"
|
||||||
|
);
|
||||||
|
if (buttonElement) {
|
||||||
|
buttonElement.click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buttonElement: this.props.statementLineRootRef.el.querySelector(
|
||||||
|
".reconciliation-model-btn-1"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
8: {
|
||||||
|
condition:
|
||||||
|
this.props.statementLineRootRef.el.querySelector(
|
||||||
|
".reconciliation-model-btn-2"
|
||||||
|
) && this.isLineSelected,
|
||||||
|
action: () => {
|
||||||
|
const buttonElement = this.props.statementLineRootRef.el.querySelector(
|
||||||
|
".reconciliation-model-btn-2"
|
||||||
|
);
|
||||||
|
if (buttonElement) {
|
||||||
|
buttonElement.click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buttonElement: this.props.statementLineRootRef.el.querySelector(
|
||||||
|
".reconciliation-model-btn-2"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Enter: {
|
||||||
|
condition:
|
||||||
|
this.props.statementLineRootRef.el.querySelector(".btn-primary") &&
|
||||||
|
this.isLineSelected,
|
||||||
|
action: () => {
|
||||||
|
const primaryButtons =
|
||||||
|
this.props.statementLineRootRef.el.querySelectorAll(".btn-primary");
|
||||||
|
if (primaryButtons.length > 0) {
|
||||||
|
primaryButtons[0].click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buttonElement: this.props.statementLineRootRef.el.querySelector(".btn-primary"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return keyActions[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
registerHotkeys() {
|
||||||
|
const hotkeyConfigs = [
|
||||||
|
{ key: "1", trigger: "alt+shift+1" },
|
||||||
|
{ key: "2", trigger: "alt+shift+2" },
|
||||||
|
{ key: "3", trigger: "alt+shift+3" },
|
||||||
|
{ key: "4", trigger: "alt+shift+4" },
|
||||||
|
{ key: "5", trigger: "alt+shift+5" },
|
||||||
|
{ key: "6", trigger: "alt+shift+6" },
|
||||||
|
{ key: "7", trigger: "alt+shift+7" },
|
||||||
|
{ key: "8", trigger: "alt+shift+8" },
|
||||||
|
{ key: "Enter", trigger: "alt+shift+enter" },
|
||||||
|
];
|
||||||
|
hotkeyConfigs.forEach(({ key, trigger }) => {
|
||||||
|
useHotkey(
|
||||||
|
trigger,
|
||||||
|
({ target }) => {
|
||||||
|
const { condition, action } = this.getKeyAction(key);
|
||||||
|
if (condition) {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
area: () => this.props.statementLineRootRef.el.parentElement,
|
||||||
|
withOverlay: () => {
|
||||||
|
const { buttonElement, condition } = this.getKeyAction(key);
|
||||||
|
return condition ? buttonElement : null;
|
||||||
|
},
|
||||||
|
isAvailable: () => {
|
||||||
|
const { condition } = this.getKeyAction(key);
|
||||||
|
return condition;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// File Uploader
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
get bankRecFileUploaderRecord() {
|
||||||
|
return {
|
||||||
|
statementLineId: this.statementLineData.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// ACTION
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
actionViewRecoModels() {
|
||||||
|
return this.action.doAction("account.action_account_reconcile_model");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// GETTER
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
get statementLineData() {
|
||||||
|
return this.props.statementLine.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isLineSelected() {
|
||||||
|
return this.statementLineData.id === this.bankReconciliation.statementLine?.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastAccountMoveLine() {
|
||||||
|
return this.statementLineData.line_ids.records.at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isCustomerRankHigher() {
|
||||||
|
return (
|
||||||
|
this.statementLineData.partner_id.customer_rank >
|
||||||
|
this.statementLineData.partner_id.supplier_rank
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSetPartnerButtonShown() {
|
||||||
|
return !this.statementLineData.partner_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSetAccountButtonShown() {
|
||||||
|
return !this.statementLineData.account_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSetReceivableButtonShown() {
|
||||||
|
return (
|
||||||
|
!this.isSetPartnerButtonShown &&
|
||||||
|
((this.statementLineData.partner_id.customer_rank && this.isCustomerRankHigher) ||
|
||||||
|
this.statementLineData.amount > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSetPayableButtonShown() {
|
||||||
|
return (
|
||||||
|
!this.isSetPartnerButtonShown &&
|
||||||
|
((this.statementLineData.partner_id.supplier_rank && !this.isCustomerRankHigher) ||
|
||||||
|
this.statementLineData.amount < 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReconcileButtonShown() {
|
||||||
|
return this.props.reconcileLineCount === null || this.props.reconcileLineCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get reconcileModelsInDropdown() {
|
||||||
|
if (this.ui.isSmall) {
|
||||||
|
return this.props.reconcileModels;
|
||||||
|
}
|
||||||
|
return this.props.reconcileModels.filter(
|
||||||
|
(model) => model.id !== this.props?.preSelectedReconciliationModel?.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get buttons() {
|
||||||
|
const buttonsToDisplay = {};
|
||||||
|
if (this.isSetPartnerButtonShown) {
|
||||||
|
buttonsToDisplay.partner = {
|
||||||
|
label: _t("Set Partner"),
|
||||||
|
action: this.setPartnerOnReconcileLine.bind(this),
|
||||||
|
classes: "set-partner-btn",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
buttonsToDisplay.receivable = {
|
||||||
|
label: _t("Receivable"),
|
||||||
|
action: this.setAccountReceivableOnReconcileLine.bind(this),
|
||||||
|
classes: "set-receivable-btn",
|
||||||
|
};
|
||||||
|
buttonsToDisplay.payable = {
|
||||||
|
label: _t("Payable"),
|
||||||
|
action: this.setAccountPayableOnReconcileLine.bind(this),
|
||||||
|
classes: "set-payable-btn",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isReconcileButtonShown) {
|
||||||
|
buttonsToDisplay.reconcile = {
|
||||||
|
label: _t("Reconcile"),
|
||||||
|
action: this.reconcileOnReconcileLine.bind(this),
|
||||||
|
count: this.props.reconcileLineCount,
|
||||||
|
classes: "reconcile-btn",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isSetAccountButtonShown) {
|
||||||
|
buttonsToDisplay.account = {
|
||||||
|
label: _t("Set Account"),
|
||||||
|
action: this.setAccountOnReconcileLine.bind(this),
|
||||||
|
classes: "set-account-btn",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.statementLineData.is_reconciled && !this.statementLineData.checked) {
|
||||||
|
buttonsToDisplay.toReview = {
|
||||||
|
label: _t("Reviewed"),
|
||||||
|
action: this.setStatementLineAsReviewed.bind(this),
|
||||||
|
toReview: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return buttonsToDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
get buttonsToDisplay() {
|
||||||
|
const buttons = this.buttons || {};
|
||||||
|
|
||||||
|
let primaryButtonKeys = [];
|
||||||
|
let secondaryButtonKeys = [];
|
||||||
|
if (buttons?.partner && buttons?.account) {
|
||||||
|
primaryButtonKeys = ["partner", "account"];
|
||||||
|
} else if (buttons?.reconcile && !!buttons.reconcile?.count) {
|
||||||
|
primaryButtonKeys = ["reconcile"];
|
||||||
|
if (this.isSetReceivableButtonShown) {
|
||||||
|
secondaryButtonKeys = ["receivable"];
|
||||||
|
} else {
|
||||||
|
secondaryButtonKeys = ["payable"];
|
||||||
|
}
|
||||||
|
} else if (this.isSetReceivableButtonShown) {
|
||||||
|
primaryButtonKeys = ["receivable"];
|
||||||
|
} else if (this.isSetPayableButtonShown) {
|
||||||
|
primaryButtonKeys = ["payable"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...primaryButtonKeys.map((key) => ({ ...buttons[key], primary: true })),
|
||||||
|
...secondaryButtonKeys.map((key) => ({ ...buttons[key] })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get buttonsInDropdown() {
|
||||||
|
const buttons = this.buttons || {};
|
||||||
|
if (this.props.preSelectedReconciliationModel) {
|
||||||
|
return Object.values(buttons);
|
||||||
|
}
|
||||||
|
const buttonToDisplayClasses = this.buttonsToDisplay.map((button) => button.classes) || [];
|
||||||
|
return Object.values(buttons).filter(
|
||||||
|
(button) => !buttonToDisplayClasses.includes(button.classes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecButtonList">
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
<t t-if="props.preSelectedReconciliationModel and !statementLineData.is_reconciled">
|
||||||
|
<BankRecButton
|
||||||
|
label="props.preSelectedReconciliationModel.display_name"
|
||||||
|
primary="true"
|
||||||
|
action.bind="() => this.triggerReconciliationModel(props.preSelectedReconciliationModel.id)"
|
||||||
|
/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="buttons?.toReview">
|
||||||
|
<BankRecButton t-props="buttons.toReview"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<t t-foreach="buttonsToDisplay" t-as="button" t-key="button_index">
|
||||||
|
<BankRecButton t-props="button"/>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<Dropdown t-if="!statementLineData.is_reconciled">
|
||||||
|
<button class="btn btn-secondary" t-att-class="{'btn-sm': !ui.isSmall}">
|
||||||
|
<i class="oi oi-ellipsis-v"/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<t t-set-slot="content">
|
||||||
|
<t t-foreach="buttonsInDropdown" t-as="button" t-key="button_index">
|
||||||
|
<DropdownItem class="'btn btn-link'" onSelected.bind="button.action">
|
||||||
|
<t t-esc="button.label"/>
|
||||||
|
</DropdownItem>
|
||||||
|
</t>
|
||||||
|
<BankRecFileUploader record="bankRecFileUploaderRecord">
|
||||||
|
<t t-set-slot="toggler">
|
||||||
|
<span class="dropdown-item dropdown-item o-navigable btn btn-link">
|
||||||
|
Upload Bills
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</BankRecFileUploader>
|
||||||
|
<div class="dropdown-divider"/>
|
||||||
|
<t t-foreach="reconcileModelsInDropdown" t-as="model" t-key="model.id">
|
||||||
|
<DropdownItem class="'btn btn-link'" onSelected.bind="() => this.triggerReconciliationModel(model.id)">
|
||||||
|
<t t-esc="model.display_name"/>
|
||||||
|
</DropdownItem>
|
||||||
|
</t>
|
||||||
|
<div t-if="reconcileModelsInDropdown.length" class="dropdown-divider"/>
|
||||||
|
<DropdownItem class="'btn btn-link'" onSelected.bind="actionViewRecoModels">
|
||||||
|
Manage Models
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem class="'btn btn-link'" onSelected.bind="deleteTransaction">
|
||||||
|
Delete Transaction
|
||||||
|
</DropdownItem>
|
||||||
|
</t>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from `account_accountant/.../chatter/chatter.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Chatter } from "@mail/chatter/web_portal/chatter";
|
||||||
|
|
||||||
|
export class BankRecChatter extends Chatter {
|
||||||
|
static props = [...Chatter.props, "statementLine?"];
|
||||||
|
|
||||||
|
async reloadParentView() {
|
||||||
|
await this.props.statementLine?.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from
|
||||||
|
* `account_accountant/.../file_uploader/file_uploader.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DocumentFileUploader } from "@account/components/document_file_uploader/document_file_uploader";
|
||||||
|
|
||||||
|
export class BankRecFileUploader extends DocumentFileUploader {
|
||||||
|
/**
|
||||||
|
* Extends `DocumentFileUploader.getExtraContext` to add the
|
||||||
|
* `statement_line_id` to the context, used by
|
||||||
|
* `account.bank.statement.line.create_document_from_attachment` to link
|
||||||
|
* the uploaded bill back to the originating statement line.
|
||||||
|
*/
|
||||||
|
getExtraContext() {
|
||||||
|
const extraContext = super.getExtraContext();
|
||||||
|
return {
|
||||||
|
...extraContext,
|
||||||
|
statement_line_id: this.props.record.statementLineId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getResModel() {
|
||||||
|
return "account.bank.statement.line";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from
|
||||||
|
* `account_accountant/.../line_info_pop_over/line_info_pop_over.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { formatMonetary } from "@web/views/fields/formatters";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class BankRecLineInfoPopOver extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecLineInfoPopOver";
|
||||||
|
static props = {
|
||||||
|
lineData: { type: Object, optional: true },
|
||||||
|
statementLineData: { type: Object, optional: true },
|
||||||
|
exchangeMove: { type: Object, optional: true },
|
||||||
|
isPartiallyReconciled: { type: Boolean, optional: true },
|
||||||
|
close: { type: Function, optional: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.action = useService("action");
|
||||||
|
}
|
||||||
|
|
||||||
|
openExchangeMove() {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "account.move",
|
||||||
|
res_id: this.props.exchangeMove.id,
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openReconciledMove() {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "account.move",
|
||||||
|
res_id: this.reconciledLineData.move_id.id,
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get reconciledMoveName() {
|
||||||
|
return this.reconciledLineData.move_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedReconciledMoveAmountCurrency() {
|
||||||
|
return formatMonetary(this.reconciledLineData.amount_currency, {
|
||||||
|
currencyId: this.reconciledLineData.currency_id.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get reconciledLineData() {
|
||||||
|
return this.props.lineData.reconciled_lines_ids.records[0].data;
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedLineDataAmountCurrency() {
|
||||||
|
return formatMonetary(this.props.lineData.amount_currency, {
|
||||||
|
currencyId: this.props.lineData.currency_id.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get exchangeDiffMoveName() {
|
||||||
|
return this.props.exchangeMove.display_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get exchangeMoveBalance() {
|
||||||
|
return this.props.exchangeMove.line_ids[0].balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedExchangeMoveBalance() {
|
||||||
|
return formatMonetary(this.exchangeMoveBalance, {
|
||||||
|
currencyId: this.props.statementLineData.company_id.currency_id?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecLineInfoPopOver">
|
||||||
|
<table class="table table-hover m-0">
|
||||||
|
<tbody>
|
||||||
|
<tr t-if="props.exchangeMove">
|
||||||
|
<td t-on-click="openExchangeMove" class="cursor-pointer">
|
||||||
|
<span class="btn btn-link p-0" t-esc="exchangeDiffMoveName"/>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle text-end" t-esc="formattedExchangeMoveBalance"/>
|
||||||
|
</tr>
|
||||||
|
<tr t-if="props.isPartiallyReconciled">
|
||||||
|
<td t-on-click="openReconciledMove" class="cursor-pointer">
|
||||||
|
<span class="btn btn-link p-0" t-esc="reconciledMoveName"/>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<span class="text-decoration-line-through me-2" t-esc="formattedReconciledMoveAmountCurrency"/>
|
||||||
|
<span t-esc="formattedLineDataAmountCurrency"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from
|
||||||
|
* `account_accountant/.../line_to_reconcile/line_to_reconcile.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, useRef } from "@odoo/owl";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { formatMonetary } from "@web/views/fields/formatters";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
|
||||||
|
import { usePopover } from "@web/core/popover/popover_hook";
|
||||||
|
import { BankRecFormDialog } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog";
|
||||||
|
import { BankRecLineInfoPopOver } from "@fusion_accounting_bank_rec/components/bank_reconciliation/line_info_pop_over/line_info_pop_over";
|
||||||
|
import { x2ManyCommands } from "@web/core/orm_service";
|
||||||
|
|
||||||
|
export class BankRecLineToReconcile extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecLineToReconcile";
|
||||||
|
|
||||||
|
static props = {
|
||||||
|
line: Object,
|
||||||
|
statementLine: Object,
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.action = useService("action");
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.dialogService = useService("dialog");
|
||||||
|
this.ui = useService("ui");
|
||||||
|
this.bankReconciliation = useBankReconciliation();
|
||||||
|
|
||||||
|
this.lineInfoRef = useRef("line-info-ref");
|
||||||
|
this.lineInfoPopOver = usePopover(BankRecLineInfoPopOver, {
|
||||||
|
position: "left",
|
||||||
|
closeOnClickAway: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickLine() {
|
||||||
|
if (this.ui.isSmall) {
|
||||||
|
this.toggleEditLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleEditLine() {
|
||||||
|
this.dialogService.add(BankRecFormDialog, {
|
||||||
|
title: _t("Edit Line"),
|
||||||
|
resModel: "account.move.line",
|
||||||
|
resId: this.lineData.id,
|
||||||
|
context: {
|
||||||
|
form_view_ref: "account_accountant.view_bank_rec_edit_line",
|
||||||
|
is_reviewed: this.lineData.move_id.checked,
|
||||||
|
},
|
||||||
|
onRecordSave: async (record) => {
|
||||||
|
await this.orm.call("account.bank.statement.line", "edit_reconcile_line", [
|
||||||
|
this.statementLineData.id,
|
||||||
|
this.lineData.id,
|
||||||
|
await record.getChanges(),
|
||||||
|
]);
|
||||||
|
this.props.statementLine.load();
|
||||||
|
this.bankReconciliation.reloadChatter();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLine() {
|
||||||
|
await this.orm.call("account.bank.statement.line", "delete_reconciled_line", [
|
||||||
|
this.statementLineData.id,
|
||||||
|
this.lineData.id,
|
||||||
|
]);
|
||||||
|
if (this.lineData.reconciled_lines_ids.records.length) {
|
||||||
|
// Only update the line count per partner if we delete
|
||||||
|
// a line which is reconciled to another move line
|
||||||
|
this.bankReconciliation.computeReconcileLineCountPerPartnerId(
|
||||||
|
this.env.model.root.records
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.props.statementLine.load();
|
||||||
|
this.bankReconciliation.reloadChatter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// ACTION
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
openMove() {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "account.move",
|
||||||
|
res_id: this.moveData.id,
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openPartner() {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "res.partner",
|
||||||
|
res_id: this.lineData.partner_id.id,
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openLineInfoPopOver() {
|
||||||
|
if (this.lineInfoPopOver.isOpen || !this.showLineInfo) {
|
||||||
|
this.lineInfoPopOver.close();
|
||||||
|
} else {
|
||||||
|
this.lineInfoPopOver.open(this.lineInfoRef.el, {
|
||||||
|
statementLineData: this.statementLineData,
|
||||||
|
lineData: this.lineData,
|
||||||
|
exchangeMove: this.exchangeMove,
|
||||||
|
isPartiallyReconciled: this.isPartiallyReconciled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTax(taxIndex) {
|
||||||
|
const taxChanged = this.lineDataTaxIds[taxIndex];
|
||||||
|
await this.orm.call("account.bank.statement.line", "edit_reconcile_line", [
|
||||||
|
this.statementLineData.id,
|
||||||
|
this.lineData.id,
|
||||||
|
{ tax_ids: [[x2ManyCommands.UNLINK, taxChanged.data.id]] },
|
||||||
|
]);
|
||||||
|
this.props.statementLine.load();
|
||||||
|
this.bankReconciliation.reloadChatter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// GETTER
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
get statementLineData() {
|
||||||
|
return this.props.statementLine.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
get lineData() {
|
||||||
|
return this.props.line;
|
||||||
|
}
|
||||||
|
|
||||||
|
get reconciledLineId() {
|
||||||
|
return this.lineData.reconciled_lines_ids.records.length === 1
|
||||||
|
? this.lineData.reconciled_lines_ids.records[0].data
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get reconciledLineExcludingExchangeDiffId() {
|
||||||
|
return this.lineData.reconciled_lines_excluding_exchange_diff_ids.records.length === 1
|
||||||
|
? this.lineData.reconciled_lines_excluding_exchange_diff_ids.records[0].data
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get moveData() {
|
||||||
|
return (
|
||||||
|
this.reconciledLineId?.move_id ||
|
||||||
|
this.reconciledLineExcludingExchangeDiffId?.move_id ||
|
||||||
|
this.lineData.move_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPartiallyReconciled() {
|
||||||
|
if (!this.reconciledLineId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !this.reconciledLineId.full_reconcile_id?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasDifferentCurrencies() {
|
||||||
|
return this.lineData.currency_id.id !== this.statementLineData.currency_id.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedAmountCurrencyOfLine() {
|
||||||
|
return formatMonetary(this.lineData.amount_currency, {
|
||||||
|
currencyId: this.lineData.currency_id.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedAmountCurrencyOfStatementLine() {
|
||||||
|
return formatMonetary(this.lineData.amount_currency, {
|
||||||
|
currencyId: this.statementLineData.currency_id.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get exchangeMove() {
|
||||||
|
return (
|
||||||
|
this.lineData.matched_debit_ids.records[0]?.data.exchange_move_id ||
|
||||||
|
this.lineData.matched_credit_ids.records[0]?.data.exchange_move_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get showLineInfo() {
|
||||||
|
return this.isPartiallyReconciled || this.exchangeMove?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isTaxLine() {
|
||||||
|
return this.lineData.tax_line_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get lineDataTaxIds() {
|
||||||
|
return this.lineData.tax_ids.records;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecLineToReconcile">
|
||||||
|
<div class="o_row" t-on-click.stop="onClickLine">
|
||||||
|
<div class="o_line_name d-flex align-items-center gap-1 text-truncate">
|
||||||
|
<a href="#" class="text-truncate fw-bold" t-esc="lineData.partner_id.display_name" t-on-click.stop="openPartner" role="button" t-att-title="lineData.partner_id.display_name" t-if="lineData.partner_id"/>
|
||||||
|
<span t-esc="lineData.account_id.display_name" class="text-truncate" t-att-class="lineData.partner_id ? 'ms-1' : undefined"/>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<t t-foreach="lineDataTaxIds" t-as="tax_id" t-key="tax_id_index">
|
||||||
|
<div class="o_tag d-inline-flex align-items-center badge rounded-pill o_tag_color_0 flex-shrink-0">
|
||||||
|
<span class="o_tag_badge_text text-truncate" t-esc="tax_id.data.display_name"/>
|
||||||
|
<i t-on-click.stop="() => this.deleteTax(tax_id_index)" class="ps-1 opacity-100-hover opacity-75 oi oi-close"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span t-if="!!moveData.display_name and moveData.id !== statementLineData.move_id.id" class="d-none d-md-inline">
|
||||||
|
<a t-on-click.stop="openMove" href="#">
|
||||||
|
<t t-esc="moveData.display_name"/>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<div class="o_line_amount d-flex align-items-center justify-content-between">
|
||||||
|
<span class="text-muted w-50 text-end" t-if="hasDifferentCurrencies">
|
||||||
|
<span t-att-class="{'btn btn-link p-0' : showLineInfo}" t-ref="line-info-ref" t-on-click.stop="openLineInfoPopOver">
|
||||||
|
<i t-if="showLineInfo" class="fa fa-info-circle me-2"/>
|
||||||
|
<t t-out="formattedAmountCurrencyOfLine"/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-end w-100" t-if="!hasDifferentCurrencies">
|
||||||
|
<span t-att-class="{'btn btn-link p-0' : showLineInfo}" t-ref="line-info-ref" t-on-click.stop="openLineInfoPopOver">
|
||||||
|
<i t-if="showLineInfo" class="fa fa-info-circle me-2"/>
|
||||||
|
<t t-out="formattedAmountCurrencyOfStatementLine"/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_line_to_reconcile_button d-none d-md-flex justify-content-end gap-2">
|
||||||
|
<button t-if="lineData.has_invalid_analytics" class="btn btn-link p-0 text-600" t-on-click.stop="toggleEditLine">
|
||||||
|
<i class="fa fa-exclamation-triangle text-warning" data-tooltip="This line has invalid analytic distribution"/>
|
||||||
|
</button>
|
||||||
|
<button t-if="!lineData.has_invalid_analytics" class="btn btn-link p-0 text-600" t-on-click.stop="toggleEditLine">
|
||||||
|
<i class="fa fa-pencil"/>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-link p-0 text-600" t-on-click.stop="deleteLine" t-if="!isTaxLine">
|
||||||
|
<i class="fa fa-trash"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from `account_accountant/.../list_view/list.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*
|
||||||
|
* NOTE: Enterprise extends `AttachmentPreviewListController` from
|
||||||
|
* `account_accountant/static/src/components/attachment_preview_list_view/...`.
|
||||||
|
* That helper isn't part of Phase 1 scope; we extend the base
|
||||||
|
* `ListController` directly and TODO-flag the methods that depend on
|
||||||
|
* the previewer state. Behaviour will be wired up in fusion-only
|
||||||
|
* Tasks 34-36 alongside the right-pane preview integration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ListController } from "@web/views/list/list_controller";
|
||||||
|
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { listView } from "@web/views/list/list_view";
|
||||||
|
import { useChildSubEnv } from "@odoo/owl";
|
||||||
|
import { makeActiveField } from "@web/model/relational_model/utils";
|
||||||
|
|
||||||
|
export class BankRecListController extends ListController {
|
||||||
|
setup() {
|
||||||
|
super.setup(...arguments);
|
||||||
|
|
||||||
|
this.skipKanbanRestore = {};
|
||||||
|
|
||||||
|
useChildSubEnv({
|
||||||
|
skipKanbanRestoreNeeded: (stLineId) => this.skipKanbanRestore[stLineId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Don't allow bank_rec_form to be restored with previous values since
|
||||||
|
* the statement line has changed.
|
||||||
|
*/
|
||||||
|
async onRecordSaved(record) {
|
||||||
|
this.skipKanbanRestore[record.resId] = true;
|
||||||
|
return super.onRecordSaved(...arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
get previewerStorageKey() {
|
||||||
|
return "fusion.statement_line_pdf_previewer_hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
get modelParams() {
|
||||||
|
const params = super.modelParams;
|
||||||
|
params.config.activeFields.bank_statement_attachment_ids = makeActiveField();
|
||||||
|
params.config.activeFields.bank_statement_attachment_ids.related = {
|
||||||
|
fields: {
|
||||||
|
mimetype: { name: "mimetype", type: "char" },
|
||||||
|
},
|
||||||
|
activeFields: {
|
||||||
|
mimetype: makeActiveField(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
params.config.activeFields.attachment_ids = makeActiveField();
|
||||||
|
params.config.activeFields.attachment_ids.related = {
|
||||||
|
fields: {
|
||||||
|
mimetype: { name: "mimetype", type: "char" },
|
||||||
|
},
|
||||||
|
activeFields: {
|
||||||
|
mimetype: makeActiveField(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO(fusion task 34-36): wire up attachment preview pane.
|
||||||
|
* Enterprise sets `this.attachmentPreviewState.selectedRecord` and
|
||||||
|
* calls `this.setThread(...)` on the AttachmentPreviewListController.
|
||||||
|
* Until that helper is mirrored, this is a no-op.
|
||||||
|
*/
|
||||||
|
async setSelectedRecord(/* accountBankStatementLineData */) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BankRecListRenderer extends ListRenderer {}
|
||||||
|
|
||||||
|
export const bankRecListView = {
|
||||||
|
...listView,
|
||||||
|
Controller: BankRecListController,
|
||||||
|
Renderer: BankRecListRenderer,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("views").add("fusion_bank_rec_list", bankRecListView);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from
|
||||||
|
* `account_accountant/.../list_view/list_view_many2one_multi_edit.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
|
||||||
|
import { buildM2OFieldDescription, Many2OneField } from "@web/views/fields/many2one/many2one_field";
|
||||||
|
|
||||||
|
export class BankRecMany2OneMultiID extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecMany2OneMultiID";
|
||||||
|
static components = { Many2One };
|
||||||
|
static props = { ...Many2OneField.props };
|
||||||
|
|
||||||
|
get m2oProps() {
|
||||||
|
const props = computeM2OProps(this.props);
|
||||||
|
if (this.props.record.selected && this.props.record.model.multiEdit) {
|
||||||
|
props.context.active_ids = this.env.model.root.selection.map((r) => r.resId);
|
||||||
|
}
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("fields").add("fusion_bank_rec_list_many2one_multi_id", {
|
||||||
|
...buildM2OFieldDescription(BankRecMany2OneMultiID),
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecMany2OneMultiID">
|
||||||
|
<Many2One t-props="m2oProps"/>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class PartnerHistoryPanel extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.PartnerHistoryPanel";
|
||||||
|
static props = {
|
||||||
|
partnerId: { type: Number },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.bankRec = useService("fusion_bank_reconciliation");
|
||||||
|
this.state = useState({ history: null, loading: true });
|
||||||
|
|
||||||
|
onWillStart(async () => {
|
||||||
|
try {
|
||||||
|
this.state.history = await this.bankRec.getPartnerHistory(
|
||||||
|
this.props.partnerId,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatAmount(value) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return "0.00";
|
||||||
|
}
|
||||||
|
return Number(value).toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.PartnerHistoryPanel">
|
||||||
|
<div class="o_fusion_partner_history_panel" style="padding: 1rem; border-left: 1px solid #e5e7eb;">
|
||||||
|
<h5 t-if="state.history">
|
||||||
|
<t t-esc="state.history.partner.name"/> — History
|
||||||
|
</h5>
|
||||||
|
<div t-if="state.loading" class="text-muted">Loading…</div>
|
||||||
|
<div t-elif="state.history">
|
||||||
|
<div t-if="state.history.pattern" class="mb-3 p-2"
|
||||||
|
style="background: #eff6ff; border-radius: 0.25rem; font-size: 0.85em;">
|
||||||
|
<strong>Learned pattern:</strong>
|
||||||
|
<div>Reconciles: <t t-esc="state.history.pattern.reconcile_count"/></div>
|
||||||
|
<div t-if="state.history.pattern.pref_strategy">
|
||||||
|
Preferred strategy: <t t-esc="state.history.pattern.pref_strategy"/>
|
||||||
|
</div>
|
||||||
|
<div t-if="state.history.pattern.typical_cadence_days">
|
||||||
|
Typical cadence: ~<t t-esc="state.history.pattern.typical_cadence_days"/> days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h6>Recent reconciles</h6>
|
||||||
|
<div t-foreach="state.history.recent_reconciles" t-as="rec" t-key="rec.precedent_id"
|
||||||
|
style="padding: 0.5rem 0; border-bottom: 1px solid #e5e7eb; font-size: 0.85em;">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span><t t-esc="rec.date"/></span>
|
||||||
|
<span><strong>$<t t-esc="formatAmount(rec.amount)"/></strong></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">
|
||||||
|
<t t-if="rec.memo_tokens"><t t-esc="rec.memo_tokens"/></t>
|
||||||
|
<span class="ms-2">(<t t-esc="rec.matched_count"/> line<t t-if="rec.matched_count !== 1">s</t>)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div t-if="state.history.recent_reconciles.length === 0" class="text-muted">
|
||||||
|
No history yet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from `account_accountant/.../quick_create/quick_create.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
KanbanRecordQuickCreate,
|
||||||
|
KanbanQuickCreateController,
|
||||||
|
} from "@web/views/kanban/kanban_record_quick_create";
|
||||||
|
|
||||||
|
export class BankRecQuickCreateController extends KanbanQuickCreateController {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecQuickCreateController";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BankRecQuickCreate extends KanbanRecordQuickCreate {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecQuickCreate";
|
||||||
|
static props = {
|
||||||
|
...KanbanRecordQuickCreate.props,
|
||||||
|
resModel: { type: String },
|
||||||
|
context: { type: Object },
|
||||||
|
group: { type: Object, optional: true },
|
||||||
|
};
|
||||||
|
static components = { BankRecQuickCreateController };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overridden — quick-create flow always works against a synthetic group
|
||||||
|
* built from the resModel + context props (rather than relying on a
|
||||||
|
* caller-provided group), matching Enterprise behaviour.
|
||||||
|
*/
|
||||||
|
async getQuickCreateProps(props) {
|
||||||
|
await super.getQuickCreateProps({
|
||||||
|
...props,
|
||||||
|
group: {
|
||||||
|
resModel: props.resModel,
|
||||||
|
context: props.context,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecQuickCreate">
|
||||||
|
<BankRecQuickCreateController t-if="state.isLoaded" t-props="quickCreateProps"/>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecQuickCreateController">
|
||||||
|
<div class="o_fusion_bank_reconciliation_quick_create o_kanban_record" t-ref="root">
|
||||||
|
<t t-component="props.Renderer" record="model.root" Compiler="props.Compiler" archInfo="props.archInfo"/>
|
||||||
|
<div class="d-flex gap-1 button_group p-2">
|
||||||
|
<button class="btn btn-primary o_kanban_add" t-on-click="() => this.validate('add')" data-hotkey="s">
|
||||||
|
Add & New
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary o_kanban_edit" t-on-click="() => this.validate('add_close')" data-hotkey="shift+s">
|
||||||
|
Add & Close
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary o_kanban_cancel" t-on-click="() => this.cancel(true)" data-hotkey="d">
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class ReconcileModelPicker extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.ReconcileModelPicker";
|
||||||
|
static props = {
|
||||||
|
statementLineId: { type: Number, optional: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.bankRec = useService("fusion_bank_reconciliation");
|
||||||
|
this.state = useState({ models: [], selected: null });
|
||||||
|
|
||||||
|
onWillStart(async () => {
|
||||||
|
const models = await this.orm.searchRead(
|
||||||
|
"account.reconcile.model",
|
||||||
|
[["rule_type", "=", "writeoff_button"]],
|
||||||
|
["id", "name", "fusion_ai_confidence_threshold"],
|
||||||
|
{ limit: 20 }
|
||||||
|
);
|
||||||
|
this.state.models = models;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(ev) {
|
||||||
|
const value = parseInt(ev.target.value, 10);
|
||||||
|
if (Number.isFinite(value)) {
|
||||||
|
this.onApplyModel(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onApplyModel(modelId) {
|
||||||
|
// Phase 1 placeholder: TODO route through dedicated endpoint when Task 38 lands
|
||||||
|
this.state.selected = modelId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.ReconcileModelPicker">
|
||||||
|
<div class="o_fusion_reconcile_model_picker">
|
||||||
|
<select class="form-select" style="max-width: 240px;"
|
||||||
|
t-on-change="onChange">
|
||||||
|
<option value="">— Apply reconcile model —</option>
|
||||||
|
<option t-foreach="state.models" t-as="m" t-key="m.id" t-att-value="m.id">
|
||||||
|
<t t-esc="m.name"/>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from
|
||||||
|
* `account_accountant/.../reconciled_line_name/reconciled_line_name.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { x2ManyCommands } from "@web/core/orm_service";
|
||||||
|
|
||||||
|
export class BankRecReconciledLineName extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecReconciledLineName";
|
||||||
|
static props = {
|
||||||
|
statementLine: { type: Object },
|
||||||
|
linesToReconcile: { type: Object },
|
||||||
|
moveLineId: { type: String },
|
||||||
|
valueToDisplay: { type: Object },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.bankReconciliation = useBankReconciliation();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTax(lineId, taxChanged) {
|
||||||
|
const lineData = this.props.linesToReconcile.filter((line) => {
|
||||||
|
return line.id === parseInt(lineId);
|
||||||
|
})[0];
|
||||||
|
await this.orm.call("account.bank.statement.line", "edit_reconcile_line", [
|
||||||
|
this.props.statementLine.data.id,
|
||||||
|
lineData.id,
|
||||||
|
{ tax_ids: [[x2ManyCommands.UNLINK, taxChanged.data.id]] },
|
||||||
|
]);
|
||||||
|
this.props.statementLine.load();
|
||||||
|
this.bankReconciliation.reloadChatter();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecReconciledLineName">
|
||||||
|
<div name="reconciled_line_name" class="text-start text-truncate text-muted">
|
||||||
|
<t t-if="props.valueToDisplay?.tax">
|
||||||
|
<t t-foreach="props.valueToDisplay.tax" t-as="tax_id" t-key="tax_id_index">
|
||||||
|
<div class="o_tag d-inline-flex align-items-center badge rounded-pill o_tag_color_0 flex-shrink-0" t-att-class="!tax_id_last ? 'me-1': ''">
|
||||||
|
<span class="o_tag_badge_text text-truncate" t-esc="tax_id.data.display_name"/>
|
||||||
|
<i t-on-click.stop="() => this.deleteTax(props.moveLineId, tax_id)" class="ps-1 opacity-100-hover opacity-75 oi oi-close"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
<t t-else="" t-out="props.valueToDisplay.move or props.valueToDisplay.account"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from
|
||||||
|
* `account_accountant/.../search_dialog/search_dialog.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
||||||
|
import { formatMonetary } from "@web/views/fields/formatters";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
const { DateTime } = luxon;
|
||||||
|
|
||||||
|
export class BankRecSelectCreateDialog extends SelectCreateDialog {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecSelectCreateDialog";
|
||||||
|
static props = {
|
||||||
|
...SelectCreateDialog.props,
|
||||||
|
suspenseAccountLine: Object,
|
||||||
|
reference: String,
|
||||||
|
date: DateTime,
|
||||||
|
size: { type: String, optional: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
...SelectCreateDialog.defaultProps,
|
||||||
|
size: "lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.ui = useService("ui");
|
||||||
|
this.state.remainingAmount = this.suspenseAccountLine.amount_currency;
|
||||||
|
this.state.hideRemainingAmount = false;
|
||||||
|
|
||||||
|
this.baseViewProps.onSelectionChanged = (resIds, selectedLines) => {
|
||||||
|
this.state.resIds = resIds;
|
||||||
|
this.changeInSelectedMoveLine(selectedLines);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeInSelectedMoveLine(selectedLines) {
|
||||||
|
if (!selectedLines?.length) {
|
||||||
|
this.state.remainingAmount = this.suspenseAccountLine.amount_currency;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedLinesSum = 0;
|
||||||
|
this.state.hideRemainingAmount = false;
|
||||||
|
if (
|
||||||
|
this.suspenseAccountLine.currency_id.id !==
|
||||||
|
this.suspenseAccountLine.company_currency_id.id
|
||||||
|
) {
|
||||||
|
const selectedLineCurrencies = selectedLines.map((line) => line.currency_id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedLineCurrencies.length !== 1 ||
|
||||||
|
(selectedLineCurrencies.length === 1 &&
|
||||||
|
selectedLineCurrencies[0] !== this.suspenseAccountLine.currency_id.id)
|
||||||
|
) {
|
||||||
|
this.state.hideRemainingAmount = true;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
selectedLinesSum = selectedLines.reduce((sum, line) => {
|
||||||
|
return sum + line.amount_residual_currency;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedLinesSum = selectedLines.reduce((sum, line) => {
|
||||||
|
return sum + line.amount_residual;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
this.state.remainingAmount = this.suspenseAccountLine.amount_currency + selectedLinesSum;
|
||||||
|
}
|
||||||
|
|
||||||
|
get suspenseAccountLine() {
|
||||||
|
return this.props?.suspenseAccountLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
get remainingAmountFormatted() {
|
||||||
|
return formatMonetary(this.state.remainingAmount, {
|
||||||
|
currencyId: this.suspenseAccountLine.currency_id.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedStatementLineDate() {
|
||||||
|
return this.props.date?.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecSelectCreateDialog" t-inherit="web.SelectCreateDialog" t-inherit-mode="primary">
|
||||||
|
<xpath expr="//Dialog" position="attributes">
|
||||||
|
<attribute name="size">props.size</attribute>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<xpath expr="//button[hasclass('o_form_button_cancel')]" position="after">
|
||||||
|
<div t-if="!this.ui.isSmall" class="d-flex align-items-center flex-grow-1 flex-shrink-1 flex-basis-0 gap-2 min-w-0 justify-content-between" name="bank_reconciliation_info">
|
||||||
|
<span t-esc="formattedStatementLineDate"/>
|
||||||
|
<div class="text-truncate" t-esc="props.reference"/>
|
||||||
|
<div class="text-nowrap text-end" name="remaining_amount">
|
||||||
|
<span class="text-muted">Balance: </span>
|
||||||
|
<t t-if="!this.state.hideRemainingAmount" t-esc="remainingAmountFormatted"/>
|
||||||
|
<t t-else=""> / </t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from
|
||||||
|
* `account_accountant/.../search_dialog/search_dialog_list.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ListController } from "@web/views/list/list_controller";
|
||||||
|
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||||
|
import { listView } from "@web/views/list/list_view";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class BankRecReconcileDialogListController extends ListController {
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.orm = useService("orm");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSelectionChanged() {
|
||||||
|
const resIds = await this.model.root.getResIds(true);
|
||||||
|
if (!resIds.length) {
|
||||||
|
this.props.onSelectionChanged(resIds, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedLines;
|
||||||
|
// When being in the list view with more elements than the limit and
|
||||||
|
// doing a select all, the user can select more elements than the
|
||||||
|
// limit. In this case the isDomainSelected is True.
|
||||||
|
if (this.isDomainSelected) {
|
||||||
|
const { resModel, context } = this.model.root._config;
|
||||||
|
selectedLines = await this.orm.read(
|
||||||
|
resModel,
|
||||||
|
resIds,
|
||||||
|
["amount_residual", "amount_residual_currency", "currency_id"],
|
||||||
|
{ context }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedLines = Object.values(this.model.root.records)
|
||||||
|
.filter((record) => resIds.includes(record._config.resId))
|
||||||
|
.map((record) => {
|
||||||
|
const data = record.data;
|
||||||
|
return {
|
||||||
|
amount_residual: data.amount_residual,
|
||||||
|
amount_residual_currency: data.amount_residual_currency,
|
||||||
|
currency_id: data.currency_id.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.props.onSelectionChanged(resIds, selectedLines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BankRecReconcileDialogListRenderer extends ListRenderer {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer";
|
||||||
|
static recordRowTemplate =
|
||||||
|
"fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer.RecordRow";
|
||||||
|
|
||||||
|
async openMoveView(record) {
|
||||||
|
this.env.services.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "account.move",
|
||||||
|
res_id: record.data.move_id.id,
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bankRecReconcileDialogListRenderer = {
|
||||||
|
...listView,
|
||||||
|
Renderer: BankRecReconcileDialogListRenderer,
|
||||||
|
Controller: BankRecReconcileDialogListController,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("views").add("fusion_bank_rec_dialog_list", bankRecReconcileDialogListRenderer);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
|
||||||
|
<xpath expr="//th[@t-if='hasOpenFormViewColumn']" position="replace">
|
||||||
|
<th class="o_list_open_form_view w-print-0 p-print-0"/>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer.RecordRow" t-inherit="web.ListRenderer.RecordRow" t-inherit-mode="primary">
|
||||||
|
<xpath expr="//t[@t-if='hasOpenFormViewColumn']" position="replace">
|
||||||
|
<td class="o_list_record_open_form_view w-print-0 p-print-0 text-center"
|
||||||
|
t-custom-click.stop="() => this.openMoveView(record)"
|
||||||
|
>
|
||||||
|
<button class="btn btn-link align-top text-end"
|
||||||
|
name="Open in form view"
|
||||||
|
aria-label="Open in form view"
|
||||||
|
>View</button>
|
||||||
|
</td>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from
|
||||||
|
* `account_accountant/static/src/components/bank_reconciliation/statement_line/statement_line.js`
|
||||||
|
*
|
||||||
|
* Phase 1 structural parity. Module IDs / template names / CSS classes
|
||||||
|
* rebranded to `fusion_accounting_bank_rec`. Behaviour delegates to the
|
||||||
|
* Enterprise-compat surface in our `fusion_bank_reconciliation` service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BankRecButtonList } from "@fusion_accounting_bank_rec/components/bank_reconciliation/button_list/button_list";
|
||||||
|
import { BankRecLineToReconcile } from "@fusion_accounting_bank_rec/components/bank_reconciliation/line_to_reconcile/line_to_reconcile";
|
||||||
|
import { BankRecReconciledLineName } from "@fusion_accounting_bank_rec/components/bank_reconciliation/reconciled_line_name/reconciled_line_name";
|
||||||
|
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||||
|
import { formatMonetary } from "@web/views/fields/formatters";
|
||||||
|
import { KanbanRecord } from "@web/views/kanban/kanban_record";
|
||||||
|
import { user } from "@web/core/user";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { onWillStart, useState, useRef } from "@odoo/owl";
|
||||||
|
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
|
||||||
|
|
||||||
|
export class BankRecStatementLine extends KanbanRecord {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecStatementLine";
|
||||||
|
static components = {
|
||||||
|
BankRecLineToReconcile,
|
||||||
|
BankRecButtonList,
|
||||||
|
DropdownItem,
|
||||||
|
BankRecReconciledLineName,
|
||||||
|
};
|
||||||
|
static props = [...KanbanRecord.props];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.ui = useService("ui");
|
||||||
|
this.bankReconciliation = useBankReconciliation();
|
||||||
|
this.state = useState({
|
||||||
|
isUnfolded: false,
|
||||||
|
});
|
||||||
|
this.statementLineRootRef = useRef("root");
|
||||||
|
if (this.env.model.config.context?.default_st_line_id === this.props.record.resId) {
|
||||||
|
this.state.isUnfolded = true;
|
||||||
|
this.bankReconciliation.selectStatementLine(this.props.record);
|
||||||
|
}
|
||||||
|
onWillStart(async () => {
|
||||||
|
this.userCanReview = await user.hasGroup("account.group_account_user");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecordClasses() {
|
||||||
|
let classes = super.getRecordClasses();
|
||||||
|
if (this.hasStatementLine === 1) {
|
||||||
|
classes += " mt-3";
|
||||||
|
}
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// ACTION
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
openStatementCreate() {
|
||||||
|
this.action.doAction("account_accountant.action_bank_statement_form_bank_rec_widget", {
|
||||||
|
additionalContext: {
|
||||||
|
split_line_id: this.recordData.id,
|
||||||
|
default_journal_id: this.recordData.journal_id.id,
|
||||||
|
},
|
||||||
|
onClose: async () => {
|
||||||
|
this.env.model.load();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openPartner() {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "res.partner",
|
||||||
|
res_id: this.partner.id,
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePartner() {
|
||||||
|
await this.orm.write("account.bank.statement.line", [this.recordData.id], {
|
||||||
|
partner_id: false,
|
||||||
|
});
|
||||||
|
this.record.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// HELPER
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
get reconciledLineName() {
|
||||||
|
const reconciledLine = {};
|
||||||
|
for (const line of this.linesToReconcile) {
|
||||||
|
if (
|
||||||
|
line.reconciled_lines_excluding_exchange_diff_ids.records.length === 1 &&
|
||||||
|
line.reconciled_lines_excluding_exchange_diff_ids.records[0].data.move_name
|
||||||
|
) {
|
||||||
|
reconciledLine[line.id] = {
|
||||||
|
move: line.reconciled_lines_excluding_exchange_diff_ids.records[0].data
|
||||||
|
.move_name,
|
||||||
|
};
|
||||||
|
} else if (line.tax_ids.count) {
|
||||||
|
reconciledLine[line.id] = { tax: line.tax_ids.records };
|
||||||
|
} else {
|
||||||
|
reconciledLine[line.id] = { account: line.account_id.display_name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reconciledLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
get record() {
|
||||||
|
return this.props.record;
|
||||||
|
}
|
||||||
|
|
||||||
|
get recordData() {
|
||||||
|
return this.props.record.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
fold() {
|
||||||
|
if (this.state.isUnfolded) {
|
||||||
|
this.toggleUnfold();
|
||||||
|
}
|
||||||
|
this.selectStatementLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
unfold() {
|
||||||
|
if (!this.state.isUnfolded) {
|
||||||
|
this.toggleUnfold();
|
||||||
|
}
|
||||||
|
this.selectStatementLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleUnfold() {
|
||||||
|
this.state.isUnfolded = !this.isUnfolded;
|
||||||
|
this.selectStatementLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectStatementLine() {
|
||||||
|
// Update the chatter with the last selected element
|
||||||
|
this.bankReconciliation.selectStatementLine(this.record);
|
||||||
|
}
|
||||||
|
|
||||||
|
openChatter() {
|
||||||
|
this.selectStatementLine();
|
||||||
|
this.bankReconciliation.openChatter();
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasInvalidAnalytics() {
|
||||||
|
return this.linesToReconcile.some((line) => line.has_invalid_analytics);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isUnfolded() {
|
||||||
|
return this.state.isUnfolded;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasStatementLine() {
|
||||||
|
return this.env.model.root.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedAmount() {
|
||||||
|
return formatMonetary(this.recordData.amount, {
|
||||||
|
currencyId: this.recordData.currency_id.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedDate() {
|
||||||
|
return this.recordData.date.toLocaleString({
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedFullDate() {
|
||||||
|
return this.recordData.date.toLocaleString({
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get partner() {
|
||||||
|
return this.recordData.partner_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get linesToReconcile() {
|
||||||
|
return this.accountMoveLines.filter((line) => {
|
||||||
|
return (
|
||||||
|
line.account_id.id !== this.recordData.journal_id?.suspense_account_id.id &&
|
||||||
|
line.account_id.id !== this.recordData.journal_id?.default_account_id.id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get suspenseAccountLine() {
|
||||||
|
return this.accountMoveLines.filter((line) => {
|
||||||
|
return line.account_id.id === this.recordData.journal_id.suspense_account_id.id;
|
||||||
|
})?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
get accountMoveLines() {
|
||||||
|
return [...this.recordData.line_ids.records.map((line) => line.data)];
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasForeignCurrencyAndSameCurrencyForAllLines() {
|
||||||
|
return (
|
||||||
|
this.recordData.foreign_currency_id &&
|
||||||
|
this.linesToReconcile &&
|
||||||
|
this.linesToReconcile.filter((line) => {
|
||||||
|
return line.currency_id.id !== this.recordData.foreign_currency_id.id;
|
||||||
|
}).length === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get suspenseAccountLineFormattedAmount() {
|
||||||
|
return formatMonetary(this.suspenseAccountLine.amount_currency, {
|
||||||
|
currencyId: this.suspenseAccountLine?.currency_id.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get activityNumber() {
|
||||||
|
return this.recordData.activity_ids.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there is at least one attachment associated with the bank
|
||||||
|
* statement line or its related records. Aggregates attachment counts from
|
||||||
|
* the move, the related move lines, and the lines reconciled with them.
|
||||||
|
*
|
||||||
|
* @returns {number} Total attachments. > 0 indicates presence.
|
||||||
|
*/
|
||||||
|
get hasAttachment() {
|
||||||
|
const statementAttachment = this.recordData.bank_statement_attachment_ids.records.map(
|
||||||
|
(attachment) => attachment.data.id
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.recordData.attachment_ids.records.length +
|
||||||
|
this.linesToReconcile
|
||||||
|
.flatMap((line) => line.reconciled_lines_ids.records)
|
||||||
|
.filter((line) => line.data.move_attachment_ids?.count)
|
||||||
|
.reduce(
|
||||||
|
(accumulator, line) =>
|
||||||
|
parseInt(accumulator) + parseInt(line.data.move_attachment_ids.count),
|
||||||
|
0
|
||||||
|
) +
|
||||||
|
this.linesToReconcile
|
||||||
|
.filter(
|
||||||
|
(line) =>
|
||||||
|
line.move_attachment_ids?.count &&
|
||||||
|
!line.move_attachment_ids.records
|
||||||
|
.map((attachment) => attachment.data.id)
|
||||||
|
.every((id) => statementAttachment.includes(id))
|
||||||
|
)
|
||||||
|
.reduce(
|
||||||
|
(accumulator, line) =>
|
||||||
|
parseInt(accumulator) + parseInt(line.move_attachment_ids.count),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get amountClasses() {
|
||||||
|
const classes = this.recordData.foreign_currency_id ? "w-50" : "w-100";
|
||||||
|
if (this.recordData.amount > 0) {
|
||||||
|
return `${classes} fw-bold`;
|
||||||
|
}
|
||||||
|
if (this.recordData.amount < 0) {
|
||||||
|
return `${classes} text-danger fw-bold`;
|
||||||
|
}
|
||||||
|
return `${classes} text-secondary`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get buttonListProps() {
|
||||||
|
return {
|
||||||
|
statementLineRootRef: this.statementLineRootRef,
|
||||||
|
statementLine: this.record,
|
||||||
|
reconcileLineCount:
|
||||||
|
this.bankReconciliation.reconcileCountPerPartnerId[this.recordData.partner_id.id] ??
|
||||||
|
null,
|
||||||
|
reconcileModels:
|
||||||
|
this.bankReconciliation.reconcileModelPerStatementLineId[this.recordData.id] ?? [],
|
||||||
|
preSelectedReconciliationModel: this.accountMoveLines
|
||||||
|
.filter((line) => line.reconcile_model_id.id)
|
||||||
|
.map((line) => line.reconcile_model_id)?.[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedAmountCurrencyInForeign() {
|
||||||
|
return formatMonetary(this.recordData.amount_currency, {
|
||||||
|
currencyId: this.recordData.foreign_currency_id.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSelected() {
|
||||||
|
return this.recordData.move_id.id === this.bankReconciliation.statementLineMoveId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isChatterOpen() {
|
||||||
|
return this.bankReconciliation.chatterState.visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecStatementLine" t-inherit="web.KanbanRecord" t-inherit-mode="primary">
|
||||||
|
<xpath expr="//article" position="replace">
|
||||||
|
<article
|
||||||
|
t-att-class="getRecordClasses()"
|
||||||
|
t-att-data-id="record.id"
|
||||||
|
t-att-tabindex="record.model.useSampleModel ? -1 : 0"
|
||||||
|
t-custom-click="onGlobalClick"
|
||||||
|
t-on-touchstart="onTouchStart"
|
||||||
|
t-on-touchmove="onTouchMoveOrCancel"
|
||||||
|
t-on-touchcancel="onTouchMoveOrCancel"
|
||||||
|
t-on-touchend="onTouchEnd"
|
||||||
|
t-ref="root">
|
||||||
|
<div name="bank_statement_line" class="o_statement_line w-100 p-2" t-on-click="selectStatementLine" t-att-class="{'o_selected_statement_line': isSelected}">
|
||||||
|
<button t-if="!recordData.statement_id" type="button" class="o_statement_btn d-none d-md-block position-absolute top-0 end-0 btn btn-sm btn-secondary" t-on-click.stop="openStatementCreate">
|
||||||
|
Statement
|
||||||
|
</button>
|
||||||
|
<div class="o_grid_container">
|
||||||
|
<div class="o_row">
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div t-att-data-tooltip="formattedFullDate">
|
||||||
|
<t t-esc="formattedDate"/>
|
||||||
|
</div>
|
||||||
|
<div t-on-click.stop="openChatter" t-if="!ui.isSmall" class="o_chatter_icon btn-link text-action" t-att-class="{'visible': activityNumber or hasAttachment}">
|
||||||
|
<div t-if="activityNumber" class="activity-container position-relative">
|
||||||
|
<i class="fa fa-lg fa-clock-o" role="img" aria-label="Activities"/>
|
||||||
|
<span class="activity-badge badge rounded-pill" t-esc="activityNumber"/>
|
||||||
|
</div>
|
||||||
|
<i t-elif="hasAttachment"
|
||||||
|
class="fa fa-lg fa-paperclip"
|
||||||
|
role="img"
|
||||||
|
aria-label="Attachment"
|
||||||
|
/>
|
||||||
|
<i t-elif="!isChatterOpen"
|
||||||
|
class="fa fa-lg fa-comments-o"
|
||||||
|
role="img"
|
||||||
|
aria-label="Journal Entry"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_payment_ref user-select-text d-none d-md-block"
|
||||||
|
t-att-class="isUnfolded ? 'overflow-wrap' : 'text-truncate'">
|
||||||
|
<span class="d-inline">
|
||||||
|
<t t-if="partner">
|
||||||
|
<a class="fw-bold" href="#" t-on-click.prevent.stop="openPartner">
|
||||||
|
<span t-esc="partner.display_name" name="statement_line_partner_name"/>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-link oi oi-close p-0 align-baseline" t-on-click.stop="removePartner" t-if="!linesToReconcile.length"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="recordData.partner_name">
|
||||||
|
<span class="fw-bold" t-esc="recordData.partner_name" name="statement_line_partner_name"/>
|
||||||
|
</t>
|
||||||
|
<span t-att-class="partner or recordData.partner_name ? 'ms-1' : undefined"
|
||||||
|
t-esc="recordData.payment_ref"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Only available on large screen -->
|
||||||
|
<div class="o_button_line d-none d-md-flex align-items-start text-truncate">
|
||||||
|
<BankRecButtonList t-props="buttonListProps" suspenseAccountLine="suspenseAccountLine" t-if="!recordData.is_reconciled or (userCanReview and !recordData.checked)"/>
|
||||||
|
<span class="badge rounded-pill py-1 ps-1" t-att-class="{ 'pe-1': !isUnfolded, 'text-success bg-success-subtle': !hasInvalidAnalytics, 'text-warning bg-warning-subtle': hasInvalidAnalytics}" t-if="recordData.is_reconciled">
|
||||||
|
<i t-if="hasInvalidAnalytics" class="fa fa-exclamation-triangle" data-tooltip="Some lines have invalid analytic distribution"/>
|
||||||
|
<i t-if="!hasInvalidAnalytics" class="fa fa-check"/>
|
||||||
|
<span t-if="isUnfolded" class="ms-1">
|
||||||
|
Reconciled
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<t t-if="recordData.is_reconciled and !isUnfolded">
|
||||||
|
<t t-foreach="Object.entries(reconciledLineName)" t-as="line" t-key="line_index">
|
||||||
|
<BankRecReconciledLineName statementLine="record" linesToReconcile="linesToReconcile" moveLineId="line[0]" valueToDisplay="line[1]"/>
|
||||||
|
<t t-if="line_index < Object.keys(reconciledLineName).length - 1">, </t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-start justify-content-between o_line_amount">
|
||||||
|
<span class="text-muted w-50 text-end text-nowrap" t-if="recordData.foreign_currency_id">
|
||||||
|
<t t-esc="formattedAmountCurrencyInForeign"/>
|
||||||
|
</span>
|
||||||
|
<span t-att-class="amountClasses" class="text-end text-nowrap" t-esc="formattedAmount"/>
|
||||||
|
</div>
|
||||||
|
<div class="d-none d-md-block text-end" t-on-click="toggleUnfold" t-if="recordData.is_reconciled">
|
||||||
|
<i class="oi" t-att-class="{'oi-chevron-up': isUnfolded, 'oi-chevron-down': !isUnfolded}"/>
|
||||||
|
</div>
|
||||||
|
<div class="d-none d-md-block" t-else=""/> <!-- To keep empty space if no chevron -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Only available on small screen -->
|
||||||
|
<div class="o_row d-md-none">
|
||||||
|
<span class="text-truncate o_payment_ref"
|
||||||
|
t-esc="recordData.payment_ref"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<t t-if="isUnfolded or !recordData.is_reconciled">
|
||||||
|
<t t-foreach="linesToReconcile" t-as="line" t-key="line_index">
|
||||||
|
<BankRecLineToReconcile statementLine="record" line="line"/>
|
||||||
|
</t>
|
||||||
|
<div class="o_row" t-if="linesToReconcile.length">
|
||||||
|
<div t-if="suspenseAccountLine" class="d-none d-md-flex fw-bold text-muted align-items-center justify-content-end o_line_amount" t-att-class="hasForeignCurrencyAndSameCurrencyForAllLines ? 'w-50' : 'w-100'">
|
||||||
|
<t t-esc="suspenseAccountLineFormattedAmount"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<div class="o_row d-md-none">
|
||||||
|
<div class="o_button_line">
|
||||||
|
<BankRecButtonList t-props="buttonListProps" suspenseAccountLine="suspenseAccountLine" t-if="!recordData.is_reconciled or (userCanReview and !recordData.checked)"/>
|
||||||
|
<span t-if="recordData.is_reconciled and !isUnfolded" class="text-start text-muted" t-esc="reconciledLineName"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrored from
|
||||||
|
* `account_accountant/.../statement_summary/statement_summary.js`.
|
||||||
|
* Phase 1 structural parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
|
||||||
|
export class BankRecStatementSummary extends Component {
|
||||||
|
static template = "fusion_accounting_bank_rec.BankRecStatementSummary";
|
||||||
|
|
||||||
|
static props = {
|
||||||
|
label: { type: String },
|
||||||
|
amount: { type: String, optional: true },
|
||||||
|
action: { type: Function },
|
||||||
|
journalId: { type: Number, optional: true },
|
||||||
|
isValid: { type: Boolean, optional: true },
|
||||||
|
journalIsInvalid: { type: Boolean, optional: true },
|
||||||
|
};
|
||||||
|
static defaultProps = {
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
actionApplyInvalidStatement() {
|
||||||
|
const facets = this.env.searchModel.facets;
|
||||||
|
const searchItems = this.env.searchModel.searchItems;
|
||||||
|
const invalidStatementFilter = Object.values(searchItems).find(
|
||||||
|
(i) => i.name == "invalid_statement"
|
||||||
|
);
|
||||||
|
const invalidStatementFacet = facets.filter(
|
||||||
|
(i) => i.groupId == invalidStatementFilter.groupId
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
invalidStatementFacet.length == 0 ||
|
||||||
|
!invalidStatementFacet[0].values.includes(invalidStatementFilter.description)
|
||||||
|
) {
|
||||||
|
this.env.searchModel.toggleSearchItem(invalidStatementFilter.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecStatementSummary">
|
||||||
|
<div class="o_statement_summary d-flex justify-content-between align-items-center w-100 p-2">
|
||||||
|
<div name="label_statement_summary" class="d-flex gap-2 align-items-center">
|
||||||
|
<h4 t-esc="props.label"
|
||||||
|
t-on-click="props.action"
|
||||||
|
class="m-0"
|
||||||
|
t-att-class="{'text-danger': !props.isValid}"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="m-0"
|
||||||
|
t-if="props.journalIsInvalid"
|
||||||
|
t-on-click="actionApplyInvalidStatement">
|
||||||
|
Invalid Statement(s)
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div t-if="props.amount"
|
||||||
|
class="btn btn-link p-0 fw-bold fs-4"
|
||||||
|
t-on-click="props.action"
|
||||||
|
t-esc="props.amount"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
91
fusion_accounting_bank_rec/static/src/scss/_variables.scss
Normal file
91
fusion_accounting_bank_rec/static/src/scss/_variables.scss
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Fusion bank reconciliation design tokens.
|
||||||
|
//
|
||||||
|
// Mirrors Enterprise's color/spacing scale where it makes sense, with
|
||||||
|
// fusion-specific additions for AI confidence bands and the suggestion
|
||||||
|
// strip. All values can be overridden in dark_mode.scss.
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Colors — semantic
|
||||||
|
// ============================================================
|
||||||
|
$fusion-color-bg-primary: #ffffff;
|
||||||
|
$fusion-color-bg-secondary: #f9fafb;
|
||||||
|
$fusion-color-bg-tertiary: #f3f4f6;
|
||||||
|
$fusion-color-border: #e5e7eb;
|
||||||
|
$fusion-color-border-strong: #d1d5db;
|
||||||
|
|
||||||
|
$fusion-color-text-primary: #111827;
|
||||||
|
$fusion-color-text-secondary: #6b7280;
|
||||||
|
$fusion-color-text-muted: #9ca3af;
|
||||||
|
$fusion-color-text-inverse: #ffffff;
|
||||||
|
|
||||||
|
$fusion-color-accent: #3b82f6; // primary brand blue
|
||||||
|
$fusion-color-accent-hover: #2563eb;
|
||||||
|
$fusion-color-accent-bg: #eff6ff;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AI Confidence band colors
|
||||||
|
// ============================================================
|
||||||
|
$fusion-confidence-high: #10b981; // green
|
||||||
|
$fusion-confidence-high-bg: #ecfdf5;
|
||||||
|
$fusion-confidence-medium: #f59e0b; // amber
|
||||||
|
$fusion-confidence-medium-bg: #fffbeb;
|
||||||
|
$fusion-confidence-low: #ef4444; // red
|
||||||
|
$fusion-confidence-low-bg: #fef2f2;
|
||||||
|
$fusion-confidence-none: #9ca3af; // gray
|
||||||
|
$fusion-confidence-none-bg: #f3f4f6;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Reconciliation state colors
|
||||||
|
// ============================================================
|
||||||
|
$fusion-state-pending-bg: #fef3c7; // amber-100
|
||||||
|
$fusion-state-reconciled-bg: #d1fae5; // emerald-100
|
||||||
|
$fusion-state-partial-bg: #fde68a; // amber-200
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Spacing scale (4px increments)
|
||||||
|
// ============================================================
|
||||||
|
$fusion-space-1: 0.25rem; // 4px
|
||||||
|
$fusion-space-2: 0.5rem; // 8px
|
||||||
|
$fusion-space-3: 0.75rem; // 12px
|
||||||
|
$fusion-space-4: 1rem; // 16px
|
||||||
|
$fusion-space-5: 1.25rem; // 20px
|
||||||
|
$fusion-space-6: 1.5rem; // 24px
|
||||||
|
$fusion-space-8: 2rem; // 32px
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Typography
|
||||||
|
// ============================================================
|
||||||
|
$fusion-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
$fusion-font-size-xs: 0.75rem; // 12px
|
||||||
|
$fusion-font-size-sm: 0.875rem; // 14px
|
||||||
|
$fusion-font-size-base: 1rem; // 16px
|
||||||
|
$fusion-font-size-lg: 1.125rem; // 18px
|
||||||
|
$fusion-font-size-xl: 1.25rem; // 20px
|
||||||
|
|
||||||
|
$fusion-font-weight-normal: 400;
|
||||||
|
$fusion-font-weight-medium: 500;
|
||||||
|
$fusion-font-weight-semibold: 600;
|
||||||
|
$fusion-font-weight-bold: 700;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Borders + radii
|
||||||
|
// ============================================================
|
||||||
|
$fusion-border-radius-sm: 0.25rem;
|
||||||
|
$fusion-border-radius: 0.375rem;
|
||||||
|
$fusion-border-radius-md: 0.5rem;
|
||||||
|
$fusion-border-radius-lg: 0.75rem;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Shadows
|
||||||
|
// ============================================================
|
||||||
|
$fusion-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
$fusion-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
$fusion-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
$fusion-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Animation
|
||||||
|
// ============================================================
|
||||||
|
$fusion-transition-fast: 150ms ease-in-out;
|
||||||
|
$fusion-transition-base: 200ms ease-in-out;
|
||||||
|
$fusion-transition-slow: 300ms ease-in-out;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@import "variables";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AI Suggestion strip (inline, on each statement line card)
|
||||||
|
// ============================================================
|
||||||
|
.o_fusion_ai_suggestion {
|
||||||
|
margin-top: $fusion-space-3;
|
||||||
|
padding: $fusion-space-3;
|
||||||
|
border-radius: $fusion-border-radius;
|
||||||
|
border: 1px solid;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $fusion-space-3;
|
||||||
|
font-size: $fusion-font-size-sm;
|
||||||
|
transition: all $fusion-transition-base;
|
||||||
|
|
||||||
|
// Confidence bands — apply via [data-band="..."] attribute
|
||||||
|
&[data-band="high"] {
|
||||||
|
background: $fusion-confidence-high-bg;
|
||||||
|
border-color: $fusion-confidence-high;
|
||||||
|
|
||||||
|
.o_fusion_confidence_value { color: $fusion-confidence-high; }
|
||||||
|
}
|
||||||
|
&[data-band="medium"] {
|
||||||
|
background: $fusion-confidence-medium-bg;
|
||||||
|
border-color: $fusion-confidence-medium;
|
||||||
|
|
||||||
|
.o_fusion_confidence_value { color: $fusion-confidence-medium; }
|
||||||
|
}
|
||||||
|
&[data-band="low"] {
|
||||||
|
background: $fusion-confidence-low-bg;
|
||||||
|
border-color: $fusion-confidence-low;
|
||||||
|
|
||||||
|
.o_fusion_confidence_value { color: $fusion-confidence-low; }
|
||||||
|
}
|
||||||
|
&[data-band="none"] {
|
||||||
|
background: $fusion-confidence-none-bg;
|
||||||
|
border-color: $fusion-confidence-none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_confidence_badge {
|
||||||
|
font-weight: $fusion-font-weight-bold;
|
||||||
|
font-size: $fusion-font-size-base;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_suggestion_text {
|
||||||
|
flex: 1;
|
||||||
|
color: $fusion-color-text-primary;
|
||||||
|
|
||||||
|
.o_fusion_reasoning {
|
||||||
|
color: $fusion-color-text-secondary;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: $fusion-space-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_suggestion_actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $fusion-space-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Alternatives panel (expandable list of other suggestions)
|
||||||
|
// ============================================================
|
||||||
|
.o_fusion_alternatives_panel {
|
||||||
|
margin-top: $fusion-space-2;
|
||||||
|
padding: $fusion-space-3;
|
||||||
|
background: $fusion-color-bg-secondary;
|
||||||
|
border: 1px solid $fusion-color-border;
|
||||||
|
border-radius: $fusion-border-radius;
|
||||||
|
font-size: $fusion-font-size-sm;
|
||||||
|
|
||||||
|
.o_fusion_alternative {
|
||||||
|
padding: $fusion-space-2 0;
|
||||||
|
border-bottom: 1px solid $fusion-color-border;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.alt_confidence {
|
||||||
|
font-weight: $fusion-font-weight-medium;
|
||||||
|
margin-right: $fusion-space-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
@import "variables";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Bank reconciliation kanban container
|
||||||
|
// ============================================================
|
||||||
|
.o_fusion_bank_rec {
|
||||||
|
background: $fusion-color-bg-secondary;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: $fusion-font-family;
|
||||||
|
color: $fusion-color-text-primary;
|
||||||
|
|
||||||
|
// Header bar with stats
|
||||||
|
&_header {
|
||||||
|
background: $fusion-color-bg-primary;
|
||||||
|
border-bottom: 1px solid $fusion-color-border;
|
||||||
|
padding: $fusion-space-4 $fusion-space-6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: $fusion-font-size-xl;
|
||||||
|
font-weight: $fusion-font-weight-semibold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_stats {
|
||||||
|
display: flex;
|
||||||
|
gap: $fusion-space-6;
|
||||||
|
font-size: $fusion-font-size-sm;
|
||||||
|
color: $fusion-color-text-secondary;
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-weight: $fusion-font-weight-semibold;
|
||||||
|
color: $fusion-color-text-primary;
|
||||||
|
margin-left: $fusion-space-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statement line cards (kanban tile)
|
||||||
|
&_line {
|
||||||
|
background: $fusion-color-bg-primary;
|
||||||
|
border: 1px solid $fusion-color-border;
|
||||||
|
border-radius: $fusion-border-radius-md;
|
||||||
|
padding: $fusion-space-4;
|
||||||
|
margin-bottom: $fusion-space-3;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $fusion-transition-base;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $fusion-color-accent;
|
||||||
|
box-shadow: $fusion-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.o_fusion_selected {
|
||||||
|
border-color: $fusion-color-accent;
|
||||||
|
background: $fusion-color-accent-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&_header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: $fusion-space-2;
|
||||||
|
|
||||||
|
.o_fusion_amount {
|
||||||
|
font-size: $fusion-font-size-lg;
|
||||||
|
font-weight: $fusion-font-weight-semibold;
|
||||||
|
|
||||||
|
&.negative {
|
||||||
|
color: $fusion-confidence-low;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_date {
|
||||||
|
font-size: $fusion-font-size-sm;
|
||||||
|
color: $fusion-color-text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&_body {
|
||||||
|
font-size: $fusion-font-size-sm;
|
||||||
|
color: $fusion-color-text-secondary;
|
||||||
|
|
||||||
|
.o_fusion_partner {
|
||||||
|
font-weight: $fusion-font-weight-medium;
|
||||||
|
color: $fusion-color-text-primary;
|
||||||
|
margin-right: $fusion-space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_memo {
|
||||||
|
font-style: italic;
|
||||||
|
color: $fusion-color-text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment count badge
|
||||||
|
.o_fusion_attachments_badge {
|
||||||
|
position: absolute;
|
||||||
|
top: $fusion-space-2;
|
||||||
|
right: $fusion-space-2;
|
||||||
|
background: $fusion-color-bg-tertiary;
|
||||||
|
border-radius: $fusion-border-radius;
|
||||||
|
padding: $fusion-space-1 $fusion-space-2;
|
||||||
|
font-size: $fusion-font-size-xs;
|
||||||
|
color: $fusion-color-text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail/edit panel (right side)
|
||||||
|
&_detail {
|
||||||
|
background: $fusion-color-bg-primary;
|
||||||
|
border-left: 1px solid $fusion-color-border;
|
||||||
|
padding: $fusion-space-6;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: $fusion-font-size-lg;
|
||||||
|
font-weight: $fusion-font-weight-semibold;
|
||||||
|
margin: 0 0 $fusion-space-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
.btn_fusion {
|
||||||
|
padding: $fusion-space-2 $fusion-space-4;
|
||||||
|
border-radius: $fusion-border-radius;
|
||||||
|
font-size: $fusion-font-size-sm;
|
||||||
|
font-weight: $fusion-font-weight-medium;
|
||||||
|
border: 1px solid $fusion-color-border;
|
||||||
|
background: $fusion-color-bg-primary;
|
||||||
|
color: $fusion-color-text-primary;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $fusion-transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $fusion-color-bg-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn_fusion_primary {
|
||||||
|
background: $fusion-color-accent;
|
||||||
|
border-color: $fusion-color-accent;
|
||||||
|
color: $fusion-color-text-inverse;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $fusion-color-accent-hover;
|
||||||
|
border-color: $fusion-color-accent-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
fusion_accounting_bank_rec/static/src/scss/dark_mode.scss
Normal file
64
fusion_accounting_bank_rec/static/src/scss/dark_mode.scss
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
@import "variables";
|
||||||
|
|
||||||
|
// Activated via [data-color-scheme="dark"] on body or any ancestor.
|
||||||
|
// Mirrors Odoo's standard dark-mode trigger pattern.
|
||||||
|
|
||||||
|
[data-color-scheme="dark"] .o_fusion_bank_rec {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #f9fafb;
|
||||||
|
|
||||||
|
&_header,
|
||||||
|
&_line,
|
||||||
|
&_detail {
|
||||||
|
background: #111827;
|
||||||
|
border-color: #374151;
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&_line {
|
||||||
|
&:hover { border-color: #60a5fa; }
|
||||||
|
&.o_fusion_selected {
|
||||||
|
background: #1e3a8a;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&_header .o_fusion_date,
|
||||||
|
&_body { color: #d1d5db; }
|
||||||
|
|
||||||
|
.o_fusion_attachments_badge {
|
||||||
|
background: #374151;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn_fusion {
|
||||||
|
background: #374151;
|
||||||
|
border-color: #4b5563;
|
||||||
|
color: #f9fafb;
|
||||||
|
|
||||||
|
&:hover { background: #4b5563; }
|
||||||
|
|
||||||
|
&.btn_fusion_primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI suggestion strip — soften background colors for dark mode
|
||||||
|
.o_fusion_ai_suggestion {
|
||||||
|
&[data-band="high"] { background: rgba(16, 185, 129, 0.1); }
|
||||||
|
&[data-band="medium"] { background: rgba(245, 158, 11, 0.1); }
|
||||||
|
&[data-band="low"] { background: rgba(239, 68, 68, 0.1); }
|
||||||
|
&[data-band="none"] { background: rgba(156, 163, 175, 0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_alternatives_panel {
|
||||||
|
background: #1f2937;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank reconciliation service.
|
||||||
|
*
|
||||||
|
* Central data layer + reactive state for the OWL bank-rec widget.
|
||||||
|
* Components inject this service via useService("fusion_bank_reconciliation")
|
||||||
|
* and read/write state through its methods.
|
||||||
|
*
|
||||||
|
* Wraps the 10 JSON-RPC endpoints from controllers/bank_rec_controller.py.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { reactive, useState, EventBus } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { browser } from "@web/core/browser/browser";
|
||||||
|
|
||||||
|
const ENDPOINT_BASE = "/fusion/bank_rec";
|
||||||
|
|
||||||
|
export class BankReconciliationService {
|
||||||
|
constructor(env, services) {
|
||||||
|
this.env = env;
|
||||||
|
this.rpc = services.rpc;
|
||||||
|
this.notification = services.notification;
|
||||||
|
this.orm = services.orm;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Enterprise-compat surface (mirrored OWL components rely on this)
|
||||||
|
// ============================================================
|
||||||
|
// Mirrored components from account_accountant expect these
|
||||||
|
// attributes/methods on the service. Most are implemented as
|
||||||
|
// stubs that no-op or return sensible defaults; structural
|
||||||
|
// parity now, behaviour wired up in fusion-only Tasks 34-36.
|
||||||
|
this.bus = new EventBus();
|
||||||
|
this.chatterState = reactive({
|
||||||
|
visible: this._readChatterPref(),
|
||||||
|
statementLine: null,
|
||||||
|
});
|
||||||
|
this.reconcileCountPerPartnerId = reactive({});
|
||||||
|
this.reconcileModelPerStatementLineId = reactive({});
|
||||||
|
|
||||||
|
// Reactive state — components depend on it via useState/reactive
|
||||||
|
this.state = reactive({
|
||||||
|
journalId: null,
|
||||||
|
companyId: null,
|
||||||
|
unreconciledCount: 0,
|
||||||
|
totalPendingAmount: 0,
|
||||||
|
lines: [],
|
||||||
|
lineCache: {}, // {lineId: {detail, suggestions, attachments}}
|
||||||
|
selectedLineId: null,
|
||||||
|
isLoading: false,
|
||||||
|
isReconciling: false,
|
||||||
|
offset: 0,
|
||||||
|
limit: 50,
|
||||||
|
filters: {},
|
||||||
|
// Cache of recently-applied actions for optimistic UI
|
||||||
|
recentActions: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Initialization
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async initForJournal(journalId, companyId) {
|
||||||
|
this.state.journalId = journalId;
|
||||||
|
this.state.companyId = companyId;
|
||||||
|
this.state.isLoading = true;
|
||||||
|
try {
|
||||||
|
const stateInfo = await this.rpc(`${ENDPOINT_BASE}/get_state`, {
|
||||||
|
journal_id: journalId, company_id: companyId,
|
||||||
|
});
|
||||||
|
this.state.unreconciledCount = stateInfo.unreconciled_count;
|
||||||
|
this.state.totalPendingAmount = stateInfo.total_pending_amount;
|
||||||
|
await this.loadLines({ reset: true });
|
||||||
|
} finally {
|
||||||
|
this.state.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// List + pagination
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async loadLines({ reset = false } = {}) {
|
||||||
|
if (reset) {
|
||||||
|
this.state.offset = 0;
|
||||||
|
this.state.lines = [];
|
||||||
|
}
|
||||||
|
this.state.isLoading = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/list_unreconciled`, {
|
||||||
|
journal_id: this.state.journalId,
|
||||||
|
company_id: this.state.companyId,
|
||||||
|
limit: this.state.limit,
|
||||||
|
offset: this.state.offset,
|
||||||
|
...this.state.filters,
|
||||||
|
});
|
||||||
|
if (reset) {
|
||||||
|
this.state.lines = result.lines;
|
||||||
|
} else {
|
||||||
|
this.state.lines = [...this.state.lines, ...result.lines];
|
||||||
|
}
|
||||||
|
this.state.unreconciledCount = result.total;
|
||||||
|
} finally {
|
||||||
|
this.state.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMore() {
|
||||||
|
this.state.offset += this.state.limit;
|
||||||
|
await this.loadLines({ reset: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(key, value) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
delete this.state.filters[key];
|
||||||
|
} else {
|
||||||
|
this.state.filters[key] = value;
|
||||||
|
}
|
||||||
|
this.loadLines({ reset: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Line detail + suggestions
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async selectLine(lineId) {
|
||||||
|
this.state.selectedLineId = lineId;
|
||||||
|
if (!this.state.lineCache[lineId]) {
|
||||||
|
await this.loadLineDetail(lineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadLineDetail(lineId) {
|
||||||
|
const detail = await this.rpc(`${ENDPOINT_BASE}/get_line_detail`, {
|
||||||
|
statement_line_id: lineId,
|
||||||
|
});
|
||||||
|
this.state.lineCache[lineId] = detail;
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshLineDetail(lineId) {
|
||||||
|
delete this.state.lineCache[lineId];
|
||||||
|
return await this.loadLineDetail(lineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async suggestMatches(lineIds, limitPerLine = 3) {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/suggest_matches`, {
|
||||||
|
statement_line_ids: lineIds,
|
||||||
|
limit_per_line: limitPerLine,
|
||||||
|
});
|
||||||
|
// Refresh cache for each line
|
||||||
|
for (const lineId of lineIds) {
|
||||||
|
await this.refreshLineDetail(lineId);
|
||||||
|
}
|
||||||
|
return result.suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Reconciliation actions
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async acceptSuggestion(suggestionId) {
|
||||||
|
this.state.isReconciling = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/accept_suggestion`, {
|
||||||
|
suggestion_id: suggestionId,
|
||||||
|
});
|
||||||
|
this._removeReconciledLineFromState(this.state.selectedLineId);
|
||||||
|
this.state.unreconciledCount = result.unreconciled_count_after;
|
||||||
|
this.notification.add("Reconciliation accepted", { type: "success" });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Accept failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.state.isReconciling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcileManual(statementLineId, againstMoveLineIds) {
|
||||||
|
this.state.isReconciling = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/reconcile_manual`, {
|
||||||
|
statement_line_id: statementLineId,
|
||||||
|
against_move_line_ids: againstMoveLineIds,
|
||||||
|
});
|
||||||
|
this._removeReconciledLineFromState(statementLineId);
|
||||||
|
this.notification.add("Reconciled", { type: "success" });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Reconcile failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.state.isReconciling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unreconcile(partialReconcileIds) {
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/unreconcile`, {
|
||||||
|
partial_reconcile_ids: partialReconcileIds,
|
||||||
|
});
|
||||||
|
// Reload list since unreconciled lines come back
|
||||||
|
await this.loadLines({ reset: true });
|
||||||
|
this.notification.add("Unreconciled", { type: "info" });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Unreconcile failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeOff({ statementLineId, accountId, amount, label, taxId = null }) {
|
||||||
|
this.state.isReconciling = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/write_off`, {
|
||||||
|
statement_line_id: statementLineId,
|
||||||
|
account_id: accountId,
|
||||||
|
amount: amount,
|
||||||
|
label: label,
|
||||||
|
tax_id: taxId,
|
||||||
|
});
|
||||||
|
this._removeReconciledLineFromState(statementLineId);
|
||||||
|
this.notification.add("Write-off applied", { type: "success" });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Write-off failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.state.isReconciling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkReconcile(statementLineIds, strategy = "auto") {
|
||||||
|
this.state.isReconciling = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/bulk_reconcile`, {
|
||||||
|
statement_line_ids: statementLineIds,
|
||||||
|
strategy: strategy,
|
||||||
|
});
|
||||||
|
await this.loadLines({ reset: true });
|
||||||
|
const msg = `${result.reconciled_count} reconciled, ${result.skipped} skipped`;
|
||||||
|
this.notification.add(msg, { type: "success" });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Bulk failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.state.isReconciling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Partner history (right-side panel)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async getPartnerHistory(partnerId, limit = 20) {
|
||||||
|
return await this.rpc(`${ENDPOINT_BASE}/get_partner_history`, {
|
||||||
|
partner_id: partnerId,
|
||||||
|
limit: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
_removeReconciledLineFromState(lineId) {
|
||||||
|
if (!lineId) return;
|
||||||
|
this.state.lines = this.state.lines.filter((l) => l.id !== lineId);
|
||||||
|
if (this.state.selectedLineId === lineId) {
|
||||||
|
this.state.selectedLineId = null;
|
||||||
|
}
|
||||||
|
delete this.state.lineCache[lineId];
|
||||||
|
if (this.state.unreconciledCount > 0) {
|
||||||
|
this.state.unreconciledCount -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confidence band helper for templates
|
||||||
|
getBandClass(line) {
|
||||||
|
return `band-${line.fusion_confidence_band || "none"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Enterprise-compat methods (stubs — wired up later)
|
||||||
|
// ============================================================
|
||||||
|
// The following surface is required by mirrored components from
|
||||||
|
// account_accountant. They are primarily no-ops or thin wrappers
|
||||||
|
// around the legacy/V19 ORM. Phase 1 prioritizes structural parity;
|
||||||
|
// fusion-only Tasks 34-36 will replace these with native
|
||||||
|
// implementations driven by our JSON-RPC endpoints.
|
||||||
|
|
||||||
|
_readChatterPref() {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
JSON.parse(
|
||||||
|
browser.sessionStorage.getItem("isFusionBankRecChatterOpened")
|
||||||
|
) ?? false
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleChatter() {
|
||||||
|
this.chatterState.visible = !this.chatterState.visible;
|
||||||
|
try {
|
||||||
|
browser.sessionStorage.setItem(
|
||||||
|
"isFusionBankRecChatterOpened",
|
||||||
|
this.chatterState.visible
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Session storage unavailable — non-fatal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// Stub: real impl to be added in fusion-only task.
|
||||||
|
// Components call this after partner edits to refresh the per-partner
|
||||||
|
// count badge. Returning empty here keeps the badge silent.
|
||||||
|
if (!this.orm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const partnerIds = (records || [])
|
||||||
|
.map((r) => r?.data?.partner_id?.id)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!partnerIds.length) {
|
||||||
|
this.reconcileCountPerPartnerId = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Best-effort: keep a zero map so templates don't blow up.
|
||||||
|
const out = {};
|
||||||
|
for (const pid of partnerIds) {
|
||||||
|
out[pid] = this.reconcileCountPerPartnerId[pid] ?? 0;
|
||||||
|
}
|
||||||
|
this.reconcileCountPerPartnerId = out;
|
||||||
|
} catch {
|
||||||
|
// Non-fatal; templates fall back to defaults.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async computeAvailableReconcileModels(records) {
|
||||||
|
// Stub: components show these as quick-action buttons. Empty for now.
|
||||||
|
const out = {};
|
||||||
|
for (const r of records || []) {
|
||||||
|
const id = r?.data?.id;
|
||||||
|
if (id) {
|
||||||
|
out[id] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.reconcileModelPerStatementLineId = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAvailableReconcileModels(recordId) {
|
||||||
|
if (recordId) {
|
||||||
|
this.reconcileModelPerStatementLineId[recordId] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadRecords(records) {
|
||||||
|
await Promise.all(
|
||||||
|
(records || []).map((record) => record?.load ? record.load() : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bankReconciliationService = {
|
||||||
|
dependencies: ["rpc", "notification", "orm"],
|
||||||
|
start(env, services) {
|
||||||
|
return new BankReconciliationService(env, services);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("services").add("fusion_bank_reconciliation", bankReconciliationService);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for OWL components mirrored from Enterprise.
|
||||||
|
*
|
||||||
|
* Enterprise's components import `useBankReconciliation` from
|
||||||
|
* `../bank_reconciliation_service`; we expose the same hook here so
|
||||||
|
* mirrored code works unmodified after the relative-import rewrite.
|
||||||
|
*/
|
||||||
|
export function useBankReconciliation() {
|
||||||
|
return useState(useService("fusion_bank_reconciliation"));
|
||||||
|
}
|
||||||
109
fusion_accounting_bank_rec/static/src/tours/bank_rec_tours.js
Normal file
109
fusion_accounting_bank_rec/static/src/tours/bank_rec_tours.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5 OWL tours for fusion_accounting_bank_rec smoke testing.
|
||||||
|
*
|
||||||
|
* Each tour scripts a user interaction with the bank-rec widget and
|
||||||
|
* is invoked from Python via HttpCase.start_tour(). Useful for catching
|
||||||
|
* UI regressions that asset-bundle compilation alone won't catch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Tour 1: Open the kanban widget and confirm it loads
|
||||||
|
registry.category("web_tour.tours").add("fusion_bank_rec_smoke", {
|
||||||
|
test: true,
|
||||||
|
url: "/odoo/action-fusion_accounting_bank_rec.action_fusion_bank_rec_widget",
|
||||||
|
steps: () => [
|
||||||
|
{
|
||||||
|
content: "Wait for header to appear",
|
||||||
|
trigger: ".o_fusion_bank_rec_header h1:contains(Bank Reconciliation)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "Confirm stats are visible",
|
||||||
|
trigger: ".o_fusion_stats",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tour 2: Select a line and confirm detail panel loads
|
||||||
|
registry.category("web_tour.tours").add("fusion_bank_rec_select_line", {
|
||||||
|
test: true,
|
||||||
|
url: "/odoo/action-fusion_accounting_bank_rec.action_fusion_bank_rec_widget",
|
||||||
|
steps: () => [
|
||||||
|
{
|
||||||
|
content: "Wait for at least one line card",
|
||||||
|
trigger: ".o_fusion_bank_rec_line:first",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "Click the first line",
|
||||||
|
trigger: ".o_fusion_bank_rec_line:first",
|
||||||
|
run: "click",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "Detail panel shows selected line",
|
||||||
|
trigger: ".o_fusion_bank_rec_detail h2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tour 3: Trigger AI suggestion and accept
|
||||||
|
registry.category("web_tour.tours").add("fusion_bank_rec_accept_suggestion", {
|
||||||
|
test: true,
|
||||||
|
url: "/odoo/action-fusion_accounting_bank_rec.action_fusion_bank_rec_widget",
|
||||||
|
steps: () => [
|
||||||
|
{
|
||||||
|
content: "Click first line with a partner",
|
||||||
|
trigger: ".o_fusion_bank_rec_line:has(.o_fusion_partner):first",
|
||||||
|
run: "click",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "Click 'Get AI suggestions' button",
|
||||||
|
trigger: ".o_fusion_bank_rec_detail .btn_fusion_primary:contains(Get AI)",
|
||||||
|
run: "click",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "Wait for at least one suggestion to appear",
|
||||||
|
trigger: ".o_fusion_ai_suggestion",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tour 4: Open auto-reconcile wizard
|
||||||
|
registry.category("web_tour.tours").add("fusion_bank_rec_auto_reconcile_wizard", {
|
||||||
|
test: true,
|
||||||
|
url: "/odoo/action-fusion_accounting_bank_rec.action_fusion_auto_reconcile_wizard",
|
||||||
|
steps: () => [
|
||||||
|
{
|
||||||
|
content: "Wizard form opens",
|
||||||
|
trigger: ".modal-dialog .o_form_view",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "Strategy field exists",
|
||||||
|
trigger: ".modal-dialog [name='strategy']",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "Close wizard",
|
||||||
|
trigger: ".modal-dialog .btn-secondary",
|
||||||
|
run: "click",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tour 5: Load more (pagination)
|
||||||
|
registry.category("web_tour.tours").add("fusion_bank_rec_load_more", {
|
||||||
|
test: true,
|
||||||
|
url: "/odoo/action-fusion_accounting_bank_rec.action_fusion_bank_rec_widget",
|
||||||
|
steps: () => [
|
||||||
|
{
|
||||||
|
content: "Wait for kanban container",
|
||||||
|
trigger: ".o_fusion_bank_rec",
|
||||||
|
},
|
||||||
|
// Pagination button only appears if there are more lines than `limit`.
|
||||||
|
// This tour is a no-op if the dataset is small — that's fine for smoke.
|
||||||
|
{
|
||||||
|
content: "Confirm app loaded (regardless of pagination state)",
|
||||||
|
trigger: ".o_fusion_bank_rec_header h1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecKanbanController">
|
||||||
|
<div class="o_fusion_bank_rec">
|
||||||
|
<div class="o_fusion_bank_rec_header">
|
||||||
|
<div>
|
||||||
|
<h1>Bank Reconciliation</h1>
|
||||||
|
<div t-if="state.journalId" class="text-muted">
|
||||||
|
Journal #<t t-esc="state.journalId"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_fusion_stats">
|
||||||
|
<div>
|
||||||
|
Unreconciled:
|
||||||
|
<span class="stat-value"><t t-esc="state.unreconciledCount"/></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Total pending:
|
||||||
|
<span class="stat-value">
|
||||||
|
$<t t-esc="formatCurrency(state.totalPendingAmount)"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex" style="gap: 1rem; padding: 1rem;">
|
||||||
|
<div style="flex: 1 1 60%; max-width: 60%;">
|
||||||
|
<div t-if="state.isLoading" class="text-center p-4 text-muted">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
<div t-elif="state.lines.length === 0" class="text-center p-4 text-muted">
|
||||||
|
Nothing to reconcile.
|
||||||
|
</div>
|
||||||
|
<div t-else="">
|
||||||
|
<BankRecLineCard
|
||||||
|
t-foreach="state.lines"
|
||||||
|
t-as="line"
|
||||||
|
t-key="line.id"
|
||||||
|
line="line"
|
||||||
|
selected="state.selectedLineId === line.id"
|
||||||
|
onSelect="() => onSelectLine(line.id)"
|
||||||
|
formatCurrency="formatCurrency.bind(this)"
|
||||||
|
/>
|
||||||
|
<div t-if="state.lines.length lt state.unreconciledCount"
|
||||||
|
class="text-center mt-3">
|
||||||
|
<button class="btn_fusion" t-on-click="onLoadMore">
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex: 1 1 40%; max-width: 40%;" class="o_fusion_bank_rec_detail">
|
||||||
|
<t t-if="state.selectedLineId">
|
||||||
|
<t t-set="detail" t-value="state.lineCache[state.selectedLineId]"/>
|
||||||
|
<div t-if="!detail" class="text-muted">Loading detail…</div>
|
||||||
|
<div t-else="">
|
||||||
|
<h2>
|
||||||
|
<t t-esc="detail.line.payment_ref || 'No reference'"/>
|
||||||
|
</h2>
|
||||||
|
<div class="text-muted mb-3">
|
||||||
|
<span><t t-esc="detail.line.date"/></span>
|
||||||
|
<span class="ms-2">
|
||||||
|
$<t t-esc="formatCurrency(detail.line.amount)"/>
|
||||||
|
</span>
|
||||||
|
<span t-if="detail.line.partner_name" class="ms-2">
|
||||||
|
· <t t-esc="detail.line.partner_name"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="detail.suggestions.length === 0">
|
||||||
|
<button class="btn_fusion btn_fusion_primary"
|
||||||
|
t-on-click="() => onSuggestForLine(detail.line.id)">
|
||||||
|
Get AI suggestions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div t-else="">
|
||||||
|
<h5>AI Suggestions</h5>
|
||||||
|
<div t-foreach="detail.suggestions" t-as="sug" t-key="sug.id"
|
||||||
|
class="o_fusion_ai_suggestion"
|
||||||
|
t-att-data-band="confidenceBandLabel(sug.confidence >= 0.85 ? 'high' : sug.confidence >= 0.6 ? 'medium' : sug.confidence > 0 ? 'low' : 'none').toLowerCase()">
|
||||||
|
<div class="o_fusion_confidence_badge">
|
||||||
|
<t t-esc="(sug.confidence * 100).toFixed(0)"/>%
|
||||||
|
</div>
|
||||||
|
<div class="o_fusion_suggestion_text">
|
||||||
|
<div><t t-esc="sug.reasoning"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="o_fusion_suggestion_actions">
|
||||||
|
<button class="btn_fusion btn_fusion_primary"
|
||||||
|
t-on-click="() => onAcceptSuggestion(sug.id)">
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<div class="text-muted">
|
||||||
|
Select a bank line on the left to see details.
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-name="fusion_accounting_bank_rec.BankRecLineCard">
|
||||||
|
<div class="o_fusion_bank_rec_line"
|
||||||
|
t-att-class="props.selected ? 'o_fusion_selected' : ''"
|
||||||
|
t-on-click="props.onSelect">
|
||||||
|
<div class="o_fusion_bank_rec_line_header">
|
||||||
|
<div class="o_fusion_amount" t-att-class="props.line.amount lt 0 ? 'negative' : ''">
|
||||||
|
$<t t-esc="props.formatCurrency(props.line.amount)"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fusion_date">
|
||||||
|
<t t-esc="props.line.date"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_fusion_bank_rec_line_body">
|
||||||
|
<span t-if="props.line.partner_name" class="o_fusion_partner">
|
||||||
|
<t t-esc="props.line.partner_name"/>
|
||||||
|
</span>
|
||||||
|
<span class="o_fusion_memo">
|
||||||
|
<t t-esc="props.line.payment_ref || 'No memo'"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div t-if="props.line.attachment_count" class="o_fusion_attachments_badge">
|
||||||
|
📎 <t t-esc="props.line.attachment_count"/>
|
||||||
|
</div>
|
||||||
|
<div t-if="props.line.fusion_confidence_band and props.line.fusion_confidence_band !== 'none'"
|
||||||
|
t-att-class="'o_fusion_ai_suggestion ' + 'band-' + props.line.fusion_confidence_band"
|
||||||
|
t-att-data-band="props.line.fusion_confidence_band">
|
||||||
|
<div class="o_fusion_confidence_badge">
|
||||||
|
AI Suggestion Available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user