diff --git a/fusion_accounting/__manifest__.py b/fusion_accounting/__manifest__.py index 75c6db9e..4bd0c3e2 100644 --- a/fusion_accounting/__manifest__.py +++ b/fusion_accounting/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting', - 'version': '19.0.1.0.0', + 'version': '19.0.1.0.1', 'category': 'Accounting/Accounting', 'sequence': 25, '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_ai AI Co-Pilot (Claude/GPT) - 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): -- fusion_accounting_bank_rec (Phase 1) - fusion_accounting_reports (Phase 2) - fusion_accounting_dashboard (Phase 3) - fusion_accounting_followup (Phase 5) @@ -33,6 +33,7 @@ Built by Nexa Systems Inc. 'fusion_accounting_core', 'fusion_accounting_ai', 'fusion_accounting_migration', + 'fusion_accounting_bank_rec', ], 'data': [], 'installable': True, diff --git a/fusion_accounting_ai/__manifest__.py b/fusion_accounting_ai/__manifest__.py index ab54bffa..b52fcce5 100644 --- a/fusion_accounting_ai/__manifest__.py +++ b/fusion_accounting_ai/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting AI', - 'version': '19.0.1.0.0', + 'version': '19.0.1.0.1', 'category': 'Accounting/Accounting', 'sequence': 26, 'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.', diff --git a/fusion_accounting_ai/services/adapters/__init__.py b/fusion_accounting_ai/services/adapters/__init__.py index 26807733..48898ded 100644 --- a/fusion_accounting_ai/services/adapters/__init__.py +++ b/fusion_accounting_ai/services/adapters/__init__.py @@ -1,2 +1,3 @@ from . import claude from . import openai_adapter +from ._base import LLMProvider diff --git a/fusion_accounting_ai/services/adapters/_base.py b/fusion_accounting_ai/services/adapters/_base.py new file mode 100644 index 00000000..3fe7d1ac --- /dev/null +++ b/fusion_accounting_ai/services/adapters/_base.py @@ -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.") diff --git a/fusion_accounting_ai/services/adapters/claude.py b/fusion_accounting_ai/services/adapters/claude.py index 70a76511..f49de153 100644 --- a/fusion_accounting_ai/services/adapters/claude.py +++ b/fusion_accounting_ai/services/adapters/claude.py @@ -4,6 +4,8 @@ import logging from odoo import models, api, _ from odoo.exceptions import UserError +from ._base import LLMProvider + _logger = logging.getLogger(__name__) try: @@ -12,6 +14,64 @@ except ImportError: anthropic_sdk = None +class ClaudeAdapter(LLMProvider): + """Plain-Python LLMProvider implementation for Anthropic Claude. + + Preserves all existing functionality (extended thinking, native tool_use + blocks) used by the Odoo AbstractModel-based adapter -- this class is + additive for the Phase 1 LLMProvider contract. + """ + + supports_tool_calling = True + supports_streaming = True + max_context_tokens = 200000 + supports_embeddings = False + + def __init__(self, env): + super().__init__(env) + if anthropic_sdk is None: + raise UserError(_("The 'anthropic' Python package is not installed.")) + ICP = env['ir.config_parameter'].sudo() + try: + api_key = env['fusion.api.service'].get_api_key( + provider_type='anthropic', + consumer='fusion_accounting', + feature='chat_with_tools', + ) + except Exception: + api_key = ICP.get_param('fusion_accounting.anthropic_api_key', '') + if not api_key: + api_key = 'not-needed' + self.client = anthropic_sdk.Anthropic(api_key=api_key) + self.model = ICP.get_param( + 'fusion_accounting.claude_model', 'claude-sonnet-4-6') + + def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict: + api_messages = [ + m for m in messages if m.get('role') in ('user', 'assistant') + ] + try: + response = self.client.messages.create( + model=self.model, + max_tokens=max_tokens, + temperature=temperature, + system=system, + messages=api_messages, + ) + except Exception as e: + _logger.error("Claude complete error: %s", e) + raise UserError(_("Claude API error: %s", str(e))) + text_parts = [b.text for b in response.content if getattr(b, 'type', None) == 'text'] + return { + 'content': '\n'.join(text_parts), + 'tokens_used': ( + getattr(response.usage, 'input_tokens', 0) + + getattr(response.usage, 'output_tokens', 0) + ), + 'model': self.model, + } + + class FusionAccountingAdapterClaude(models.AbstractModel): _name = 'fusion.accounting.adapter.claude' _description = 'Claude AI Adapter' diff --git a/fusion_accounting_ai/services/adapters/openai_adapter.py b/fusion_accounting_ai/services/adapters/openai_adapter.py index 8e791f6f..a3972e34 100644 --- a/fusion_accounting_ai/services/adapters/openai_adapter.py +++ b/fusion_accounting_ai/services/adapters/openai_adapter.py @@ -4,6 +4,8 @@ import logging from odoo import models, api, _ from odoo.exceptions import UserError +from ._base import LLMProvider + _logger = logging.getLogger(__name__) try: @@ -12,6 +14,71 @@ except ImportError: OpenAI = None +DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1' + + +class OpenAIAdapter(LLMProvider): + """Plain-Python LLMProvider implementation backed by an OpenAI-compatible + HTTP endpoint. + + The OpenAI Python SDK speaks to any server that exposes the OpenAI + Chat Completions surface: OpenAI itself, Ollama, LM Studio, vLLM, + llamafile, llama.cpp HTTP server, etc. Configure the endpoint via + the ``fusion_accounting.openai_base_url`` ir.config_parameter. + """ + + supports_tool_calling = True + supports_streaming = True + max_context_tokens = 128000 + supports_embeddings = True + + def __init__(self, env): + super().__init__(env) + if OpenAI is None: + raise UserError(_("The 'openai' Python package is not installed.")) + ICP = env['ir.config_parameter'].sudo() + base_url = ICP.get_param( + 'fusion_accounting.openai_base_url', DEFAULT_OPENAI_BASE_URL, + ) or DEFAULT_OPENAI_BASE_URL + try: + api_key = env['fusion.api.service'].get_api_key( + provider_type='openai', + consumer='fusion_accounting', + feature='chat_with_tools', + ) + except Exception: + api_key = ICP.get_param('fusion_accounting.openai_api_key', '') + if not api_key: + # Local LLM servers (Ollama, LM Studio, llama.cpp) usually do not + # require a real key but the SDK insists on a non-empty string. + api_key = 'not-needed' + self.base_url = base_url + self.client = OpenAI(api_key=api_key, base_url=base_url) + self.model = ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini') + + def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict: + api_messages = [{'role': 'system', 'content': system}] + for msg in messages: + if msg.get('role') in ('user', 'assistant', 'tool'): + api_messages.append(msg) + try: + response = self.client.chat.completions.create( + model=self.model, + messages=api_messages, + max_tokens=max_tokens, + temperature=temperature, + ) + except Exception as e: + _logger.error("OpenAI complete error: %s", e) + raise UserError(_("OpenAI API error: %s", str(e))) + choice = response.choices[0] + return { + 'content': choice.message.content or '', + 'tokens_used': getattr(response.usage, 'total_tokens', 0), + 'model': self.model, + } + + class FusionAccountingAdapterOpenAI(models.AbstractModel): _name = 'fusion.accounting.adapter.openai' _description = 'OpenAI AI Adapter' diff --git a/fusion_accounting_ai/services/data_adapters/bank_rec.py b/fusion_accounting_ai/services/data_adapters/bank_rec.py index 2f48be80..8e956d8a 100644 --- a/fusion_accounting_ai/services/data_adapters/bank_rec.py +++ b/fusion_accounting_ai/services/data_adapters/bank_rec.py @@ -4,6 +4,12 @@ Routes bank-rec data lookups across: - FUSION: fusion.bank.rec.widget (added by fusion_accounting_bank_rec, Phase 1) - ENTERPRISE: account_accountant's bank_rec_widget JS service - 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 @@ -14,6 +20,10 @@ class BankRecAdapter(DataAdapter): FUSION_MODEL = 'fusion.bank.rec.widget' ENTERPRISE_MODULE = 'account_accountant' + # ------------------------------------------------------------ + # list_unreconciled + # ------------------------------------------------------------ + def list_unreconciled(self, journal_id=None, limit=100, date_from=None, date_to=None, min_amount=None, company_id=None): """Return unreconciled bank statement lines. @@ -31,13 +41,29 @@ class BankRecAdapter(DataAdapter): def list_unreconciled_via_fusion(self, journal_id=None, limit=100, date_from=None, date_to=None, min_amount=None, company_id=None): - # Phase 1 will add fusion.bank.rec.widget; this method becomes the primary path. - # For now: even when the model exists, delegate to community read shape. - return self.list_unreconciled_via_community( + """Community shape + fusion AI fields (top suggestion, band, attachments).""" + base = self.list_unreconciled_via_community( journal_id=journal_id, limit=limit, date_from=date_from, date_to=date_to, 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, date_from=None, date_to=None, @@ -83,5 +109,121 @@ class BankRecAdapter(DataAdapter): 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) diff --git a/fusion_accounting_ai/services/prompts/__init__.py b/fusion_accounting_ai/services/prompts/__init__.py index ff7682de..d2a2b3e1 100644 --- a/fusion_accounting_ai/services/prompts/__init__.py +++ b/fusion_accounting_ai/services/prompts/__init__.py @@ -1,2 +1,3 @@ from . import system_prompt from . import domain_prompts +from . import bank_rec_prompt diff --git a/fusion_accounting_ai/services/prompts/bank_rec_prompt.py b/fusion_accounting_ai/services/prompts/bank_rec_prompt.py new file mode 100644 index 00000000..7f0f82b3 --- /dev/null +++ b/fusion_accounting_ai/services/prompts/bank_rec_prompt.py @@ -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": , "confidence": , "reason": ""}, + ... + ] +} + +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) diff --git a/fusion_accounting_ai/services/tools/bank_reconciliation.py b/fusion_accounting_ai/services/tools/bank_reconciliation.py index 7c1b0a5b..7ae9d2e9 100644 --- a/fusion_accounting_ai/services/tools/bank_reconciliation.py +++ b/fusion_accounting_ai/services/tools/bank_reconciliation.py @@ -67,7 +67,16 @@ def match_bank_line_to_payments(env, params): st_line = env['account.bank.statement.line'].browse(st_line_id) if not st_line.exists(): 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 { 'status': 'matched', 'statement_line_id': st_line_id, @@ -83,7 +92,12 @@ def auto_reconcile_bank_lines(env, params): ('company_id', '=', int(company_id)), ]) 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([ ('is_reconciled', '=', False), ('company_id', '=', int(company_id)), @@ -946,6 +960,171 @@ def _format_aml_candidates(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 = { 'get_unreconciled_bank_lines': get_unreconciled_bank_lines, 'get_unreconciled_receipts': get_unreconciled_receipts, @@ -962,4 +1141,10 @@ TOOLS = { 'reconcile_payroll_cheques': reconcile_payroll_cheques, 'suggest_bank_line_matches': suggest_bank_line_matches, '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, } diff --git a/fusion_accounting_ai/tests/__init__.py b/fusion_accounting_ai/tests/__init__.py index e3410185..cf080727 100644 --- a/fusion_accounting_ai/tests/__init__.py +++ b/fusion_accounting_ai/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_post_migration from . import test_data_adapters +from . import test_llm_provider_contract diff --git a/fusion_accounting_ai/tests/test_llm_provider_contract.py b/fusion_accounting_ai/tests/test_llm_provider_contract.py new file mode 100644 index 00000000..ba67f619 --- /dev/null +++ b/fusion_accounting_ai/tests/test_llm_provider_contract.py @@ -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)) diff --git a/fusion_accounting_bank_rec/CLAUDE.md b/fusion_accounting_bank_rec/CLAUDE.md new file mode 100644 index 00000000..7ae05d5b --- /dev/null +++ b/fusion_accounting_bank_rec/CLAUDE.md @@ -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 diff --git a/fusion_accounting_bank_rec/README.md b/fusion_accounting_bank_rec/README.md new file mode 100644 index 00000000..50c7a91a --- /dev/null +++ b/fusion_accounting_bank_rec/README.md @@ -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 diff --git a/fusion_accounting_bank_rec/UPGRADE_NOTES.md b/fusion_accounting_bank_rec/UPGRADE_NOTES.md new file mode 100644 index 00000000..83af2799 --- /dev/null +++ b/fusion_accounting_bank_rec/UPGRADE_NOTES.md @@ -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). diff --git a/fusion_accounting_bank_rec/__init__.py b/fusion_accounting_bank_rec/__init__.py new file mode 100644 index 00000000..c7e7f580 --- /dev/null +++ b/fusion_accounting_bank_rec/__init__.py @@ -0,0 +1,5 @@ +from . import models +from . import controllers +from . import services +from . import wizards +from . import reports diff --git a/fusion_accounting_bank_rec/__manifest__.py b/fusion_accounting_bank_rec/__manifest__.py new file mode 100644 index 00000000..5a052d9c --- /dev/null +++ b/fusion_accounting_bank_rec/__manifest__.py @@ -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', +} diff --git a/fusion_accounting_bank_rec/controllers/__init__.py b/fusion_accounting_bank_rec/controllers/__init__.py new file mode 100644 index 00000000..98384940 --- /dev/null +++ b/fusion_accounting_bank_rec/controllers/__init__.py @@ -0,0 +1 @@ +from . import bank_rec_controller diff --git a/fusion_accounting_bank_rec/controllers/bank_rec_controller.py b/fusion_accounting_bank_rec/controllers/bank_rec_controller.py new file mode 100644 index 00000000..44a9af20 --- /dev/null +++ b/fusion_accounting_bank_rec/controllers/bank_rec_controller.py @@ -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), + } diff --git a/fusion_accounting_bank_rec/data/cron.xml b/fusion_accounting_bank_rec/data/cron.xml new file mode 100644 index 00000000..b20cc42d --- /dev/null +++ b/fusion_accounting_bank_rec/data/cron.xml @@ -0,0 +1,35 @@ + + + + + Fusion Bank Rec — Warm AI Suggestions + + code + model._cron_suggest_pending() + 30 + minutes + + + + + Fusion Bank Rec — Refresh Partner Patterns + + code + model._cron_refresh_patterns() + 1 + days + + + + + + Fusion Bank Rec — Refresh Unreconciled MV + + code + model._cron_refresh_mv() + 5 + minutes + + + + diff --git a/fusion_accounting_bank_rec/data/sql/create_mv_unreconciled_bank_line.sql b/fusion_accounting_bank_rec/data/sql/create_mv_unreconciled_bank_line.sql new file mode 100644 index 00000000..81f1c1a3 --- /dev/null +++ b/fusion_accounting_bank_rec/data/sql/create_mv_unreconciled_bank_line.sql @@ -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); diff --git a/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_auto_reconcile_wizard.py b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_auto_reconcile_wizard.py new file mode 100644 index 00000000..89259ea4 --- /dev/null +++ b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_auto_reconcile_wizard.py @@ -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.")) diff --git a/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_reconcile_model.py b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_reconcile_model.py new file mode 100644 index 00000000..c1478769 --- /dev/null +++ b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_reconcile_model.py @@ -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 diff --git a/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js new file mode 100644 index 00000000..e4e4b08a --- /dev/null +++ b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js @@ -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")); +} diff --git a/fusion_accounting_bank_rec/models/__init__.py b/fusion_accounting_bank_rec/models/__init__.py new file mode 100644 index 00000000..d1964a92 --- /dev/null +++ b/fusion_accounting_bank_rec/models/__init__.py @@ -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 diff --git a/fusion_accounting_bank_rec/models/account_bank_statement_line.py b/fusion_accounting_bank_rec/models/account_bank_statement_line.py new file mode 100644 index 00000000..40ef7af6 --- /dev/null +++ b/fusion_accounting_bank_rec/models/account_bank_statement_line.py @@ -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'] + ) diff --git a/fusion_accounting_bank_rec/models/account_reconcile_model.py b/fusion_accounting_bank_rec/models/account_reconcile_model.py new file mode 100644 index 00000000..ca9dab92 --- /dev/null +++ b/fusion_accounting_bank_rec/models/account_reconcile_model.py @@ -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.") diff --git a/fusion_accounting_bank_rec/models/fusion_bank_rec_cron.py b/fusion_accounting_bank_rec/models/fusion_bank_rec_cron.py new file mode 100644 index 00000000..047c734f --- /dev/null +++ b/fusion_accounting_bank_rec/models/fusion_bank_rec_cron.py @@ -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) diff --git a/fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py b/fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py new file mode 100644 index 00000000..9a9e0fcb --- /dev/null +++ b/fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py @@ -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}, + } diff --git a/fusion_accounting_bank_rec/models/fusion_migration_wizard.py b/fusion_accounting_bank_rec/models/fusion_migration_wizard.py new file mode 100644 index 00000000..1e16c7d5 --- /dev/null +++ b/fusion_accounting_bank_rec/models/fusion_migration_wizard.py @@ -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, + }, + } diff --git a/fusion_accounting_bank_rec/models/fusion_reconcile_engine.py b/fusion_accounting_bank_rec/models/fusion_reconcile_engine.py new file mode 100644 index 00000000..2691bd7c --- /dev/null +++ b/fusion_accounting_bank_rec/models/fusion_reconcile_engine.py @@ -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) diff --git a/fusion_accounting_bank_rec/models/fusion_reconcile_pattern.py b/fusion_accounting_bank_rec/models/fusion_reconcile_pattern.py new file mode 100644 index 00000000..65dcb7e8 --- /dev/null +++ b/fusion_accounting_bank_rec/models/fusion_reconcile_pattern.py @@ -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.', + ) diff --git a/fusion_accounting_bank_rec/models/fusion_reconcile_precedent.py b/fusion_accounting_bank_rec/models/fusion_reconcile_precedent.py new file mode 100644 index 00000000..336caa56 --- /dev/null +++ b/fusion_accounting_bank_rec/models/fusion_reconcile_precedent.py @@ -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 diff --git a/fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py b/fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py new file mode 100644 index 00000000..4faeacd1 --- /dev/null +++ b/fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py @@ -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) diff --git a/fusion_accounting_bank_rec/models/fusion_unreconciled_bank_line_mv.py b/fusion_accounting_bank_rec/models/fusion_unreconciled_bank_line_mv.py new file mode 100644 index 00000000..28fbdded --- /dev/null +++ b/fusion_accounting_bank_rec/models/fusion_unreconciled_bank_line_mv.py @@ -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 diff --git a/fusion_accounting_bank_rec/reports/__init__.py b/fusion_accounting_bank_rec/reports/__init__.py new file mode 100644 index 00000000..9064facf --- /dev/null +++ b/fusion_accounting_bank_rec/reports/__init__.py @@ -0,0 +1 @@ +from . import migration_audit_report diff --git a/fusion_accounting_bank_rec/reports/migration_audit_report.py b/fusion_accounting_bank_rec/reports/migration_audit_report.py new file mode 100644 index 00000000..0fa2c777 --- /dev/null +++ b/fusion_accounting_bank_rec/reports/migration_audit_report.py @@ -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, + } diff --git a/fusion_accounting_bank_rec/reports/migration_audit_report_action.xml b/fusion_accounting_bank_rec/reports/migration_audit_report_action.xml new file mode 100644 index 00000000..558db4da --- /dev/null +++ b/fusion_accounting_bank_rec/reports/migration_audit_report_action.xml @@ -0,0 +1,12 @@ + + + + Bank-Rec Migration Audit + fusion.migration.wizard + qweb-pdf + fusion_accounting_bank_rec.migration_audit_template + fusion_accounting_bank_rec.migration_audit_template + + report + + diff --git a/fusion_accounting_bank_rec/reports/migration_audit_report_views.xml b/fusion_accounting_bank_rec/reports/migration_audit_report_views.xml new file mode 100644 index 00000000..fd616f0b --- /dev/null +++ b/fusion_accounting_bank_rec/reports/migration_audit_report_views.xml @@ -0,0 +1,42 @@ + + + + diff --git a/fusion_accounting_bank_rec/security/ir.model.access.csv b/fusion_accounting_bank_rec/security/ir.model.access.csv new file mode 100644 index 00000000..f4a6424b --- /dev/null +++ b/fusion_accounting_bank_rec/security/ir.model.access.csv @@ -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 diff --git a/fusion_accounting_bank_rec/services/__init__.py b/fusion_accounting_bank_rec/services/__init__.py new file mode 100644 index 00000000..322234d0 --- /dev/null +++ b/fusion_accounting_bank_rec/services/__init__.py @@ -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 diff --git a/fusion_accounting_bank_rec/services/confidence_scoring.py b/fusion_accounting_bank_rec/services/confidence_scoring.py new file mode 100644 index 00000000..4b428938 --- /dev/null +++ b/fusion_accounting_bank_rec/services/confidence_scoring.py @@ -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 diff --git a/fusion_accounting_bank_rec/services/exchange_diff.py b/fusion_accounting_bank_rec/services/exchange_diff.py new file mode 100644 index 00000000..a5a865aa --- /dev/null +++ b/fusion_accounting_bank_rec/services/exchange_diff.py @@ -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), + ) diff --git a/fusion_accounting_bank_rec/services/matching_strategies.py b/fusion_accounting_bank_rec/services/matching_strategies.py new file mode 100644 index 00000000..56e30f3a --- /dev/null +++ b/fusion_accounting_bank_rec/services/matching_strategies.py @@ -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') diff --git a/fusion_accounting_bank_rec/services/memo_tokenizer.py b/fusion_accounting_bank_rec/services/memo_tokenizer.py new file mode 100644 index 00000000..92166995 --- /dev/null +++ b/fusion_accounting_bank_rec/services/memo_tokenizer.py @@ -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 diff --git a/fusion_accounting_bank_rec/services/pattern_extractor.py b/fusion_accounting_bank_rec/services/pattern_extractor.py new file mode 100644 index 00000000..abbd6196 --- /dev/null +++ b/fusion_accounting_bank_rec/services/pattern_extractor.py @@ -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 diff --git a/fusion_accounting_bank_rec/services/precedent_backfill.py b/fusion_accounting_bank_rec/services/precedent_backfill.py new file mode 100644 index 00000000..21a4864c --- /dev/null +++ b/fusion_accounting_bank_rec/services/precedent_backfill.py @@ -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} diff --git a/fusion_accounting_bank_rec/services/precedent_lookup.py b/fusion_accounting_bank_rec/services/precedent_lookup.py new file mode 100644 index 00000000..1619cf4e --- /dev/null +++ b/fusion_accounting_bank_rec/services/precedent_lookup.py @@ -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) diff --git a/fusion_accounting_bank_rec/static/description/icon.png b/fusion_accounting_bank_rec/static/description/icon.png new file mode 100644 index 00000000..6773c627 Binary files /dev/null and b/fusion_accounting_bank_rec/static/description/icon.png differ diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.js new file mode 100644 index 00000000..ef00a280 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.js @@ -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(); + } + } +} diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.xml new file mode 100644 index 00000000..b7658a9e --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.xml @@ -0,0 +1,23 @@ + + + +
+
Other AI suggestions
+
+
+ + % + + +
+ +
+
+ +
+
+
+
diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.js new file mode 100644 index 00000000..91c5358d --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.js @@ -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); + } +} diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.xml new file mode 100644 index 00000000..d23fa0fd --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.xml @@ -0,0 +1,18 @@ + + + +
+
+ +
+
+
Amount match: %
+
Partner pattern: %
+
Precedent similarity: %
+
+ AI re-rank: % +
+
+
+
+
diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.js new file mode 100644 index 00000000..b137925b --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.js @@ -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(); + } + } +} diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.xml new file mode 100644 index 00000000..aa77fde8 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.xml @@ -0,0 +1,24 @@ + + + +
+
+ % +
+
+
+ +
+
+
+ + +
+
+
+
diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.js new file mode 100644 index 00000000..4eba3f9b --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.js @@ -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); diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.xml new file mode 100644 index 00000000..784cb753 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.xml @@ -0,0 +1,6 @@ + + + +
+ + diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.js new file mode 100644 index 00000000..942eac2f --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.js @@ -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`; + } +} diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.xml new file mode 100644 index 00000000..841c03be --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.xml @@ -0,0 +1,18 @@ + + + +
+
+ No attachments +
+ + + + +
+
+
diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_reconciliation_service.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_reconciliation_service.js new file mode 100644 index 00000000..cd02c726 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_reconciliation_service.js @@ -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"; diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.js new file mode 100644 index 00000000..d28c0a39 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.js @@ -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); diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.xml new file mode 100644 index 00000000..4ce585e2 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.js new file mode 100644 index 00000000..d12e25c7 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.js @@ -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); + } +} diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.xml new file mode 100644 index 00000000..a3b7479f --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.xml @@ -0,0 +1,17 @@ + + + +
+ + selected + + + +
+
+
diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.js new file mode 100644 index 00000000..11f4a802 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.js @@ -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"); + } +} diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.xml new file mode 100644 index 00000000..99a60743 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.js b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.js new file mode 100644 index 00000000..8c0f662b --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.js @@ -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) + ); + } +} diff --git a/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.xml b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.xml new file mode 100644 index 00000000..1312ff54 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.xml @@ -0,0 +1,56 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + Upload Bills + + + +