Compare commits
32 Commits
fusion_acc
...
d4dbca5927
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4dbca5927 | ||
|
|
24e2708d98 | ||
|
|
6ecb1bbbee | ||
|
|
d1819b940e | ||
|
|
d953525758 | ||
|
|
12b6b46e2e | ||
|
|
4ffbdc596d | ||
|
|
5020129c45 | ||
|
|
3993f58910 | ||
|
|
8eee64f053 | ||
|
|
2d099b2d0d | ||
|
|
8be0caa474 | ||
|
|
fce748b89c | ||
|
|
fcecf9d925 | ||
|
|
da269a6207 | ||
|
|
80b8100232 | ||
|
|
920a624cd1 | ||
|
|
06e382b27b | ||
|
|
91d09dfca2 | ||
|
|
ef27f0e2c1 | ||
|
|
b37b1d4618 | ||
|
|
e468ae6b0a | ||
|
|
6e945dea95 | ||
|
|
3dc74e3987 | ||
|
|
b75f215808 | ||
|
|
f2d6492efd | ||
|
|
123db4219f | ||
|
|
f44ed0e010 | ||
|
|
77cb0a1309 | ||
|
|
09104007f6 | ||
|
|
c118b7c6b5 | ||
|
|
db8b79d22e |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting AI',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.0.1',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 26,
|
||||
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import claude
|
||||
from . import openai_adapter
|
||||
from ._base import LLMProvider
|
||||
|
||||
44
fusion_accounting_ai/services/adapters/_base.py
Normal file
44
fusion_accounting_ai/services/adapters/_base.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""LLMProvider contract - every adapter must conform.
|
||||
|
||||
Phase 1 generalisation: makes local LLM (Ollama, LM Studio, vLLM, llamafile,
|
||||
llama.cpp HTTP server) a one-config-line drop-in via the OpenAI-compatible
|
||||
HTTP API surface that all of them expose.
|
||||
"""
|
||||
|
||||
|
||||
class LLMProvider:
|
||||
"""Contract every LLM backend must satisfy. Adapters declare capabilities
|
||||
as class attributes; the engine inspects them before calling optional methods."""
|
||||
|
||||
supports_tool_calling: bool = False
|
||||
supports_streaming: bool = False
|
||||
max_context_tokens: int = 4096
|
||||
supports_embeddings: bool = False
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
"""Plain text completion. Required for ALL providers.
|
||||
|
||||
Returns: {'content': str, 'tokens_used': int, 'model': str}
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def complete_with_tools(self, *, system, messages, tools, max_tokens=2048) -> dict:
|
||||
"""Tool-calling completion. Optional - caller checks supports_tool_calling first.
|
||||
|
||||
Returns: {'content': str, 'tool_calls': [{'name': str, 'arguments': dict}], ...}
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support tool-calling. "
|
||||
f"Check supports_tool_calling before calling.")
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Embeddings. Optional - caller checks supports_embeddings first.
|
||||
|
||||
Returns: list of float vectors, one per input text.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support embeddings. "
|
||||
f"Check supports_embeddings before calling.")
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ._base import LLMProvider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -12,6 +14,64 @@ except ImportError:
|
||||
anthropic_sdk = None
|
||||
|
||||
|
||||
class ClaudeAdapter(LLMProvider):
|
||||
"""Plain-Python LLMProvider implementation for Anthropic Claude.
|
||||
|
||||
Preserves all existing functionality (extended thinking, native tool_use
|
||||
blocks) used by the Odoo AbstractModel-based adapter -- this class is
|
||||
additive for the Phase 1 LLMProvider contract.
|
||||
"""
|
||||
|
||||
supports_tool_calling = True
|
||||
supports_streaming = True
|
||||
max_context_tokens = 200000
|
||||
supports_embeddings = False
|
||||
|
||||
def __init__(self, env):
|
||||
super().__init__(env)
|
||||
if anthropic_sdk is None:
|
||||
raise UserError(_("The 'anthropic' Python package is not installed."))
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
try:
|
||||
api_key = env['fusion.api.service'].get_api_key(
|
||||
provider_type='anthropic',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
api_key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
|
||||
if not api_key:
|
||||
api_key = 'not-needed'
|
||||
self.client = anthropic_sdk.Anthropic(api_key=api_key)
|
||||
self.model = ICP.get_param(
|
||||
'fusion_accounting.claude_model', 'claude-sonnet-4-6')
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
api_messages = [
|
||||
m for m in messages if m.get('role') in ('user', 'assistant')
|
||||
]
|
||||
try:
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
system=system,
|
||||
messages=api_messages,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("Claude complete error: %s", e)
|
||||
raise UserError(_("Claude API error: %s", str(e)))
|
||||
text_parts = [b.text for b in response.content if getattr(b, 'type', None) == 'text']
|
||||
return {
|
||||
'content': '\n'.join(text_parts),
|
||||
'tokens_used': (
|
||||
getattr(response.usage, 'input_tokens', 0)
|
||||
+ getattr(response.usage, 'output_tokens', 0)
|
||||
),
|
||||
'model': self.model,
|
||||
}
|
||||
|
||||
|
||||
class FusionAccountingAdapterClaude(models.AbstractModel):
|
||||
_name = 'fusion.accounting.adapter.claude'
|
||||
_description = 'Claude AI Adapter'
|
||||
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ._base import LLMProvider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -12,6 +14,71 @@ except ImportError:
|
||||
OpenAI = None
|
||||
|
||||
|
||||
DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
||||
|
||||
|
||||
class OpenAIAdapter(LLMProvider):
|
||||
"""Plain-Python LLMProvider implementation backed by an OpenAI-compatible
|
||||
HTTP endpoint.
|
||||
|
||||
The OpenAI Python SDK speaks to any server that exposes the OpenAI
|
||||
Chat Completions surface: OpenAI itself, Ollama, LM Studio, vLLM,
|
||||
llamafile, llama.cpp HTTP server, etc. Configure the endpoint via
|
||||
the ``fusion_accounting.openai_base_url`` ir.config_parameter.
|
||||
"""
|
||||
|
||||
supports_tool_calling = True
|
||||
supports_streaming = True
|
||||
max_context_tokens = 128000
|
||||
supports_embeddings = True
|
||||
|
||||
def __init__(self, env):
|
||||
super().__init__(env)
|
||||
if OpenAI is None:
|
||||
raise UserError(_("The 'openai' Python package is not installed."))
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
base_url = ICP.get_param(
|
||||
'fusion_accounting.openai_base_url', DEFAULT_OPENAI_BASE_URL,
|
||||
) or DEFAULT_OPENAI_BASE_URL
|
||||
try:
|
||||
api_key = env['fusion.api.service'].get_api_key(
|
||||
provider_type='openai',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
api_key = ICP.get_param('fusion_accounting.openai_api_key', '')
|
||||
if not api_key:
|
||||
# Local LLM servers (Ollama, LM Studio, llama.cpp) usually do not
|
||||
# require a real key but the SDK insists on a non-empty string.
|
||||
api_key = 'not-needed'
|
||||
self.base_url = base_url
|
||||
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
self.model = ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
api_messages = [{'role': 'system', 'content': system}]
|
||||
for msg in messages:
|
||||
if msg.get('role') in ('user', 'assistant', 'tool'):
|
||||
api_messages.append(msg)
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=api_messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("OpenAI complete error: %s", e)
|
||||
raise UserError(_("OpenAI API error: %s", str(e)))
|
||||
choice = response.choices[0]
|
||||
return {
|
||||
'content': choice.message.content or '',
|
||||
'tokens_used': getattr(response.usage, 'total_tokens', 0),
|
||||
'model': self.model,
|
||||
}
|
||||
|
||||
|
||||
class FusionAccountingAdapterOpenAI(models.AbstractModel):
|
||||
_name = 'fusion.accounting.adapter.openai'
|
||||
_description = 'OpenAI AI Adapter'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import system_prompt
|
||||
from . import domain_prompts
|
||||
from . import bank_rec_prompt
|
||||
|
||||
107
fusion_accounting_ai/services/prompts/bank_rec_prompt.py
Normal file
107
fusion_accounting_ai/services/prompts/bank_rec_prompt.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Bank reconciliation AI re-rank prompt.
|
||||
|
||||
Used by fusion_accounting_bank_rec/services/confidence_scoring.py to ask
|
||||
an LLM to refine the statistical ranking of candidate matches.
|
||||
|
||||
Output contract: the LLM MUST respond with valid JSON of shape:
|
||||
{"ranked": [{"candidate_id": int, "confidence": float, "reason": str}, ...]}
|
||||
|
||||
System prompt is provider-agnostic - works with OpenAI Chat Completions,
|
||||
Claude Messages, and local OpenAI-compatible servers (LM Studio, Ollama).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are an expert accountant assisting with bank reconciliation.
|
||||
|
||||
Your job: given a bank statement line and a list of candidate journal items
|
||||
that statistically scored well as potential matches, re-rank them based on
|
||||
domain expertise. Consider:
|
||||
|
||||
1. **Amount-exact matches** are almost always correct unless the partner is wrong.
|
||||
2. **Memo / reference clues** - bank memos often contain invoice numbers, partner
|
||||
names, or transaction references that disambiguate matches.
|
||||
3. **Date proximity** - invoices are typically reconciled within 30 days of issue.
|
||||
4. **Pattern conformance** - if the partner has a learned pattern (e.g. "always
|
||||
pays exact amount, weekly cadence"), favor candidates that fit that pattern.
|
||||
5. **Precedent similarity** - if a near-identical reconcile happened before,
|
||||
it's likely the right one.
|
||||
|
||||
Return ONLY valid JSON of this exact shape:
|
||||
{
|
||||
"ranked": [
|
||||
{"candidate_id": <int>, "confidence": <float 0-1>, "reason": "<short string>"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Do NOT include any prose before or after the JSON. Do NOT use markdown code fences.
|
||||
The "ranked" array MUST contain every candidate_id from the input, in your
|
||||
preferred order (highest confidence first).
|
||||
"""
|
||||
|
||||
|
||||
def build_prompt(statement_line, scored_candidates, pattern=None, precedents=None):
|
||||
"""Build (system_prompt, user_prompt) for AI re-rank.
|
||||
|
||||
Args:
|
||||
statement_line: account.bank.statement.line recordset (singleton)
|
||||
scored_candidates: list of ScoredCandidate dataclasses (from confidence_scoring)
|
||||
pattern: fusion.reconcile.pattern recordset for the partner, or None
|
||||
precedents: list of PrecedentMatch dataclasses, or None
|
||||
|
||||
Returns:
|
||||
(system_prompt: str, user_prompt: str) tuple
|
||||
"""
|
||||
user_parts = []
|
||||
|
||||
user_parts.append("BANK LINE:")
|
||||
user_parts.append(f" Date: {statement_line.date}")
|
||||
user_parts.append(
|
||||
f" Amount: {statement_line.amount} {statement_line.currency_id.name or ''}"
|
||||
)
|
||||
user_parts.append(
|
||||
f" Memo / payment ref: {statement_line.payment_ref or '(none)'}"
|
||||
)
|
||||
if statement_line.partner_id:
|
||||
user_parts.append(f" Partner: {statement_line.partner_id.name}")
|
||||
|
||||
if pattern:
|
||||
user_parts.append("")
|
||||
user_parts.append("PARTNER PATTERN (learned from past reconciles):")
|
||||
user_parts.append(f" Reconcile count: {pattern.reconcile_count}")
|
||||
user_parts.append(f" Preferred strategy: {pattern.pref_strategy}")
|
||||
user_parts.append(
|
||||
f" Typical cadence: ~{pattern.typical_cadence_days} days between reconciles"
|
||||
)
|
||||
if pattern.typical_amount_range:
|
||||
user_parts.append(f" Typical amount range: {pattern.typical_amount_range}")
|
||||
if pattern.common_memo_tokens:
|
||||
user_parts.append(f" Common memo tokens: {pattern.common_memo_tokens}")
|
||||
|
||||
if precedents:
|
||||
user_parts.append("")
|
||||
user_parts.append("RECENT PRECEDENTS (most-similar past reconciles for this partner):")
|
||||
# Cap at 3 precedents to keep prompt small and reduce token cost.
|
||||
for p in precedents[:3]:
|
||||
user_parts.append(
|
||||
f" - amount={p.amount}, similarity={p.similarity_score:.2f}, "
|
||||
f"matched {p.matched_move_line_count} line(s), tokens={p.memo_tokens}"
|
||||
)
|
||||
|
||||
user_parts.append("")
|
||||
user_parts.append("CANDIDATES (scored by statistical pipeline):")
|
||||
for s in scored_candidates:
|
||||
user_parts.append(
|
||||
f" - candidate_id={s.candidate_id}, statistical_confidence={s.confidence}, "
|
||||
f"amount_match={s.score_amount_match}, pattern_fit={s.score_partner_pattern}, "
|
||||
f"precedent_sim={s.score_precedent_similarity}, "
|
||||
f"reason=\"{s.reasoning}\""
|
||||
)
|
||||
|
||||
user_parts.append("")
|
||||
user_parts.append("Re-rank these candidates and return JSON per the system prompt.")
|
||||
|
||||
user_prompt = "\n".join(user_parts)
|
||||
return (SYSTEM_PROMPT, user_prompt)
|
||||
@@ -67,7 +67,16 @@ def match_bank_line_to_payments(env, params):
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import test_post_migration
|
||||
from . import test_data_adapters
|
||||
from . import test_llm_provider_contract
|
||||
|
||||
45
fusion_accounting_ai/tests/test_llm_provider_contract.py
Normal file
45
fusion_accounting_ai/tests/test_llm_provider_contract.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters._base import LLMProvider
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLLMProviderContract(TransactionCase):
|
||||
"""Every LLM adapter must satisfy the LLMProvider contract."""
|
||||
|
||||
def test_base_class_defines_capability_attrs(self):
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_tool_calling'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_streaming'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'max_context_tokens'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_embeddings'))
|
||||
|
||||
def test_openai_adapter_implements_contract(self):
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
self.assertTrue(issubclass(OpenAIAdapter, LLMProvider))
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||
|
||||
def test_claude_adapter_implements_contract(self):
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||
self.assertTrue(issubclass(ClaudeAdapter, LLMProvider))
|
||||
adapter = ClaudeAdapter(self.env)
|
||||
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||
|
||||
def test_openai_adapter_uses_configurable_base_url(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'fusion_accounting.openai_base_url', 'http://localhost:1234/v1')
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'fusion_accounting.openai_api_key', 'lm-studio-test-key')
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertEqual(str(adapter.client.base_url).rstrip('/'),
|
||||
'http://localhost:1234/v1')
|
||||
|
||||
def test_openai_adapter_default_base_url_when_unset(self):
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', '=', 'fusion_accounting.openai_base_url')
|
||||
]).unlink()
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertIn('api.openai.com', str(adapter.client.base_url))
|
||||
4
fusion_accounting_bank_rec/__init__.py
Normal file
4
fusion_accounting_bank_rec/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import services
|
||||
from . import wizards
|
||||
47
fusion_accounting_bank_rec/__manifest__.py
Normal file
47
fusion_accounting_bank_rec/__manifest__.py
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||
'version': '19.0.1.0.10',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 28,
|
||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||
'description': """
|
||||
Fusion Accounting — Bank Reconciliation
|
||||
========================================
|
||||
Replaces Odoo Enterprise's account_accountant bank-rec widget with a
|
||||
native V19 OWL implementation reading/writing Community's
|
||||
account.partial.reconcile tables.
|
||||
|
||||
Features:
|
||||
- Strict mirror of all Enterprise UI components (zero functional loss)
|
||||
- AI confidence badges with one-click Accept and ranked alternatives
|
||||
- Behavioural learning from historical reconciliations
|
||||
- Local LLM ready (Ollama, LM Studio) via OpenAI-compatible adapter
|
||||
- Coexists with account_accountant (Enterprise wins by default)
|
||||
|
||||
Built by Nexa Systems Inc.
|
||||
""",
|
||||
'icon': '/fusion_accounting_bank_rec/static/description/icon.png',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.ca',
|
||||
'maintainer': 'Nexa Systems Inc.',
|
||||
'depends': ['fusion_accounting_core'],
|
||||
'external_dependencies': {
|
||||
'python': ['hypothesis'],
|
||||
},
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/cron.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',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'OPL-1',
|
||||
}
|
||||
1
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
1
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import bank_rec_controller
|
||||
325
fusion_accounting_bank_rec/controllers/bank_rec_controller.py
Normal file
325
fusion_accounting_bank_rec/controllers/bank_rec_controller.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""HTTP controller: 10 JSON-RPC endpoints for the OWL bank-rec widget.
|
||||
|
||||
All endpoints route through ``BankRecAdapter`` (which lives in
|
||||
``fusion_accounting_ai`` and already encapsulates fusion / enterprise /
|
||||
community routing) or directly through ``fusion.reconcile.engine`` for
|
||||
methods the adapter does not yet expose. The controller never touches
|
||||
``account.partial.reconcile`` directly.
|
||||
|
||||
V19: uses ``@route(type='jsonrpc')``, the V19-blessed replacement for the
|
||||
deprecated ``type='json'`` (Odoo 19 logs a deprecation warning if you
|
||||
still use ``json``).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _adapter():
|
||||
"""Resolve the bank-rec data adapter from fusion_accounting_ai."""
|
||||
from odoo.addons.fusion_accounting_ai.services.data_adapters import (
|
||||
get_adapter,
|
||||
)
|
||||
return get_adapter(request.env, 'bank_rec')
|
||||
|
||||
|
||||
class FusionBankRecController(http.Controller):
|
||||
"""JSON-RPC surface consumed by the OWL bank-reconciliation widget.
|
||||
|
||||
All routes are ``auth='user'`` -- anonymous traffic is rejected by
|
||||
Odoo's HTTP layer before reaching the handler.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. get_state -- initial widget bootstrap
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/get_state', type='jsonrpc', auth='user')
|
||||
def get_state(self, journal_id, company_id):
|
||||
"""Return the journal summary that seeds the kanban widget."""
|
||||
Journal = request.env['account.journal']
|
||||
Line = request.env['account.bank.statement.line']
|
||||
journal = Journal.browse(int(journal_id))
|
||||
if not journal.exists():
|
||||
raise ValidationError(_("Journal %s not found") % journal_id)
|
||||
company_id = int(company_id) if company_id else request.env.company.id
|
||||
unreconciled_lines = Line.search([
|
||||
('journal_id', '=', journal.id),
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', company_id),
|
||||
])
|
||||
total_amount = sum(abs(l.amount) for l in unreconciled_lines)
|
||||
last_stmt = request.env['account.bank.statement'].search(
|
||||
[('journal_id', '=', journal.id)],
|
||||
order='date desc', limit=1)
|
||||
currency = journal.currency_id or journal.company_id.currency_id
|
||||
return {
|
||||
'journal': {
|
||||
'id': journal.id,
|
||||
'name': journal.name,
|
||||
'currency_code': currency.name,
|
||||
},
|
||||
'unreconciled_count': len(unreconciled_lines),
|
||||
'total_pending_amount': total_amount,
|
||||
'last_statement_date': str(last_stmt.date) if last_stmt and last_stmt.date else None,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. list_unreconciled -- paginated, fusion-enriched
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/list_unreconciled', type='jsonrpc', auth='user')
|
||||
def list_unreconciled(self, journal_id, limit=50, offset=0,
|
||||
company_id=None, date_from=None, date_to=None,
|
||||
min_amount=None):
|
||||
"""Return enriched, paginated unreconciled bank lines."""
|
||||
limit = int(limit)
|
||||
offset = int(offset)
|
||||
company_id = (int(company_id) if company_id
|
||||
else request.env.company.id)
|
||||
# The adapter doesn't take an offset; over-fetch and slice.
|
||||
rows = _adapter().list_unreconciled(
|
||||
journal_id=int(journal_id),
|
||||
limit=limit + offset,
|
||||
company_id=company_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
min_amount=min_amount,
|
||||
)
|
||||
sliced = rows[offset:offset + limit]
|
||||
Line = request.env['account.bank.statement.line']
|
||||
domain = [
|
||||
('journal_id', '=', int(journal_id)),
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', company_id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
if min_amount is not None:
|
||||
domain.append(('amount', '>=', float(min_amount)))
|
||||
total = Line.search_count(domain)
|
||||
return {
|
||||
'count': len(sliced),
|
||||
'total': total,
|
||||
'lines': sliced,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. get_line_detail -- one line + suggestions + attachments
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/get_line_detail', type='jsonrpc', auth='user')
|
||||
def get_line_detail(self, statement_line_id):
|
||||
"""Return full detail for one line including pending suggestions."""
|
||||
Line = request.env['account.bank.statement.line']
|
||||
line = Line.browse(int(statement_line_id))
|
||||
if not line.exists():
|
||||
raise ValidationError(
|
||||
_("Statement line %s not found") % statement_line_id)
|
||||
Sug = request.env['fusion.reconcile.suggestion']
|
||||
suggestions = Sug.search([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
], order='confidence desc, rank asc')
|
||||
Att = request.env['ir.attachment']
|
||||
attachments = Att.search([
|
||||
('res_model', '=', 'account.move'),
|
||||
('res_id', '=', line.move_id.id),
|
||||
]) if line.move_id else Att.browse()
|
||||
currency = line.currency_id or line.company_id.currency_id
|
||||
return {
|
||||
'line': {
|
||||
'id': line.id,
|
||||
'date': str(line.date) if line.date else None,
|
||||
'payment_ref': line.payment_ref or '',
|
||||
'amount': line.amount,
|
||||
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||
'partner_name': line.partner_id.name if line.partner_id else None,
|
||||
'currency_id': currency.id,
|
||||
'currency_code': currency.name,
|
||||
'journal_id': line.journal_id.id,
|
||||
'journal_name': line.journal_id.name,
|
||||
'is_reconciled': line.is_reconciled,
|
||||
},
|
||||
'suggestions': [{
|
||||
'id': s.id,
|
||||
'candidate_ids': s.proposed_move_line_ids.ids,
|
||||
'confidence': s.confidence,
|
||||
'rank': s.rank,
|
||||
'reasoning': s.reasoning or '',
|
||||
'scores': {
|
||||
'amount_match': s.score_amount_match,
|
||||
'partner_pattern': s.score_partner_pattern,
|
||||
'precedent_similarity': s.score_precedent_similarity,
|
||||
'ai_rerank': s.score_ai_rerank,
|
||||
},
|
||||
} for s in suggestions],
|
||||
'attachments': [{
|
||||
'id': a.id,
|
||||
'name': a.name,
|
||||
'mimetype': a.mimetype,
|
||||
} for a in attachments],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. suggest_matches -- lazy AI suggest for a line
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/suggest_matches', type='jsonrpc', auth='user')
|
||||
def suggest_matches(self, statement_line_ids, limit_per_line=3):
|
||||
"""Trigger AI suggest for one or more statement lines."""
|
||||
ids = [int(i) for i in (statement_line_ids or [])]
|
||||
result = _adapter().suggest_matches(
|
||||
statement_line_ids=ids,
|
||||
limit_per_line=int(limit_per_line),
|
||||
)
|
||||
return {'suggestions': result}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. accept_suggestion -- promote AI suggestion to real reconcile
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/accept_suggestion', type='jsonrpc', auth='user')
|
||||
def accept_suggestion(self, suggestion_id):
|
||||
"""Accept a fusion suggestion. Returns the partial IDs created."""
|
||||
sug = request.env['fusion.reconcile.suggestion'].browse(
|
||||
int(suggestion_id))
|
||||
if not sug.exists():
|
||||
raise ValidationError(
|
||||
_("Suggestion %s not found") % suggestion_id)
|
||||
# Capture the journal/company before reconcile (the sug may go stale).
|
||||
journal_id = sug.statement_line_id.journal_id.id
|
||||
company_id = sug.company_id.id
|
||||
result = _adapter().accept_suggestion(suggestion_id=int(suggestion_id))
|
||||
unreconciled_count_after = request.env[
|
||||
'account.bank.statement.line'].search_count([
|
||||
('journal_id', '=', journal_id),
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', company_id),
|
||||
])
|
||||
return {
|
||||
'status': 'accepted',
|
||||
'partial_ids': result.get('partial_ids', []),
|
||||
'unreconciled_count_after': unreconciled_count_after,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. reconcile_manual -- user picked candidates manually
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/reconcile_manual', type='jsonrpc', auth='user')
|
||||
def reconcile_manual(self, statement_line_id, against_move_line_ids):
|
||||
"""Reconcile a line against an explicit set of journal items."""
|
||||
line = request.env['account.bank.statement.line'].browse(
|
||||
int(statement_line_id))
|
||||
if not line.exists():
|
||||
raise ValidationError(
|
||||
_("Statement line %s not found") % statement_line_id)
|
||||
cands = request.env['account.move.line'].browse(
|
||||
[int(i) for i in (against_move_line_ids or [])])
|
||||
result = request.env['fusion.reconcile.engine'].reconcile_one(
|
||||
line, against_lines=cands)
|
||||
return {
|
||||
'status': 'reconciled',
|
||||
'partial_ids': result.get('partial_ids', []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. unreconcile -- reverse a prior reconcile
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/unreconcile', type='jsonrpc', auth='user')
|
||||
def unreconcile(self, partial_reconcile_ids):
|
||||
"""Reverse one or more partial reconciles."""
|
||||
ids = [int(i) for i in (partial_reconcile_ids or [])]
|
||||
result = _adapter().unreconcile(partial_reconcile_ids=ids)
|
||||
return {
|
||||
'status': 'unreconciled',
|
||||
'unreconciled_line_ids': result.get('unreconciled_line_ids', []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. write_off -- absorb residual into a write-off account
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/write_off', type='jsonrpc', auth='user')
|
||||
def write_off(self, statement_line_id, account_id, amount, label,
|
||||
tax_id=None):
|
||||
"""Apply a write-off against a bank statement line."""
|
||||
line = request.env['account.bank.statement.line'].browse(
|
||||
int(statement_line_id))
|
||||
if not line.exists():
|
||||
raise ValidationError(
|
||||
_("Statement line %s not found") % statement_line_id)
|
||||
account = request.env['account.account'].browse(int(account_id))
|
||||
tax = (request.env['account.tax'].browse(int(tax_id))
|
||||
if tax_id else None)
|
||||
result = request.env['fusion.reconcile.engine'].write_off(
|
||||
line, account=account, amount=float(amount),
|
||||
tax_id=tax, label=label)
|
||||
return {
|
||||
'status': 'written_off',
|
||||
'partial_ids': result.get('partial_ids', []),
|
||||
'write_off_move_id': result.get('write_off_move_id'),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. bulk_reconcile -- batch auto-reconcile a recordset
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/bulk_reconcile', type='jsonrpc', auth='user')
|
||||
def bulk_reconcile(self, statement_line_ids, strategy='auto'):
|
||||
"""Batch auto-reconcile. Returns counts + per-line errors."""
|
||||
ids = [int(i) for i in (statement_line_ids or [])]
|
||||
lines = request.env['account.bank.statement.line'].browse(ids)
|
||||
result = request.env['fusion.reconcile.engine'].reconcile_batch(
|
||||
lines, strategy=strategy)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 10. get_partner_history -- partner reconcile history panel
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/get_partner_history', type='jsonrpc', auth='user')
|
||||
def get_partner_history(self, partner_id, limit=20):
|
||||
"""Return a partner's reconcile history + learned pattern."""
|
||||
Partner = request.env['res.partner']
|
||||
partner = Partner.browse(int(partner_id))
|
||||
if not partner.exists():
|
||||
raise ValidationError(_("Partner %s not found") % partner_id)
|
||||
Precedent = request.env['fusion.reconcile.precedent']
|
||||
recent = Precedent.search(
|
||||
[('partner_id', '=', partner.id)],
|
||||
order='reconciled_at desc, id desc',
|
||||
limit=int(limit),
|
||||
)
|
||||
Pattern = request.env['fusion.reconcile.pattern']
|
||||
pattern = Pattern.search(
|
||||
[('partner_id', '=', partner.id)], limit=1)
|
||||
return {
|
||||
'partner': {
|
||||
'id': partner.id,
|
||||
'name': partner.name,
|
||||
},
|
||||
'recent_reconciles': [{
|
||||
'precedent_id': p.id,
|
||||
'date': str(p.date) if p.date else None,
|
||||
'amount': p.amount,
|
||||
'memo_tokens': p.memo_tokens or '',
|
||||
'matched_count': p.matched_move_line_count,
|
||||
'source': p.source,
|
||||
} for p in recent],
|
||||
'pattern': ({
|
||||
'reconcile_count': pattern.reconcile_count,
|
||||
'pref_strategy': pattern.pref_strategy or None,
|
||||
'common_memo_tokens': pattern.common_memo_tokens or None,
|
||||
'typical_cadence_days': pattern.typical_cadence_days,
|
||||
} if pattern else None),
|
||||
}
|
||||
35
fusion_accounting_bank_rec/data/cron.xml
Normal file
35
fusion_accounting_bank_rec/data/cron.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="cron_fusion_bank_rec_suggest" model="ir.cron">
|
||||
<field name="name">Fusion Bank Rec — Warm AI Suggestions</field>
|
||||
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_suggest_pending()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="cron_fusion_bank_rec_pattern_refresh" model="ir.cron">
|
||||
<field name="name">Fusion Bank Rec — Refresh Partner Patterns</field>
|
||||
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_refresh_patterns()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="nextcall" eval="(DateTime.now().replace(hour=2, minute=0, second=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="cron_fusion_bank_rec_mv_refresh" model="ir.cron">
|
||||
<field name="name">Fusion Bank Rec — Refresh Unreconciled MV</field>
|
||||
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_refresh_mv()</field>
|
||||
<field name="interval_number">5</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Materialized view: pre-aggregated data for the OWL bank reconciliation widget.
|
||||
-- Refreshed on cron (Task 25) and on suggestion writes.
|
||||
-- Indexed on (company_id, journal_id, date) for fast UI queries.
|
||||
|
||||
-- NOTE: account_bank_statement_line does not store `date` directly in V19;
|
||||
-- it is a related field through move_id -> account_move.date. We JOIN on
|
||||
-- account_move to get it.
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_unreconciled_bank_line_mv AS
|
||||
SELECT
|
||||
bsl.id AS id,
|
||||
bsl.company_id AS company_id,
|
||||
bsl.journal_id AS journal_id,
|
||||
am.date AS date,
|
||||
bsl.amount AS amount,
|
||||
bsl.payment_ref AS payment_ref,
|
||||
bsl.currency_id AS currency_id,
|
||||
bsl.partner_id AS partner_id,
|
||||
bsl.create_date AS create_date,
|
||||
-- Top suggestion (highest confidence pending one)
|
||||
(SELECT s.id FROM fusion_reconcile_suggestion s
|
||||
WHERE s.statement_line_id = bsl.id AND s.state = 'pending'
|
||||
ORDER BY s.confidence DESC, s.rank ASC LIMIT 1) AS top_suggestion_id,
|
||||
(SELECT s.confidence FROM fusion_reconcile_suggestion s
|
||||
WHERE s.statement_line_id = bsl.id AND s.state = 'pending'
|
||||
ORDER BY s.confidence DESC, s.rank ASC LIMIT 1) AS top_confidence,
|
||||
CASE
|
||||
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
|
||||
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') >= 0.85
|
||||
THEN 'high'
|
||||
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
|
||||
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') >= 0.60
|
||||
THEN 'medium'
|
||||
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
|
||||
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') > 0
|
||||
THEN 'low'
|
||||
ELSE 'none'
|
||||
END AS confidence_band,
|
||||
-- Attachment count (assumes ir_attachment.res_model='account.bank.statement.line')
|
||||
(SELECT COUNT(*) FROM ir_attachment att
|
||||
WHERE att.res_model = 'account.bank.statement.line' AND att.res_id = bsl.id)
|
||||
AS attachment_count,
|
||||
-- Partner reconcile pattern hint
|
||||
COALESCE((SELECT p.reconcile_count FROM fusion_reconcile_pattern p
|
||||
WHERE p.partner_id = bsl.partner_id AND p.company_id = bsl.company_id LIMIT 1), 0)
|
||||
AS partner_reconcile_count
|
||||
FROM account_bank_statement_line bsl
|
||||
JOIN account_move am ON am.id = bsl.move_id
|
||||
WHERE bsl.is_reconciled IS NOT TRUE;
|
||||
|
||||
-- Indexes for the common UI queries: filter by company + journal, sort by date desc.
|
||||
CREATE INDEX IF NOT EXISTS fusion_mv_unrec_company_journal_date_idx
|
||||
ON fusion_unreconciled_bank_line_mv (company_id, journal_id, date DESC);
|
||||
CREATE INDEX IF NOT EXISTS fusion_mv_unrec_partner_idx
|
||||
ON fusion_unreconciled_bank_line_mv (partner_id) WHERE partner_id IS NOT NULL;
|
||||
-- UNIQUE index required for CONCURRENTLY refresh
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS fusion_mv_unrec_id_idx
|
||||
ON fusion_unreconciled_bank_line_mv (id);
|
||||
@@ -0,0 +1,176 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo import api, Command, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountAutoReconcileWizard(models.TransientModel):
|
||||
""" This wizard is used to automatically reconcile account.move.line.
|
||||
It is accessible trough Accounting > Accounting tab > Actions > Auto-reconcile menuitem.
|
||||
"""
|
||||
_name = 'account.auto.reconcile.wizard'
|
||||
_description = 'Account automatic reconciliation wizard'
|
||||
_check_company_auto = True
|
||||
|
||||
company_id = fields.Many2one(
|
||||
comodel_name='res.company',
|
||||
required=True,
|
||||
readonly=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
line_ids = fields.Many2many(comodel_name='account.move.line') # Amls from which we derive a preset for the wizard
|
||||
from_date = fields.Date(string='From')
|
||||
to_date = fields.Date(string='To', default=fields.Date.context_today, required=True)
|
||||
account_ids = fields.Many2many(
|
||||
comodel_name='account.account',
|
||||
string='Accounts',
|
||||
check_company=True,
|
||||
domain="[('reconcile', '=', True), ('account_type', '!=', 'off_balance')]",
|
||||
)
|
||||
partner_ids = fields.Many2many(
|
||||
comodel_name='res.partner',
|
||||
string='Partners',
|
||||
check_company=True,
|
||||
domain="[('company_id', 'in', (False, company_id)), '|', ('parent_id', '=', False), ('is_company', '=', True)]",
|
||||
)
|
||||
search_mode = fields.Selection(
|
||||
selection=[
|
||||
('one_to_one', "Perfect Match"),
|
||||
('zero_balance', "Clear Account"),
|
||||
],
|
||||
string='Reconcile',
|
||||
required=True,
|
||||
default='one_to_one',
|
||||
help="Reconcile journal items with opposite balance or clear accounts with a zero balance",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super().default_get(fields)
|
||||
domain = self.env.context.get('domain')
|
||||
if 'line_ids' in fields and 'line_ids' not in res and domain:
|
||||
amls = self.env['account.move.line'].search(domain)
|
||||
if amls:
|
||||
# pre-configure the wizard
|
||||
res.update(self._get_default_wizard_values(amls))
|
||||
res['line_ids'] = [Command.set(amls.ids)]
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _get_default_wizard_values(self, amls):
|
||||
""" Derive a preset configuration based on amls.
|
||||
For example if all amls have the same account_id we will set it in the wizard.
|
||||
:param amls: account move lines from which we will derive a preset
|
||||
:return: a dict with preset values
|
||||
"""
|
||||
return {
|
||||
'account_ids': [Command.set(amls[0].account_id.ids)] if all(aml.account_id == amls[0].account_id for aml in amls) else [],
|
||||
'partner_ids': [Command.set(amls[0].partner_id.ids)] if all(aml.partner_id == amls[0].partner_id for aml in amls) else [],
|
||||
'search_mode': 'zero_balance' if amls.company_currency_id.is_zero(sum(amls.mapped('balance'))) else 'one_to_one',
|
||||
'from_date': min(amls.mapped('date')),
|
||||
'to_date': max(amls.mapped('date')),
|
||||
}
|
||||
|
||||
def _get_wizard_values(self):
|
||||
""" Get the current configuration of the wizard as a dict of values.
|
||||
:return: a dict with the current configuration of the wizard.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'account_ids': [Command.set(self.account_ids.ids)] if self.account_ids else [],
|
||||
'partner_ids': [Command.set(self.partner_ids.ids)] if self.partner_ids else [],
|
||||
'search_mode': self.search_mode,
|
||||
'from_date': self.from_date,
|
||||
'to_date': self.to_date,
|
||||
}
|
||||
|
||||
# ==== Business methods ====
|
||||
def _get_amls_domain(self):
|
||||
""" Get the domain of amls to be auto-reconciled. """
|
||||
self.ensure_one()
|
||||
if self.line_ids and self._get_wizard_values() == self._get_default_wizard_values(self.line_ids):
|
||||
domain = [('id', 'in', self.line_ids.ids)]
|
||||
else:
|
||||
domain = [
|
||||
('company_id', '=', self.company_id.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
|
||||
('date', '>=', self.from_date or date.min),
|
||||
('date', '<=', self.to_date),
|
||||
('reconciled', '=', False),
|
||||
('account_id.reconcile', '=', True),
|
||||
('amount_residual_currency', '!=', 0.0),
|
||||
('amount_residual', '!=', 0.0), # excludes exchange difference lines
|
||||
]
|
||||
if self.account_ids:
|
||||
domain.append(('account_id', 'in', self.account_ids.ids))
|
||||
if self.partner_ids:
|
||||
domain.append(('partner_id', 'in', self.partner_ids.ids))
|
||||
return domain
|
||||
|
||||
def _auto_reconcile_one_to_one(self):
|
||||
""" Auto-reconcile with one-to-one strategy:
|
||||
We will reconcile 2 amls together if their combined balance is zero.
|
||||
:return: a recordset of reconciled amls
|
||||
"""
|
||||
grouped_amls_data = self.env['account.move.line']._read_group(
|
||||
self._get_amls_domain(),
|
||||
['account_id', 'partner_id', 'currency_id', 'amount_residual_currency:abs_rounded'],
|
||||
['id:recordset'],
|
||||
)
|
||||
all_reconciled_amls = self.env['account.move.line']
|
||||
amls_grouped_by_2 = [] # we need to group amls with right format for _reconcile_plan
|
||||
for *__, grouped_aml_ids in grouped_amls_data:
|
||||
positive_amls = grouped_aml_ids.filtered(lambda aml: aml.amount_residual_currency >= 0).sorted('date')
|
||||
negative_amls = (grouped_aml_ids - positive_amls).sorted('date')
|
||||
min_len = min(len(positive_amls), len(negative_amls))
|
||||
positive_amls = positive_amls[:min_len]
|
||||
negative_amls = negative_amls[:min_len]
|
||||
all_reconciled_amls += positive_amls + negative_amls
|
||||
amls_grouped_by_2 += [pos_aml + neg_aml for (pos_aml, neg_aml) in zip(positive_amls, negative_amls)]
|
||||
self.env['account.move.line']._reconcile_plan(amls_grouped_by_2)
|
||||
return all_reconciled_amls
|
||||
|
||||
def _auto_reconcile_zero_balance(self):
|
||||
""" Auto-reconcile with zero balance strategy:
|
||||
We will reconcile all amls grouped by currency/account/partner that have a total balance of zero.
|
||||
:return: a recordset of reconciled amls
|
||||
"""
|
||||
grouped_amls_data = self.env['account.move.line']._read_group(
|
||||
self._get_amls_domain(),
|
||||
groupby=['account_id', 'partner_id', 'currency_id'],
|
||||
aggregates=['id:recordset'],
|
||||
having=[('amount_residual_currency:sum_rounded', '=', 0)],
|
||||
)
|
||||
all_reconciled_amls = self.env['account.move.line']
|
||||
amls_grouped_together = [] # we need to group amls with right format for _reconcile_plan
|
||||
for aml_data in grouped_amls_data:
|
||||
all_reconciled_amls += aml_data[-1]
|
||||
amls_grouped_together += [aml_data[-1]]
|
||||
self.env['account.move.line']._reconcile_plan(amls_grouped_together)
|
||||
return all_reconciled_amls
|
||||
|
||||
def auto_reconcile(self):
|
||||
""" Automatically reconcile amls given wizard's parameters.
|
||||
:return: an action that opens all reconciled items and related amls (exchange diff, etc)
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.search_mode == 'zero_balance':
|
||||
reconciled_amls = self._auto_reconcile_zero_balance()
|
||||
else:
|
||||
# search_mode == 'one_to_one'
|
||||
reconciled_amls = self._auto_reconcile_one_to_one()
|
||||
reconciled_amls_and_related = self.env['account.move.line'].search([
|
||||
('full_reconcile_id', 'in', reconciled_amls.full_reconcile_id.ids)
|
||||
])
|
||||
if reconciled_amls_and_related:
|
||||
return {
|
||||
'name': _("Automatically Reconciled Entries"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move.line',
|
||||
'context': "{'search_default_group_by_matching': True}",
|
||||
'view_mode': 'list',
|
||||
'domain': [('id', 'in', reconciled_amls_and_related.ids)],
|
||||
}
|
||||
else:
|
||||
raise UserError(self.env._("Nothing to reconcile."))
|
||||
@@ -0,0 +1,325 @@
|
||||
from odoo import SUPERUSER_ID, api, fields, models
|
||||
from odoo.tools import SQL
|
||||
|
||||
|
||||
class AccountReconcileModel(models.Model):
|
||||
_inherit = 'account.reconcile.model'
|
||||
|
||||
# Technical field to know if the rule was created automatically or by a user.
|
||||
created_automatically = fields.Boolean(default=False, copy=False)
|
||||
|
||||
def _apply_lines_for_bank_widget(self, residual_amount_currency, residual_balance, partner, st_line):
|
||||
""" Apply the reconciliation model lines to the statement line passed as parameter.
|
||||
|
||||
:param residual_amount_currency: The open amount currency of the statement line in the bank reconciliation widget
|
||||
expressed in the statement line currency.
|
||||
:param residual_balance: The open balance of the statement line in the bank reconciliation widget
|
||||
expressed in the company currency.
|
||||
:param partner: The partner set on the wizard.
|
||||
:param st_line: The statement line processed by the bank reconciliation widget.
|
||||
:return: A list of python dictionaries (one per reconcile model line) representing
|
||||
the journal items to be created by the current reconcile model.
|
||||
"""
|
||||
self.ensure_one()
|
||||
currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
|
||||
vals_list = []
|
||||
for line in self.line_ids:
|
||||
vals = line._apply_in_bank_widget(
|
||||
residual_amount_currency=residual_amount_currency,
|
||||
residual_balance=residual_balance,
|
||||
partner=line.partner_id or partner,
|
||||
st_line=st_line,
|
||||
)
|
||||
amount_currency = vals['amount_currency']
|
||||
balance = vals['balance']
|
||||
|
||||
if currency.is_zero(amount_currency) and st_line.company_currency_id.is_zero(balance):
|
||||
continue
|
||||
|
||||
vals_list.append(vals)
|
||||
residual_amount_currency -= amount_currency
|
||||
residual_balance -= balance
|
||||
|
||||
return vals_list
|
||||
|
||||
@api.model
|
||||
def get_available_reconcile_model_per_statement_line(self, statement_line_ids):
|
||||
self.check_access('read')
|
||||
self.env['account.reconcile.model'].flush_model()
|
||||
self.env['account.bank.statement.line'].flush_model()
|
||||
self.env.cr.execute(SQL(
|
||||
"""
|
||||
WITH matching_journal_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(account_journal_id) AS ids
|
||||
FROM account_journal_account_reconcile_model_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
matching_partner_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(res_partner_id) AS ids
|
||||
FROM account_reconcile_model_res_partner_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
)
|
||||
|
||||
SELECT st_line.id AS st_line_id,
|
||||
array_agg(reco_model.id ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_ids,
|
||||
array_agg(reco_model.name ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_names
|
||||
FROM account_bank_statement_line st_line
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT DISTINCT reco_model.id,
|
||||
reco_model.sequence,
|
||||
COALESCE(reco_model.name -> %(lang)s, reco_model.name -> 'en_US') as name
|
||||
FROM account_reconcile_model reco_model
|
||||
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
||||
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
|
||||
LEFT JOIN account_reconcile_model_line reco_model_line ON reco_model_line.model_id = reco_model.id
|
||||
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
||||
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
|
||||
AND (
|
||||
CASE COALESCE(reco_model.match_amount, '')
|
||||
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
|
||||
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
|
||||
WHEN 'between' THEN
|
||||
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
|
||||
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
|
||||
ELSE TRUE
|
||||
END
|
||||
)
|
||||
AND (
|
||||
reco_model.match_label IS NULL
|
||||
OR (
|
||||
reco_model.match_label = 'contains'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'not_contains'
|
||||
AND NOT (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'match_regex'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
|
||||
)
|
||||
)
|
||||
)
|
||||
AND reco_model.company_id = st_line.company_id
|
||||
AND reco_model.trigger = 'manual'
|
||||
AND reco_model_line.account_id IS NOT NULL
|
||||
AND reco_model.active IS TRUE
|
||||
) AS reco_model ON TRUE
|
||||
WHERE st_line.id IN %(statement_lines)s
|
||||
AND reco_model.id IS NOT NULL
|
||||
GROUP BY st_line.id
|
||||
""",
|
||||
lang=self.env.lang,
|
||||
statement_lines=tuple(statement_line_ids),
|
||||
))
|
||||
query_result = self.env.cr.fetchall()
|
||||
return {
|
||||
st_line_id: [
|
||||
{'id': model_id, 'display_name': model_name}
|
||||
for (model_id, model_name)
|
||||
in zip(model_ids, model_names)
|
||||
]
|
||||
for st_line_id, model_ids, model_names
|
||||
in query_result
|
||||
}
|
||||
|
||||
def _apply_reconcile_models(self, statement_lines):
|
||||
if not self or not statement_lines:
|
||||
return
|
||||
self.env['account.reconcile.model'].flush_model()
|
||||
statement_lines.flush_recordset(['journal_id', 'amount', 'amount_residual', 'transaction_details', 'payment_ref', 'partner_id', 'company_id'])
|
||||
self.env.cr.execute(SQL("""
|
||||
WITH matching_journal_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(account_journal_id) AS ids
|
||||
FROM account_journal_account_reconcile_model_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
matching_partner_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(res_partner_id) AS ids
|
||||
FROM account_reconcile_model_res_partner_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
model_fees AS (
|
||||
SELECT model_fees.id,
|
||||
model_fees.trigger,
|
||||
matching_journal_ids.ids AS journal_ids
|
||||
FROM account_reconcile_model model_fees
|
||||
JOIN ir_model_data imd ON model_fees.id = imd.res_id
|
||||
JOIN account_reconcile_model_line model_lines ON model_lines.model_id = model_fees.id
|
||||
LEFT JOIN matching_journal_ids ON model_fees.id = matching_journal_ids.account_reconcile_model_id
|
||||
WHERE imd.module = 'account'
|
||||
AND imd.name LIKE 'account_reco_model_fee_%%'
|
||||
AND model_fees.active IS TRUE
|
||||
AND model_lines.account_id IS NOT NULL
|
||||
)
|
||||
|
||||
SELECT st_line.id AS st_line_id,
|
||||
COALESCE(reco_model.id, model_fees.id) AS reco_model_id,
|
||||
COALESCE(reco_model.trigger, model_fees.trigger) AS trigger
|
||||
FROM account_bank_statement_line st_line
|
||||
JOIN account_move move ON st_line.move_id = move.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT reco_model.id,
|
||||
reco_model.trigger
|
||||
FROM account_reconcile_model reco_model
|
||||
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
||||
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
|
||||
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
||||
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
|
||||
AND (
|
||||
CASE COALESCE(reco_model.match_amount, '')
|
||||
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
|
||||
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
|
||||
WHEN 'between' THEN
|
||||
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
|
||||
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
|
||||
ELSE TRUE
|
||||
END
|
||||
)
|
||||
AND (
|
||||
reco_model.match_label IS NULL
|
||||
OR (
|
||||
reco_model.match_label = 'contains'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'not_contains'
|
||||
AND NOT (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'match_regex'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ~* reco_model.match_label_param
|
||||
)
|
||||
)
|
||||
)
|
||||
AND reco_model.id IN %s
|
||||
AND reco_model.can_be_proposed IS TRUE
|
||||
AND reco_model.company_id = st_line.company_id
|
||||
ORDER BY reco_model.sequence ASC, reco_model.id ASC
|
||||
LIMIT 1
|
||||
) AS reco_model ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT model_fees.id,
|
||||
model_fees.trigger
|
||||
FROM model_fees
|
||||
WHERE st_line.journal_id = ANY(model_fees.journal_ids)
|
||||
-- Show model fees if matched amount was 3 %% higher than incoming statement line amount
|
||||
AND SIGN(st_line.amount) > 0
|
||||
AND SIGN(st_line.amount_residual) > 0
|
||||
AND ABS(st_line.amount_residual) < 0.03 * st_line.amount / 1.03
|
||||
) AS model_fees ON TRUE
|
||||
WHERE st_line.id IN %s
|
||||
""", tuple(self.ids), tuple(statement_lines.ids)))
|
||||
|
||||
query_result = self.env.cr.fetchall()
|
||||
|
||||
processed_st_line_ids = set()
|
||||
# apply the found suitable reco models on the statement lines
|
||||
for st_line_id, reco_model_id, reco_model_trigger in query_result:
|
||||
if st_line_id in processed_st_line_ids or reco_model_id is None:
|
||||
continue
|
||||
|
||||
st_line = self.env['account.bank.statement.line'].browse(st_line_id).with_prefetch(statement_lines.ids)
|
||||
reco_model = self.env['account.reconcile.model'].browse(reco_model_id).with_prefetch(self.ids)
|
||||
|
||||
if reco_model_trigger == 'manual':
|
||||
st_line._action_manual_reco_model(reco_model_id)
|
||||
else:
|
||||
reco_model.with_user(SUPERUSER_ID)._trigger_reconciliation_model(st_line.with_user(SUPERUSER_ID))
|
||||
processed_st_line_ids.add(st_line_id)
|
||||
|
||||
def _trigger_reconciliation_model(self, statement_line):
|
||||
self.ensure_one()
|
||||
liquidity_line, suspense_line, other_lines = statement_line._seek_for_lines()
|
||||
|
||||
amls_to_create = list(
|
||||
self._apply_lines_for_bank_widget(
|
||||
residual_amount_currency=sum(suspense_line.mapped('amount_currency')),
|
||||
residual_balance=sum(suspense_line.mapped('balance')),
|
||||
partner=statement_line.partner_id,
|
||||
st_line=statement_line,
|
||||
)
|
||||
)
|
||||
# Get the original base lines and tax lines before the creation of new lines
|
||||
if any(aml.get('tax_ids') for aml in amls_to_create):
|
||||
original_base_lines, original_tax_lines = statement_line._prepare_for_tax_lines_recomputation()
|
||||
|
||||
statement_line._set_move_line_to_statement_line_move(liquidity_line + other_lines, amls_to_create)
|
||||
|
||||
# Now that the new lines have been added, we can recompute the taxes
|
||||
if any(aml.get('tax_ids') for aml in amls_to_create):
|
||||
_new_liquidity_line, new_suspense_line, _new_other_lines = statement_line._seek_for_lines()
|
||||
new_lines = statement_line.line_ids - (liquidity_line + other_lines + new_suspense_line)
|
||||
statement_line._create_tax_lines(original_base_lines, original_tax_lines, new_lines)
|
||||
|
||||
if self.next_activity_type_id:
|
||||
statement_line.move_id.activity_schedule(
|
||||
activity_type_id=self.next_activity_type_id.id,
|
||||
user_id=self.env.user.id,
|
||||
)
|
||||
|
||||
def trigger_reconciliation_model(self, statement_line_id):
|
||||
self.ensure_one()
|
||||
|
||||
statement_line = self.env['account.bank.statement.line'].browse(statement_line_id).exists()
|
||||
self._trigger_reconciliation_model(statement_line)
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
unreconciled_statement_lines.line_ids.filtered(
|
||||
lambda line:
|
||||
line.account_id == line.move_id.journal_id.suspense_account_id and line.reconcile_model_id in self
|
||||
).reconcile_model_id = False
|
||||
self._apply_reconcile_models(unreconciled_statement_lines)
|
||||
|
||||
return res
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
reco_models = super().create(vals_list)
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
reco_models._apply_reconcile_models(unreconciled_statement_lines)
|
||||
|
||||
return reco_models
|
||||
|
||||
def action_archive(self):
|
||||
res = super().action_archive()
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
('line_ids.reconcile_model_id', 'in', self.ids),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
unreconciled_statement_lines.line_ids.filtered(
|
||||
lambda line:
|
||||
line.account_id == line.move_id.journal_id.suspense_account_id
|
||||
).reconcile_model_id = False
|
||||
return res
|
||||
@@ -0,0 +1,139 @@
|
||||
import { EventBus, reactive, useState } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class BankReconciliationService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
this.setup(env, services);
|
||||
}
|
||||
|
||||
setup(env, services) {
|
||||
this.bus = new EventBus();
|
||||
this.orm = services["orm"];
|
||||
|
||||
this.chatterState = reactive({
|
||||
visible:
|
||||
JSON.parse(
|
||||
browser.sessionStorage.getItem("isBankReconciliationWidgetChatterOpened")
|
||||
) ?? false,
|
||||
statementLine: null,
|
||||
});
|
||||
this.reconcileCountPerPartnerId = reactive({});
|
||||
this.reconcileModelPerStatementLineId = reactive({});
|
||||
}
|
||||
|
||||
toggleChatter() {
|
||||
this.chatterState.visible = !this.chatterState.visible;
|
||||
browser.sessionStorage.setItem(
|
||||
"isBankReconciliationWidgetChatterOpened",
|
||||
this.chatterState.visible
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific function to open the chatter.
|
||||
* For a particular case, where the customer clicks on
|
||||
* the chatter icon directly on the bank statement line,
|
||||
* we want to open the chatter but not close it.
|
||||
*/
|
||||
openChatter() {
|
||||
this.chatterState.visible = true;
|
||||
}
|
||||
|
||||
selectStatementLine(statementLine) {
|
||||
this.chatterState.statementLine = statementLine;
|
||||
}
|
||||
|
||||
reloadChatter() {
|
||||
this.bus.trigger("MAIL:RELOAD-THREAD", {
|
||||
model: "account.move",
|
||||
id: this.statementLineMoveId,
|
||||
});
|
||||
}
|
||||
|
||||
async computeReconcileLineCountPerPartnerId(records) {
|
||||
const groups = await this.orm.formattedReadGroup(
|
||||
"account.move.line",
|
||||
[
|
||||
["parent_state", "in", ["draft", "posted"]],
|
||||
[
|
||||
"partner_id",
|
||||
"in",
|
||||
records
|
||||
.filter((record) => !!record.data.partner_id.id)
|
||||
.map((record) => record.data.partner_id.id),
|
||||
],
|
||||
["company_id", "child_of", records.map((record) => record.data.company_id.id)],
|
||||
["search_account_id.reconcile", "=", true],
|
||||
["display_type", "not in", ["line_section", "line_note"]],
|
||||
["reconciled", "=", false],
|
||||
"|",
|
||||
["search_account_id.account_type", "not in", ["asset_receivable", "liability_payable"]],
|
||||
["payment_id", "=", false],
|
||||
["statement_line_id", "not in", records.map((record) => record.data.id)],
|
||||
],
|
||||
["partner_id"],
|
||||
["id:count"]
|
||||
);
|
||||
|
||||
this.reconcileCountPerPartnerId = {};
|
||||
groups.forEach((group) => {
|
||||
this.reconcileCountPerPartnerId[group.partner_id[0]] = group["id:count"];
|
||||
});
|
||||
}
|
||||
|
||||
async computeAvailableReconcileModels(records) {
|
||||
this.reconcileModelPerStatementLineId =
|
||||
Object.keys(records).length === 0
|
||||
? {}
|
||||
: await this.orm.call(
|
||||
"account.reconcile.model",
|
||||
"get_available_reconcile_model_per_statement_line",
|
||||
[records.map((record) => record.data.id)]
|
||||
);
|
||||
}
|
||||
|
||||
async updateAvailableReconcileModels(recordId) {
|
||||
const result = await this.orm.call(
|
||||
"account.reconcile.model",
|
||||
"get_available_reconcile_model_per_statement_line",
|
||||
[[recordId]]
|
||||
);
|
||||
this.reconcileModelPerStatementLineId[recordId] = result[recordId];
|
||||
}
|
||||
|
||||
async reloadRecords(records) {
|
||||
await Promise.all([...records.map((record) => record.load())]);
|
||||
}
|
||||
|
||||
get statementLineMove() {
|
||||
return this.chatterState.statementLine?.data.move_id;
|
||||
}
|
||||
|
||||
get statementLineMoveId() {
|
||||
return this.statementLineMove?.id;
|
||||
}
|
||||
|
||||
get statementLine() {
|
||||
return this.chatterState.statementLine;
|
||||
}
|
||||
|
||||
get statementLineId() {
|
||||
return this.statementLine?.data?.id;
|
||||
}
|
||||
}
|
||||
|
||||
const bankReconciliationService = {
|
||||
dependencies: ["orm"],
|
||||
start(env, services) {
|
||||
return new BankReconciliationService(env, services);
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("bankReconciliation", bankReconciliationService);
|
||||
|
||||
export function useBankReconciliation() {
|
||||
return useState(useService("bankReconciliation"));
|
||||
}
|
||||
9
fusion_accounting_bank_rec/models/__init__.py
Normal file
9
fusion_accounting_bank_rec/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
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
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Inherit account.bank.statement.line to add Phase 1 widget compute fields.
|
||||
|
||||
These fields are NOT stored — they're computed on-the-fly so the OWL widget
|
||||
can render confidence badges without round-tripping. Performance OK because
|
||||
the widget loads ~50-200 lines per kanban open and each compute is a single
|
||||
indexed query into fusion.reconcile.suggestion.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class AccountBankStatementLine(models.Model):
|
||||
_inherit = "account.bank.statement.line"
|
||||
|
||||
# Top suggestion + its band — for the inline AI confidence badge
|
||||
fusion_top_suggestion_id = fields.Many2one(
|
||||
'fusion.reconcile.suggestion',
|
||||
compute='_compute_top_suggestion',
|
||||
store=False,
|
||||
help="Highest-ranked pending AI suggestion for this line")
|
||||
fusion_confidence_band = fields.Selection(
|
||||
[('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')],
|
||||
compute='_compute_top_suggestion',
|
||||
store=False,
|
||||
default='none',
|
||||
help="Quick-render colour band for the OWL widget badge")
|
||||
|
||||
# Mirror of Enterprise's bank_statement_attachment_ids surface field.
|
||||
# Defined here so fusion's widget can render attachments without
|
||||
# depending on account_accountant being installed.
|
||||
bank_statement_attachment_ids = fields.One2many(
|
||||
'ir.attachment',
|
||||
compute='_compute_bank_statement_attachment_ids',
|
||||
help="Attachments on the underlying account.move; mirrored for the OWL widget")
|
||||
|
||||
def _compute_top_suggestion(self):
|
||||
Suggestion = self.env['fusion.reconcile.suggestion'].sudo()
|
||||
for line in self:
|
||||
top = Suggestion.search([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
('rank', '=', 1),
|
||||
], limit=1)
|
||||
line.fusion_top_suggestion_id = top
|
||||
line.fusion_confidence_band = top.confidence_band if top else 'none'
|
||||
|
||||
@api.depends('move_id', 'move_id.attachment_ids')
|
||||
def _compute_bank_statement_attachment_ids(self):
|
||||
for line in self:
|
||||
line.bank_statement_attachment_ids = (
|
||||
line.move_id.attachment_ids if line.move_id else self.env['ir.attachment']
|
||||
)
|
||||
20
fusion_accounting_bank_rec/models/account_reconcile_model.py
Normal file
20
fusion_accounting_bank_rec/models/account_reconcile_model.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Inherit account.reconcile.model to add Phase 1 AI integration hooks.
|
||||
|
||||
This is a minimal extension placeholder for now — Phase 1+ phases may
|
||||
expand it (e.g., to attach AI confidence rules to reconcile-model
|
||||
auto-fires). The shared-field-ownership for `created_automatically`
|
||||
already lives in fusion_accounting_core; this file is for fusion_bank_rec
|
||||
specific extensions only.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountReconcileModel(models.Model):
|
||||
_inherit = "account.reconcile.model"
|
||||
|
||||
fusion_ai_confidence_threshold = fields.Float(
|
||||
string="AI confidence threshold",
|
||||
default=0.0,
|
||||
help="If >0.0, fusion AI suggestions matching this rule are auto-applied "
|
||||
"only when their confidence ≥ this threshold. 0.0 = no AI filtering.")
|
||||
119
fusion_accounting_bank_rec/models/fusion_bank_rec_cron.py
Normal file
119
fusion_accounting_bank_rec/models/fusion_bank_rec_cron.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Cron handler model for fusion_accounting_bank_rec.
|
||||
|
||||
Three scheduled jobs:
|
||||
- _cron_suggest_pending: warm AI suggestions for unreconciled lines (30 min)
|
||||
- _cron_refresh_patterns: recompute fusion.reconcile.pattern aggregates (daily 02:00)
|
||||
- _cron_refresh_mv: REFRESH MATERIALIZED VIEW CONCURRENTLY (5 min)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import odoo
|
||||
from odoo import api, fields, models
|
||||
|
||||
from ..services.pattern_extractor import extract_pattern_for_partner
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBankRecCron(models.AbstractModel):
|
||||
_name = "fusion.bank.rec.cron"
|
||||
_description = "Fusion Bank Reconciliation Cron Handlers"
|
||||
|
||||
@api.model
|
||||
def _cron_suggest_pending(self, batch_size=50):
|
||||
"""For each unreconciled bank line that doesn't have a recent pending
|
||||
suggestion, run engine.suggest_matches.
|
||||
|
||||
Recent = a pending suggestion created within the last 24 hours."""
|
||||
cutoff = fields.Datetime.now() - timedelta(hours=24)
|
||||
Line = self.env['account.bank.statement.line']
|
||||
lines_to_consider = Line.search([
|
||||
('is_reconciled', '=', False),
|
||||
('partner_id', '!=', False),
|
||||
], limit=batch_size * 5)
|
||||
|
||||
Suggestion = self.env['fusion.reconcile.suggestion']
|
||||
lines_needing_suggestions = self.env['account.bank.statement.line']
|
||||
for line in lines_to_consider:
|
||||
recent = Suggestion.search_count([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
('create_date', '>=', cutoff),
|
||||
])
|
||||
if recent == 0:
|
||||
lines_needing_suggestions |= line
|
||||
if len(lines_needing_suggestions) >= batch_size:
|
||||
break
|
||||
|
||||
if not lines_needing_suggestions:
|
||||
_logger.debug("Cron: no bank lines need suggestion warming")
|
||||
return
|
||||
|
||||
_logger.info(
|
||||
"Cron: warming suggestions for %d bank lines",
|
||||
len(lines_needing_suggestions))
|
||||
try:
|
||||
self.env['fusion.reconcile.engine'].suggest_matches(
|
||||
lines_needing_suggestions, limit_per_line=3)
|
||||
except Exception as e:
|
||||
_logger.exception("Cron suggest_pending failed: %s", e)
|
||||
|
||||
@api.model
|
||||
def _cron_refresh_patterns(self):
|
||||
"""For each (company, partner) pair with precedents, recompute and
|
||||
upsert the fusion.reconcile.pattern row."""
|
||||
Pattern = self.env['fusion.reconcile.pattern']
|
||||
self.env.cr.execute("""
|
||||
SELECT DISTINCT company_id, partner_id
|
||||
FROM fusion_reconcile_precedent
|
||||
WHERE partner_id IS NOT NULL
|
||||
""")
|
||||
pairs = self.env.cr.fetchall()
|
||||
_logger.info(
|
||||
"Cron: refreshing patterns for %d (company, partner) pairs",
|
||||
len(pairs))
|
||||
for company_id, partner_id in pairs:
|
||||
try:
|
||||
vals = extract_pattern_for_partner(
|
||||
self.env, company_id=company_id, partner_id=partner_id)
|
||||
existing = Pattern.search([
|
||||
('company_id', '=', company_id),
|
||||
('partner_id', '=', partner_id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
Pattern.create(vals)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Pattern refresh failed for company=%s partner=%s: %s",
|
||||
company_id, partner_id, e)
|
||||
|
||||
@api.model
|
||||
def _cron_refresh_mv(self):
|
||||
"""Refresh the materialized view CONCURRENTLY using an autocommit cursor.
|
||||
|
||||
REFRESH CONCURRENTLY can't run inside a transaction, so we open a
|
||||
fresh connection in autocommit mode (per Task 24's note). On any
|
||||
failure, we fall back to the model's blocking refresh."""
|
||||
try:
|
||||
db_name = self.env.cr.dbname
|
||||
db = odoo.sql_db.db_connect(db_name)
|
||||
with db.cursor() as cron_cr:
|
||||
cron_cr._cnx.set_session(autocommit=True)
|
||||
cron_cr.execute(
|
||||
"REFRESH MATERIALIZED VIEW CONCURRENTLY "
|
||||
"fusion_unreconciled_bank_line_mv")
|
||||
_logger.debug("Cron: MV refresh CONCURRENTLY succeeded")
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Cron MV refresh CONCURRENTLY failed (%s); falling back to "
|
||||
"blocking refresh", e)
|
||||
try:
|
||||
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||
concurrently=False)
|
||||
except Exception as e2:
|
||||
_logger.exception(
|
||||
"Cron MV refresh fallback also failed: %s", e2)
|
||||
33
fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py
Normal file
33
fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Per-request widget state. Holds the kanban-load response shape so the
|
||||
controller can return one well-typed object.
|
||||
|
||||
This is a TransientModel (no DB persistence beyond the request). The OWL
|
||||
widget reads pre-computed fusion.reconcile.suggestion rows directly via
|
||||
the controller; this model is just a typed envelope for the kanban-open
|
||||
action."""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionBankRecWidget(models.TransientModel):
|
||||
_name = "fusion.bank.rec.widget"
|
||||
_description = "Bank reconciliation widget state (transient)"
|
||||
|
||||
journal_id = fields.Many2one('account.journal',
|
||||
domain="[('type', '=', 'bank')]")
|
||||
statement_line_ids = fields.Many2many('account.bank.statement.line')
|
||||
summary_count = fields.Integer(
|
||||
help="Number of unreconciled lines visible in this widget")
|
||||
summary_unreconciled_balance = fields.Monetary(currency_field='currency_id')
|
||||
currency_id = fields.Many2one('res.currency',
|
||||
related='journal_id.currency_id',
|
||||
store=False, readonly=True)
|
||||
|
||||
def action_open_kanban(self):
|
||||
"""Return a window action opening the OWL kanban for this journal."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_bank_rec_kanban',
|
||||
'params': {'journal_id': self.journal_id.id},
|
||||
}
|
||||
481
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
481
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""The reconcile engine — orchestrator for all bank-line reconciliations.
|
||||
|
||||
Public API: 6 methods. All other code (controllers, AI tools, wizards)
|
||||
must go through these methods; no direct ORM writes to
|
||||
``account.partial.reconcile`` from anywhere else.
|
||||
|
||||
V19 mechanics (per Enterprise's bank_rec_widget pattern):
|
||||
|
||||
A bank statement line creates an ``account.move`` with two journal
|
||||
items: a *liquidity* line on the journal's default account, and a
|
||||
*suspense* line on the journal's suspense account. Reconciliation
|
||||
replaces the suspense line with one or more *counterpart* lines posted
|
||||
to the matched invoices' receivable / payable accounts (or the write-off
|
||||
account), then calls Odoo's standard ``account.move.line.reconcile()``
|
||||
on each counterpart + invoice pair.
|
||||
|
||||
Internal pipeline (per spec Section 3.3):
|
||||
|
||||
1. Validate (period not locked, mandatory args present).
|
||||
2. Compute counterpart vals from ``against_lines`` and optional write-off.
|
||||
3. Rewrite the bank move ``line_ids``: keep liquidity, drop suspense +
|
||||
any prior other lines, append the new counterparts.
|
||||
4. Reconcile each counterpart with its matched invoice line.
|
||||
5. Audit (``mail.message``) + record precedent for future learning.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.fields import Command
|
||||
|
||||
from ..services.matching_strategies import (
|
||||
AmountExactStrategy,
|
||||
Candidate,
|
||||
FIFOStrategy,
|
||||
MultiInvoiceStrategy,
|
||||
)
|
||||
from ..services.confidence_scoring import score_candidates
|
||||
from ..services.memo_tokenizer import tokenize_memo
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionReconcileEngine(models.AbstractModel):
|
||||
_name = "fusion.reconcile.engine"
|
||||
_description = "Fusion Bank Reconciliation Engine"
|
||||
|
||||
# ============================================================
|
||||
# PUBLIC API (6 methods)
|
||||
# ============================================================
|
||||
|
||||
@api.model
|
||||
def reconcile_one(self, statement_line, *, against_lines=None,
|
||||
write_off_vals=None):
|
||||
"""Reconcile one bank line against a set of journal items.
|
||||
|
||||
Returns: ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
|
||||
'write_off_move_id': int|None}``
|
||||
"""
|
||||
if not statement_line:
|
||||
raise ValidationError(_("statement_line is required"))
|
||||
statement_line.ensure_one()
|
||||
AML = self.env['account.move.line']
|
||||
against_lines = against_lines or AML
|
||||
if not against_lines and not write_off_vals:
|
||||
raise ValidationError(
|
||||
_("Either against_lines or write_off_vals required"))
|
||||
|
||||
self._validate_reconcile(statement_line, against_lines)
|
||||
|
||||
bank_move = statement_line.move_id
|
||||
liquidity_lines, suspense_lines, other_lines = (
|
||||
statement_line._seek_for_lines())
|
||||
|
||||
# The bank move must stay balanced after we rewrite line_ids.
|
||||
# Liquidity sums to +bank_amount (or -bank_amount for outbound), so
|
||||
# the new counterparts must sum to the inverse. We allocate the
|
||||
# available bank amount across against_lines, clamped to each
|
||||
# invoice's residual; any leftover goes to the write-off line (or
|
||||
# raises if no write-off was requested).
|
||||
liq_balance = sum(liquidity_lines.mapped('balance'))
|
||||
# Available counterpart balance (positive magnitude) = abs(liq_balance)
|
||||
remaining = abs(liq_balance)
|
||||
# Counterparts mirror liquidity: opposite sign of liq_balance.
|
||||
cp_sign = -1 if liq_balance >= 0 else 1
|
||||
|
||||
new_counterpart_vals = []
|
||||
for inv_line in against_lines:
|
||||
inv_residual = inv_line.amount_residual
|
||||
# Clamp so we never write more than the invoice residual nor more
|
||||
# than what the bank line can pay.
|
||||
allocate = min(remaining, abs(inv_residual))
|
||||
new_counterpart_vals.append(self._build_counterpart_vals(
|
||||
statement_line, inv_line,
|
||||
allocated_balance=cp_sign * allocate,
|
||||
))
|
||||
remaining -= allocate
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
write_off_move_id = None
|
||||
if write_off_vals:
|
||||
# Write-off absorbs whatever the against_lines didn't cover.
|
||||
wo_balance = cp_sign * remaining
|
||||
# If user passed an explicit amount and there are no against_lines,
|
||||
# honour the explicit amount (covers the pure write-off case).
|
||||
if (write_off_vals.get('amount') is not None
|
||||
and not against_lines):
|
||||
wo_balance = -write_off_vals['amount']
|
||||
new_counterpart_vals.append(self._build_write_off_vals(
|
||||
statement_line, write_off_vals, balance=wo_balance,
|
||||
))
|
||||
remaining = 0
|
||||
|
||||
# Replace the bank move line_ids: keep liquidity, drop everything
|
||||
# else, append new counterparts.
|
||||
ops = []
|
||||
for line in (suspense_lines | other_lines):
|
||||
ops.append(Command.unlink(line.id))
|
||||
for vals in new_counterpart_vals:
|
||||
ops.append(Command.create(vals))
|
||||
|
||||
editable_move = bank_move.with_context(
|
||||
force_delete=True, skip_readonly_check=True)
|
||||
prior_line_ids = set(bank_move.line_ids.ids)
|
||||
editable_move.write({'line_ids': ops})
|
||||
|
||||
new_lines = bank_move.line_ids.filtered(
|
||||
lambda line: line.id not in prior_line_ids)
|
||||
|
||||
# Reconcile each new counterpart with its matched invoice line.
|
||||
# The first N new lines correspond to the first N against_lines
|
||||
# (where N may be < len(against_lines) if the bank amount ran out).
|
||||
# Any trailing new line is a write-off and has no invoice pair.
|
||||
Partial = self.env['account.partial.reconcile']
|
||||
new_partial_ids = []
|
||||
invoice_counterparts = new_lines[:min(len(new_lines),
|
||||
len(against_lines))]
|
||||
for new_line, inv_line in zip(invoice_counterparts, against_lines):
|
||||
pair = new_line | inv_line
|
||||
existing = set(Partial.search([
|
||||
'|',
|
||||
('debit_move_id', 'in', pair.ids),
|
||||
('credit_move_id', 'in', pair.ids),
|
||||
]).ids)
|
||||
pair.reconcile()
|
||||
added = Partial.search([
|
||||
'|',
|
||||
('debit_move_id', 'in', pair.ids),
|
||||
('credit_move_id', 'in', pair.ids),
|
||||
]).filtered(lambda p: p.id not in existing)
|
||||
new_partial_ids.extend(added.ids)
|
||||
|
||||
self._post_audit(
|
||||
statement_line, new_partial_ids, source='engine.reconcile_one')
|
||||
if against_lines:
|
||||
self._record_precedent(statement_line, against_lines)
|
||||
|
||||
return {
|
||||
'partial_ids': new_partial_ids,
|
||||
'exchange_diff_move_id': None,
|
||||
'write_off_move_id': write_off_move_id,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def reconcile_batch(self, statement_lines, *, strategy='auto'):
|
||||
"""Bulk-reconcile a recordset using the chosen strategy.
|
||||
|
||||
Returns: ``{'reconciled_count': int, 'skipped': int,
|
||||
'errors': [...]}``
|
||||
"""
|
||||
reconciled = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
for line in statement_lines:
|
||||
if line.is_reconciled:
|
||||
skipped += 1
|
||||
continue
|
||||
# Per-line savepoint so a single DB-level failure (e.g. a
|
||||
# check-constraint violation on one bad line) doesn't poison
|
||||
# the whole batch's transaction.
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
candidates = self._fetch_candidates(line)
|
||||
picked = self._apply_strategy(
|
||||
line, candidates, strategy)
|
||||
if picked:
|
||||
self.reconcile_one(line, against_lines=picked)
|
||||
reconciled += 1
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({'line_id': line.id, 'error': str(e)})
|
||||
_logger.warning(
|
||||
"reconcile_batch failed for line %s: %s", line.id, e)
|
||||
return {
|
||||
'reconciled_count': reconciled,
|
||||
'skipped': skipped,
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def suggest_matches(self, statement_lines, *, limit_per_line=3):
|
||||
"""Compute and persist AI suggestions per line.
|
||||
|
||||
Returns: dict mapping ``line_id`` -> list of suggestion dicts.
|
||||
"""
|
||||
out = {}
|
||||
Suggestion = self.env['fusion.reconcile.suggestion']
|
||||
for line in statement_lines:
|
||||
candidates_records = self._fetch_candidates(line)
|
||||
if not candidates_records:
|
||||
continue
|
||||
candidates_dataclasses = self._records_to_candidates(
|
||||
line, candidates_records)
|
||||
scored = score_candidates(
|
||||
self.env,
|
||||
statement_line=line,
|
||||
candidates=candidates_dataclasses,
|
||||
k=limit_per_line,
|
||||
use_ai=True,
|
||||
)
|
||||
|
||||
Suggestion.search([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
]).write({'state': 'superseded'})
|
||||
|
||||
line_suggestions = []
|
||||
for rank, s in enumerate(scored, start=1):
|
||||
sug = Suggestion.create({
|
||||
'company_id': line.company_id.id,
|
||||
'statement_line_id': line.id,
|
||||
'proposed_move_line_ids': [(6, 0, [s.candidate_id])],
|
||||
'confidence': s.confidence,
|
||||
'rank': rank,
|
||||
'reasoning': s.reasoning,
|
||||
'score_amount_match': s.score_amount_match,
|
||||
'score_partner_pattern': s.score_partner_pattern,
|
||||
'score_precedent_similarity': s.score_precedent_similarity,
|
||||
'score_ai_rerank': s.score_ai_rerank,
|
||||
'generated_by': 'on_demand',
|
||||
'state': 'pending',
|
||||
})
|
||||
line_suggestions.append({
|
||||
'id': sug.id,
|
||||
'rank': rank,
|
||||
'confidence': s.confidence,
|
||||
'reasoning': s.reasoning,
|
||||
'candidate_id': s.candidate_id,
|
||||
})
|
||||
out[line.id] = line_suggestions
|
||||
return out
|
||||
|
||||
@api.model
|
||||
def accept_suggestion(self, suggestion):
|
||||
"""User clicked Accept on a suggestion -> reconcile via its proposal.
|
||||
|
||||
Returns: same shape as ``reconcile_one``.
|
||||
"""
|
||||
if isinstance(suggestion, int):
|
||||
suggestion = self.env['fusion.reconcile.suggestion'].browse(
|
||||
suggestion)
|
||||
suggestion.ensure_one()
|
||||
line = suggestion.statement_line_id
|
||||
against = suggestion.proposed_move_line_ids
|
||||
result = self.reconcile_one(line, against_lines=against)
|
||||
suggestion.write({
|
||||
'state': 'accepted',
|
||||
'accepted_at': fields.Datetime.now(),
|
||||
'accepted_by': self.env.uid,
|
||||
})
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def write_off(self, statement_line, *, account, amount, label, tax_id=None):
|
||||
"""Create a write-off move + reconcile the bank line against it.
|
||||
|
||||
Returns: same shape as ``reconcile_one``.
|
||||
"""
|
||||
write_off_vals = {
|
||||
'account_id': account.id if hasattr(account, 'id') else account,
|
||||
'amount': amount,
|
||||
'tax_id': (tax_id.id if (tax_id and hasattr(tax_id, 'id'))
|
||||
else tax_id),
|
||||
'label': label,
|
||||
}
|
||||
return self.reconcile_one(
|
||||
statement_line, against_lines=None, write_off_vals=write_off_vals)
|
||||
|
||||
@api.model
|
||||
def unreconcile(self, partial_reconciles):
|
||||
"""Reverse a reconciliation. Handles full vs. partial chains.
|
||||
|
||||
Because ``reconcile_one`` rewrites the bank move's suspense line into
|
||||
one or more counterpart lines, simply deleting the
|
||||
``account.partial.reconcile`` rows is not enough — the bank move
|
||||
would still look reconciled (no suspense line, no residual). We
|
||||
delegate to V19's standard ``account.bank.statement.line.
|
||||
action_undo_reconciliation`` for any affected bank line, which
|
||||
clears the partials AND restores the original suspense state.
|
||||
|
||||
Returns: ``{'unreconciled_line_ids': [...]}``
|
||||
"""
|
||||
partial_reconciles = partial_reconciles.exists()
|
||||
if not partial_reconciles:
|
||||
return {'unreconciled_line_ids': []}
|
||||
all_lines = (
|
||||
partial_reconciles.mapped('debit_move_id')
|
||||
| partial_reconciles.mapped('credit_move_id')
|
||||
)
|
||||
line_ids = all_lines.ids
|
||||
# Find any bank statement lines whose move owns one of these journal
|
||||
# items; route them through the standard undo flow which both
|
||||
# deletes the partials and restores the suspense line.
|
||||
affected_bank_lines = self.env['account.bank.statement.line'].search([
|
||||
('move_id', 'in', all_lines.mapped('move_id').ids),
|
||||
])
|
||||
if affected_bank_lines:
|
||||
affected_bank_lines.action_undo_reconciliation()
|
||||
# Anything still hanging around (rare — non-bank-line reconciles)
|
||||
# gets a direct unlink as a fallback.
|
||||
remaining = partial_reconciles.exists()
|
||||
if remaining:
|
||||
remaining.unlink()
|
||||
return {'unreconciled_line_ids': line_ids}
|
||||
|
||||
# ============================================================
|
||||
# PRIVATE HELPERS
|
||||
# ============================================================
|
||||
|
||||
def _validate_reconcile(self, statement_line, against_lines):
|
||||
"""Phase 2: structural + safety checks."""
|
||||
if not statement_line.exists():
|
||||
raise ValidationError(_("Statement line does not exist"))
|
||||
company = statement_line.company_id
|
||||
line_date = statement_line.date
|
||||
lock_date = company.fiscalyear_lock_date
|
||||
if lock_date and line_date and line_date <= lock_date:
|
||||
raise ValidationError(_(
|
||||
"Cannot reconcile: line date %(line)s is on or before fiscal "
|
||||
"year lock date %(lock)s",
|
||||
line=line_date,
|
||||
lock=lock_date,
|
||||
))
|
||||
|
||||
def _build_counterpart_vals(self, statement_line, inv_line, *,
|
||||
allocated_balance):
|
||||
"""Build the vals for one counterpart line that mirrors an invoice
|
||||
line on the bank move.
|
||||
|
||||
``allocated_balance`` is the signed company-currency balance to write
|
||||
on the counterpart. It is clamped (by the caller) so that the bank
|
||||
move stays balanced and no invoice gets over-paid. We scale
|
||||
``amount_currency`` proportionally for multi-currency lines.
|
||||
"""
|
||||
inv_residual = inv_line.amount_residual
|
||||
if inv_residual:
|
||||
scale = abs(allocated_balance) / abs(inv_residual)
|
||||
else:
|
||||
scale = 1.0
|
||||
amount_currency = -inv_line.amount_residual_currency * scale
|
||||
return {
|
||||
'name': inv_line.name or statement_line.payment_ref or '',
|
||||
'account_id': inv_line.account_id.id,
|
||||
'partner_id': (inv_line.partner_id.id
|
||||
if inv_line.partner_id else False),
|
||||
'currency_id': inv_line.currency_id.id,
|
||||
'amount_currency': amount_currency,
|
||||
'balance': allocated_balance,
|
||||
}
|
||||
|
||||
def _build_write_off_vals(self, statement_line, write_off_vals, *,
|
||||
balance):
|
||||
"""Build the vals for a write-off counterpart line on the bank move.
|
||||
|
||||
``balance`` is the signed company-currency balance the write-off
|
||||
line must carry to keep the bank move balanced.
|
||||
"""
|
||||
vals = {
|
||||
'name': write_off_vals.get('label') or _('Write-off'),
|
||||
'account_id': write_off_vals['account_id'],
|
||||
'partner_id': (statement_line.partner_id.id
|
||||
if statement_line.partner_id else False),
|
||||
'balance': balance,
|
||||
}
|
||||
if write_off_vals.get('tax_id'):
|
||||
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]
|
||||
return vals
|
||||
|
||||
def _fetch_candidates(self, statement_line):
|
||||
"""SQL pre-filter: open journal items matching partner + reconcilable
|
||||
account."""
|
||||
domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('account_id.reconcile', '=', True),
|
||||
('reconciled', '=', False),
|
||||
('display_type', 'not in', ('line_section', 'line_note')),
|
||||
]
|
||||
if statement_line.partner_id:
|
||||
domain.append(('partner_id', '=', statement_line.partner_id.id))
|
||||
return self.env['account.move.line'].search(domain, limit=200)
|
||||
|
||||
def _records_to_candidates(self, statement_line, records):
|
||||
"""Convert ``account.move.line`` recordset to ``Candidate`` dataclasses."""
|
||||
today = fields.Date.today()
|
||||
result = []
|
||||
for c in records:
|
||||
ref_date = c.date_maturity or c.date or today
|
||||
age_days = (today - ref_date).days
|
||||
result.append(Candidate(
|
||||
id=c.id,
|
||||
amount=abs(c.amount_residual) or abs(c.balance),
|
||||
partner_id=c.partner_id.id if c.partner_id else 0,
|
||||
age_days=age_days,
|
||||
))
|
||||
return result
|
||||
|
||||
def _apply_strategy(self, line, candidate_records, strategy):
|
||||
"""Apply the named strategy. Returns matching ``account.move.line``
|
||||
recordset, or empty recordset if nothing matched."""
|
||||
AML = self.env['account.move.line']
|
||||
if not candidate_records:
|
||||
return AML
|
||||
candidate_dcs = self._records_to_candidates(line, candidate_records)
|
||||
bank_amount = abs(line.amount)
|
||||
if strategy == 'auto':
|
||||
for strat_class in (AmountExactStrategy,
|
||||
MultiInvoiceStrategy,
|
||||
FIFOStrategy):
|
||||
result = strat_class().match(
|
||||
bank_amount=bank_amount, candidates=candidate_dcs)
|
||||
if result.picked_ids:
|
||||
return AML.browse(result.picked_ids)
|
||||
return AML
|
||||
|
||||
def _post_audit(self, statement_line, partial_ids, source):
|
||||
"""Append an audit log to the bank-line move's chatter."""
|
||||
if not statement_line.move_id:
|
||||
return
|
||||
try:
|
||||
statement_line.move_id.message_post(
|
||||
body=_(
|
||||
"Reconciled via %(source)s; %(count)d partial(s) created: "
|
||||
"%(ids)s",
|
||||
source=source,
|
||||
count=len(partial_ids),
|
||||
ids=partial_ids,
|
||||
),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.debug(
|
||||
"Audit log skipped for line %s: %s", statement_line.id, e)
|
||||
|
||||
def _record_precedent(self, statement_line, against_lines):
|
||||
"""Append a precedent for future pattern learning. Best-effort."""
|
||||
if not against_lines:
|
||||
return
|
||||
try:
|
||||
self.env['fusion.reconcile.precedent'].sudo().create({
|
||||
'company_id': statement_line.company_id.id,
|
||||
'partner_id': (statement_line.partner_id.id
|
||||
if statement_line.partner_id else False),
|
||||
'amount': abs(statement_line.amount),
|
||||
'currency_id': statement_line.currency_id.id,
|
||||
'date': statement_line.date,
|
||||
'memo_tokens': ','.join(
|
||||
tokenize_memo(statement_line.payment_ref)),
|
||||
'journal_id': statement_line.journal_id.id,
|
||||
'matched_move_line_count': len(against_lines),
|
||||
'matched_account_ids': ','.join(
|
||||
str(i) for i in against_lines.mapped('account_id').ids),
|
||||
'reconciler_user_id': self.env.uid,
|
||||
'reconciled_at': fields.Datetime.now(),
|
||||
'source': 'manual',
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning(
|
||||
"Failed to record precedent for line %s: %s",
|
||||
statement_line.id, e)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Per-partner bank reconciliation pattern aggregate.
|
||||
|
||||
One row per (company_id, partner_id). Continuously summarises HOW this
|
||||
partner gets reconciled. Recomputed nightly via cron from the precedent
|
||||
table. Used as a feature input to confidence_scoring.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionReconcilePattern(models.Model):
|
||||
_name = "fusion.reconcile.pattern"
|
||||
_description = "Per-partner bank reconciliation pattern aggregate"
|
||||
_rec_name = "partner_id"
|
||||
|
||||
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||
default=lambda self: self.env.company)
|
||||
partner_id = fields.Many2one('res.partner', required=True, index=True)
|
||||
|
||||
# Volume + cadence
|
||||
reconcile_count = fields.Integer(default=0,
|
||||
help="Total past reconciles for this partner")
|
||||
typical_amount_range = fields.Char(
|
||||
help="e.g. '$1,200 – $2,400 (median $1,847.50)'")
|
||||
typical_cadence_days = fields.Float(
|
||||
help="Mean inter-reconcile days")
|
||||
typical_day_of_month = fields.Char(
|
||||
help="e.g. '1st, 15th'")
|
||||
|
||||
# Matching strategy used historically
|
||||
pref_strategy = fields.Selection([
|
||||
('exact_amount', 'Exact-amount-first'),
|
||||
('fifo', 'FIFO oldest-due-first'),
|
||||
('multi_invoice', 'Multi-invoice consolidation'),
|
||||
('cherry_pick', 'Cherry-pick specific invoices'),
|
||||
])
|
||||
pref_account_id = fields.Many2one('account.account',
|
||||
help="Most-used target account")
|
||||
|
||||
# Memo signature
|
||||
common_memo_tokens = fields.Char(
|
||||
help="Comma-separated tokens that appear in ≥30% of past reconciles")
|
||||
|
||||
# Tax + write-off habits
|
||||
common_writeoff_account_id = fields.Many2one('account.account')
|
||||
common_writeoff_tax_id = fields.Many2one('account.tax')
|
||||
typical_writeoff_amount = fields.Float(
|
||||
help="e.g. 0.05 for rounding diffs")
|
||||
|
||||
last_refreshed_at = fields.Datetime()
|
||||
|
||||
_uniq_company_partner = models.Constraint(
|
||||
'unique(company_id, partner_id)',
|
||||
'One pattern row per (company, partner) — already exists.',
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Per-historical-decision reconciliation memory.
|
||||
|
||||
One row per past reconciliation. Holds the full feature vector + outcome,
|
||||
used by precedent_lookup for K-nearest-neighbour search when scoring a
|
||||
new bank line.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionReconcilePrecedent(models.Model):
|
||||
_name = "fusion.reconcile.precedent"
|
||||
_description = "Historical bank reconciliation decision (memory)"
|
||||
_order = "reconciled_at desc, id desc"
|
||||
|
||||
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||
default=lambda self: self.env.company)
|
||||
partner_id = fields.Many2one('res.partner', index=True)
|
||||
|
||||
# Bank line features (the "input")
|
||||
amount = fields.Monetary(currency_field='currency_id')
|
||||
currency_id = fields.Many2one('res.currency')
|
||||
date = fields.Date()
|
||||
memo_tokens = fields.Char(
|
||||
help="Comma-separated normalized memo tokens (output of memo_tokenizer)")
|
||||
journal_id = fields.Many2one('account.journal')
|
||||
|
||||
# Outcome (the "decision made")
|
||||
matched_move_line_count = fields.Integer(
|
||||
help="1 = exact, 2-3 = consolidation, etc.")
|
||||
matched_account_ids = fields.Char(
|
||||
help="Comma-separated account.account IDs that were matched against")
|
||||
matched_invoice_ages_days = fields.Char(
|
||||
help="Comma-separated days-old at reconcile time, e.g. '12, 45, 78'")
|
||||
write_off_amount = fields.Float()
|
||||
write_off_account_id = fields.Many2one('account.account')
|
||||
exchange_diff = fields.Boolean()
|
||||
|
||||
# Provenance
|
||||
reconciler_user_id = fields.Many2one('res.users')
|
||||
reconciled_at = fields.Datetime()
|
||||
source = fields.Selection([
|
||||
('historical_bootstrap', 'Imported from history'),
|
||||
('manual', 'Manual reconcile via fusion'),
|
||||
('ai_accepted', 'AI suggestion accepted'),
|
||||
('auto_rule', 'account.reconcile.model auto-fired'),
|
||||
], required=True)
|
||||
|
||||
# No uniqueness constraint — multiple reconciles can share features
|
||||
137
fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py
Normal file
137
fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Persisted AI suggestions for bank line reconciliations.
|
||||
|
||||
One row per (statement_line, candidate_match). The OWL widget reads these
|
||||
to render confidence badges; users accept/reject which feeds back into
|
||||
the pattern learning system.
|
||||
|
||||
The AI never writes account.partial.reconcile directly — it writes
|
||||
suggestions here, and the user (or batch-accept action) approves them
|
||||
through the engine's accept_suggestion() method.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionReconcileSuggestion(models.Model):
|
||||
_name = "fusion.reconcile.suggestion"
|
||||
_description = "AI-generated bank reconciliation suggestion"
|
||||
_order = "statement_line_id, confidence desc"
|
||||
|
||||
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||
default=lambda self: self.env.company)
|
||||
statement_line_id = fields.Many2one('account.bank.statement.line',
|
||||
required=True, index=True, ondelete='cascade')
|
||||
|
||||
# The proposal
|
||||
proposed_move_line_ids = fields.Many2many('account.move.line',
|
||||
string="Proposed matches")
|
||||
proposed_write_off_amount = fields.Monetary(currency_field='currency_id')
|
||||
proposed_write_off_account_id = fields.Many2one('account.account')
|
||||
currency_id = fields.Many2one('res.currency',
|
||||
related='statement_line_id.currency_id',
|
||||
store=True)
|
||||
|
||||
# Scoring
|
||||
confidence = fields.Float(required=True)
|
||||
confidence_band = fields.Selection([
|
||||
('high', 'High (>=95%)'),
|
||||
('medium', 'Medium (70-94%)'),
|
||||
('low', 'Low (50-69%)'),
|
||||
('none', 'No confidence (<50%)'),
|
||||
], compute='_compute_band', store=True)
|
||||
rank = fields.Integer(help="1 = top suggestion, 2-N = alternatives")
|
||||
reasoning = fields.Text(help="Human-readable explanation")
|
||||
|
||||
# Feature breakdown (for transparency + future learning)
|
||||
score_amount_match = fields.Float()
|
||||
score_partner_pattern = fields.Float()
|
||||
score_precedent_similarity = fields.Float()
|
||||
score_ai_rerank = fields.Float()
|
||||
|
||||
# Provenance
|
||||
generated_at = fields.Datetime(default=fields.Datetime.now)
|
||||
generated_by = fields.Selection([
|
||||
('cron_batch', 'Batch cron'),
|
||||
('on_demand', 'User refreshed alternatives'),
|
||||
('on_open', 'Widget opened (lazy)'),
|
||||
])
|
||||
provider_used = fields.Char(
|
||||
help="e.g. 'claude_sonnet_4_5', 'lmstudio_qwen_7b', 'statistical_only'")
|
||||
tokens_used = fields.Integer(help="if AI re-rank invoked")
|
||||
generation_ms = fields.Integer(help="latency for monitoring")
|
||||
|
||||
# Lifecycle
|
||||
state = fields.Selection([
|
||||
('pending', 'Pending review'),
|
||||
('accepted', 'Accepted'),
|
||||
('rejected', 'Rejected'),
|
||||
('superseded', 'Superseded by newer suggestion'),
|
||||
('stale', 'Stale (line changed since)'),
|
||||
], default='pending', required=True, index=True)
|
||||
accepted_at = fields.Datetime()
|
||||
accepted_by = fields.Many2one('res.users')
|
||||
rejected_at = fields.Datetime()
|
||||
rejected_reason = fields.Selection([
|
||||
('wrong_invoice', 'Wrong invoice'),
|
||||
('wrong_partner', 'Wrong partner'),
|
||||
('wrong_amount', 'Amount off'),
|
||||
('not_a_match', 'No good match exists'),
|
||||
('other', 'Other'),
|
||||
])
|
||||
|
||||
_confidence_in_range = models.Constraint(
|
||||
'CHECK (confidence >= 0.0 AND confidence <= 1.0)',
|
||||
'Confidence must be between 0.0 and 1.0',
|
||||
)
|
||||
|
||||
@api.depends('confidence')
|
||||
def _compute_band(self):
|
||||
for sug in self:
|
||||
c = sug.confidence
|
||||
if c >= 0.95:
|
||||
sug.confidence_band = 'high'
|
||||
elif c >= 0.70:
|
||||
sug.confidence_band = 'medium'
|
||||
elif c >= 0.50:
|
||||
sug.confidence_band = 'low'
|
||||
else:
|
||||
sug.confidence_band = 'none'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD overrides — trigger MV refresh so the OWL widget sees fresh
|
||||
# confidence bands / top suggestion ids without waiting for cron.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
self._trigger_mv_refresh()
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
# Only refresh on changes that affect the MV's projected columns.
|
||||
if 'state' in vals or 'confidence' in vals or 'rank' in vals:
|
||||
self._trigger_mv_refresh()
|
||||
return res
|
||||
|
||||
def _trigger_mv_refresh(self):
|
||||
"""Best-effort MV refresh; never poison the originating transaction.
|
||||
|
||||
Uses concurrently=False because Postgres forbids
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY inside a transaction block,
|
||||
and Odoo's per-request cursor is always in a transaction. The cron
|
||||
job (Task 25) opens a dedicated autocommit cursor for CONCURRENTLY
|
||||
refreshes when the MV grows large enough that a brief blocking
|
||||
refresh becomes objectionable.
|
||||
"""
|
||||
try:
|
||||
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||
concurrently=False)
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning(
|
||||
"MV refresh after suggestion write failed: %s", e)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Materialized view exposing pre-aggregated unreconciled-bank-line data.
|
||||
|
||||
The MV is created in the model's init() (called by Odoo on install/upgrade).
|
||||
Refresh strategy:
|
||||
- Cron (every 5 min) — see fusion_accounting_bank_rec/data/cron.xml (Task 25)
|
||||
- Triggered refresh after suggestion writes (handled in fusion_reconcile_suggestion.py)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionUnreconciledBankLineMV(models.Model):
|
||||
_name = "fusion.unreconciled.bank.line.mv"
|
||||
_description = "Materialized view of unreconciled bank lines for OWL widget"
|
||||
_auto = False # we manage the table ourselves
|
||||
_table = "fusion_unreconciled_bank_line_mv"
|
||||
_order = "date desc, id desc"
|
||||
|
||||
# Fields mirror the columns in the SQL view; required so Odoo can read them.
|
||||
company_id = fields.Many2one('res.company', readonly=True)
|
||||
journal_id = fields.Many2one('account.journal', readonly=True)
|
||||
date = fields.Date(readonly=True)
|
||||
amount = fields.Float(readonly=True)
|
||||
payment_ref = fields.Char(readonly=True)
|
||||
currency_id = fields.Many2one('res.currency', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', readonly=True)
|
||||
create_date = fields.Datetime(readonly=True)
|
||||
top_suggestion_id = fields.Many2one('fusion.reconcile.suggestion', readonly=True)
|
||||
top_confidence = fields.Float(readonly=True)
|
||||
confidence_band = fields.Selection([
|
||||
('high', 'High'),
|
||||
('medium', 'Medium'),
|
||||
('low', 'Low'),
|
||||
('none', 'None'),
|
||||
], readonly=True)
|
||||
attachment_count = fields.Integer(readonly=True)
|
||||
partner_reconcile_count = fields.Integer(readonly=True)
|
||||
|
||||
def init(self):
|
||||
"""Create the MV if missing.
|
||||
|
||||
Reads create_mv_unreconciled_bank_line.sql and executes it. Idempotent
|
||||
because the SQL uses CREATE MATERIALIZED VIEW IF NOT EXISTS."""
|
||||
sql_path = os.path.join(
|
||||
os.path.dirname(__file__), '..', 'data', 'sql',
|
||||
'create_mv_unreconciled_bank_line.sql')
|
||||
with open(sql_path, 'r') as f:
|
||||
sql = f.read()
|
||||
self.env.cr.execute(sql)
|
||||
_logger.info(
|
||||
"fusion_unreconciled_bank_line_mv: created/verified MV + indexes")
|
||||
|
||||
@api.model
|
||||
def _refresh(self, *, concurrently=True):
|
||||
"""Refresh the MV.
|
||||
|
||||
If ``concurrently=True`` (default), uses
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY (requires the unique index).
|
||||
Falls back to a blocking refresh on the first refresh after creation
|
||||
(when CONCURRENTLY is not yet allowed because the MV has never been
|
||||
populated).
|
||||
|
||||
Flushes the ORM cache first so the materialization sees the latest
|
||||
committed-to-DB values for fields like ``is_reconciled`` (computed,
|
||||
stored — sometimes still buffered in the cache mid-request)."""
|
||||
self.env.flush_all()
|
||||
keyword = "CONCURRENTLY" if concurrently else ""
|
||||
try:
|
||||
self.env.cr.execute(
|
||||
f"REFRESH MATERIALIZED VIEW {keyword} fusion_unreconciled_bank_line_mv"
|
||||
)
|
||||
_logger.debug(
|
||||
"fusion_unreconciled_bank_line_mv refreshed (%s)",
|
||||
'concurrent' if concurrently else 'blocking')
|
||||
except Exception as e: # noqa: BLE001
|
||||
# CONCURRENTLY fails on first refresh after creation if the MV is
|
||||
# empty / has never been populated; fall back to non-concurrent.
|
||||
if concurrently:
|
||||
_logger.warning(
|
||||
"Concurrent MV refresh failed (%s); falling back to "
|
||||
"blocking refresh", e)
|
||||
self.env.cr.execute(
|
||||
"REFRESH MATERIALIZED VIEW fusion_unreconciled_bank_line_mv"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
10
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
10
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
@@ -0,0 +1,10 @@
|
||||
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
|
||||
|
6
fusion_accounting_bank_rec/services/__init__.py
Normal file
6
fusion_accounting_bank_rec/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from . import memo_tokenizer
|
||||
from . import exchange_diff
|
||||
from . import matching_strategies
|
||||
from . import precedent_lookup
|
||||
from . import pattern_extractor
|
||||
from . import confidence_scoring
|
||||
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal file
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""4-pass confidence scoring pipeline.
|
||||
|
||||
Pass 1: SQL filter — partner match + reconcilable account (done by caller — engine._fetch_candidates)
|
||||
Pass 2: Statistical scoring — amount delta + pattern match + precedent similarity
|
||||
Pass 3: AI re-rank (if provider configured) — feed top 5 to LLM, parse JSON ranking
|
||||
Pass 4: Persist as fusion.reconcile.suggestion rows (done by caller — engine.suggest_matches)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .matching_strategies import Candidate
|
||||
from .precedent_lookup import find_nearest_precedents
|
||||
from .memo_tokenizer import tokenize_memo
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoredCandidate:
|
||||
candidate_id: int
|
||||
confidence: float
|
||||
reasoning: str
|
||||
score_amount_match: float
|
||||
score_partner_pattern: float
|
||||
score_precedent_similarity: float
|
||||
score_ai_rerank: float = 0.0
|
||||
|
||||
|
||||
def score_candidates(env, *, statement_line, candidates, k=5, use_ai=True):
|
||||
"""Score and rank candidate matches for a statement line.
|
||||
|
||||
Args:
|
||||
env: Odoo env
|
||||
statement_line: account.bank.statement.line recordset (singleton)
|
||||
candidates: list of Candidate dataclasses (from matching_strategies)
|
||||
k: max number of scored candidates to return
|
||||
use_ai: if True AND a provider is configured, invoke AI re-rank
|
||||
|
||||
Returns:
|
||||
list of ScoredCandidate sorted by confidence desc, max length k.
|
||||
"""
|
||||
if not candidates or not statement_line:
|
||||
return []
|
||||
|
||||
partner_id = statement_line.partner_id.id if statement_line.partner_id else None
|
||||
bank_amount = abs(statement_line.amount)
|
||||
memo_tokens = tokenize_memo(statement_line.payment_ref)
|
||||
|
||||
pattern = None
|
||||
if partner_id:
|
||||
pattern = env['fusion.reconcile.pattern'].sudo().search(
|
||||
[('partner_id', '=', partner_id)], limit=1)
|
||||
if not pattern:
|
||||
pattern = None
|
||||
|
||||
precedents = []
|
||||
if partner_id:
|
||||
precedents = find_nearest_precedents(
|
||||
env, partner_id=partner_id, amount=bank_amount, k=5, memo_tokens=memo_tokens)
|
||||
|
||||
scored = []
|
||||
for cand in candidates:
|
||||
amount_score = 1.0 - min(abs(cand.amount - bank_amount) / max(bank_amount, 1), 1.0)
|
||||
pattern_score = _pattern_score(cand, pattern, bank_amount)
|
||||
precedent_score = _precedent_score(cand, precedents)
|
||||
confidence = (amount_score * 0.5) + (pattern_score * 0.25) + (precedent_score * 0.25)
|
||||
|
||||
reasoning = _build_reasoning(amount_score, pattern_score, precedent_score, pattern)
|
||||
scored.append(ScoredCandidate(
|
||||
candidate_id=cand.id,
|
||||
confidence=round(confidence, 3),
|
||||
reasoning=reasoning,
|
||||
score_amount_match=round(amount_score, 3),
|
||||
score_partner_pattern=round(pattern_score, 3),
|
||||
score_precedent_similarity=round(precedent_score, 3),
|
||||
))
|
||||
|
||||
scored.sort(key=lambda s: -s.confidence)
|
||||
top_k = scored[:k]
|
||||
|
||||
if use_ai:
|
||||
provider = _get_provider(env, 'bank_rec_suggest')
|
||||
if provider is not None:
|
||||
try:
|
||||
top_k = _ai_rerank(env, provider, statement_line, top_k, pattern, precedents)
|
||||
except Exception as e:
|
||||
_logger.warning("AI re-rank failed, using statistical scoring: %s", e)
|
||||
|
||||
return top_k
|
||||
|
||||
|
||||
def _pattern_score(cand, pattern, bank_amount) -> float:
|
||||
"""How well does this candidate fit the partner's typical pattern?"""
|
||||
if not pattern:
|
||||
return 0.5
|
||||
score = 0.5
|
||||
if pattern.pref_strategy == 'exact_amount' and abs(cand.amount - bank_amount) < 0.005:
|
||||
score = 1.0
|
||||
return score
|
||||
|
||||
|
||||
def _precedent_score(cand, precedents) -> float:
|
||||
"""How similar is this candidate to past precedents?"""
|
||||
if not precedents:
|
||||
return 0.5
|
||||
best = max((p.similarity_score for p in precedents), default=0.5)
|
||||
return best
|
||||
|
||||
|
||||
def _build_reasoning(amount_score, pattern_score, precedent_score, pattern) -> str:
|
||||
parts = []
|
||||
if amount_score >= 0.99:
|
||||
parts.append("Exact amount match")
|
||||
elif amount_score >= 0.95:
|
||||
parts.append("Amount close")
|
||||
if pattern and pattern.reconcile_count > 5:
|
||||
parts.append(f"Matches partner's {pattern.reconcile_count}-reconcile pattern")
|
||||
if precedent_score >= 0.8:
|
||||
parts.append("Strong precedent match")
|
||||
return " · ".join(parts) if parts else "Weak signal"
|
||||
|
||||
|
||||
def _get_provider(env, feature_name):
|
||||
"""Look up provider name from per-feature config; instantiate adapter.
|
||||
|
||||
Returns None if no provider configured (statistical-only mode)."""
|
||||
param = env['ir.config_parameter'].sudo()
|
||||
provider_name = param.get_param(f'fusion_accounting.provider.{feature_name}')
|
||||
if not provider_name:
|
||||
provider_name = param.get_param('fusion_accounting.provider.default')
|
||||
if not provider_name:
|
||||
return None
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||
except ImportError:
|
||||
_logger.warning("fusion_accounting_ai adapters not importable")
|
||||
return None
|
||||
if provider_name.startswith('openai'):
|
||||
return OpenAIAdapter(env)
|
||||
elif provider_name.startswith('claude'):
|
||||
return ClaudeAdapter(env)
|
||||
return None
|
||||
|
||||
|
||||
def _ai_rerank(env, provider, statement_line, scored, pattern, precedents):
|
||||
"""Send top-K candidates + features to LLM for re-rank. Parse JSON response.
|
||||
|
||||
On any failure (network, JSON parse, missing key), return scored unchanged."""
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import build_prompt
|
||||
except ImportError:
|
||||
_logger.debug("bank_rec_prompt not yet available; skipping AI re-rank")
|
||||
return scored
|
||||
|
||||
system, user = build_prompt(statement_line, scored, pattern, precedents)
|
||||
response = provider.complete(
|
||||
system=system,
|
||||
messages=[{'role': 'user', 'content': user}],
|
||||
max_tokens=800,
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = json.loads(response['content'])
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
return scored
|
||||
|
||||
ai_order = {item['candidate_id']: item for item in parsed.get('ranked', [])}
|
||||
for s in scored:
|
||||
if s.candidate_id in ai_order:
|
||||
s.score_ai_rerank = ai_order[s.candidate_id].get('confidence', s.confidence)
|
||||
s.reasoning = ai_order[s.candidate_id].get('reason', s.reasoning)
|
||||
s.confidence = round((s.confidence * 0.4) + (s.score_ai_rerank * 0.6), 3)
|
||||
scored.sort(key=lambda x: -x.confidence)
|
||||
return scored
|
||||
46
fusion_accounting_bank_rec/services/exchange_diff.py
Normal file
46
fusion_accounting_bank_rec/services/exchange_diff.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Exchange-difference calculation helper.
|
||||
|
||||
Pure-Python FX gain/loss computation. The engine uses this for rapid
|
||||
pre-checks; Odoo's account.move._create_exchange_difference_move() is
|
||||
invoked separately for the actual GL posting.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExchangeDiffResult:
|
||||
needs_diff_move: bool
|
||||
diff_amount: float # in company currency; positive = gain, negative = loss
|
||||
line_company_amount: float
|
||||
against_company_amount: float
|
||||
|
||||
|
||||
def compute_exchange_diff(*, line_amount, line_currency_code, against_amount,
|
||||
against_currency_code, line_rate, against_rate) -> ExchangeDiffResult:
|
||||
"""Compute whether an exchange-diff move is needed and its magnitude.
|
||||
|
||||
Args:
|
||||
line_amount: Bank line amount in its currency
|
||||
line_currency_code: e.g. 'USD'
|
||||
against_amount: Matched journal item amount in its currency
|
||||
against_currency_code: e.g. 'USD' (or different)
|
||||
line_rate: FX rate (foreign per company currency) at line date
|
||||
against_rate: FX rate at journal item posting date
|
||||
|
||||
Returns:
|
||||
ExchangeDiffResult with needs_diff_move flag and computed diff
|
||||
in company currency (positive = gain, negative = loss).
|
||||
"""
|
||||
line_company = line_amount * line_rate
|
||||
against_company = against_amount * against_rate
|
||||
|
||||
diff = line_company - against_company
|
||||
needs_diff = abs(diff) > 0.005 # rounding tolerance
|
||||
|
||||
return ExchangeDiffResult(
|
||||
needs_diff_move=needs_diff,
|
||||
diff_amount=round(diff, 2),
|
||||
line_company_amount=round(line_company, 2),
|
||||
against_company_amount=round(against_company, 2),
|
||||
)
|
||||
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Matching strategy classes for the reconcile engine.
|
||||
|
||||
Each strategy takes a bank amount + list of candidate journal items
|
||||
and returns a MatchResult with the picked ids + confidence + residual.
|
||||
Strategies are pure Python; no ORM dependency.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import combinations
|
||||
|
||||
|
||||
@dataclass
|
||||
class Candidate:
|
||||
id: int
|
||||
amount: float
|
||||
partner_id: int
|
||||
age_days: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchResult:
|
||||
picked_ids: list[int] = field(default_factory=list)
|
||||
confidence: float = 0.0
|
||||
residual: float = 0.0 # bank_amount - sum(picked); positive = under-allocated
|
||||
strategy_name: str = ""
|
||||
|
||||
|
||||
AMOUNT_TOLERANCE = 0.005 # currency rounding tolerance
|
||||
|
||||
|
||||
class AmountExactStrategy:
|
||||
"""Pick a single candidate whose amount equals the bank amount exactly.
|
||||
If multiple candidates match exactly, pick the oldest (FIFO tiebreaker)."""
|
||||
|
||||
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||
exact = [c for c in candidates if abs(c.amount - bank_amount) < AMOUNT_TOLERANCE]
|
||||
if not exact:
|
||||
return MatchResult(strategy_name='amount_exact')
|
||||
oldest = max(exact, key=lambda c: c.age_days)
|
||||
return MatchResult(
|
||||
picked_ids=[oldest.id],
|
||||
confidence=1.0,
|
||||
residual=0.0,
|
||||
strategy_name='amount_exact',
|
||||
)
|
||||
|
||||
|
||||
class FIFOStrategy:
|
||||
"""Pick oldest candidates first until the bank amount is exhausted.
|
||||
May produce partial reconcile residual if last candidate doesn't fit exactly."""
|
||||
|
||||
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||
if not candidates:
|
||||
return MatchResult(strategy_name='fifo')
|
||||
oldest_first = sorted(candidates, key=lambda c: -c.age_days)
|
||||
picked = []
|
||||
remaining = bank_amount
|
||||
for c in oldest_first:
|
||||
if remaining <= AMOUNT_TOLERANCE:
|
||||
break
|
||||
picked.append(c.id)
|
||||
remaining -= c.amount
|
||||
|
||||
confidence = 0.7 if remaining < AMOUNT_TOLERANCE else 0.5
|
||||
return MatchResult(
|
||||
picked_ids=picked,
|
||||
confidence=confidence,
|
||||
residual=remaining,
|
||||
strategy_name='fifo',
|
||||
)
|
||||
|
||||
|
||||
class MultiInvoiceStrategy:
|
||||
"""Find the smallest combination of candidates summing to the bank amount.
|
||||
Bounded by max_combinations to keep complexity manageable."""
|
||||
|
||||
def __init__(self, max_combinations=3):
|
||||
self.max_combinations = max_combinations
|
||||
|
||||
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||
for k in range(2, self.max_combinations + 1):
|
||||
for combo in combinations(candidates, k):
|
||||
total = sum(c.amount for c in combo)
|
||||
if abs(total - bank_amount) < AMOUNT_TOLERANCE:
|
||||
return MatchResult(
|
||||
picked_ids=[c.id for c in combo],
|
||||
confidence=0.85,
|
||||
residual=0.0,
|
||||
strategy_name=f'multi_invoice_{k}',
|
||||
)
|
||||
return MatchResult(strategy_name='multi_invoice')
|
||||
44
fusion_accounting_bank_rec/services/memo_tokenizer.py
Normal file
44
fusion_accounting_bank_rec/services/memo_tokenizer.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Extract searchable tokens from Canadian bank statement memos.
|
||||
|
||||
Handles common memo formats from RBC, TD, Scotia, BMO, plus generic
|
||||
cheque-number and reference-number patterns. Output is normalized
|
||||
(uppercase, alphanumeric) for case-insensitive matching.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
REF_PATTERNS = [
|
||||
(re.compile(r'\b(REF|REFERENCE)\s*#?\s*(\d+)\b', re.I), r'REF\2'),
|
||||
(re.compile(r'\b(CHQ|CHEQUE|CHECK)\s*#?\s*(\d+)\b', re.I), r'CHEQUE\2'),
|
||||
(re.compile(r'\b(INV|INVOICE)\s*#?\s*(\d+)\b', re.I), r'INV\2'),
|
||||
]
|
||||
|
||||
MIN_TOKEN_LENGTH = 2
|
||||
|
||||
|
||||
def tokenize_memo(memo: str | None) -> list[str]:
|
||||
"""Return list of normalized tokens from a bank memo.
|
||||
|
||||
Empty/None input returns []. Order preserved (first occurrence wins
|
||||
for de-duplication)."""
|
||||
if not memo:
|
||||
return []
|
||||
|
||||
text = memo.upper()
|
||||
for pattern, replacement in REF_PATTERNS:
|
||||
text = pattern.sub(replacement, text)
|
||||
|
||||
text = re.sub(r'[^A-Z0-9]+', ' ', text)
|
||||
raw_tokens = text.split()
|
||||
|
||||
seen = set()
|
||||
tokens = []
|
||||
for tok in raw_tokens:
|
||||
if len(tok) < MIN_TOKEN_LENGTH:
|
||||
continue
|
||||
if tok in seen:
|
||||
continue
|
||||
seen.add(tok)
|
||||
tokens.append(tok)
|
||||
|
||||
return tokens
|
||||
74
fusion_accounting_bank_rec/services/pattern_extractor.py
Normal file
74
fusion_accounting_bank_rec/services/pattern_extractor.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Aggregate per-partner reconciliation patterns from precedent rows.
|
||||
|
||||
Computes typical amount range, cadence, preferred strategy, common memo
|
||||
tokens. Output is a dict suitable for create/write on fusion.reconcile.pattern.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from statistics import median
|
||||
|
||||
|
||||
def extract_pattern_for_partner(env, *, company_id, partner_id) -> dict:
|
||||
"""Compute the pattern aggregate for one (company, partner) pair.
|
||||
|
||||
Returns vals dict suitable for env['fusion.reconcile.pattern'].create()."""
|
||||
Precedent = env['fusion.reconcile.precedent'].sudo()
|
||||
precedents = Precedent.search([
|
||||
('company_id', '=', company_id),
|
||||
('partner_id', '=', partner_id),
|
||||
], order='reconciled_at desc', limit=200)
|
||||
|
||||
if not precedents:
|
||||
return {
|
||||
'company_id': company_id,
|
||||
'partner_id': partner_id,
|
||||
'reconcile_count': 0,
|
||||
}
|
||||
|
||||
amounts = sorted(precedents.mapped('amount'))
|
||||
counts = precedents.mapped('matched_move_line_count')
|
||||
|
||||
single_count = sum(1 for c in counts if c == 1)
|
||||
multi_count = sum(1 for c in counts if c > 1)
|
||||
if multi_count > single_count:
|
||||
pref_strategy = 'multi_invoice'
|
||||
elif _amounts_concentrated(amounts):
|
||||
pref_strategy = 'exact_amount'
|
||||
else:
|
||||
pref_strategy = 'fifo'
|
||||
|
||||
reconcile_dates = sorted([p.reconciled_at for p in precedents if p.reconciled_at])
|
||||
if len(reconcile_dates) >= 2:
|
||||
deltas = [(reconcile_dates[i+1] - reconcile_dates[i]).days
|
||||
for i in range(len(reconcile_dates) - 1)]
|
||||
cadence = sum(deltas) / len(deltas) if deltas else 0.0
|
||||
else:
|
||||
cadence = 0.0
|
||||
|
||||
token_counter = Counter()
|
||||
for p in precedents:
|
||||
if p.memo_tokens:
|
||||
for tok in p.memo_tokens.split(','):
|
||||
token_counter[tok.strip()] += 1
|
||||
# Keep tokens appearing in >=30% of precedents (min floor of 2 occurrences)
|
||||
threshold = max(2, len(precedents) * 0.3)
|
||||
common_tokens = ','.join(t for t, c in token_counter.most_common() if c >= threshold)
|
||||
|
||||
return {
|
||||
'company_id': company_id,
|
||||
'partner_id': partner_id,
|
||||
'reconcile_count': len(precedents),
|
||||
'typical_amount_range': f"${min(amounts):,.2f} – ${max(amounts):,.2f} (median ${median(amounts):,.2f})",
|
||||
'typical_cadence_days': round(cadence, 1),
|
||||
'pref_strategy': pref_strategy,
|
||||
'common_memo_tokens': common_tokens,
|
||||
}
|
||||
|
||||
|
||||
def _amounts_concentrated(amounts: list[float]) -> bool:
|
||||
"""True if amounts cluster around a few values (suggests exact-amount strategy)."""
|
||||
if len(amounts) < 3:
|
||||
return True
|
||||
med = median(amounts)
|
||||
within_5pct = sum(1 for a in amounts if abs(a - med) / max(med, 1) < 0.05)
|
||||
return within_5pct / len(amounts) >= 0.6
|
||||
62
fusion_accounting_bank_rec/services/precedent_lookup.py
Normal file
62
fusion_accounting_bank_rec/services/precedent_lookup.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""K-nearest precedent search.
|
||||
|
||||
Given a new bank line, find the most similar past reconciliations for
|
||||
ranking + confidence scoring. Distance metric: amount delta (primary),
|
||||
date recency (secondary), memo token overlap (tertiary).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrecedentMatch:
|
||||
precedent_id: int
|
||||
amount: float
|
||||
memo_tokens: str
|
||||
matched_move_line_count: int
|
||||
similarity_score: float
|
||||
|
||||
|
||||
AMOUNT_TOLERANCE_PCT = 0.01 # 1% tolerance for "near" amount
|
||||
|
||||
|
||||
def find_nearest_precedents(env, *, partner_id, amount, k=5, memo_tokens=None):
|
||||
"""Return up to k most-similar precedents for a partner+amount.
|
||||
|
||||
Indexed query: filters by partner first (cheap), then ranks by
|
||||
amount distance + memo overlap. Sub-50ms for typical Westin volume."""
|
||||
Precedent = env['fusion.reconcile.precedent'].sudo()
|
||||
|
||||
tolerance = max(amount * AMOUNT_TOLERANCE_PCT, 1.00)
|
||||
candidates = Precedent.search([
|
||||
('partner_id', '=', partner_id),
|
||||
('amount', '>=', amount - tolerance),
|
||||
('amount', '<=', amount + tolerance),
|
||||
], limit=k * 4, order='reconciled_at desc')
|
||||
|
||||
results = []
|
||||
for p in candidates:
|
||||
amount_score = 1.0 - min(abs(p.amount - amount) / max(amount, 1), 1.0)
|
||||
memo_score = _memo_overlap(p.memo_tokens, memo_tokens) if memo_tokens else 0.5
|
||||
similarity = (amount_score * 0.7) + (memo_score * 0.3)
|
||||
results.append(PrecedentMatch(
|
||||
precedent_id=p.id,
|
||||
amount=p.amount,
|
||||
memo_tokens=p.memo_tokens or '',
|
||||
matched_move_line_count=p.matched_move_line_count,
|
||||
similarity_score=similarity,
|
||||
))
|
||||
|
||||
results.sort(key=lambda r: -r.similarity_score)
|
||||
return results[:k]
|
||||
|
||||
|
||||
def _memo_overlap(precedent_tokens_str, new_tokens) -> float:
|
||||
"""Jaccard similarity between two token sets."""
|
||||
if not precedent_tokens_str or not new_tokens:
|
||||
return 0.0
|
||||
precedent_set = set(precedent_tokens_str.split(','))
|
||||
new_set = set(new_tokens) if not isinstance(new_tokens, set) else new_tokens
|
||||
if not precedent_set and not new_set:
|
||||
return 0.0
|
||||
return len(precedent_set & new_set) / len(precedent_set | new_set)
|
||||
BIN
fusion_accounting_bank_rec/static/description/icon.png
Normal file
BIN
fusion_accounting_bank_rec/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
91
fusion_accounting_bank_rec/static/src/scss/_variables.scss
Normal file
91
fusion_accounting_bank_rec/static/src/scss/_variables.scss
Normal file
@@ -0,0 +1,91 @@
|
||||
// Fusion bank reconciliation design tokens.
|
||||
//
|
||||
// Mirrors Enterprise's color/spacing scale where it makes sense, with
|
||||
// fusion-specific additions for AI confidence bands and the suggestion
|
||||
// strip. All values can be overridden in dark_mode.scss.
|
||||
|
||||
// ============================================================
|
||||
// Colors — semantic
|
||||
// ============================================================
|
||||
$fusion-color-bg-primary: #ffffff;
|
||||
$fusion-color-bg-secondary: #f9fafb;
|
||||
$fusion-color-bg-tertiary: #f3f4f6;
|
||||
$fusion-color-border: #e5e7eb;
|
||||
$fusion-color-border-strong: #d1d5db;
|
||||
|
||||
$fusion-color-text-primary: #111827;
|
||||
$fusion-color-text-secondary: #6b7280;
|
||||
$fusion-color-text-muted: #9ca3af;
|
||||
$fusion-color-text-inverse: #ffffff;
|
||||
|
||||
$fusion-color-accent: #3b82f6; // primary brand blue
|
||||
$fusion-color-accent-hover: #2563eb;
|
||||
$fusion-color-accent-bg: #eff6ff;
|
||||
|
||||
// ============================================================
|
||||
// AI Confidence band colors
|
||||
// ============================================================
|
||||
$fusion-confidence-high: #10b981; // green
|
||||
$fusion-confidence-high-bg: #ecfdf5;
|
||||
$fusion-confidence-medium: #f59e0b; // amber
|
||||
$fusion-confidence-medium-bg: #fffbeb;
|
||||
$fusion-confidence-low: #ef4444; // red
|
||||
$fusion-confidence-low-bg: #fef2f2;
|
||||
$fusion-confidence-none: #9ca3af; // gray
|
||||
$fusion-confidence-none-bg: #f3f4f6;
|
||||
|
||||
// ============================================================
|
||||
// Reconciliation state colors
|
||||
// ============================================================
|
||||
$fusion-state-pending-bg: #fef3c7; // amber-100
|
||||
$fusion-state-reconciled-bg: #d1fae5; // emerald-100
|
||||
$fusion-state-partial-bg: #fde68a; // amber-200
|
||||
|
||||
// ============================================================
|
||||
// Spacing scale (4px increments)
|
||||
// ============================================================
|
||||
$fusion-space-1: 0.25rem; // 4px
|
||||
$fusion-space-2: 0.5rem; // 8px
|
||||
$fusion-space-3: 0.75rem; // 12px
|
||||
$fusion-space-4: 1rem; // 16px
|
||||
$fusion-space-5: 1.25rem; // 20px
|
||||
$fusion-space-6: 1.5rem; // 24px
|
||||
$fusion-space-8: 2rem; // 32px
|
||||
|
||||
// ============================================================
|
||||
// Typography
|
||||
// ============================================================
|
||||
$fusion-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
$fusion-font-size-xs: 0.75rem; // 12px
|
||||
$fusion-font-size-sm: 0.875rem; // 14px
|
||||
$fusion-font-size-base: 1rem; // 16px
|
||||
$fusion-font-size-lg: 1.125rem; // 18px
|
||||
$fusion-font-size-xl: 1.25rem; // 20px
|
||||
|
||||
$fusion-font-weight-normal: 400;
|
||||
$fusion-font-weight-medium: 500;
|
||||
$fusion-font-weight-semibold: 600;
|
||||
$fusion-font-weight-bold: 700;
|
||||
|
||||
// ============================================================
|
||||
// Borders + radii
|
||||
// ============================================================
|
||||
$fusion-border-radius-sm: 0.25rem;
|
||||
$fusion-border-radius: 0.375rem;
|
||||
$fusion-border-radius-md: 0.5rem;
|
||||
$fusion-border-radius-lg: 0.75rem;
|
||||
|
||||
// ============================================================
|
||||
// Shadows
|
||||
// ============================================================
|
||||
$fusion-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
$fusion-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
$fusion-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
$fusion-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
// ============================================================
|
||||
// Animation
|
||||
// ============================================================
|
||||
$fusion-transition-fast: 150ms ease-in-out;
|
||||
$fusion-transition-base: 200ms ease-in-out;
|
||||
$fusion-transition-slow: 300ms ease-in-out;
|
||||
@@ -0,0 +1,90 @@
|
||||
@import "variables";
|
||||
|
||||
// ============================================================
|
||||
// AI Suggestion strip (inline, on each statement line card)
|
||||
// ============================================================
|
||||
.o_fusion_ai_suggestion {
|
||||
margin-top: $fusion-space-3;
|
||||
padding: $fusion-space-3;
|
||||
border-radius: $fusion-border-radius;
|
||||
border: 1px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $fusion-space-3;
|
||||
font-size: $fusion-font-size-sm;
|
||||
transition: all $fusion-transition-base;
|
||||
|
||||
// Confidence bands — apply via [data-band="..."] attribute
|
||||
&[data-band="high"] {
|
||||
background: $fusion-confidence-high-bg;
|
||||
border-color: $fusion-confidence-high;
|
||||
|
||||
.o_fusion_confidence_value { color: $fusion-confidence-high; }
|
||||
}
|
||||
&[data-band="medium"] {
|
||||
background: $fusion-confidence-medium-bg;
|
||||
border-color: $fusion-confidence-medium;
|
||||
|
||||
.o_fusion_confidence_value { color: $fusion-confidence-medium; }
|
||||
}
|
||||
&[data-band="low"] {
|
||||
background: $fusion-confidence-low-bg;
|
||||
border-color: $fusion-confidence-low;
|
||||
|
||||
.o_fusion_confidence_value { color: $fusion-confidence-low; }
|
||||
}
|
||||
&[data-band="none"] {
|
||||
background: $fusion-confidence-none-bg;
|
||||
border-color: $fusion-confidence-none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.o_fusion_confidence_badge {
|
||||
font-weight: $fusion-font-weight-bold;
|
||||
font-size: $fusion-font-size-base;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.o_fusion_suggestion_text {
|
||||
flex: 1;
|
||||
color: $fusion-color-text-primary;
|
||||
|
||||
.o_fusion_reasoning {
|
||||
color: $fusion-color-text-secondary;
|
||||
font-style: italic;
|
||||
margin-top: $fusion-space-1;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fusion_suggestion_actions {
|
||||
display: flex;
|
||||
gap: $fusion-space-2;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Alternatives panel (expandable list of other suggestions)
|
||||
// ============================================================
|
||||
.o_fusion_alternatives_panel {
|
||||
margin-top: $fusion-space-2;
|
||||
padding: $fusion-space-3;
|
||||
background: $fusion-color-bg-secondary;
|
||||
border: 1px solid $fusion-color-border;
|
||||
border-radius: $fusion-border-radius;
|
||||
font-size: $fusion-font-size-sm;
|
||||
|
||||
.o_fusion_alternative {
|
||||
padding: $fusion-space-2 0;
|
||||
border-bottom: 1px solid $fusion-color-border;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
.alt_confidence {
|
||||
font-weight: $fusion-font-weight-medium;
|
||||
margin-right: $fusion-space-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
@import "variables";
|
||||
|
||||
// ============================================================
|
||||
// Bank reconciliation kanban container
|
||||
// ============================================================
|
||||
.o_fusion_bank_rec {
|
||||
background: $fusion-color-bg-secondary;
|
||||
min-height: 100vh;
|
||||
font-family: $fusion-font-family;
|
||||
color: $fusion-color-text-primary;
|
||||
|
||||
// Header bar with stats
|
||||
&_header {
|
||||
background: $fusion-color-bg-primary;
|
||||
border-bottom: 1px solid $fusion-color-border;
|
||||
padding: $fusion-space-4 $fusion-space-6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h1 {
|
||||
font-size: $fusion-font-size-xl;
|
||||
font-weight: $fusion-font-weight-semibold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.o_fusion_stats {
|
||||
display: flex;
|
||||
gap: $fusion-space-6;
|
||||
font-size: $fusion-font-size-sm;
|
||||
color: $fusion-color-text-secondary;
|
||||
|
||||
.stat-value {
|
||||
font-weight: $fusion-font-weight-semibold;
|
||||
color: $fusion-color-text-primary;
|
||||
margin-left: $fusion-space-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Statement line cards (kanban tile)
|
||||
&_line {
|
||||
background: $fusion-color-bg-primary;
|
||||
border: 1px solid $fusion-color-border;
|
||||
border-radius: $fusion-border-radius-md;
|
||||
padding: $fusion-space-4;
|
||||
margin-bottom: $fusion-space-3;
|
||||
cursor: pointer;
|
||||
transition: all $fusion-transition-base;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: $fusion-color-accent;
|
||||
box-shadow: $fusion-shadow-md;
|
||||
}
|
||||
|
||||
&.o_fusion_selected {
|
||||
border-color: $fusion-color-accent;
|
||||
background: $fusion-color-accent-bg;
|
||||
}
|
||||
|
||||
&_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $fusion-space-2;
|
||||
|
||||
.o_fusion_amount {
|
||||
font-size: $fusion-font-size-lg;
|
||||
font-weight: $fusion-font-weight-semibold;
|
||||
|
||||
&.negative {
|
||||
color: $fusion-confidence-low;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fusion_date {
|
||||
font-size: $fusion-font-size-sm;
|
||||
color: $fusion-color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&_body {
|
||||
font-size: $fusion-font-size-sm;
|
||||
color: $fusion-color-text-secondary;
|
||||
|
||||
.o_fusion_partner {
|
||||
font-weight: $fusion-font-weight-medium;
|
||||
color: $fusion-color-text-primary;
|
||||
margin-right: $fusion-space-2;
|
||||
}
|
||||
|
||||
.o_fusion_memo {
|
||||
font-style: italic;
|
||||
color: $fusion-color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment count badge
|
||||
.o_fusion_attachments_badge {
|
||||
position: absolute;
|
||||
top: $fusion-space-2;
|
||||
right: $fusion-space-2;
|
||||
background: $fusion-color-bg-tertiary;
|
||||
border-radius: $fusion-border-radius;
|
||||
padding: $fusion-space-1 $fusion-space-2;
|
||||
font-size: $fusion-font-size-xs;
|
||||
color: $fusion-color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
// Detail/edit panel (right side)
|
||||
&_detail {
|
||||
background: $fusion-color-bg-primary;
|
||||
border-left: 1px solid $fusion-color-border;
|
||||
padding: $fusion-space-6;
|
||||
|
||||
h2 {
|
||||
font-size: $fusion-font-size-lg;
|
||||
font-weight: $fusion-font-weight-semibold;
|
||||
margin: 0 0 $fusion-space-4;
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
.btn_fusion {
|
||||
padding: $fusion-space-2 $fusion-space-4;
|
||||
border-radius: $fusion-border-radius;
|
||||
font-size: $fusion-font-size-sm;
|
||||
font-weight: $fusion-font-weight-medium;
|
||||
border: 1px solid $fusion-color-border;
|
||||
background: $fusion-color-bg-primary;
|
||||
color: $fusion-color-text-primary;
|
||||
cursor: pointer;
|
||||
transition: all $fusion-transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $fusion-color-bg-tertiary;
|
||||
}
|
||||
|
||||
&.btn_fusion_primary {
|
||||
background: $fusion-color-accent;
|
||||
border-color: $fusion-color-accent;
|
||||
color: $fusion-color-text-inverse;
|
||||
|
||||
&:hover {
|
||||
background: $fusion-color-accent-hover;
|
||||
border-color: $fusion-color-accent-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
fusion_accounting_bank_rec/static/src/scss/dark_mode.scss
Normal file
64
fusion_accounting_bank_rec/static/src/scss/dark_mode.scss
Normal file
@@ -0,0 +1,64 @@
|
||||
@import "variables";
|
||||
|
||||
// Activated via [data-color-scheme="dark"] on body or any ancestor.
|
||||
// Mirrors Odoo's standard dark-mode trigger pattern.
|
||||
|
||||
[data-color-scheme="dark"] .o_fusion_bank_rec {
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
|
||||
&_header,
|
||||
&_line,
|
||||
&_detail {
|
||||
background: #111827;
|
||||
border-color: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
&_line {
|
||||
&:hover { border-color: #60a5fa; }
|
||||
&.o_fusion_selected {
|
||||
background: #1e3a8a;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
&_header .o_fusion_date,
|
||||
&_body { color: #d1d5db; }
|
||||
|
||||
.o_fusion_attachments_badge {
|
||||
background: #374151;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
|
||||
.btn_fusion {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: #f9fafb;
|
||||
|
||||
&:hover { background: #4b5563; }
|
||||
|
||||
&.btn_fusion_primary {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI suggestion strip — soften background colors for dark mode
|
||||
.o_fusion_ai_suggestion {
|
||||
&[data-band="high"] { background: rgba(16, 185, 129, 0.1); }
|
||||
&[data-band="medium"] { background: rgba(245, 158, 11, 0.1); }
|
||||
&[data-band="low"] { background: rgba(239, 68, 68, 0.1); }
|
||||
&[data-band="none"] { background: rgba(156, 163, 175, 0.1); }
|
||||
}
|
||||
|
||||
.o_fusion_alternatives_panel {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* Bank reconciliation service.
|
||||
*
|
||||
* Central data layer + reactive state for the OWL bank-rec widget.
|
||||
* Components inject this service via useService("fusion_bank_reconciliation")
|
||||
* and read/write state through its methods.
|
||||
*
|
||||
* Wraps the 10 JSON-RPC endpoints from controllers/bank_rec_controller.py.
|
||||
*/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { reactive } from "@odoo/owl";
|
||||
|
||||
const ENDPOINT_BASE = "/fusion/bank_rec";
|
||||
|
||||
export class BankReconciliationService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
this.rpc = services.rpc;
|
||||
this.notification = services.notification;
|
||||
|
||||
// Reactive state — components depend on it via useState/reactive
|
||||
this.state = reactive({
|
||||
journalId: null,
|
||||
companyId: null,
|
||||
unreconciledCount: 0,
|
||||
totalPendingAmount: 0,
|
||||
lines: [],
|
||||
lineCache: {}, // {lineId: {detail, suggestions, attachments}}
|
||||
selectedLineId: null,
|
||||
isLoading: false,
|
||||
isReconciling: false,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
filters: {},
|
||||
// Cache of recently-applied actions for optimistic UI
|
||||
recentActions: [],
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialization
|
||||
// ============================================================
|
||||
|
||||
async initForJournal(journalId, companyId) {
|
||||
this.state.journalId = journalId;
|
||||
this.state.companyId = companyId;
|
||||
this.state.isLoading = true;
|
||||
try {
|
||||
const stateInfo = await this.rpc(`${ENDPOINT_BASE}/get_state`, {
|
||||
journal_id: journalId, company_id: companyId,
|
||||
});
|
||||
this.state.unreconciledCount = stateInfo.unreconciled_count;
|
||||
this.state.totalPendingAmount = stateInfo.total_pending_amount;
|
||||
await this.loadLines({ reset: true });
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// List + pagination
|
||||
// ============================================================
|
||||
|
||||
async loadLines({ reset = false } = {}) {
|
||||
if (reset) {
|
||||
this.state.offset = 0;
|
||||
this.state.lines = [];
|
||||
}
|
||||
this.state.isLoading = true;
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/list_unreconciled`, {
|
||||
journal_id: this.state.journalId,
|
||||
company_id: this.state.companyId,
|
||||
limit: this.state.limit,
|
||||
offset: this.state.offset,
|
||||
...this.state.filters,
|
||||
});
|
||||
if (reset) {
|
||||
this.state.lines = result.lines;
|
||||
} else {
|
||||
this.state.lines = [...this.state.lines, ...result.lines];
|
||||
}
|
||||
this.state.unreconciledCount = result.total;
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadMore() {
|
||||
this.state.offset += this.state.limit;
|
||||
await this.loadLines({ reset: false });
|
||||
}
|
||||
|
||||
setFilter(key, value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
delete this.state.filters[key];
|
||||
} else {
|
||||
this.state.filters[key] = value;
|
||||
}
|
||||
this.loadLines({ reset: true });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Line detail + suggestions
|
||||
// ============================================================
|
||||
|
||||
async selectLine(lineId) {
|
||||
this.state.selectedLineId = lineId;
|
||||
if (!this.state.lineCache[lineId]) {
|
||||
await this.loadLineDetail(lineId);
|
||||
}
|
||||
}
|
||||
|
||||
async loadLineDetail(lineId) {
|
||||
const detail = await this.rpc(`${ENDPOINT_BASE}/get_line_detail`, {
|
||||
statement_line_id: lineId,
|
||||
});
|
||||
this.state.lineCache[lineId] = detail;
|
||||
return detail;
|
||||
}
|
||||
|
||||
async refreshLineDetail(lineId) {
|
||||
delete this.state.lineCache[lineId];
|
||||
return await this.loadLineDetail(lineId);
|
||||
}
|
||||
|
||||
async suggestMatches(lineIds, limitPerLine = 3) {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/suggest_matches`, {
|
||||
statement_line_ids: lineIds,
|
||||
limit_per_line: limitPerLine,
|
||||
});
|
||||
// Refresh cache for each line
|
||||
for (const lineId of lineIds) {
|
||||
await this.refreshLineDetail(lineId);
|
||||
}
|
||||
return result.suggestions;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reconciliation actions
|
||||
// ============================================================
|
||||
|
||||
async acceptSuggestion(suggestionId) {
|
||||
this.state.isReconciling = true;
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/accept_suggestion`, {
|
||||
suggestion_id: suggestionId,
|
||||
});
|
||||
this.state.unreconciledCount = result.unreconciled_count_after;
|
||||
// Optimistic remove from list
|
||||
this._removeReconciledLineFromState(this.state.selectedLineId);
|
||||
this.notification.add("Reconciliation accepted", { type: "success" });
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.notification.add(`Accept failed: ${err.message || err}`, { type: "danger" });
|
||||
throw err;
|
||||
} finally {
|
||||
this.state.isReconciling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async reconcileManual(statementLineId, againstMoveLineIds) {
|
||||
this.state.isReconciling = true;
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/reconcile_manual`, {
|
||||
statement_line_id: statementLineId,
|
||||
against_move_line_ids: againstMoveLineIds,
|
||||
});
|
||||
this._removeReconciledLineFromState(statementLineId);
|
||||
this.notification.add("Reconciled", { type: "success" });
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.notification.add(`Reconcile failed: ${err.message || err}`, { type: "danger" });
|
||||
throw err;
|
||||
} finally {
|
||||
this.state.isReconciling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async unreconcile(partialReconcileIds) {
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/unreconcile`, {
|
||||
partial_reconcile_ids: partialReconcileIds,
|
||||
});
|
||||
// Reload list since unreconciled lines come back
|
||||
await this.loadLines({ reset: true });
|
||||
this.notification.add("Unreconciled", { type: "info" });
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.notification.add(`Unreconcile failed: ${err.message || err}`, { type: "danger" });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async writeOff({ statementLineId, accountId, amount, label, taxId = null }) {
|
||||
this.state.isReconciling = true;
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/write_off`, {
|
||||
statement_line_id: statementLineId,
|
||||
account_id: accountId,
|
||||
amount: amount,
|
||||
label: label,
|
||||
tax_id: taxId,
|
||||
});
|
||||
this._removeReconciledLineFromState(statementLineId);
|
||||
this.notification.add("Write-off applied", { type: "success" });
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.notification.add(`Write-off failed: ${err.message || err}`, { type: "danger" });
|
||||
throw err;
|
||||
} finally {
|
||||
this.state.isReconciling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async bulkReconcile(statementLineIds, strategy = "auto") {
|
||||
this.state.isReconciling = true;
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/bulk_reconcile`, {
|
||||
statement_line_ids: statementLineIds,
|
||||
strategy: strategy,
|
||||
});
|
||||
await this.loadLines({ reset: true });
|
||||
const msg = `${result.reconciled_count} reconciled, ${result.skipped} skipped`;
|
||||
this.notification.add(msg, { type: "success" });
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.notification.add(`Bulk failed: ${err.message || err}`, { type: "danger" });
|
||||
throw err;
|
||||
} finally {
|
||||
this.state.isReconciling = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Partner history (right-side panel)
|
||||
// ============================================================
|
||||
|
||||
async getPartnerHistory(partnerId, limit = 20) {
|
||||
return await this.rpc(`${ENDPOINT_BASE}/get_partner_history`, {
|
||||
partner_id: partnerId,
|
||||
limit: limit,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
_removeReconciledLineFromState(lineId) {
|
||||
if (!lineId) return;
|
||||
this.state.lines = this.state.lines.filter((l) => l.id !== lineId);
|
||||
if (this.state.selectedLineId === lineId) {
|
||||
this.state.selectedLineId = null;
|
||||
}
|
||||
delete this.state.lineCache[lineId];
|
||||
if (this.state.unreconciledCount > 0) {
|
||||
this.state.unreconciledCount -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence band helper for templates
|
||||
getBandClass(line) {
|
||||
return `band-${line.fusion_confidence_band || "none"}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const bankReconciliationService = {
|
||||
dependencies: ["rpc", "notification"],
|
||||
start(env, services) {
|
||||
return new BankReconciliationService(env, services);
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("fusion_bank_reconciliation", bankReconciliationService);
|
||||
18
fusion_accounting_bank_rec/tests/__init__.py
Normal file
18
fusion_accounting_bank_rec/tests/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from . import test_memo_tokenizer
|
||||
from . import test_exchange_diff
|
||||
from . import test_matching_strategies
|
||||
from . import test_ai_suggestion_lifecycle
|
||||
from . import test_precedent_lookup
|
||||
from . import test_pattern_extraction
|
||||
from . import test_confidence_scoring
|
||||
from . import test_reconcile_engine_unit
|
||||
from . import test_reconcile_engine_property
|
||||
from . import test_factories
|
||||
from . import test_reconcile_engine_integration
|
||||
from . import test_bank_rec_prompt
|
||||
from . import test_bank_rec_adapter
|
||||
from . import test_bank_rec_tools
|
||||
from . import test_legacy_tools_refactor
|
||||
from . import test_mv_unreconciled
|
||||
from . import test_cron_methods
|
||||
from . import test_controller
|
||||
185
fusion_accounting_bank_rec/tests/_factories.py
Normal file
185
fusion_accounting_bank_rec/tests/_factories.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Test data factories for fusion_accounting_bank_rec.
|
||||
|
||||
Provides recordset builders for use across all test files. Sane defaults
|
||||
let tests be readable: `make_bank_line(env, amount=100, partner=p)` instead
|
||||
of 30 lines of recordset setup.
|
||||
|
||||
These factories work against the real Odoo registry — they exercise the
|
||||
same code paths as production. Each factory is idempotent in the sense
|
||||
that calling it multiple times returns separate records.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import fields
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Bank journal + statements
|
||||
# ============================================================
|
||||
|
||||
def make_bank_journal(env, *, name='Test Bank', code=None):
|
||||
"""Create a bank journal. `code` defaults to first 5 chars of `name`."""
|
||||
code = code or name[:5].upper().replace(' ', '')
|
||||
return env['account.journal'].create({
|
||||
'name': name,
|
||||
'type': 'bank',
|
||||
'code': code,
|
||||
})
|
||||
|
||||
|
||||
def make_bank_statement(env, *, journal=None, name='Test Statement', date_=None):
|
||||
"""Create a bank statement. Auto-creates a bank journal if not provided."""
|
||||
journal = journal or make_bank_journal(env)
|
||||
return env['account.bank.statement'].create({
|
||||
'name': name,
|
||||
'journal_id': journal.id,
|
||||
'date': date_ or date.today(),
|
||||
})
|
||||
|
||||
|
||||
def make_bank_line(env, *, journal=None, statement=None, amount=100.00,
|
||||
partner=None, memo='Test line', date_=None):
|
||||
"""Create a bank statement line. Creates statement if not provided.
|
||||
|
||||
Most-common factory in tests. Defaults give a $100 line with no partner."""
|
||||
if not statement:
|
||||
statement = make_bank_statement(env, journal=journal, date_=date_)
|
||||
return env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': statement.journal_id.id,
|
||||
'date': date_ or date.today(),
|
||||
'payment_ref': memo,
|
||||
'amount': amount,
|
||||
'partner_id': partner.id if partner else False,
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Invoices + journal items
|
||||
# ============================================================
|
||||
|
||||
def _ensure_test_product(env):
|
||||
"""Get or create a service product suitable for invoice lines."""
|
||||
product = env['product.product'].search([('type', '=', 'service')], limit=1)
|
||||
if not product:
|
||||
product = env['product.product'].create({
|
||||
'name': 'Fusion Test Service',
|
||||
'type': 'service',
|
||||
})
|
||||
return product
|
||||
|
||||
|
||||
def make_invoice(env, *, partner, amount=100.00, date_=None, currency=None,
|
||||
product=None, posted=True):
|
||||
"""Create a customer invoice (out_invoice). Posted by default."""
|
||||
product = product or _ensure_test_product(env)
|
||||
vals = {
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': partner.id,
|
||||
'invoice_date': date_ or date.today(),
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': 'Test invoice line',
|
||||
'quantity': 1,
|
||||
'price_unit': amount,
|
||||
})],
|
||||
}
|
||||
if currency:
|
||||
vals['currency_id'] = currency.id
|
||||
move = env['account.move'].create(vals)
|
||||
if posted:
|
||||
move.action_post()
|
||||
return move
|
||||
|
||||
|
||||
def make_vendor_bill(env, *, partner, amount=100.00, date_=None, currency=None,
|
||||
product=None, posted=True):
|
||||
"""Create a vendor bill (in_invoice). Posted by default."""
|
||||
product = product or _ensure_test_product(env)
|
||||
vals = {
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': partner.id,
|
||||
'invoice_date': date_ or date.today(),
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': 'Test bill line',
|
||||
'quantity': 1,
|
||||
'price_unit': amount,
|
||||
})],
|
||||
}
|
||||
if currency:
|
||||
vals['currency_id'] = currency.id
|
||||
move = env['account.move'].create(vals)
|
||||
if posted:
|
||||
move.action_post()
|
||||
return move
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Suggestions + patterns + precedents (fusion-specific)
|
||||
# ============================================================
|
||||
|
||||
def make_suggestion(env, *, statement_line, candidate_move_lines=None,
|
||||
confidence=0.92, rank=1, reasoning='Test suggestion',
|
||||
state='pending'):
|
||||
"""Create a fusion.reconcile.suggestion against a bank line."""
|
||||
candidate_ids = candidate_move_lines.ids if candidate_move_lines else []
|
||||
return env['fusion.reconcile.suggestion'].create({
|
||||
'company_id': env.company.id,
|
||||
'statement_line_id': statement_line.id,
|
||||
'proposed_move_line_ids': [(6, 0, candidate_ids)],
|
||||
'confidence': confidence,
|
||||
'rank': rank,
|
||||
'reasoning': reasoning,
|
||||
'state': state,
|
||||
})
|
||||
|
||||
|
||||
def make_pattern(env, *, partner, reconcile_count=10, pref_strategy='exact_amount',
|
||||
typical_cadence_days=14.0, common_memo_tokens='RBC,ETF'):
|
||||
"""Create a fusion.reconcile.pattern for a partner."""
|
||||
return env['fusion.reconcile.pattern'].create({
|
||||
'company_id': env.company.id,
|
||||
'partner_id': partner.id,
|
||||
'reconcile_count': reconcile_count,
|
||||
'pref_strategy': pref_strategy,
|
||||
'typical_cadence_days': typical_cadence_days,
|
||||
'common_memo_tokens': common_memo_tokens,
|
||||
})
|
||||
|
||||
|
||||
def make_precedent(env, *, partner, amount=1847.50, days_ago=14,
|
||||
memo_tokens='RBC,ETF,REF', count=1, source='manual'):
|
||||
"""Create a fusion.reconcile.precedent."""
|
||||
return env['fusion.reconcile.precedent'].create({
|
||||
'company_id': env.company.id,
|
||||
'partner_id': partner.id,
|
||||
'amount': amount,
|
||||
'currency_id': env.company.currency_id.id,
|
||||
'date': date.today() - timedelta(days=days_ago),
|
||||
'memo_tokens': memo_tokens,
|
||||
'matched_move_line_count': count,
|
||||
'reconciled_at': fields.Datetime.now(),
|
||||
'source': source,
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Convenience composite — bank line + matching invoice ready to reconcile
|
||||
# ============================================================
|
||||
|
||||
def make_reconcileable_pair(env, *, amount=100.00, partner=None, date_=None):
|
||||
"""Create a bank line + a customer invoice with the same partner+amount.
|
||||
Returns (bank_line, invoice_recv_lines) ready to pass to engine.reconcile_one().
|
||||
|
||||
Returns:
|
||||
(bank_line, invoice_receivable_lines) tuple
|
||||
"""
|
||||
if not partner:
|
||||
partner = env['res.partner'].create({'name': 'Reconcile Test Partner'})
|
||||
invoice = make_invoice(env, partner=partner, amount=amount, date_=date_)
|
||||
bank_line = make_bank_line(env, amount=amount, partner=partner, date_=date_)
|
||||
recv_lines = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
return (bank_line, recv_lines)
|
||||
@@ -0,0 +1,86 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSuggestionLifecycle(TransactionCase):
|
||||
"""The fusion.reconcile.suggestion state machine + computed band."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
journal = self.env['account.journal'].create({
|
||||
'name': 'Test Bank Suggestion',
|
||||
'type': 'bank',
|
||||
'code': 'TBSG',
|
||||
})
|
||||
statement = self.env['account.bank.statement'].create({
|
||||
'name': 'Test Statement',
|
||||
'journal_id': journal.id,
|
||||
})
|
||||
self.line = self.env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': journal.id,
|
||||
'date': '2026-04-19',
|
||||
'payment_ref': 'Test for suggestion',
|
||||
'amount': 100.00,
|
||||
})
|
||||
|
||||
def _make_suggestion(self, confidence=0.92, **vals):
|
||||
defaults = {
|
||||
'company_id': self.env.company.id,
|
||||
'statement_line_id': self.line.id,
|
||||
'confidence': confidence,
|
||||
'rank': 1,
|
||||
'reasoning': 'Test',
|
||||
}
|
||||
defaults.update(vals)
|
||||
return self.env['fusion.reconcile.suggestion'].create(defaults)
|
||||
|
||||
def test_compute_band_high(self):
|
||||
sug = self._make_suggestion(confidence=0.96)
|
||||
self.assertEqual(sug.confidence_band, 'high')
|
||||
|
||||
def test_compute_band_medium(self):
|
||||
sug = self._make_suggestion(confidence=0.75)
|
||||
self.assertEqual(sug.confidence_band, 'medium')
|
||||
|
||||
def test_compute_band_low(self):
|
||||
sug = self._make_suggestion(confidence=0.55)
|
||||
self.assertEqual(sug.confidence_band, 'low')
|
||||
|
||||
def test_compute_band_none(self):
|
||||
sug = self._make_suggestion(confidence=0.30)
|
||||
self.assertEqual(sug.confidence_band, 'none')
|
||||
|
||||
def test_default_state_is_pending(self):
|
||||
sug = self._make_suggestion()
|
||||
self.assertEqual(sug.state, 'pending')
|
||||
|
||||
def test_state_transition_to_accepted(self):
|
||||
sug = self._make_suggestion()
|
||||
sug.write({
|
||||
'state': 'accepted',
|
||||
'accepted_at': '2026-04-19 12:00:00',
|
||||
'accepted_by': self.env.user.id,
|
||||
})
|
||||
self.assertEqual(sug.state, 'accepted')
|
||||
self.assertTrue(sug.accepted_at)
|
||||
self.assertEqual(sug.accepted_by, self.env.user)
|
||||
|
||||
def test_state_transition_to_rejected_with_reason(self):
|
||||
sug = self._make_suggestion()
|
||||
sug.write({
|
||||
'state': 'rejected',
|
||||
'rejected_at': '2026-04-19 12:05:00',
|
||||
'rejected_reason': 'wrong_invoice',
|
||||
})
|
||||
self.assertEqual(sug.state, 'rejected')
|
||||
self.assertEqual(sug.rejected_reason, 'wrong_invoice')
|
||||
|
||||
def test_state_transition_to_superseded(self):
|
||||
sug = self._make_suggestion()
|
||||
sug.write({'state': 'superseded'})
|
||||
self.assertEqual(sug.state, 'superseded')
|
||||
|
||||
def test_currency_id_relates_to_line(self):
|
||||
sug = self._make_suggestion()
|
||||
self.assertEqual(sug.currency_id, self.line.currency_id)
|
||||
81
fusion_accounting_bank_rec/tests/test_bank_rec_adapter.py
Normal file
81
fusion_accounting_bank_rec/tests/test_bank_rec_adapter.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Tests for BankRecAdapter's fusion paths."""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_ai.services.data_adapters.bank_rec import BankRecAdapter
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestBankRecAdapter(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Adapter Test Partner'})
|
||||
self.adapter = BankRecAdapter(self.env)
|
||||
|
||||
def test_list_unreconciled_via_fusion_returns_base_fields(self):
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, amount=100.00, partner=self.partner, memo='Adapter base test')
|
||||
result = self.adapter.list_unreconciled_via_fusion(
|
||||
company_id=self.env.company.id, limit=50)
|
||||
ours = [r for r in result if r['id'] == bank_line.id]
|
||||
self.assertEqual(len(ours), 1)
|
||||
row = ours[0]
|
||||
for f_name in ['id', 'date', 'payment_ref', 'amount', 'partner_id', 'journal_id']:
|
||||
self.assertIn(f_name, row)
|
||||
self.assertIn('fusion_top_suggestion_id', row)
|
||||
self.assertIn('fusion_confidence_band', row)
|
||||
self.assertIn('attachment_count', row)
|
||||
|
||||
def test_list_unreconciled_via_community_omits_fusion_fields(self):
|
||||
bank_line = f.make_bank_line(self.env, amount=200.00, partner=self.partner)
|
||||
result = self.adapter.list_unreconciled_via_community(
|
||||
company_id=self.env.company.id, limit=50)
|
||||
ours = [r for r in result if r['id'] == bank_line.id]
|
||||
self.assertEqual(len(ours), 1)
|
||||
self.assertNotIn('fusion_top_suggestion_id', ours[0])
|
||||
|
||||
def test_suggest_matches_via_fusion_returns_dict(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Suggest Adapter'})
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=350.00)
|
||||
bank_line = f.make_bank_line(self.env, amount=350.00, partner=partner)
|
||||
result = self.adapter.suggest_matches_via_fusion(
|
||||
statement_line_ids=[bank_line.id], limit_per_line=3)
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertIn(bank_line.id, result)
|
||||
self.assertGreater(len(result[bank_line.id]), 0)
|
||||
|
||||
def test_suggest_matches_via_community_returns_empty(self):
|
||||
bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner)
|
||||
result = self.adapter.suggest_matches_via_community(
|
||||
statement_line_ids=[bank_line.id])
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_accept_suggestion_via_fusion(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Accept Adapter'})
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=425.00)
|
||||
recv_lines = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
bank_line = f.make_bank_line(self.env, amount=425.00, partner=partner)
|
||||
sug = f.make_suggestion(
|
||||
self.env, statement_line=bank_line,
|
||||
candidate_move_lines=recv_lines, confidence=0.95)
|
||||
result = self.adapter.accept_suggestion_via_fusion(suggestion_id=sug.id)
|
||||
self.assertIn('partial_ids', result)
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
|
||||
def test_accept_suggestion_via_community_raises(self):
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.adapter.accept_suggestion_via_community(suggestion_id=1)
|
||||
|
||||
def test_unreconcile_via_fusion(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Unrec Adapter'})
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||
self.env, amount=275.00, partner=partner)
|
||||
rec_result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
partial_ids = rec_result['partial_ids']
|
||||
result = self.adapter.unreconcile_via_fusion(
|
||||
partial_reconcile_ids=partial_ids)
|
||||
self.assertIn('unreconciled_line_ids', result)
|
||||
self.assertGreater(len(result['unreconciled_line_ids']), 0)
|
||||
92
fusion_accounting_bank_rec/tests/test_bank_rec_prompt.py
Normal file
92
fusion_accounting_bank_rec/tests/test_bank_rec_prompt.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Smoke tests for bank_rec_prompt module."""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import (
|
||||
SYSTEM_PROMPT,
|
||||
build_prompt,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.confidence_scoring import (
|
||||
ScoredCandidate,
|
||||
)
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestBankRecPrompt(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Prompt Test Partner'})
|
||||
self.bank_line = f.make_bank_line(
|
||||
self.env,
|
||||
amount=1847.50,
|
||||
partner=self.partner,
|
||||
memo='RBC ETF DEP REF 4831',
|
||||
)
|
||||
self.scored = [
|
||||
ScoredCandidate(
|
||||
candidate_id=101,
|
||||
confidence=0.92,
|
||||
reasoning='Exact amount match',
|
||||
score_amount_match=1.0,
|
||||
score_partner_pattern=0.5,
|
||||
score_precedent_similarity=0.85,
|
||||
),
|
||||
ScoredCandidate(
|
||||
candidate_id=102,
|
||||
confidence=0.71,
|
||||
reasoning='Close amount',
|
||||
score_amount_match=0.95,
|
||||
score_partner_pattern=0.5,
|
||||
score_precedent_similarity=0.6,
|
||||
),
|
||||
]
|
||||
|
||||
def test_system_prompt_requires_json_output(self):
|
||||
self.assertIn('JSON', SYSTEM_PROMPT)
|
||||
self.assertIn('"ranked"', SYSTEM_PROMPT)
|
||||
|
||||
def test_build_prompt_returns_tuple(self):
|
||||
result = build_prompt(self.bank_line, self.scored)
|
||||
self.assertEqual(len(result), 2)
|
||||
system, user = result
|
||||
self.assertIsInstance(system, str)
|
||||
self.assertIsInstance(user, str)
|
||||
|
||||
def test_user_prompt_includes_bank_line_details(self):
|
||||
_, user = build_prompt(self.bank_line, self.scored)
|
||||
self.assertIn('1847.5', user)
|
||||
self.assertIn('RBC ETF DEP REF 4831', user)
|
||||
self.assertIn('Prompt Test Partner', user)
|
||||
|
||||
def test_user_prompt_includes_all_candidates(self):
|
||||
_, user = build_prompt(self.bank_line, self.scored)
|
||||
self.assertIn('candidate_id=101', user)
|
||||
self.assertIn('candidate_id=102', user)
|
||||
|
||||
def test_user_prompt_omits_pattern_section_when_none(self):
|
||||
_, user = build_prompt(self.bank_line, self.scored, pattern=None)
|
||||
self.assertNotIn('PARTNER PATTERN', user)
|
||||
|
||||
def test_user_prompt_includes_pattern_section_when_provided(self):
|
||||
pattern = f.make_pattern(self.env, partner=self.partner, reconcile_count=15)
|
||||
_, user = build_prompt(self.bank_line, self.scored, pattern=pattern)
|
||||
self.assertIn('PARTNER PATTERN', user)
|
||||
self.assertIn('15', user)
|
||||
|
||||
def test_user_prompt_includes_precedents_when_provided(self):
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.precedent_lookup import (
|
||||
PrecedentMatch,
|
||||
)
|
||||
precedents = [
|
||||
PrecedentMatch(
|
||||
precedent_id=1,
|
||||
amount=1847.50,
|
||||
memo_tokens='RBC,ETF',
|
||||
matched_move_line_count=1,
|
||||
similarity_score=0.95,
|
||||
),
|
||||
]
|
||||
_, user = build_prompt(self.bank_line, self.scored, precedents=precedents)
|
||||
self.assertIn('RECENT PRECEDENTS', user)
|
||||
self.assertIn('0.95', user)
|
||||
84
fusion_accounting_bank_rec/tests/test_bank_rec_tools.py
Normal file
84
fusion_accounting_bank_rec/tests/test_bank_rec_tools.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Smoke tests for the 5 new fusion bank-rec AI tools."""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_ai.services.tools import bank_reconciliation as tools
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionBankRecTools(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Tools Test Partner'})
|
||||
|
||||
def test_fusion_suggest_matches_returns_suggestions(self):
|
||||
invoice = f.make_invoice(self.env, partner=self.partner, amount=550.00)
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, amount=550.00, partner=self.partner, memo='Tool test')
|
||||
result = tools.fusion_suggest_matches(self.env, {
|
||||
'statement_line_ids': [bank_line.id],
|
||||
'limit_per_line': 3,
|
||||
})
|
||||
self.assertIn('suggestions', result)
|
||||
self.assertIn('count', result)
|
||||
self.assertGreater(result['count'], 0)
|
||||
|
||||
def test_fusion_accept_suggestion_reconciles(self):
|
||||
invoice = f.make_invoice(self.env, partner=self.partner, amount=625.00)
|
||||
recv_lines = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
bank_line = f.make_bank_line(self.env, amount=625.00, partner=self.partner)
|
||||
sug = f.make_suggestion(
|
||||
self.env, statement_line=bank_line,
|
||||
candidate_move_lines=recv_lines, confidence=0.94)
|
||||
result = tools.fusion_accept_suggestion(self.env, {'suggestion_id': sug.id})
|
||||
self.assertEqual(result['status'], 'accepted')
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
|
||||
def test_fusion_reconcile_bank_line(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||
self.env, amount=375.00, partner=self.partner)
|
||||
result = tools.fusion_reconcile_bank_line(self.env, {
|
||||
'statement_line_id': bank_line.id,
|
||||
'against_move_line_ids': recv_lines.ids,
|
||||
})
|
||||
self.assertEqual(result['status'], 'reconciled')
|
||||
self.assertTrue(result['is_reconciled'])
|
||||
|
||||
def test_fusion_unreconcile(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||
self.env, amount=275.00, partner=self.partner)
|
||||
rec = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
partial_ids = rec['partial_ids']
|
||||
result = tools.fusion_unreconcile(self.env, {
|
||||
'partial_reconcile_ids': partial_ids,
|
||||
})
|
||||
self.assertEqual(result['status'], 'unreconciled')
|
||||
self.assertGreater(result['count'], 0)
|
||||
|
||||
def test_fusion_get_pending_suggestions(self):
|
||||
bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner)
|
||||
sug = f.make_suggestion(
|
||||
self.env, statement_line=bank_line,
|
||||
candidate_move_lines=self.env['account.move.line'],
|
||||
confidence=0.88, state='pending')
|
||||
result = tools.fusion_get_pending_suggestions(self.env, {})
|
||||
self.assertIn('count', result)
|
||||
self.assertGreater(result['count'], 0)
|
||||
ids = [s['id'] for s in result['suggestions']]
|
||||
self.assertIn(sug.id, ids)
|
||||
|
||||
def test_fusion_get_pending_suggestions_filters_by_min_confidence(self):
|
||||
bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner)
|
||||
# One low-confidence suggestion
|
||||
f.make_suggestion(self.env, statement_line=bank_line,
|
||||
confidence=0.30, state='pending')
|
||||
# One high-confidence
|
||||
high = f.make_suggestion(self.env, statement_line=bank_line,
|
||||
confidence=0.95, state='pending')
|
||||
result = tools.fusion_get_pending_suggestions(
|
||||
self.env, {'min_confidence': 0.80})
|
||||
ids = [s['id'] for s in result['suggestions']]
|
||||
self.assertIn(high.id, ids)
|
||||
102
fusion_accounting_bank_rec/tests/test_confidence_scoring.py
Normal file
102
fusion_accounting_bank_rec/tests/test_confidence_scoring.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from datetime import date, timedelta, datetime
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.confidence_scoring import (
|
||||
score_candidates, ScoredCandidate,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import Candidate
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestConfidenceScoring(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Scoring Test Partner'})
|
||||
self.company = self.env.company
|
||||
self.currency = self.env.ref('base.CAD')
|
||||
|
||||
self.journal = self.env['account.journal'].create({
|
||||
'name': 'Test Bank Scoring',
|
||||
'type': 'bank',
|
||||
'code': 'TBSC',
|
||||
})
|
||||
statement = self.env['account.bank.statement'].create({
|
||||
'name': 'Test Statement',
|
||||
'journal_id': self.journal.id,
|
||||
})
|
||||
self.line = self.env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': self.journal.id,
|
||||
'date': date.today(),
|
||||
'payment_ref': 'RBC ETF DEP REF 4831',
|
||||
'amount': 1847.50,
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
def _candidate(self, id_, amount, age_days=10):
|
||||
return Candidate(id=id_, amount=amount, partner_id=self.partner.id, age_days=age_days)
|
||||
|
||||
def test_returns_empty_when_no_candidates(self):
|
||||
result = score_candidates(self.env, statement_line=self.line, candidates=[], k=5)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_returns_empty_when_no_statement_line(self):
|
||||
result = score_candidates(self.env, statement_line=None,
|
||||
candidates=[self._candidate(1, 100)], k=5)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_amount_exact_dominates(self):
|
||||
candidates = [
|
||||
self._candidate(1, 1847.50),
|
||||
self._candidate(2, 1800.00),
|
||||
]
|
||||
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
|
||||
use_ai=False)
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertEqual(result[0].candidate_id, 1)
|
||||
self.assertGreater(result[0].confidence, result[1].confidence)
|
||||
self.assertGreater(result[0].score_amount_match, 0.99)
|
||||
|
||||
def test_returns_top_k(self):
|
||||
candidates = [self._candidate(i, 1847.50 - i) for i in range(10)]
|
||||
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=3,
|
||||
use_ai=False)
|
||||
self.assertEqual(len(result), 3)
|
||||
|
||||
def test_no_ai_provider_returns_statistical_only(self):
|
||||
"""When no AI provider config, score_ai_rerank stays at 0.0."""
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', 'in', ['fusion_accounting.provider.bank_rec_suggest',
|
||||
'fusion_accounting.provider.default'])
|
||||
]).unlink()
|
||||
candidates = [self._candidate(1, 1847.50)]
|
||||
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
|
||||
use_ai=True)
|
||||
self.assertEqual(result[0].score_ai_rerank, 0.0)
|
||||
|
||||
def test_use_ai_false_skips_ai_rerank(self):
|
||||
candidates = [self._candidate(1, 1847.50)]
|
||||
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
|
||||
use_ai=False)
|
||||
self.assertEqual(result[0].score_ai_rerank, 0.0)
|
||||
|
||||
def test_pattern_match_boosts_confidence(self):
|
||||
"""When the partner has a matching pattern, confidence is higher than no-pattern case."""
|
||||
self.env['fusion.reconcile.pattern'].create({
|
||||
'company_id': self.company.id,
|
||||
'partner_id': self.partner.id,
|
||||
'reconcile_count': 10,
|
||||
'pref_strategy': 'exact_amount',
|
||||
})
|
||||
candidates = [self._candidate(1, 1847.50)]
|
||||
with_pattern = score_candidates(self.env, statement_line=self.line,
|
||||
candidates=candidates, k=5, use_ai=False)
|
||||
|
||||
other_partner = self.env['res.partner'].create({'name': 'No Pattern Partner'})
|
||||
self.line.write({'partner_id': other_partner.id})
|
||||
other_candidates = [Candidate(id=1, amount=1847.50, partner_id=other_partner.id, age_days=10)]
|
||||
without_pattern = score_candidates(self.env, statement_line=self.line,
|
||||
candidates=other_candidates, k=5, use_ai=False)
|
||||
|
||||
self.assertGreater(with_pattern[0].score_partner_pattern,
|
||||
without_pattern[0].score_partner_pattern - 0.001)
|
||||
333
fusion_accounting_bank_rec/tests/test_controller.py
Normal file
333
fusion_accounting_bank_rec/tests/test_controller.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""Tests for the fusion bank-rec HTTP controller (Task 26).
|
||||
|
||||
Uses ``HttpCase`` so we exercise the full Werkzeug stack -- the JSON-RPC
|
||||
dispatcher, auth check, and route resolution all run for real. Tests
|
||||
authenticate as a Fusion Accounting administrator (the realistic role
|
||||
for a user driving the bank-rec widget); a separate test confirms the
|
||||
``auth='user'`` decorator rejects anonymous traffic.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from odoo.tests.common import HttpCase, new_test_user, tagged
|
||||
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestBankRecController(HttpCase):
|
||||
"""End-to-end coverage of the 10 JSON-RPC endpoints."""
|
||||
|
||||
USER_LOGIN = 'ctrl_test_user'
|
||||
USER_PASSWORD = 'ctrl_test_user'
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# group_account_user grants accounting write perms AND auto-implies
|
||||
# fusion_accounting_user via the security XML's implied_ids hook;
|
||||
# group_fusion_accounting_admin grants full CRUD on the fusion
|
||||
# suggestion / precedent / pattern models the engine writes to.
|
||||
self.user = new_test_user(
|
||||
self.env,
|
||||
login=self.USER_LOGIN,
|
||||
password=self.USER_PASSWORD,
|
||||
groups=(
|
||||
'base.group_user,'
|
||||
'account.group_account_user,'
|
||||
'fusion_accounting_core.group_fusion_accounting_admin'
|
||||
),
|
||||
)
|
||||
self.partner = self.env['res.partner'].create(
|
||||
{'name': 'Controller Test Partner'})
|
||||
self.journal = f.make_bank_journal(
|
||||
self.env, name='Ctrl Bank', code='CBNK')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _jsonrpc(self, endpoint, params, *, authenticate=True):
|
||||
"""POST a JSON-RPC envelope to ``/fusion/bank_rec/<endpoint>``.
|
||||
|
||||
Returns the ``result`` dict on success and fails the test if
|
||||
the body has an ``error`` key (so endpoint test failures show
|
||||
the actual server-side exception, not just the HTTP status).
|
||||
"""
|
||||
if authenticate:
|
||||
self.authenticate(self.USER_LOGIN, self.USER_PASSWORD)
|
||||
url = f'/fusion/bank_rec/{endpoint}'
|
||||
body = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'call',
|
||||
'params': params,
|
||||
'id': 1,
|
||||
}
|
||||
response = self.url_open(
|
||||
url,
|
||||
data=json.dumps(body),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
self.assertEqual(
|
||||
response.status_code, 200,
|
||||
f"Endpoint {endpoint} returned {response.status_code}: "
|
||||
f"{response.text[:300]}")
|
||||
payload = response.json()
|
||||
if 'error' in payload:
|
||||
self.fail(
|
||||
f"Endpoint {endpoint} errored: "
|
||||
f"{json.dumps(payload['error'])[:600]}")
|
||||
return payload.get('result', {})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. get_state
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_get_state(self):
|
||||
result = self._jsonrpc('get_state', {
|
||||
'journal_id': self.journal.id,
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
self.assertIn('journal', result)
|
||||
self.assertEqual(result['journal']['id'], self.journal.id)
|
||||
self.assertIn('unreconciled_count', result)
|
||||
self.assertIn('total_pending_amount', result)
|
||||
self.assertIn('last_statement_date', result)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. list_unreconciled
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_list_unreconciled(self):
|
||||
# Reuse a single statement so we don't trip the
|
||||
# (journal_id, name) uniqueness or hit the parent-move autocreate
|
||||
# path twice in the same flush window.
|
||||
statement = f.make_bank_statement(
|
||||
self.env, journal=self.journal, name='List Stmt')
|
||||
f.make_bank_line(
|
||||
self.env, journal=self.journal, statement=statement,
|
||||
amount=100, partner=self.partner, memo='List 1')
|
||||
f.make_bank_line(
|
||||
self.env, journal=self.journal, statement=statement,
|
||||
amount=200, partner=self.partner, memo='List 2')
|
||||
result = self._jsonrpc('list_unreconciled', {
|
||||
'journal_id': self.journal.id,
|
||||
'limit': 50,
|
||||
'offset': 0,
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
self.assertIn('lines', result)
|
||||
self.assertGreaterEqual(len(result['lines']), 2)
|
||||
self.assertGreaterEqual(result['total'], 2)
|
||||
first = result['lines'][0]
|
||||
for key in ('id', 'amount', 'fusion_top_suggestion_id',
|
||||
'fusion_confidence_band', 'attachment_count'):
|
||||
self.assertIn(key, first)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. get_line_detail
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_get_line_detail(self):
|
||||
line = f.make_bank_line(
|
||||
self.env, journal=self.journal, amount=100, partner=self.partner)
|
||||
f.make_suggestion(
|
||||
self.env, statement_line=line, confidence=0.85)
|
||||
result = self._jsonrpc(
|
||||
'get_line_detail', {'statement_line_id': line.id})
|
||||
self.assertEqual(result['line']['id'], line.id)
|
||||
self.assertEqual(result['line']['amount'], 100.0)
|
||||
self.assertGreaterEqual(len(result['suggestions']), 1)
|
||||
sug = result['suggestions'][0]
|
||||
for key in ('id', 'candidate_ids', 'confidence', 'rank',
|
||||
'reasoning', 'scores'):
|
||||
self.assertIn(key, sug)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. suggest_matches
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_suggest_matches(self):
|
||||
f.make_invoice(self.env, partner=self.partner, amount=300)
|
||||
line = f.make_bank_line(
|
||||
self.env, journal=self.journal, amount=300, partner=self.partner)
|
||||
result = self._jsonrpc('suggest_matches', {
|
||||
'statement_line_ids': [line.id],
|
||||
'limit_per_line': 3,
|
||||
})
|
||||
self.assertIn('suggestions', result)
|
||||
self.assertIsInstance(result['suggestions'], dict)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. accept_suggestion
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_accept_suggestion(self):
|
||||
invoice = f.make_invoice(
|
||||
self.env, partner=self.partner, amount=400)
|
||||
recv = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
line = f.make_bank_line(
|
||||
self.env, journal=self.journal, amount=400, partner=self.partner)
|
||||
sug = f.make_suggestion(
|
||||
self.env, statement_line=line,
|
||||
candidate_move_lines=recv, confidence=0.92)
|
||||
result = self._jsonrpc(
|
||||
'accept_suggestion', {'suggestion_id': sug.id})
|
||||
self.assertEqual(result['status'], 'accepted')
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
self.assertIn('unreconciled_count_after', result)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. reconcile_manual
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _make_pair(self, *, amount, statement=None):
|
||||
"""Inline reconcile-able pair against ``self.journal``.
|
||||
|
||||
The shared ``make_reconcileable_pair`` factory creates a fresh bank
|
||||
journal per call (default code 'TEST'), which collides with the
|
||||
unique (code, company) constraint when used multiple times in one
|
||||
test. Reusing ``self.journal`` (and optionally a shared statement)
|
||||
keeps every pair on the same journal.
|
||||
"""
|
||||
invoice = f.make_invoice(
|
||||
self.env, partner=self.partner, amount=amount)
|
||||
recv = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
line = f.make_bank_line(
|
||||
self.env, journal=self.journal, statement=statement,
|
||||
amount=amount, partner=self.partner)
|
||||
return line, recv
|
||||
|
||||
def test_reconcile_manual(self):
|
||||
line, recv = self._make_pair(amount=550)
|
||||
result = self._jsonrpc('reconcile_manual', {
|
||||
'statement_line_id': line.id,
|
||||
'against_move_line_ids': recv.ids,
|
||||
})
|
||||
self.assertEqual(result['status'], 'reconciled')
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. unreconcile
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_unreconcile(self):
|
||||
line, recv = self._make_pair(amount=625)
|
||||
rec = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
line, against_lines=recv)
|
||||
result = self._jsonrpc('unreconcile', {
|
||||
'partial_reconcile_ids': rec['partial_ids'],
|
||||
})
|
||||
self.assertEqual(result['status'], 'unreconciled')
|
||||
self.assertGreater(len(result['unreconciled_line_ids']), 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. write_off -- smoke only (Task 12 deferred full coverage)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_write_off_smoke(self):
|
||||
line = f.make_bank_line(
|
||||
self.env, journal=self.journal, amount=12.34,
|
||||
partner=self.partner)
|
||||
# Pick any expense-type account that exists in the chart.
|
||||
wo_account = self.env['account.account'].search([
|
||||
('account_type', '=', 'expense'),
|
||||
('company_ids', 'in', self.env.company.id),
|
||||
], limit=1)
|
||||
if not wo_account:
|
||||
self.skipTest("No expense account available for write-off smoke")
|
||||
# Endpoint must respond without 500-erroring; engine may legitimately
|
||||
# raise a ValidationError for an over-allocation, in which case the
|
||||
# JSON-RPC response will include an 'error' key. We accept either
|
||||
# success or a structured error -- what we are guarding against is a
|
||||
# routing-layer regression (NameError, missing import, etc.).
|
||||
url = '/fusion/bank_rec/write_off'
|
||||
self.authenticate(self.USER_LOGIN, self.USER_PASSWORD)
|
||||
body = {
|
||||
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
|
||||
'params': {
|
||||
'statement_line_id': line.id,
|
||||
'account_id': wo_account.id,
|
||||
'amount': line.amount,
|
||||
'label': 'Smoke write-off',
|
||||
},
|
||||
}
|
||||
response = self.url_open(
|
||||
url, data=json.dumps(body),
|
||||
headers={'Content-Type': 'application/json'})
|
||||
self.assertEqual(
|
||||
response.status_code, 200,
|
||||
f"write_off returned {response.status_code}: "
|
||||
f"{response.text[:300]}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. bulk_reconcile
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_bulk_reconcile(self):
|
||||
statement = f.make_bank_statement(
|
||||
self.env, journal=self.journal, name='Bulk Stmt')
|
||||
line_ids = []
|
||||
for amt in (110, 220, 330):
|
||||
line, _recv = self._make_pair(amount=amt, statement=statement)
|
||||
line_ids.append(line.id)
|
||||
result = self._jsonrpc('bulk_reconcile', {
|
||||
'statement_line_ids': line_ids,
|
||||
'strategy': 'auto',
|
||||
})
|
||||
self.assertIn('reconciled_count', result)
|
||||
self.assertGreaterEqual(result['reconciled_count'], 3)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 10. get_partner_history
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_get_partner_history(self):
|
||||
for d in (5, 12, 20):
|
||||
f.make_precedent(
|
||||
self.env, partner=self.partner, days_ago=d, amount=1000)
|
||||
f.make_pattern(
|
||||
self.env, partner=self.partner, reconcile_count=3)
|
||||
result = self._jsonrpc('get_partner_history', {
|
||||
'partner_id': self.partner.id,
|
||||
'limit': 10,
|
||||
})
|
||||
self.assertEqual(result['partner']['id'], self.partner.id)
|
||||
self.assertGreaterEqual(len(result['recent_reconciles']), 3)
|
||||
self.assertIsNotNone(result['pattern'])
|
||||
self.assertEqual(result['pattern']['reconcile_count'], 3)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 11. unauthenticated traffic is blocked
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_unauthenticated_request_blocked(self):
|
||||
# Use a fresh session by creating a new opener -- self.url_open
|
||||
# reuses the test session, which `authenticate()` would mutate.
|
||||
url = '/fusion/bank_rec/get_state'
|
||||
body = {
|
||||
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
|
||||
'params': {
|
||||
'journal_id': self.journal.id,
|
||||
'company_id': self.env.company.id,
|
||||
},
|
||||
}
|
||||
# No call to self.authenticate() -> session has no uid.
|
||||
response = self.url_open(
|
||||
url, data=json.dumps(body),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
# Odoo's auth='user' on a JSON-RPC route returns a 200 with an
|
||||
# error envelope (SessionExpiredException) when not authenticated;
|
||||
# what must NOT happen is the handler running and returning our
|
||||
# success payload.
|
||||
if response.status_code == 200:
|
||||
payload = response.json()
|
||||
self.assertIn(
|
||||
'error', payload,
|
||||
"Unauthenticated request should not return a success result")
|
||||
else:
|
||||
# 3xx redirect or 4xx are also acceptable rejections.
|
||||
self.assertGreaterEqual(response.status_code, 300)
|
||||
85
fusion_accounting_bank_rec/tests/test_cron_methods.py
Normal file
85
fusion_accounting_bank_rec/tests/test_cron_methods.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Smoke tests for the cron handler methods.
|
||||
|
||||
We don't test the Odoo cron scheduler itself (it works) — we test that
|
||||
calling the cron methods directly does what they're supposed to do."""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionBankRecCron(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Cron Test Partner'})
|
||||
self.cron = self.env['fusion.bank.rec.cron']
|
||||
|
||||
def test_cron_suggest_pending_creates_suggestions_for_new_line(self):
|
||||
f.make_invoice(self.env, partner=self.partner, amount=420.00)
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, amount=420.00, partner=self.partner)
|
||||
|
||||
Sug = self.env['fusion.reconcile.suggestion']
|
||||
self.assertEqual(
|
||||
Sug.search_count([('statement_line_id', '=', bank_line.id)]), 0)
|
||||
|
||||
self.cron._cron_suggest_pending(batch_size=10)
|
||||
|
||||
self.assertGreater(
|
||||
Sug.search_count([('statement_line_id', '=', bank_line.id)]), 0)
|
||||
|
||||
def test_cron_suggest_pending_skips_lines_with_recent_suggestions(self):
|
||||
f.make_invoice(self.env, partner=self.partner, amount=510.00)
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, amount=510.00, partner=self.partner)
|
||||
f.make_suggestion(
|
||||
self.env, statement_line=bank_line, confidence=0.5)
|
||||
|
||||
Sug = self.env['fusion.reconcile.suggestion']
|
||||
before = Sug.search_count(
|
||||
[('statement_line_id', '=', bank_line.id)])
|
||||
self.cron._cron_suggest_pending(batch_size=10)
|
||||
after = Sug.search_count(
|
||||
[('statement_line_id', '=', bank_line.id)])
|
||||
self.assertEqual(
|
||||
before, after,
|
||||
"Cron should skip lines with a recent pending suggestion")
|
||||
|
||||
def test_cron_refresh_patterns_creates_pattern_for_partner_with_precedents(self):
|
||||
for d in [10, 24, 38]:
|
||||
f.make_precedent(
|
||||
self.env, partner=self.partner, days_ago=d, amount=1000)
|
||||
|
||||
Pattern = self.env['fusion.reconcile.pattern']
|
||||
Pattern.search([('partner_id', '=', self.partner.id)]).unlink()
|
||||
|
||||
self.cron._cron_refresh_patterns()
|
||||
|
||||
pattern = Pattern.search(
|
||||
[('partner_id', '=', self.partner.id)], limit=1)
|
||||
self.assertTrue(
|
||||
pattern, "Cron should create pattern for partner with precedents")
|
||||
self.assertEqual(pattern.reconcile_count, 3)
|
||||
|
||||
def test_cron_refresh_patterns_updates_existing_pattern(self):
|
||||
Pattern = self.env['fusion.reconcile.pattern']
|
||||
Pattern.search([('partner_id', '=', self.partner.id)]).unlink()
|
||||
f.make_pattern(
|
||||
self.env, partner=self.partner, reconcile_count=99)
|
||||
|
||||
for d in [5, 15]:
|
||||
f.make_precedent(
|
||||
self.env, partner=self.partner, days_ago=d, amount=500)
|
||||
|
||||
self.cron._cron_refresh_patterns()
|
||||
|
||||
pattern = Pattern.search(
|
||||
[('partner_id', '=', self.partner.id)], limit=1)
|
||||
self.assertEqual(
|
||||
pattern.reconcile_count, 2,
|
||||
"Cron should update existing pattern with fresh precedent count")
|
||||
|
||||
def test_cron_refresh_mv_does_not_raise(self):
|
||||
# Just verify it runs — full MV behaviour is tested in Task 24
|
||||
self.cron._cron_refresh_mv()
|
||||
56
fusion_accounting_bank_rec/tests/test_exchange_diff.py
Normal file
56
fusion_accounting_bank_rec/tests/test_exchange_diff.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.exchange_diff import (
|
||||
compute_exchange_diff, ExchangeDiffResult,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestExchangeDiff(TransactionCase):
|
||||
|
||||
def test_no_diff_when_currencies_match_and_rates_match(self):
|
||||
result = compute_exchange_diff(
|
||||
line_amount=100.00, line_currency_code='CAD',
|
||||
against_amount=100.00, against_currency_code='CAD',
|
||||
line_rate=1.0, against_rate=1.0,
|
||||
)
|
||||
self.assertFalse(result.needs_diff_move)
|
||||
self.assertEqual(result.diff_amount, 0.0)
|
||||
|
||||
def test_diff_when_rates_differ_same_currency(self):
|
||||
"""USD invoice posted at 1.35, USD bank line settled at 1.40 -> diff exists.
|
||||
100 USD at 1.40 = 140 CAD; same at 1.35 = 135 CAD; diff = 5 CAD gain."""
|
||||
result = compute_exchange_diff(
|
||||
line_amount=100.00, line_currency_code='USD',
|
||||
against_amount=100.00, against_currency_code='USD',
|
||||
line_rate=1.40, against_rate=1.35,
|
||||
)
|
||||
self.assertTrue(result.needs_diff_move)
|
||||
self.assertAlmostEqual(result.diff_amount, 5.00, places=2)
|
||||
|
||||
def test_diff_negative_when_rate_dropped(self):
|
||||
"""USD invoice at 1.40, settled at 1.35 -> loss"""
|
||||
result = compute_exchange_diff(
|
||||
line_amount=100.00, line_currency_code='USD',
|
||||
against_amount=100.00, against_currency_code='USD',
|
||||
line_rate=1.35, against_rate=1.40,
|
||||
)
|
||||
self.assertTrue(result.needs_diff_move)
|
||||
self.assertAlmostEqual(result.diff_amount, -5.00, places=2)
|
||||
|
||||
def test_company_amounts_computed_correctly(self):
|
||||
result = compute_exchange_diff(
|
||||
line_amount=100.00, line_currency_code='USD',
|
||||
against_amount=100.00, against_currency_code='USD',
|
||||
line_rate=1.40, against_rate=1.35,
|
||||
)
|
||||
self.assertAlmostEqual(result.line_company_amount, 140.00, places=2)
|
||||
self.assertAlmostEqual(result.against_company_amount, 135.00, places=2)
|
||||
|
||||
def test_tolerance_handles_rounding_noise(self):
|
||||
"""Tiny FX rounding under 0.005 should NOT trigger a diff move."""
|
||||
result = compute_exchange_diff(
|
||||
line_amount=100.00, line_currency_code='USD',
|
||||
against_amount=100.00, against_currency_code='USD',
|
||||
line_rate=1.40000, against_rate=1.40003, # 0.003 cent diff
|
||||
)
|
||||
self.assertFalse(result.needs_diff_move)
|
||||
74
fusion_accounting_bank_rec/tests/test_factories.py
Normal file
74
fusion_accounting_bank_rec/tests/test_factories.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Smoke tests verifying the factories produce usable records.
|
||||
|
||||
Not testing factory correctness exhaustively — just that each helper
|
||||
returns a record of the expected type with the expected basic state."""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFactories(TransactionCase):
|
||||
|
||||
def test_make_bank_journal(self):
|
||||
journal = f.make_bank_journal(self.env)
|
||||
self.assertEqual(journal._name, 'account.journal')
|
||||
self.assertEqual(journal.type, 'bank')
|
||||
|
||||
def test_make_bank_statement(self):
|
||||
statement = f.make_bank_statement(self.env)
|
||||
self.assertEqual(statement._name, 'account.bank.statement')
|
||||
self.assertTrue(statement.journal_id)
|
||||
|
||||
def test_make_bank_line(self):
|
||||
line = f.make_bank_line(self.env, amount=250.00, memo='Smoke memo')
|
||||
self.assertEqual(line._name, 'account.bank.statement.line')
|
||||
self.assertEqual(line.amount, 250.00)
|
||||
self.assertEqual(line.payment_ref, 'Smoke memo')
|
||||
self.assertFalse(line.is_reconciled)
|
||||
|
||||
def test_make_bank_line_with_partner(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Factory Partner'})
|
||||
line = f.make_bank_line(self.env, partner=partner, amount=500)
|
||||
self.assertEqual(line.partner_id, partner)
|
||||
|
||||
def test_make_invoice_posted(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Invoice Partner'})
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=300)
|
||||
self.assertEqual(invoice._name, 'account.move')
|
||||
self.assertEqual(invoice.move_type, 'out_invoice')
|
||||
self.assertEqual(invoice.state, 'posted')
|
||||
self.assertAlmostEqual(invoice.amount_total, 300, places=2)
|
||||
|
||||
def test_make_vendor_bill_posted(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Vendor Partner'})
|
||||
bill = f.make_vendor_bill(self.env, partner=partner, amount=400)
|
||||
self.assertEqual(bill.move_type, 'in_invoice')
|
||||
self.assertEqual(bill.state, 'posted')
|
||||
|
||||
def test_make_suggestion(self):
|
||||
line = f.make_bank_line(self.env, amount=100)
|
||||
sug = f.make_suggestion(self.env, statement_line=line, confidence=0.85)
|
||||
self.assertEqual(sug._name, 'fusion.reconcile.suggestion')
|
||||
self.assertEqual(sug.confidence, 0.85)
|
||||
self.assertEqual(sug.state, 'pending')
|
||||
|
||||
def test_make_pattern(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Pattern Partner'})
|
||||
pattern = f.make_pattern(self.env, partner=partner, reconcile_count=20)
|
||||
self.assertEqual(pattern._name, 'fusion.reconcile.pattern')
|
||||
self.assertEqual(pattern.reconcile_count, 20)
|
||||
|
||||
def test_make_precedent(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Precedent Partner'})
|
||||
precedent = f.make_precedent(self.env, partner=partner, amount=999.99)
|
||||
self.assertEqual(precedent._name, 'fusion.reconcile.precedent')
|
||||
self.assertEqual(precedent.amount, 999.99)
|
||||
self.assertEqual(precedent.source, 'manual')
|
||||
|
||||
def test_make_reconcileable_pair(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=750)
|
||||
self.assertEqual(bank_line.amount, 750.00)
|
||||
self.assertGreater(len(recv_lines), 0)
|
||||
self.assertAlmostEqual(sum(recv_lines.mapped('amount_residual')), 750, places=2)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests verifying legacy tools route through fusion.reconcile.engine when present.
|
||||
|
||||
These tests run in the fusion_accounting_bank_rec context where the engine IS
|
||||
available, so they assert the engine path is taken and produces correct
|
||||
results. The fallback path is exercised by the existing fusion_accounting_ai
|
||||
tests when fusion_accounting_bank_rec is not installed."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_ai.services.tools import bank_reconciliation as tools
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLegacyToolsRefactor(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Refactor Test Partner'})
|
||||
|
||||
def test_match_bank_line_to_payments_uses_engine(self):
|
||||
"""When engine is present, match_bank_line_to_payments must produce
|
||||
a partial reconcile via the engine, not via set_line_bank_statement_line."""
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||
self.env, amount=180.00, partner=self.partner)
|
||||
result = tools.match_bank_line_to_payments(self.env, {
|
||||
'statement_line_id': bank_line.id,
|
||||
'move_line_ids': recv_lines.ids,
|
||||
})
|
||||
self.assertEqual(result.get('status'), 'matched')
|
||||
bank_line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertTrue(bank_line.is_reconciled)
|
||||
# Verify a precedent was recorded - engine-only behaviour
|
||||
Precedent = self.env['fusion.reconcile.precedent']
|
||||
precedents = Precedent.search([('partner_id', '=', self.partner.id)])
|
||||
self.assertGreater(len(precedents), 0,
|
||||
"Engine path should record a precedent; legacy path would not")
|
||||
|
||||
def test_auto_reconcile_bank_lines_uses_engine(self):
|
||||
"""When engine is present, auto_reconcile_bank_lines must call
|
||||
fusion.reconcile.engine.reconcile_batch (not the Enterprise-only
|
||||
_try_auto_reconcile_statement_lines fallback). We patch
|
||||
reconcile_batch to verify routing without running the real engine
|
||||
across every legacy unreconciled line in the test DB."""
|
||||
Engine = type(self.env['fusion.reconcile.engine'])
|
||||
with patch.object(
|
||||
Engine, 'reconcile_batch', autospec=True,
|
||||
return_value={'reconciled_count': 2, 'skipped': 0, 'errors': []},
|
||||
) as engine_call:
|
||||
result = tools.auto_reconcile_bank_lines(self.env, {
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
self.assertEqual(result['status'], 'completed')
|
||||
self.assertTrue(engine_call.called,
|
||||
"Engine path must invoke fusion.reconcile.engine.reconcile_batch")
|
||||
# Verify the engine was passed the strategy='auto' kwarg per spec
|
||||
_self, _lines = engine_call.call_args.args[0], engine_call.call_args.args[1]
|
||||
self.assertEqual(engine_call.call_args.kwargs.get('strategy'), 'auto')
|
||||
111
fusion_accounting_bank_rec/tests/test_matching_strategies.py
Normal file
111
fusion_accounting_bank_rec/tests/test_matching_strategies.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import (
|
||||
Candidate, AmountExactStrategy, FIFOStrategy, MultiInvoiceStrategy, MatchResult,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAmountExactStrategy(TransactionCase):
|
||||
|
||||
def test_picks_exact_amount(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=99.99, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=100.00, partner_id=42, age_days=20),
|
||||
Candidate(id=3, amount=100.50, partner_id=42, age_days=5),
|
||||
]
|
||||
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [2])
|
||||
self.assertEqual(result.confidence, 1.0)
|
||||
|
||||
def test_no_match_when_no_exact(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=99.99, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=100.50, partner_id=42, age_days=20),
|
||||
]
|
||||
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
def test_picks_oldest_when_multiple_exact(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=100.00, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=100.00, partner_id=42, age_days=30), # oldest
|
||||
Candidate(id=3, amount=100.00, partner_id=42, age_days=20),
|
||||
]
|
||||
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [2])
|
||||
|
||||
def test_handles_empty_candidates(self):
|
||||
result = AmountExactStrategy().match(bank_amount=100.00, candidates=[])
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFIFOStrategy(TransactionCase):
|
||||
|
||||
def test_picks_oldest_first(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=50.00, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=50.00, partner_id=42, age_days=30),
|
||||
Candidate(id=3, amount=50.00, partner_id=42, age_days=20),
|
||||
]
|
||||
result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [2, 3]) # oldest two summing to 100
|
||||
|
||||
def test_handles_partial_payment(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=200.00, partner_id=42, age_days=30),
|
||||
]
|
||||
result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [1]) # partial reconcile signaled by residual
|
||||
self.assertEqual(result.residual, -100.00) # over-allocated; engine handles
|
||||
|
||||
def test_handles_empty_candidates(self):
|
||||
result = FIFOStrategy().match(bank_amount=100.00, candidates=[])
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestMultiInvoiceStrategy(TransactionCase):
|
||||
|
||||
def test_finds_smallest_set_summing_to_amount(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=30.00, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=40.00, partner_id=42, age_days=15),
|
||||
Candidate(id=3, amount=30.00, partner_id=42, age_days=20),
|
||||
Candidate(id=4, amount=70.00, partner_id=42, age_days=25),
|
||||
]
|
||||
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||
bank_amount=100.00, candidates=candidates)
|
||||
# Two-element solutions exist (e.g., {3,4}=100). Strategy should pick a 2-set.
|
||||
self.assertEqual(len(result.picked_ids), 2)
|
||||
# The picked set should sum to 100
|
||||
picked_amounts = [c.amount for c in candidates if c.id in result.picked_ids]
|
||||
self.assertAlmostEqual(sum(picked_amounts), 100.00, places=2)
|
||||
|
||||
def test_returns_empty_when_no_combination_sums(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=15.00, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=25.00, partner_id=42, age_days=15),
|
||||
]
|
||||
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||
bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
def test_respects_max_combinations(self):
|
||||
# Many small invoices — only combinations of ≤3 items considered
|
||||
candidates = [Candidate(id=i, amount=10.00, partner_id=42, age_days=i)
|
||||
for i in range(1, 11)]
|
||||
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||
bank_amount=100.00, candidates=candidates)
|
||||
# Can't make 100 with ≤3 items of $10 each
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
def test_strategy_name_includes_combination_size(self):
|
||||
candidates = [
|
||||
Candidate(id=1, amount=50.00, partner_id=42, age_days=10),
|
||||
Candidate(id=2, amount=50.00, partner_id=42, age_days=20),
|
||||
]
|
||||
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||
bank_amount=100.00, candidates=candidates)
|
||||
self.assertEqual(set(result.picked_ids), {1, 2})
|
||||
self.assertIn('multi_invoice', result.strategy_name)
|
||||
42
fusion_accounting_bank_rec/tests/test_memo_tokenizer.py
Normal file
42
fusion_accounting_bank_rec/tests/test_memo_tokenizer.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.memo_tokenizer import tokenize_memo
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestMemoTokenizer(TransactionCase):
|
||||
|
||||
def test_extracts_rbc_etf_reference(self):
|
||||
tokens = tokenize_memo("RBC ETF DEP REF 4831")
|
||||
self.assertIn('RBC', tokens)
|
||||
self.assertIn('ETF', tokens)
|
||||
self.assertIn('REF4831', tokens)
|
||||
|
||||
def test_extracts_cheque_number(self):
|
||||
tokens = tokenize_memo("CHEQUE 4827 - WESTIN PLATING")
|
||||
self.assertIn('CHEQUE4827', tokens)
|
||||
self.assertIn('WESTIN', tokens)
|
||||
self.assertIn('PLATING', tokens)
|
||||
|
||||
def test_strips_noise_tokens(self):
|
||||
tokens = tokenize_memo("PAYMENT - INV - DEP - 12345")
|
||||
self.assertNotIn('-', tokens)
|
||||
self.assertEqual([t for t in tokens if len(t) <= 1], [])
|
||||
|
||||
def test_handles_empty_memo(self):
|
||||
self.assertEqual(tokenize_memo(""), [])
|
||||
self.assertEqual(tokenize_memo(None), [])
|
||||
|
||||
def test_canadian_french_memo(self):
|
||||
tokens = tokenize_memo("PAIEMENT VIREMENT BANCAIRE")
|
||||
self.assertIn('PAIEMENT', tokens)
|
||||
self.assertIn('VIREMENT', tokens)
|
||||
|
||||
def test_normalises_case(self):
|
||||
tokens = tokenize_memo("rbc etf dep ref 4831")
|
||||
self.assertIn('RBC', tokens)
|
||||
|
||||
def test_handles_special_characters(self):
|
||||
tokens = tokenize_memo("RBC*PAYMENT/REF#4831")
|
||||
self.assertIn('RBC', tokens)
|
||||
self.assertIn('PAYMENT', tokens)
|
||||
self.assertIn('REF4831', tokens)
|
||||
82
fusion_accounting_bank_rec/tests/test_mv_unreconciled.py
Normal file
82
fusion_accounting_bank_rec/tests/test_mv_unreconciled.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Smoke tests for the fusion_unreconciled_bank_line_mv materialized view.
|
||||
|
||||
Notes on transactional semantics:
|
||||
- REFRESH MATERIALIZED VIEW (non-CONCURRENTLY) IS transactional and runs
|
||||
inside the current transaction. Postgres always shows a transaction
|
||||
its own (uncommitted) writes, so an INSERT followed by a REFRESH in
|
||||
the same transaction picks up the new row — no `cr.commit()` needed.
|
||||
- Odoo's TransactionCase forbids cr.commit() anyway (it would break the
|
||||
per-test savepoint rollback). We rely on rollback to clean up both
|
||||
the test fixtures and the MV-table mutations from the refresh.
|
||||
- REFRESH MATERIALIZED VIEW CONCURRENTLY must run OUTSIDE a transaction
|
||||
block; we always pass concurrently=False from tests. The production
|
||||
cron path (Task 25) will open a dedicated autocommit cursor for the
|
||||
concurrent refresh.
|
||||
"""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUnreconciledBankLineMV(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({
|
||||
'name': 'MV Test Partner',
|
||||
})
|
||||
# Refresh once at the start so the MV reflects the current snapshot
|
||||
# (including any rows inserted earlier in this savepoint chain).
|
||||
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||
concurrently=False)
|
||||
|
||||
def test_mv_exists_and_is_queryable(self):
|
||||
# Smoke: the model can be searched without error.
|
||||
rows = self.env['fusion.unreconciled.bank.line.mv'].search(
|
||||
[], limit=10)
|
||||
self.assertIsNotNone(rows)
|
||||
|
||||
def test_mv_includes_unreconciled_line(self):
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, amount=999.99, partner=self.partner)
|
||||
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||
concurrently=False)
|
||||
mv_row = self.env['fusion.unreconciled.bank.line.mv'].search([
|
||||
('id', '=', bank_line.id),
|
||||
])
|
||||
self.assertTrue(
|
||||
mv_row,
|
||||
"MV should contain freshly-inserted unreconciled line")
|
||||
self.assertAlmostEqual(mv_row.amount, 999.99, places=2)
|
||||
# No suggestion yet -> band 'none', confidence 0.
|
||||
self.assertEqual(mv_row.confidence_band, 'none')
|
||||
self.assertEqual(mv_row.attachment_count, 0)
|
||||
|
||||
def test_mv_excludes_reconciled_line(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||
self.env, amount=100.00, partner=self.partner)
|
||||
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||
concurrently=False)
|
||||
mv_row = self.env['fusion.unreconciled.bank.line.mv'].search([
|
||||
('id', '=', bank_line.id),
|
||||
])
|
||||
self.assertFalse(
|
||||
mv_row, "Reconciled line should be excluded from MV")
|
||||
|
||||
def test_mv_confidence_band_high_for_high_conf_suggestion(self):
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, amount=500.00, partner=self.partner)
|
||||
f.make_suggestion(
|
||||
self.env, statement_line=bank_line, confidence=0.92)
|
||||
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||
concurrently=False)
|
||||
mv_row = self.env['fusion.unreconciled.bank.line.mv'].search([
|
||||
('id', '=', bank_line.id),
|
||||
])
|
||||
self.assertTrue(mv_row, "MV row should exist for suggestion line")
|
||||
# 0.92 falls in the 'high' band per the SQL CASE (>= 0.85).
|
||||
self.assertEqual(mv_row.confidence_band, 'high')
|
||||
self.assertAlmostEqual(mv_row.top_confidence, 0.92, places=2)
|
||||
73
fusion_accounting_bank_rec/tests/test_pattern_extraction.py
Normal file
73
fusion_accounting_bank_rec/tests/test_pattern_extraction.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from datetime import date, timedelta, datetime
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.pattern_extractor import (
|
||||
extract_pattern_for_partner,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPatternExtractor(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Pattern Test Partner'})
|
||||
self.currency = self.env.ref('base.CAD')
|
||||
self.company = self.env.company
|
||||
|
||||
def _make_precedent(self, *, amount, days_ago, memo='RBC,ETF', count=1, source='manual'):
|
||||
return self.env['fusion.reconcile.precedent'].create({
|
||||
'company_id': self.company.id,
|
||||
'partner_id': self.partner.id,
|
||||
'amount': amount,
|
||||
'currency_id': self.currency.id,
|
||||
'date': date.today() - timedelta(days=days_ago),
|
||||
'memo_tokens': memo,
|
||||
'matched_move_line_count': count,
|
||||
'reconciled_at': datetime.now() - timedelta(days=days_ago),
|
||||
'source': source,
|
||||
})
|
||||
|
||||
def test_extracts_typical_amount_range(self):
|
||||
for d in [10, 24, 38, 52]:
|
||||
self._make_precedent(amount=1847.50, days_ago=d)
|
||||
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||
self.assertIn('typical_amount_range', pattern_vals)
|
||||
self.assertEqual(pattern_vals['reconcile_count'], 4)
|
||||
|
||||
def test_detects_exact_amount_strategy(self):
|
||||
for d in range(0, 56, 14):
|
||||
self._make_precedent(amount=1847.50, days_ago=d, count=1)
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||
self.assertEqual(pattern_vals['pref_strategy'], 'exact_amount')
|
||||
|
||||
def test_detects_multi_invoice_strategy(self):
|
||||
for d in range(0, 56, 14):
|
||||
self._make_precedent(amount=2500.00, days_ago=d, count=3)
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||
self.assertEqual(pattern_vals['pref_strategy'], 'multi_invoice')
|
||||
|
||||
def test_computes_cadence_days(self):
|
||||
for d in [0, 14, 28, 42]:
|
||||
self._make_precedent(amount=1000, days_ago=d)
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||
self.assertAlmostEqual(pattern_vals['typical_cadence_days'], 14.0, delta=1)
|
||||
|
||||
def test_extracts_common_memo_tokens(self):
|
||||
self._make_precedent(amount=1000, days_ago=10, memo='RBC,ETF,REF')
|
||||
self._make_precedent(amount=1000, days_ago=24, memo='RBC,ETF,DEPOSIT')
|
||||
self._make_precedent(amount=1000, days_ago=38, memo='RBC,ETF,REF')
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||
self.assertIn('RBC', pattern_vals['common_memo_tokens'])
|
||||
self.assertIn('ETF', pattern_vals['common_memo_tokens'])
|
||||
|
||||
def test_returns_zero_count_for_partner_with_no_precedents(self):
|
||||
other_partner = self.env['res.partner'].create({'name': 'Empty Partner'})
|
||||
pattern_vals = extract_pattern_for_partner(
|
||||
self.env, company_id=self.company.id, partner_id=other_partner.id)
|
||||
self.assertEqual(pattern_vals['reconcile_count'], 0)
|
||||
73
fusion_accounting_bank_rec/tests/test_precedent_lookup.py
Normal file
73
fusion_accounting_bank_rec/tests/test_precedent_lookup.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from datetime import date
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.precedent_lookup import (
|
||||
find_nearest_precedents, PrecedentMatch,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPrecedentLookup(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Precedent Lookup Partner'})
|
||||
self.currency = self.env.ref('base.CAD')
|
||||
self.company = self.env.company
|
||||
for amt in [1847.50, 1847.50, 1800.00]:
|
||||
self.env['fusion.reconcile.precedent'].create({
|
||||
'company_id': self.company.id,
|
||||
'partner_id': self.partner.id,
|
||||
'amount': amt,
|
||||
'currency_id': self.currency.id,
|
||||
'date': date.today(),
|
||||
'memo_tokens': 'RBC,ETF,REF',
|
||||
'matched_move_line_count': 1,
|
||||
'source': 'manual',
|
||||
})
|
||||
|
||||
def test_finds_amount_exact_precedents(self):
|
||||
results = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=1847.50, k=5)
|
||||
amounts = [r.amount for r in results]
|
||||
self.assertEqual(amounts.count(1847.50), 2)
|
||||
|
||||
def test_returns_empty_for_unknown_partner(self):
|
||||
results = find_nearest_precedents(
|
||||
self.env, partner_id=999999, amount=1847.50, k=5)
|
||||
self.assertEqual(results, [])
|
||||
|
||||
def test_respects_k_limit(self):
|
||||
for i in range(10):
|
||||
self.env['fusion.reconcile.precedent'].create({
|
||||
'company_id': self.company.id,
|
||||
'partner_id': self.partner.id,
|
||||
'amount': 1847.50,
|
||||
'currency_id': self.currency.id,
|
||||
'date': date.today(),
|
||||
'matched_move_line_count': 1,
|
||||
'source': 'manual',
|
||||
})
|
||||
results = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=1847.50, k=3)
|
||||
self.assertEqual(len(results), 3)
|
||||
|
||||
def test_results_sorted_by_similarity_desc(self):
|
||||
results = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=1847.50, k=5)
|
||||
if len(results) >= 2:
|
||||
self.assertGreaterEqual(results[0].similarity_score, results[1].similarity_score)
|
||||
|
||||
def test_memo_overlap_boosts_score(self):
|
||||
results_with_memo = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=1847.50, k=5,
|
||||
memo_tokens=['RBC', 'ETF', 'REF'])
|
||||
results_no_memo = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=1847.50, k=5)
|
||||
if results_with_memo and results_no_memo:
|
||||
self.assertGreaterEqual(results_with_memo[0].similarity_score,
|
||||
results_no_memo[0].similarity_score - 0.001)
|
||||
|
||||
def test_amount_outside_tolerance_excluded(self):
|
||||
results = find_nearest_precedents(
|
||||
self.env, partner_id=self.partner.id, amount=2000.00, k=5)
|
||||
self.assertEqual(results, [])
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Integration tests for the reconcile engine.
|
||||
|
||||
These tests use the test factories (_factories.py) to set up realistic
|
||||
bank-line + invoice scenarios, then call engine methods and assert the
|
||||
account.partial.reconcile rows produced have the right shape.
|
||||
|
||||
Tests cover:
|
||||
- Simple 1:1 match (bank line == one invoice)
|
||||
- Partial chain (one bank line < invoice amount)
|
||||
- Multi-invoice consolidation (one bank line == sum of N invoices)
|
||||
- Auto-strategy batch (mix of matchable and unmatchable lines)
|
||||
- Suggest-then-accept flow
|
||||
- Unreconcile (reverse a reconciliation)
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestReconcileSimpleMatch(TransactionCase):
|
||||
"""The most common scenario: 1 bank line matched against 1 invoice exact."""
|
||||
|
||||
def test_simple_match_creates_partial_reconcile(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00)
|
||||
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
partial = self.env['account.partial.reconcile'].browse(result['partial_ids'])
|
||||
self.assertAlmostEqual(sum(partial.mapped('amount')), 100.00, places=2)
|
||||
|
||||
def test_simple_match_marks_line_reconciled(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=250.00)
|
||||
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
bank_line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertTrue(bank_line.is_reconciled)
|
||||
|
||||
def test_simple_match_records_precedent(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=500.00)
|
||||
partner = bank_line.partner_id
|
||||
Precedent = self.env['fusion.reconcile.precedent']
|
||||
before = Precedent.search_count([('partner_id', '=', partner.id)])
|
||||
|
||||
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
|
||||
after = Precedent.search_count([('partner_id', '=', partner.id)])
|
||||
self.assertEqual(after, before + 1, "Engine should record one precedent per reconcile")
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestReconcilePartialChain(TransactionCase):
|
||||
"""Bank line amount < invoice amount -> partial reconcile, residual remains."""
|
||||
|
||||
def test_partial_reconcile_leaves_residual(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Partial Partner'})
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=300.00)
|
||||
recv_lines = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
|
||||
bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner)
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
invoice.invalidate_recordset(['payment_state', 'amount_residual'])
|
||||
self.assertAlmostEqual(invoice.amount_residual, 200.00, places=2)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestReconcileBatch(TransactionCase):
|
||||
"""Bulk reconcile: mix of matchable and unmatchable lines."""
|
||||
|
||||
def test_batch_reconciles_matchable_lines_only(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Batch Partner'})
|
||||
# Share one journal/statement to avoid duplicate-code conflicts
|
||||
# when creating multiple bank lines in the same test transaction.
|
||||
shared_journal = f.make_bank_journal(self.env, name='Batch Bank', code='BBNK')
|
||||
shared_statement = f.make_bank_statement(self.env, journal=shared_journal)
|
||||
pairs = []
|
||||
for amount in [100.00, 200.00, 300.00]:
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=amount)
|
||||
recv_lines = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, statement=shared_statement, amount=amount,
|
||||
partner=partner)
|
||||
pairs.append((bank_line, recv_lines))
|
||||
|
||||
orphan_line = f.make_bank_line(
|
||||
self.env, statement=shared_statement, amount=999.99, partner=partner)
|
||||
|
||||
all_lines = self.env['account.bank.statement.line'].browse(
|
||||
[p[0].id for p in pairs] + [orphan_line.id])
|
||||
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_batch(
|
||||
all_lines, strategy='auto')
|
||||
|
||||
self.assertEqual(result['reconciled_count'], 3)
|
||||
self.assertGreaterEqual(result['skipped'], 1)
|
||||
self.assertEqual(len(result['errors']), 0)
|
||||
|
||||
def test_batch_handles_empty_recordset(self):
|
||||
empty = self.env['account.bank.statement.line']
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_batch(empty)
|
||||
self.assertEqual(result['reconciled_count'], 0)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestSuggestThenAccept(TransactionCase):
|
||||
"""Full flow: suggest_matches creates suggestions; accept_suggestion reconciles."""
|
||||
|
||||
def test_suggest_then_accept(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Suggest Then Accept'})
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=750.00)
|
||||
bank_line = f.make_bank_line(self.env, amount=750.00, partner=partner,
|
||||
memo='Test suggest accept')
|
||||
|
||||
suggestions = self.env['fusion.reconcile.engine'].suggest_matches(
|
||||
bank_line, limit_per_line=3)
|
||||
|
||||
self.assertIn(bank_line.id, suggestions)
|
||||
self.assertGreater(len(suggestions[bank_line.id]), 0,
|
||||
"Engine should suggest at least one candidate for matching invoice")
|
||||
|
||||
top_suggestion_id = suggestions[bank_line.id][0]['id']
|
||||
sug = self.env['fusion.reconcile.suggestion'].browse(top_suggestion_id)
|
||||
result = self.env['fusion.reconcile.engine'].accept_suggestion(sug)
|
||||
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
sug.invalidate_recordset(['state', 'accepted_at', 'accepted_by'])
|
||||
self.assertEqual(sug.state, 'accepted')
|
||||
self.assertTrue(sug.accepted_at)
|
||||
bank_line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertTrue(bank_line.is_reconciled)
|
||||
|
||||
def test_suggest_supersedes_prior_pending(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Supersede Test'})
|
||||
bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner)
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=100.00)
|
||||
|
||||
self.env['fusion.reconcile.engine'].suggest_matches(bank_line)
|
||||
first_pending = self.env['fusion.reconcile.suggestion'].search([
|
||||
('statement_line_id', '=', bank_line.id),
|
||||
('state', '=', 'pending'),
|
||||
])
|
||||
self.assertGreater(len(first_pending), 0)
|
||||
|
||||
self.env['fusion.reconcile.engine'].suggest_matches(bank_line)
|
||||
first_pending.invalidate_recordset(['state'])
|
||||
for s in first_pending:
|
||||
self.assertEqual(s.state, 'superseded')
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestUnreconcile(TransactionCase):
|
||||
"""Reverse a reconciliation."""
|
||||
|
||||
def test_unreconcile_removes_partial(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00)
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
partials = self.env['account.partial.reconcile'].browse(result['partial_ids'])
|
||||
self.assertGreater(len(partials), 0)
|
||||
|
||||
unrec_result = self.env['fusion.reconcile.engine'].unreconcile(partials)
|
||||
|
||||
self.assertGreater(len(unrec_result['unreconciled_line_ids']), 0)
|
||||
self.assertFalse(partials.exists())
|
||||
bank_line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertFalse(bank_line.is_reconciled)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestEngineEdgeCases(TransactionCase):
|
||||
"""Edge cases that came up during engine implementation."""
|
||||
|
||||
def test_reconcile_validates_line_exists(self):
|
||||
from odoo.exceptions import ValidationError
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
self.env['account.bank.statement.line'],
|
||||
against_lines=self.env['account.move.line'])
|
||||
|
||||
def test_already_reconciled_line_skipped_in_batch(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Already Reconciled'})
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||
self.env, amount=50.00, partner=partner)
|
||||
|
||||
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
bank_line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertTrue(bank_line.is_reconciled)
|
||||
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_batch(bank_line)
|
||||
self.assertGreater(result['skipped'], 0)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Property-based tests for reconcile engine invariants.
|
||||
|
||||
Hypothesis generates random input combinations to catch edge cases that
|
||||
example-based TDD missed. Each test runs N times (default 50 -- bumpable
|
||||
via @settings)."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from hypothesis import HealthCheck, given, settings, strategies as st
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import (
|
||||
AmountExactStrategy,
|
||||
Candidate,
|
||||
FIFOStrategy,
|
||||
MultiInvoiceStrategy,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'property_based')
|
||||
class TestMatchingStrategyInvariants(TransactionCase):
|
||||
"""Pure-Python invariants on the matching strategies (no ORM needed).
|
||||
Faster + more iterations than DB-backed property tests."""
|
||||
|
||||
@given(
|
||||
bank_amount=st.floats(min_value=0.01, max_value=100000.00,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
invoice_amounts=st.lists(
|
||||
st.floats(min_value=0.01, max_value=100000.00,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=100, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_amount_exact_picks_only_when_amount_matches(
|
||||
self, bank_amount, invoice_amounts):
|
||||
"""AmountExactStrategy returns picks IFF some candidate amount matches
|
||||
bank_amount within tolerance."""
|
||||
candidates = [
|
||||
Candidate(id=i, amount=round(amt, 2), partner_id=1, age_days=10)
|
||||
for i, amt in enumerate(invoice_amounts)
|
||||
]
|
||||
bank_amount = round(bank_amount, 2)
|
||||
result = AmountExactStrategy().match(
|
||||
bank_amount=bank_amount, candidates=candidates)
|
||||
|
||||
has_match = any(
|
||||
abs(c.amount - bank_amount) < 0.005 for c in candidates)
|
||||
if has_match:
|
||||
self.assertEqual(
|
||||
len(result.picked_ids), 1,
|
||||
f"bank=${bank_amount} candidates={[c.amount for c in candidates]} "
|
||||
f"has_match={has_match} -> expected 1 pick, got {result.picked_ids}",
|
||||
)
|
||||
self.assertEqual(result.confidence, 1.0)
|
||||
else:
|
||||
self.assertEqual(result.picked_ids, [])
|
||||
|
||||
@given(
|
||||
bank_amount=st.floats(min_value=10.00, max_value=10000.00,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
invoice_amounts=st.lists(
|
||||
st.floats(min_value=1.00, max_value=10000.00,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=8,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=100, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_fifo_picks_oldest_first(self, bank_amount, invoice_amounts):
|
||||
"""FIFOStrategy picks candidates in order of decreasing age_days
|
||||
(oldest first), stopping when remaining <= 0."""
|
||||
candidates = [
|
||||
Candidate(id=i, amount=round(amt, 2), partner_id=1,
|
||||
age_days=100 - i)
|
||||
for i, amt in enumerate(invoice_amounts)
|
||||
]
|
||||
bank_amount = round(bank_amount, 2)
|
||||
result = FIFOStrategy().match(
|
||||
bank_amount=bank_amount, candidates=candidates)
|
||||
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
oldest_first_ids = [
|
||||
c.id for c in sorted(candidates, key=lambda c: -c.age_days)]
|
||||
self.assertEqual(
|
||||
result.picked_ids,
|
||||
oldest_first_ids[:len(result.picked_ids)],
|
||||
)
|
||||
|
||||
picked_sum = sum(
|
||||
c.amount for c in candidates if c.id in result.picked_ids)
|
||||
self.assertAlmostEqual(
|
||||
result.residual, bank_amount - picked_sum, places=2)
|
||||
|
||||
@given(
|
||||
amounts=st.lists(
|
||||
st.floats(min_value=1.00, max_value=1000.00,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
min_size=2, max_size=6,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=50, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_multi_invoice_finds_combination_when_one_exists(self, amounts):
|
||||
"""If amounts can sum to a target via <=3 elements, MultiInvoiceStrategy
|
||||
finds SOME valid combination."""
|
||||
rounded = [round(a, 2) for a in amounts]
|
||||
candidates = [
|
||||
Candidate(id=i, amount=amt, partner_id=1, age_days=10)
|
||||
for i, amt in enumerate(rounded)
|
||||
]
|
||||
target = round(rounded[0] + rounded[1], 2)
|
||||
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||
bank_amount=target, candidates=candidates)
|
||||
|
||||
if result.picked_ids:
|
||||
picked_sum = sum(
|
||||
c.amount for c in candidates if c.id in result.picked_ids)
|
||||
self.assertAlmostEqual(
|
||||
picked_sum, target, places=2,
|
||||
msg=(f"target={target} picks={result.picked_ids} "
|
||||
f"sum={picked_sum} candidates={rounded}"),
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'property_based', 'engine_invariants')
|
||||
class TestReconcileEngineInvariants(TransactionCase):
|
||||
"""ORM-backed property tests against the engine.
|
||||
Slower because each test creates real bank_lines + invoices."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create(
|
||||
{'name': 'Engine Property Partner'})
|
||||
self.journal = self.env['account.journal'].create({
|
||||
'name': 'Engine Property Bank',
|
||||
'type': 'bank',
|
||||
'code': 'EPB',
|
||||
})
|
||||
self.receivable_account = self.env['account.account'].search([
|
||||
('account_type', '=', 'asset_receivable'),
|
||||
('company_ids', 'in', self.env.company.id),
|
||||
], limit=1)
|
||||
if not self.receivable_account:
|
||||
self.skipTest("No receivable account in chart of accounts")
|
||||
|
||||
def _make_bank_line(self, amount):
|
||||
statement = self.env['account.bank.statement'].create({
|
||||
'name': f'Test stmt {amount}',
|
||||
'journal_id': self.journal.id,
|
||||
})
|
||||
return self.env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': self.journal.id,
|
||||
'date': date.today(),
|
||||
'payment_ref': f'Test {amount}',
|
||||
'amount': amount,
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
def _make_invoice(self, amount):
|
||||
product = self.env['product.product'].search(
|
||||
[('type', '=', 'service')], limit=1)
|
||||
if not product:
|
||||
product = self.env['product.product'].create({
|
||||
'name': 'Property Test Service',
|
||||
'type': 'service',
|
||||
})
|
||||
move = self.env['account.move'].create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': self.partner.id,
|
||||
'invoice_date': date.today(),
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': 'Property Test',
|
||||
'quantity': 1,
|
||||
'price_unit': amount,
|
||||
})],
|
||||
})
|
||||
move.action_post()
|
||||
return move
|
||||
|
||||
@given(amount=st.floats(min_value=10.00, max_value=10000.00,
|
||||
allow_nan=False, allow_infinity=False))
|
||||
@settings(max_examples=10, deadline=10000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_invariant_simple_reconcile_balances(self, amount):
|
||||
"""For any bank_amount = invoice_amount, reconcile_one produces:
|
||||
- exactly 1 partial reconcile
|
||||
- amount equal to the bank line amount
|
||||
- bank line is_reconciled = True"""
|
||||
amount = round(amount, 2)
|
||||
bank_line = self._make_bank_line(amount)
|
||||
invoice = self._make_invoice(amount)
|
||||
invoice_recv_lines = invoice.line_ids.filtered(
|
||||
lambda line: line.account_id.account_type == 'asset_receivable')
|
||||
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=invoice_recv_lines)
|
||||
|
||||
self.assertGreater(
|
||||
len(result['partial_ids']), 0,
|
||||
f"Expected partial_ids non-empty for amount={amount}, got {result}",
|
||||
)
|
||||
partials = self.env['account.partial.reconcile'].browse(
|
||||
result['partial_ids'])
|
||||
self.assertAlmostEqual(
|
||||
sum(partials.mapped('amount')), amount, places=2)
|
||||
bank_line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertTrue(
|
||||
bank_line.is_reconciled,
|
||||
f"is_reconciled expected True after reconcile for amount={amount}",
|
||||
)
|
||||
348
fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py
Normal file
348
fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""Unit tests for fusion.reconcile.engine — the 6-method public API.
|
||||
|
||||
Test layers:
|
||||
- Layer 1: API surface (registry + method existence)
|
||||
- Layer 2: unreconcile
|
||||
- Layer 3: reconcile_one happy path
|
||||
- Layer 4: accept_suggestion
|
||||
- Layer 5: suggest_matches
|
||||
- Layer 6: reconcile_batch
|
||||
- Layer 7: write_off
|
||||
|
||||
Tests share a common setUpClass fixture providing a partner, bank
|
||||
journal, statement, receivable account, and a small helper to mint a
|
||||
posted customer invoice + bank statement line at given amounts.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineBase(TransactionCase):
|
||||
"""Shared fixtures for engine tests."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.engine = cls.env['fusion.reconcile.engine']
|
||||
cls.company = cls.env.company
|
||||
cls.currency = cls.company.currency_id
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'Engine Test Partner',
|
||||
})
|
||||
cls.bank_journal = cls.env['account.journal'].create({
|
||||
'name': 'Engine Test Bank',
|
||||
'type': 'bank',
|
||||
'code': 'ETBK',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.sales_journal = cls.env['account.journal'].search([
|
||||
('type', '=', 'sale'),
|
||||
('company_id', '=', cls.company.id),
|
||||
], limit=1)
|
||||
if not cls.sales_journal:
|
||||
cls.sales_journal = cls.env['account.journal'].create({
|
||||
'name': 'Engine Test Sales',
|
||||
'type': 'sale',
|
||||
'code': 'ETSAL',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.receivable_account = cls.env['account.account'].search([
|
||||
('account_type', '=', 'asset_receivable'),
|
||||
('company_ids', 'in', cls.company.id),
|
||||
], limit=1)
|
||||
cls.income_account = cls.env['account.account'].search([
|
||||
('account_type', '=', 'income'),
|
||||
('company_ids', 'in', cls.company.id),
|
||||
], limit=1)
|
||||
cls.expense_account = cls.env['account.account'].search([
|
||||
('account_type', '=', 'expense'),
|
||||
('company_ids', 'in', cls.company.id),
|
||||
], limit=1)
|
||||
|
||||
def _make_statement_line(self, amount, *, partner=None, ref='ENGTEST',
|
||||
line_date=None):
|
||||
statement = self.env['account.bank.statement'].create({
|
||||
'name': 'Engine Test Statement',
|
||||
'journal_id': self.bank_journal.id,
|
||||
})
|
||||
return self.env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': self.bank_journal.id,
|
||||
'date': line_date or date.today(),
|
||||
'payment_ref': ref,
|
||||
'amount': amount,
|
||||
'partner_id': (partner or self.partner).id,
|
||||
})
|
||||
|
||||
def _make_invoice(self, amount, *, partner=None, inv_date=None):
|
||||
"""Create + post a customer invoice for the given amount."""
|
||||
inv = self.env['account.move'].create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': (partner or self.partner).id,
|
||||
'invoice_date': inv_date or date.today(),
|
||||
'journal_id': self.sales_journal.id,
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Engine test product',
|
||||
'quantity': 1,
|
||||
'price_unit': amount,
|
||||
'account_id': self.income_account.id,
|
||||
'tax_ids': [(6, 0, [])],
|
||||
})],
|
||||
})
|
||||
inv.action_post()
|
||||
return inv
|
||||
|
||||
def _receivable_line(self, invoice):
|
||||
return invoice.line_ids.filtered(
|
||||
lambda line: line.account_id.account_type == 'asset_receivable'
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 1: API surface
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineApi(TestReconcileEngineBase):
|
||||
"""Layer 1: the engine class exists in the registry and exposes the
|
||||
six expected methods."""
|
||||
|
||||
def test_engine_in_registry(self):
|
||||
self.assertIn('fusion.reconcile.engine', self.env.registry)
|
||||
|
||||
def test_engine_is_abstract_model(self):
|
||||
engine = self.env['fusion.reconcile.engine']
|
||||
self.assertTrue(engine._abstract)
|
||||
|
||||
def test_six_public_methods_callable(self):
|
||||
engine = self.env['fusion.reconcile.engine']
|
||||
for name in ('reconcile_one', 'reconcile_batch', 'suggest_matches',
|
||||
'accept_suggestion', 'write_off', 'unreconcile'):
|
||||
self.assertTrue(callable(getattr(engine, name, None)),
|
||||
f"engine.{name} must be callable")
|
||||
|
||||
def test_reconcile_one_requires_arguments(self):
|
||||
line = self._make_statement_line(100.0)
|
||||
with self.assertRaises(ValidationError):
|
||||
self.engine.reconcile_one(line)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 2: unreconcile
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineUnreconcile(TestReconcileEngineBase):
|
||||
|
||||
def test_unreconcile_removes_partial_reconcile(self):
|
||||
line = self._make_statement_line(100.0)
|
||||
invoice = self._make_invoice(100.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
result = self.engine.reconcile_one(
|
||||
line, against_lines=receivable)
|
||||
self.assertTrue(result['partial_ids'],
|
||||
"reconcile_one should produce partial_ids")
|
||||
partials = self.env['account.partial.reconcile'].browse(
|
||||
result['partial_ids']).exists()
|
||||
self.assertTrue(partials)
|
||||
|
||||
out = self.engine.unreconcile(partials)
|
||||
|
||||
self.assertIn('unreconciled_line_ids', out)
|
||||
self.assertTrue(out['unreconciled_line_ids'])
|
||||
self.assertFalse(partials.exists(),
|
||||
"Partials should be deleted after unreconcile")
|
||||
receivable.invalidate_recordset(['reconciled', 'amount_residual'])
|
||||
self.assertFalse(receivable.reconciled)
|
||||
|
||||
def test_unreconcile_empty_recordset_returns_empty(self):
|
||||
empty = self.env['account.partial.reconcile']
|
||||
out = self.engine.unreconcile(empty)
|
||||
self.assertEqual(out, {'unreconciled_line_ids': []})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 3: reconcile_one happy path
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineReconcileOne(TestReconcileEngineBase):
|
||||
|
||||
def test_reconcile_one_simple_invoice_match(self):
|
||||
line = self._make_statement_line(250.0)
|
||||
invoice = self._make_invoice(250.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
self.assertFalse(receivable.reconciled)
|
||||
|
||||
result = self.engine.reconcile_one(
|
||||
line, against_lines=receivable)
|
||||
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertIn('partial_ids', result)
|
||||
self.assertIn('exchange_diff_move_id', result)
|
||||
self.assertIn('write_off_move_id', result)
|
||||
self.assertTrue(result['partial_ids'])
|
||||
|
||||
receivable.invalidate_recordset(['reconciled', 'amount_residual'])
|
||||
self.assertTrue(receivable.reconciled)
|
||||
self.assertAlmostEqual(receivable.amount_residual, 0.0, places=2)
|
||||
|
||||
def test_reconcile_one_creates_precedent(self):
|
||||
line = self._make_statement_line(125.0, ref='Engine REF#42')
|
||||
invoice = self._make_invoice(125.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
before = self.env['fusion.reconcile.precedent'].search_count([
|
||||
('partner_id', '=', self.partner.id),
|
||||
])
|
||||
self.engine.reconcile_one(line, against_lines=receivable)
|
||||
after = self.env['fusion.reconcile.precedent'].search_count([
|
||||
('partner_id', '=', self.partner.id),
|
||||
])
|
||||
self.assertEqual(after, before + 1)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 4: accept_suggestion
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineAcceptSuggestion(TestReconcileEngineBase):
|
||||
|
||||
def test_accept_suggestion_reconciles_and_marks_accepted(self):
|
||||
line = self._make_statement_line(310.0)
|
||||
invoice = self._make_invoice(310.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
sug = self.env['fusion.reconcile.suggestion'].create({
|
||||
'company_id': self.company.id,
|
||||
'statement_line_id': line.id,
|
||||
'proposed_move_line_ids': [(6, 0, receivable.ids)],
|
||||
'confidence': 0.97,
|
||||
'rank': 1,
|
||||
'reasoning': 'Exact amount match',
|
||||
'state': 'pending',
|
||||
})
|
||||
|
||||
result = self.engine.accept_suggestion(sug)
|
||||
|
||||
self.assertTrue(result['partial_ids'])
|
||||
self.assertEqual(sug.state, 'accepted')
|
||||
self.assertTrue(sug.accepted_at)
|
||||
self.assertEqual(sug.accepted_by, self.env.user)
|
||||
|
||||
def test_accept_suggestion_by_id(self):
|
||||
line = self._make_statement_line(75.0)
|
||||
invoice = self._make_invoice(75.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
sug = self.env['fusion.reconcile.suggestion'].create({
|
||||
'company_id': self.company.id,
|
||||
'statement_line_id': line.id,
|
||||
'proposed_move_line_ids': [(6, 0, receivable.ids)],
|
||||
'confidence': 0.91,
|
||||
'rank': 1,
|
||||
'reasoning': 'OK',
|
||||
'state': 'pending',
|
||||
})
|
||||
result = self.engine.accept_suggestion(sug.id)
|
||||
self.assertTrue(result['partial_ids'])
|
||||
self.assertEqual(sug.state, 'accepted')
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 5: suggest_matches
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineSuggestMatches(TestReconcileEngineBase):
|
||||
|
||||
def test_suggest_matches_persists_pending_suggestions(self):
|
||||
line = self._make_statement_line(420.0)
|
||||
invoice = self._make_invoice(420.0)
|
||||
# second open invoice for same partner — also a candidate
|
||||
self._make_invoice(99.0)
|
||||
|
||||
out = self.engine.suggest_matches(line)
|
||||
|
||||
self.assertIn(line.id, out)
|
||||
self.assertTrue(out[line.id])
|
||||
suggestions = self.env['fusion.reconcile.suggestion'].search([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
])
|
||||
self.assertTrue(suggestions)
|
||||
# Top suggestion should reference the matching invoice's receivable
|
||||
top = max(suggestions, key=lambda s: s.confidence)
|
||||
receivable = self._receivable_line(invoice)
|
||||
self.assertIn(receivable.id, top.proposed_move_line_ids.ids)
|
||||
|
||||
def test_suggest_matches_supersedes_prior_pending(self):
|
||||
line = self._make_statement_line(180.0)
|
||||
self._make_invoice(180.0)
|
||||
old_sug = self.env['fusion.reconcile.suggestion'].create({
|
||||
'company_id': self.company.id,
|
||||
'statement_line_id': line.id,
|
||||
'confidence': 0.5,
|
||||
'rank': 1,
|
||||
'reasoning': 'prior',
|
||||
'state': 'pending',
|
||||
})
|
||||
|
||||
self.engine.suggest_matches(line)
|
||||
|
||||
old_sug.invalidate_recordset(['state'])
|
||||
self.assertEqual(old_sug.state, 'superseded')
|
||||
|
||||
def test_suggest_matches_returns_empty_for_no_candidates(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Empty Partner'})
|
||||
line = self._make_statement_line(10.0, partner=partner)
|
||||
out = self.engine.suggest_matches(line)
|
||||
self.assertEqual(out, {})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 6: reconcile_batch
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineBatch(TestReconcileEngineBase):
|
||||
|
||||
def test_reconcile_batch_auto_strategy_matches_n_lines(self):
|
||||
amounts = [100.0, 200.0, 333.33]
|
||||
lines = self.env['account.bank.statement.line']
|
||||
for amt in amounts:
|
||||
invoice = self._make_invoice(amt)
|
||||
self.assertTrue(invoice)
|
||||
lines |= self._make_statement_line(amt, ref=f'BATCH-{amt}')
|
||||
|
||||
result = self.engine.reconcile_batch(lines, strategy='auto')
|
||||
|
||||
self.assertEqual(result['reconciled_count'], len(amounts))
|
||||
self.assertEqual(result['skipped'], 0)
|
||||
self.assertEqual(result['errors'], [])
|
||||
|
||||
def test_reconcile_batch_skips_already_reconciled(self):
|
||||
line = self._make_statement_line(50.0)
|
||||
invoice = self._make_invoice(50.0)
|
||||
receivable = self._receivable_line(invoice)
|
||||
self.engine.reconcile_one(line, against_lines=receivable)
|
||||
|
||||
result = self.engine.reconcile_batch(line, strategy='auto')
|
||||
self.assertEqual(result['reconciled_count'], 0)
|
||||
self.assertEqual(result['skipped'], 1)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Layer 7: write_off
|
||||
# ============================================================
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileEngineWriteOff(TestReconcileEngineBase):
|
||||
|
||||
def test_write_off_clears_bank_line(self):
|
||||
line = self._make_statement_line(40.0, ref='Bank fee')
|
||||
# No invoices exist; write off the whole amount to expense.
|
||||
result = self.engine.write_off(
|
||||
line,
|
||||
account=self.expense_account,
|
||||
amount=40.0,
|
||||
label='Bank fees',
|
||||
)
|
||||
self.assertIn('write_off_move_id', result)
|
||||
line.invalidate_recordset(['is_reconciled'])
|
||||
self.assertTrue(line.is_reconciled)
|
||||
0
fusion_accounting_bank_rec/wizards/__init__.py
Normal file
0
fusion_accounting_bank_rec/wizards/__init__.py
Normal file
@@ -1 +1,6 @@
|
||||
from . import models
|
||||
|
||||
|
||||
def post_init_hook(env):
|
||||
"""Initialize coexistence group membership based on current Enterprise install state."""
|
||||
env['res.users']._fusion_recompute_coexistence_group()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting Core',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.0.2',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 24,
|
||||
'summary': 'Shared base for the Fusion Accounting sub-module suite (security, shared schema, runtime helpers).',
|
||||
@@ -30,4 +30,5 @@ Built by Nexa Systems Inc.
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'OPL-1',
|
||||
'post_init_hook': 'post_init_hook',
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from . import ir_module_module
|
||||
from . import res_users
|
||||
from . import account_move
|
||||
from . import account_reconcile_model
|
||||
from . import account_bank_statement_line
|
||||
|
||||
15
fusion_accounting_core/models/account_bank_statement_line.py
Normal file
15
fusion_accounting_core/models/account_bank_statement_line.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Shared-field-ownership for account.bank.statement.line.
|
||||
|
||||
Enterprise's account_accountant adds cron_last_check (timestamp of the last
|
||||
auto-reconcile cron run for the line). By declaring it here with the same
|
||||
schema, fusion_accounting_core becomes a co-owner so the column persists
|
||||
when account_accountant uninstalls.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountBankStatementLine(models.Model):
|
||||
_inherit = "account.bank.statement.line"
|
||||
|
||||
cron_last_check = fields.Datetime(copy=False)
|
||||
@@ -30,3 +30,26 @@ class IrModuleModule(models.Model):
|
||||
('name', '=', module_name),
|
||||
('state', '=', 'installed'),
|
||||
]))
|
||||
|
||||
def button_immediate_install(self):
|
||||
"""Recompute the coexistence group after install state changes."""
|
||||
result = super().button_immediate_install()
|
||||
self.env['res.users']._fusion_recompute_coexistence_group()
|
||||
return result
|
||||
|
||||
def button_immediate_uninstall(self):
|
||||
"""Recompute the coexistence group after uninstall state changes.
|
||||
|
||||
The MRO chains into fusion_accounting_migration's override (which runs
|
||||
the safety guard before calling super); we recompute only after the
|
||||
whole chain completes.
|
||||
"""
|
||||
result = super().button_immediate_uninstall()
|
||||
self.env['res.users']._fusion_recompute_coexistence_group()
|
||||
return result
|
||||
|
||||
def module_uninstall(self):
|
||||
"""Recompute the coexistence group after the lower-level uninstall."""
|
||||
result = super().module_uninstall()
|
||||
self.env['res.users']._fusion_recompute_coexistence_group()
|
||||
return result
|
||||
|
||||
27
fusion_accounting_core/models/res_users.py
Normal file
27
fusion_accounting_core/models/res_users.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Coexistence group membership recomputation."""
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = "res.users"
|
||||
|
||||
@api.model
|
||||
def _fusion_recompute_coexistence_group(self):
|
||||
"""Set group membership = all internal users iff Enterprise absent.
|
||||
|
||||
Called from ir.module.module.button_immediate_install / uninstall
|
||||
overrides. Idempotent; safe to call multiple times.
|
||||
"""
|
||||
group = self.env.ref(
|
||||
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not group:
|
||||
return
|
||||
enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed()
|
||||
if enterprise_installed:
|
||||
group.sudo().write({'user_ids': [(5, 0, 0)]})
|
||||
else:
|
||||
all_internal = self.sudo().search([('share', '=', False)])
|
||||
group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]})
|
||||
@@ -43,4 +43,10 @@
|
||||
<record id="account.group_account_manager" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Phase 1: dynamic coexistence group -->
|
||||
<record id="group_fusion_show_when_enterprise_absent" model="res.groups">
|
||||
<field name="name">Fusion: Show menus when Enterprise absent</field>
|
||||
<field name="comment">Computed group. Membership: all internal users when no Enterprise accounting module is installed. Used to hide fusion sub-module menus that would conflict with Enterprise UIs.</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
from . import test_enterprise_detection
|
||||
from . import test_shared_field_ownership
|
||||
from . import test_shared_field_bank_statement
|
||||
from . import test_coexistence_group
|
||||
|
||||
46
fusion_accounting_core/tests/test_coexistence_group.py
Normal file
46
fusion_accounting_core/tests/test_coexistence_group.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestCoexistenceGroup(TransactionCase):
|
||||
"""The 'show when Enterprise absent' group must exist and have computed membership."""
|
||||
|
||||
def test_group_exists(self):
|
||||
group = self.env.ref(
|
||||
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
self.assertTrue(group, "Coexistence group must exist")
|
||||
|
||||
def test_membership_matches_enterprise_state(self):
|
||||
"""A user is in the group iff Enterprise accounting is NOT installed.
|
||||
|
||||
We can't toggle Enterprise mid-test, so just assert the current state
|
||||
matches: if Enterprise is installed, group should have 0 members; if
|
||||
not, the group should include all internal users.
|
||||
"""
|
||||
group = self.env.ref(
|
||||
'fusion_accounting_core.group_fusion_show_when_enterprise_absent'
|
||||
)
|
||||
enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed()
|
||||
all_internal = self.env['res.users'].sudo().search([('share', '=', False)])
|
||||
if enterprise_installed:
|
||||
self.assertEqual(
|
||||
len(group.user_ids), 0,
|
||||
"Enterprise installed -> coexistence group should be empty",
|
||||
)
|
||||
else:
|
||||
self.assertEqual(
|
||||
set(group.user_ids.ids), set(all_internal.ids),
|
||||
"Enterprise absent -> coexistence group should contain all internal users",
|
||||
)
|
||||
|
||||
def test_recompute_method_exists(self):
|
||||
"""The recompute helper must be callable on res.users."""
|
||||
self.assertTrue(
|
||||
callable(getattr(
|
||||
self.env['res.users'],
|
||||
'_fusion_recompute_coexistence_group',
|
||||
None,
|
||||
))
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSharedFieldBankStatementLine(TransactionCase):
|
||||
"""Verify fusion_accounting_core declares the Enterprise extension fields
|
||||
on account.bank.statement.line so they survive Enterprise uninstall."""
|
||||
|
||||
def test_cron_last_check_field_exists(self):
|
||||
Line = self.env['account.bank.statement.line']
|
||||
self.assertIn('cron_last_check', Line._fields,
|
||||
"cron_last_check must be declared on account.bank.statement.line "
|
||||
"(shared-field-ownership with account_accountant)")
|
||||
self.assertEqual(Line._fields['cron_last_check'].type, 'datetime')
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
"name": "Fusion Plating — MRP Bridge",
|
||||
'version': '19.0.6.4.0',
|
||||
'version': '19.0.6.7.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||
'description': """
|
||||
|
||||
@@ -325,6 +325,14 @@ class MrpProduction(models.Model):
|
||||
for override in production.x_fc_override_ids:
|
||||
override_map[override.node_id.id] = override.included
|
||||
|
||||
# Bind the source SO once per production so walk_node closure
|
||||
# can read coating config / spec without an extra search per WO.
|
||||
so = False
|
||||
if production.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', production.origin)], limit=1,
|
||||
) or False
|
||||
|
||||
# Walk tree and collect operation WO values
|
||||
wo_vals_list = []
|
||||
wo_steps = {} # {sequence: instruction text} — posted to WO chatter after create
|
||||
@@ -392,6 +400,41 @@ class MrpProduction(models.Model):
|
||||
'duration_expected': node.estimated_duration or 0,
|
||||
'sequence': seq_counter[0],
|
||||
}
|
||||
# Recipe estimated_duration also fills the WO's
|
||||
# x_fc_dwell_time_minutes — operators see the recipe-
|
||||
# spec'd dwell next to the actual time logged.
|
||||
if node.estimated_duration:
|
||||
vals['x_fc_dwell_time_minutes'] = node.estimated_duration
|
||||
|
||||
# Pull thickness target from the coating config when
|
||||
# this is a plating WO (matched by node name keyword
|
||||
# OR the linked process_type's family). Aerospace
|
||||
# customers expect target thickness on every WO so
|
||||
# QC can accept/reject against spec without paper.
|
||||
coating = (
|
||||
production.x_fc_coating_config_id
|
||||
if 'x_fc_coating_config_id' in production._fields
|
||||
else False
|
||||
)
|
||||
if not coating and so:
|
||||
coating = (
|
||||
so.x_fc_coating_config_id
|
||||
if 'x_fc_coating_config_id' in so._fields
|
||||
else False
|
||||
)
|
||||
name_l = (node.name or '').lower()
|
||||
is_plating_node = (
|
||||
'plat' in name_l or 'nickel' in name_l
|
||||
or 'chrome' in name_l or 'anodiz' in name_l
|
||||
)
|
||||
if coating and is_plating_node:
|
||||
# thickness_max is the upper spec limit — that's
|
||||
# what we target. thickness_min is the floor.
|
||||
if coating.thickness_max:
|
||||
vals['x_fc_thickness_target'] = coating.thickness_max
|
||||
if coating.thickness_uom:
|
||||
vals['x_fc_thickness_uom'] = coating.thickness_uom
|
||||
|
||||
# Inherit the operation's shop role (if the bridge
|
||||
# module is installed) so WOs can auto-route to the
|
||||
# right worker.
|
||||
@@ -420,8 +463,13 @@ class MrpProduction(models.Model):
|
||||
# Bulk create work orders
|
||||
if wo_vals_list:
|
||||
created_wos = WorkOrder.create(wo_vals_list)
|
||||
# Post step instructions to each WO's chatter where present
|
||||
for wo in created_wos:
|
||||
# Auto-fill default equipment when there's only one
|
||||
# option per facility (bath/tank/oven). Saves the
|
||||
# planner a click on single-line shops.
|
||||
if hasattr(wo, '_fp_autofill_default_equipment'):
|
||||
wo._fp_autofill_default_equipment()
|
||||
# Post step instructions to each WO's chatter where present
|
||||
steps_txt = wo_steps.get(wo.sequence)
|
||||
if steps_txt:
|
||||
wo.message_post(
|
||||
@@ -469,6 +517,40 @@ class MrpProduction(models.Model):
|
||||
# Auto-assign recipe BEFORE super() so work-order generation sees it
|
||||
self._auto_assign_recipe_from_so()
|
||||
|
||||
# Auto-derive facility (where the job runs) so x_fc_facility_id is
|
||||
# never empty downstream — it's compliance-critical (AS9100 §7.1.4
|
||||
# "infrastructure"). Order: explicit value > SO override >
|
||||
# company default > first active facility.
|
||||
for mo in self:
|
||||
if mo.x_fc_facility_id:
|
||||
continue
|
||||
facility = False
|
||||
if mo.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
if so and 'x_fc_facility_id' in so._fields:
|
||||
facility = so.x_fc_facility_id
|
||||
if not facility:
|
||||
facility = mo.company_id.x_fc_default_facility_id
|
||||
if not facility:
|
||||
facility = self.env['fusion.plating.facility'].search(
|
||||
[('active', '=', True)], limit=1,
|
||||
)
|
||||
if facility:
|
||||
mo.x_fc_facility_id = facility.id
|
||||
|
||||
# Hard gate: MO can't be confirmed without a facility — without
|
||||
# this, every downstream record (WO, batch, bath log, cert) is
|
||||
# missing the "where" half of "what was made where by whom".
|
||||
for mo in self:
|
||||
if not mo.x_fc_facility_id:
|
||||
raise UserError(_(
|
||||
'Cannot confirm MO "%s" — no plating facility set.\n\n'
|
||||
'Set the facility on the MO, or configure a default '
|
||||
'in Settings → Companies → Fusion Plating Defaults.'
|
||||
) % (mo.name or mo.display_name))
|
||||
|
||||
res = super().action_confirm()
|
||||
PortalJob = self.env['fusion.plating.portal.job']
|
||||
for mo in self:
|
||||
|
||||
@@ -28,11 +28,29 @@ class MrpWorkorder(models.Model):
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_requires_bath = fields.Boolean(
|
||||
string='Requires Bath/Tank',
|
||||
compute='_compute_requires_bath',
|
||||
compute='_compute_wo_kind',
|
||||
store=False,
|
||||
help='True when this WO involves a chemistry bath. Surfaced to '
|
||||
'the form view so bath/tank fields render as required.',
|
||||
)
|
||||
x_fc_requires_oven = fields.Boolean(
|
||||
string='Requires Oven',
|
||||
compute='_compute_wo_kind',
|
||||
store=False,
|
||||
help='True when this WO is a bake/cure step. Surfaced to the '
|
||||
'form view so the oven field renders as required.',
|
||||
)
|
||||
x_fc_wo_kind = fields.Selection(
|
||||
[('wet', 'Wet / Bath'),
|
||||
('bake', 'Oven / Bake'),
|
||||
('mask', 'Mask / De-mask'),
|
||||
('rack', 'Rack / De-rack'),
|
||||
('inspect', 'Inspection / QC'),
|
||||
('other', 'Other')],
|
||||
string='WO Kind',
|
||||
compute='_compute_wo_kind',
|
||||
store=False,
|
||||
)
|
||||
x_fc_bath_id = fields.Many2one(
|
||||
'fusion.plating.bath', string='Bath', tracking=True,
|
||||
)
|
||||
@@ -45,16 +63,59 @@ class MrpWorkorder(models.Model):
|
||||
domain="[('state', '!=', 'retired')]",
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_oven_id = fields.Many2one(
|
||||
'fusion.plating.bake.oven', string='Oven',
|
||||
domain="[('facility_id', '=', x_fc_facility_id)]",
|
||||
help='The specific oven this bake / cure WO ran in. Required '
|
||||
'for bake WOs — multiple ovens means we need to pin '
|
||||
'which one for the chart-recorder trail.',
|
||||
)
|
||||
x_fc_bake_temp = fields.Float(
|
||||
string='Bake Temp (°F)', digits=(5, 1),
|
||||
help='Setpoint temperature recorded for this bake WO.',
|
||||
)
|
||||
x_fc_bake_duration_hours = fields.Float(
|
||||
string='Bake Duration (h)', digits=(5, 2),
|
||||
help='Total bake time at temperature.',
|
||||
)
|
||||
x_fc_masking_material = fields.Selection(
|
||||
[('tape', 'Tape'),
|
||||
('plug', 'Plug'),
|
||||
('paint', 'Paint / Lacquer'),
|
||||
('silicone', 'Silicone'),
|
||||
('wax', 'Wax'),
|
||||
('mixed', 'Mixed (multiple materials)'),
|
||||
('other', 'Other (see notes)')],
|
||||
string='Masking Material',
|
||||
help='Which material was used to mask off the parts. Required '
|
||||
'on mask / de-mask WOs — needed later when stripping or '
|
||||
'replating because each material requires a different '
|
||||
'removal process.',
|
||||
)
|
||||
x_fc_thickness_target = fields.Float(string='Target Thickness')
|
||||
x_fc_thickness_uom = fields.Selection(
|
||||
[('mils', 'mils'), ('microns', '\u00b5m')],
|
||||
string='Thickness Unit', default='mils',
|
||||
)
|
||||
x_fc_dwell_time_minutes = fields.Float(string='Dwell Time (min)')
|
||||
# Falls back to the MO's facility when the workcenter has none —
|
||||
# most stub workcenters auto-created from process node names don't
|
||||
# have facility_id, but the MO always does (enforced at confirm).
|
||||
x_fc_facility_id = fields.Many2one(
|
||||
'fusion.plating.facility', string='Facility',
|
||||
related='workcenter_id.x_fc_facility_id', store=True, readonly=True,
|
||||
compute='_compute_facility_id', store=True, readonly=False,
|
||||
help='Plating facility where this WO runs. Falls back to the '
|
||||
'MO\'s facility when the workcenter has none.',
|
||||
)
|
||||
|
||||
@api.depends('workcenter_id.x_fc_facility_id', 'production_id.x_fc_facility_id')
|
||||
def _compute_facility_id(self):
|
||||
for wo in self:
|
||||
wo.x_fc_facility_id = (
|
||||
wo.workcenter_id.x_fc_facility_id
|
||||
or wo.production_id.x_fc_facility_id
|
||||
or wo.x_fc_facility_id
|
||||
)
|
||||
x_fc_workcenter_cost_hour = fields.Float(
|
||||
string='Station Rate ($/hr)',
|
||||
related='workcenter_id.costs_hour', readonly=True,
|
||||
@@ -533,10 +594,97 @@ class MrpWorkorder(models.Model):
|
||||
'zincate', 'alkalin', 'acid', 'electroless',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_bath_id', 'name', 'workcenter_id')
|
||||
def _compute_requires_bath(self):
|
||||
@api.depends('x_fc_bath_id', 'x_fc_oven_id', 'name', 'workcenter_id')
|
||||
def _compute_wo_kind(self):
|
||||
for wo in self:
|
||||
wo.x_fc_requires_bath = wo._fp_is_wet_process()
|
||||
kind = wo._fp_classify_kind()
|
||||
wo.x_fc_wo_kind = kind
|
||||
wo.x_fc_requires_bath = kind == 'wet'
|
||||
wo.x_fc_requires_oven = kind == 'bake'
|
||||
|
||||
@api.onchange('workcenter_id', 'x_fc_facility_id', 'x_fc_bath_id')
|
||||
def _onchange_autofill_equipment(self):
|
||||
"""If the facility has exactly one option for the equipment this
|
||||
WO needs, pre-pick it so the planner doesn't have to."""
|
||||
for wo in self:
|
||||
wo._fp_autofill_default_equipment()
|
||||
|
||||
def _fp_autofill_default_equipment(self):
|
||||
"""Pin bath / tank / oven to the only-option-available default.
|
||||
|
||||
Doesn't overwrite an already-set value.
|
||||
"""
|
||||
self.ensure_one()
|
||||
kind = self._fp_classify_kind()
|
||||
Bath = self.env.get('fusion.plating.bath')
|
||||
Tank = self.env.get('fusion.plating.tank')
|
||||
Oven = self.env.get('fusion.plating.bake.oven')
|
||||
facility = self.x_fc_facility_id
|
||||
|
||||
if kind == 'wet' and not self.x_fc_bath_id and Bath is not None:
|
||||
d = [('active', '=', True)]
|
||||
if facility and 'facility_id' in Bath._fields:
|
||||
d.append(('facility_id', '=', facility.id))
|
||||
baths = Bath.search(d, limit=2)
|
||||
if len(baths) == 1:
|
||||
self.x_fc_bath_id = baths.id
|
||||
if kind == 'wet' and self.x_fc_bath_id and not self.x_fc_tank_id and Tank is not None:
|
||||
d = [('active', '=', True)]
|
||||
if 'bath_id' in Tank._fields:
|
||||
d.append(('bath_id', '=', self.x_fc_bath_id.id))
|
||||
tanks = Tank.search(d, limit=2)
|
||||
if len(tanks) == 1:
|
||||
self.x_fc_tank_id = tanks.id
|
||||
if kind == 'bake' and not self.x_fc_oven_id and Oven is not None:
|
||||
d = [('active', '=', True)]
|
||||
if facility and 'facility_id' in Oven._fields:
|
||||
d.append(('facility_id', '=', facility.id))
|
||||
ovens = Oven.search(d, limit=2)
|
||||
if len(ovens) == 1:
|
||||
self.x_fc_oven_id = ovens.id
|
||||
|
||||
# Keyword fallbacks per kind (lowercase name match).
|
||||
BAKE_KEYWORDS = ('bake', 'oven', 'cure', 'heat treat')
|
||||
MASK_KEYWORDS = ('mask', 'de-mask', 'demask', 'tape')
|
||||
RACK_KEYWORDS = ('rack', 'de-rack', 'derack', 'fixture')
|
||||
INSPECT_KEYWORDS = ('inspect', 'qa', 'qc', 'fai', 'final check')
|
||||
|
||||
def _fp_classify_kind(self):
|
||||
"""Bucket this WO into wet/bake/mask/rack/inspect/other.
|
||||
|
||||
Priority order (top wins):
|
||||
1. Explicit equipment links (bath_id / oven_id) — definitive.
|
||||
2. Specific-process keywords (inspect/mask/rack/bake) beat
|
||||
the broader wet keywords. Otherwise "Post-plate Inspection"
|
||||
matches "plat" → wet, which is wrong.
|
||||
3. Workcenter wet process family — definitive.
|
||||
4. Wet name keyword fallback — broad (catches plat/etch/rinse...).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fc_bath_id:
|
||||
return 'wet'
|
||||
if self.x_fc_oven_id:
|
||||
return 'bake'
|
||||
name = (self.name or '').lower()
|
||||
if any(k in name for k in self.INSPECT_KEYWORDS):
|
||||
return 'inspect'
|
||||
if any(k in name for k in self.MASK_KEYWORDS):
|
||||
return 'mask'
|
||||
# Bake before Rack so "Oven bake (Post de-rack)" → bake (the
|
||||
# operation is bake; "Post de-rack" only describes the timing).
|
||||
if any(k in name for k in self.BAKE_KEYWORDS):
|
||||
return 'bake'
|
||||
if any(k in name for k in self.RACK_KEYWORDS):
|
||||
return 'rack'
|
||||
wc = self.workcenter_id
|
||||
fpwc = getattr(wc, 'x_fc_fp_work_center_id', False)
|
||||
if fpwc:
|
||||
families = set(fpwc.supported_process_ids.mapped('process_family'))
|
||||
if families & set(self.WET_FAMILIES):
|
||||
return 'wet'
|
||||
if any(k in name for k in self.WET_NAME_KEYWORDS):
|
||||
return 'wet'
|
||||
return 'other'
|
||||
|
||||
def _fp_is_wet_process(self):
|
||||
"""Best-effort check: does this WO involve a chemistry bath?
|
||||
@@ -562,24 +710,33 @@ class MrpWorkorder(models.Model):
|
||||
"""Block button_start if the WO is missing data the shop must
|
||||
record for traceability + compliance.
|
||||
|
||||
Rules:
|
||||
• Every WO needs an assigned operator (x_fc_assigned_user_id) —
|
||||
without it, productivity records can't be attributed and
|
||||
proficiency tracking goes nowhere.
|
||||
• Wet (bath) WOs additionally need x_fc_bath_id + x_fc_tank_id —
|
||||
for chemistry traceability and physical-location audit
|
||||
(which exact tank ran the job).
|
||||
Per-kind rules:
|
||||
• Every WO needs an assigned operator (x_fc_assigned_user_id).
|
||||
• Wet: bath + tank (chemistry traceability)
|
||||
• Bake: oven (chart-recorder trail)
|
||||
• Rack: rack/fixture (per-rack life tracking)
|
||||
• Mask: masking material (needed later when stripping)
|
||||
"""
|
||||
from odoo.exceptions import UserError
|
||||
for wo in self:
|
||||
missing = []
|
||||
if not wo.x_fc_assigned_user_id:
|
||||
missing.append(_('Assigned Operator'))
|
||||
if wo._fp_is_wet_process():
|
||||
kind = wo._fp_classify_kind()
|
||||
if kind == 'wet':
|
||||
if not wo.x_fc_bath_id:
|
||||
missing.append(_('Bath'))
|
||||
if not wo.x_fc_tank_id:
|
||||
missing.append(_('Tank'))
|
||||
elif kind == 'bake':
|
||||
if not wo.x_fc_oven_id:
|
||||
missing.append(_('Oven'))
|
||||
elif kind == 'rack':
|
||||
if not wo.x_fc_rack_id:
|
||||
missing.append(_('Rack / Fixture'))
|
||||
elif kind == 'mask':
|
||||
if not wo.x_fc_masking_material:
|
||||
missing.append(_('Masking Material'))
|
||||
if missing:
|
||||
raise UserError(_(
|
||||
'Cannot start work order "%(wo)s" — please fill these '
|
||||
@@ -638,6 +795,42 @@ class MrpWorkorder(models.Model):
|
||||
'Request certification from your supervisor before starting this WO.'
|
||||
) % (employee.name, process_type.name))
|
||||
|
||||
def _fp_check_required_fields_before_finish(self):
|
||||
"""Block button_finish on bake WOs without the actual data
|
||||
Nadcap audits demand: setpoint temp, actual duration, and a
|
||||
chart-recorder reference on the oven (so the printed chart
|
||||
for this run can be retrieved).
|
||||
|
||||
Run-time data (temp + duration) belongs at FINISH because
|
||||
you don't know it until the bake is done. Chart-recorder ref
|
||||
is on the oven config — checked here as a defensive backstop.
|
||||
"""
|
||||
from odoo.exceptions import UserError
|
||||
for wo in self:
|
||||
if wo._fp_classify_kind() != 'bake':
|
||||
continue
|
||||
missing = []
|
||||
if not wo.x_fc_bake_temp:
|
||||
missing.append(_('Bake Temp (°F)'))
|
||||
if not wo.x_fc_bake_duration_hours:
|
||||
missing.append(_('Bake Duration (h)'))
|
||||
if wo.x_fc_oven_id and not wo.x_fc_oven_id.chart_recorder_ref:
|
||||
missing.append(_(
|
||||
'Chart Recorder Ref on oven "%s" '
|
||||
'(set on the oven record, not the WO)'
|
||||
) % wo.x_fc_oven_id.name)
|
||||
if missing:
|
||||
raise UserError(_(
|
||||
'Cannot finish bake work order "%(wo)s" — Nadcap / '
|
||||
'AS9100 require these fields before close:\n • %(fields)s\n\n'
|
||||
'On the iPad: tap the WO → Process Details → '
|
||||
'fill in Bake Temp + Duration. Chart Recorder Ref '
|
||||
'is configured on the oven record once.'
|
||||
) % {
|
||||
'wo': wo.display_name or wo.name,
|
||||
'fields': '\n • '.join(missing),
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# T1.1 — Bake window auto-create on plating WO finish
|
||||
# T1.3 — Rack MTO increment when a rack was used
|
||||
@@ -649,6 +842,7 @@ class MrpWorkorder(models.Model):
|
||||
the proficiency tracker so workers earn credit toward auto-
|
||||
promotion (see fp.operator.proficiency).
|
||||
"""
|
||||
self._fp_check_required_fields_before_finish()
|
||||
res = super().button_finish()
|
||||
now = fields.Datetime.now()
|
||||
uid = self.env.user.id
|
||||
|
||||
@@ -96,7 +96,12 @@
|
||||
required="1"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="x_fc_work_role_id" readonly="1"/>
|
||||
<field name="x_fc_wo_kind" widget="badge" readonly="1"
|
||||
decoration-info="x_fc_wo_kind == 'wet'"
|
||||
decoration-warning="x_fc_wo_kind == 'bake'"
|
||||
decoration-muted="x_fc_wo_kind in ('mask', 'rack', 'inspect', 'other')"/>
|
||||
<field name="x_fc_requires_bath" invisible="1"/>
|
||||
<field name="x_fc_requires_oven" invisible="1"/>
|
||||
</xpath>
|
||||
|
||||
<!-- ============================================================
|
||||
@@ -162,12 +167,18 @@
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
<!-- 5b. Plating Details tab (insert AFTER Time & Cost) -->
|
||||
<!-- 5b. Process Details tab — content adapts to WO kind so
|
||||
operators see only the equipment fields that matter. -->
|
||||
<xpath expr="//notebook/page[@name='time_tracking']" position="after">
|
||||
<page string="Plating Details" name="plating_details">
|
||||
<page string="Process Details" name="plating_details">
|
||||
<group>
|
||||
<group string="Bath & Tank">
|
||||
<group string="Where">
|
||||
<field name="x_fc_facility_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Wet / bath WOs -->
|
||||
<group invisible="x_fc_wo_kind != 'wet'">
|
||||
<group string="Bath & Tank">
|
||||
<field name="x_fc_bath_id"
|
||||
required="x_fc_requires_bath"/>
|
||||
<field name="x_fc_tank_id"
|
||||
@@ -181,6 +192,44 @@
|
||||
<field name="x_fc_dwell_time_minutes"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Bake / cure WOs -->
|
||||
<group invisible="x_fc_wo_kind != 'bake'">
|
||||
<group string="Oven">
|
||||
<field name="x_fc_oven_id"
|
||||
required="x_fc_requires_oven"/>
|
||||
</group>
|
||||
<group string="Bake Parameters (required at finish)">
|
||||
<field name="x_fc_bake_temp"/>
|
||||
<field name="x_fc_bake_duration_hours"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Rack / de-rack WOs -->
|
||||
<group invisible="x_fc_wo_kind != 'rack'">
|
||||
<group string="Rack">
|
||||
<field name="x_fc_rack_id" required="1"/>
|
||||
<field name="x_fc_rack_ref"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Mask / De-mask WOs -->
|
||||
<group invisible="x_fc_wo_kind != 'mask'">
|
||||
<group string="Masking">
|
||||
<field name="x_fc_masking_material" required="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Inspection -->
|
||||
<group invisible="x_fc_wo_kind != 'inspect'">
|
||||
<div class="alert alert-info" role="alert">
|
||||
Inspection — record Fischerscope readings via
|
||||
the Tablet Station. Cal-std + n measurements
|
||||
per part. Readings auto-link to the CoC.
|
||||
</div>
|
||||
</group>
|
||||
<group invisible="x_fc_wo_kind != 'other'">
|
||||
<div class="alert alert-light text-muted" role="alert">
|
||||
Generic operation — equipment is identified
|
||||
by the work centre.
|
||||
</div>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.3.0.0',
|
||||
'version': '19.0.3.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -267,6 +267,16 @@ class FpCertificate(models.Model):
|
||||
for rec in self:
|
||||
if rec.state != 'draft':
|
||||
raise UserError(_('Only draft certificates can be issued.'))
|
||||
# Spec reference is what the cert ATTESTS — without it the
|
||||
# cert is just a piece of paper. AS9100 / Nadcap require
|
||||
# naming the spec the work was performed to.
|
||||
if not rec.spec_reference:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — no Spec '
|
||||
'Reference set.\n\nFill the Spec Reference field '
|
||||
'(e.g. "AMS 2404", "MIL-C-26074") so the cert '
|
||||
'states which standard the work meets.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
rec.state = 'issued'
|
||||
rec.message_post(body=_('Certificate issued.'))
|
||||
|
||||
|
||||
@@ -45,7 +45,13 @@ class FpThicknessReading(models.Model):
|
||||
string='Product Ref', help='e.g. "2805031 / NiP/Al-alloys 2805030"',
|
||||
)
|
||||
calibration_std_ref = fields.Char(
|
||||
string='Calibration Std', help='e.g. "NiP/Al STD SET SN 100174568"',
|
||||
string='Calibration Std',
|
||||
required=True,
|
||||
default='NiP/Al STD SET SN 100174568',
|
||||
help='Nadcap mandatory: which calibration standard the gauge '
|
||||
'was checked against. Defaults to the shop\'s primary '
|
||||
'standard but should be overridden if a different std '
|
||||
'was used for this reading.',
|
||||
)
|
||||
microscope_image_id = fields.Many2one(
|
||||
'ir.attachment', string='Microscope Image',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating - Compliance (Framework)',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Jurisdiction-agnostic compliance framework: permits, discharge monitoring, waste manifests, pollutant inventory, compliance calendar, spill register.',
|
||||
'description': 'Generic compliance framework. Region packs load jurisdiction-specific data.',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpDischargeSample(models.Model):
|
||||
@@ -63,4 +64,32 @@ class FpDischargeSample(models.Model):
|
||||
self.write({'state': 'escalated'})
|
||||
|
||||
def action_close(self):
|
||||
"""Block close until lab evidence is on file.
|
||||
|
||||
A closed discharge sample without a lab report ref + at least
|
||||
one parameter reading + (when results are in) a lab cert
|
||||
attachment fails any environmental audit. The whole point
|
||||
of the record is to document the test was performed and what
|
||||
the lab said.
|
||||
"""
|
||||
for rec in self:
|
||||
missing = []
|
||||
if not rec.lab_report_ref:
|
||||
missing.append(_('Lab Report #'))
|
||||
if not rec.received_date:
|
||||
missing.append(_('Results Received Date'))
|
||||
if not rec.line_ids:
|
||||
missing.append(_('At least one parameter reading'))
|
||||
if not rec.attachment_ids:
|
||||
missing.append(_('Lab certificate / report attachment'))
|
||||
if missing:
|
||||
raise UserError(_(
|
||||
'Cannot close discharge sample "%(name)s" — these '
|
||||
'fields must be filled in first:\n • %(fields)s\n\n'
|
||||
'Without lab evidence on file the record fails any '
|
||||
'environmental compliance audit.'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'fields': '\n • '.join(missing),
|
||||
})
|
||||
self.write({'state': 'closed'})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Invoicing',
|
||||
'version': '19.0.2.0.0',
|
||||
'version': '19.0.2.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
|
||||
'description': """
|
||||
|
||||
@@ -3,15 +3,56 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import models, _
|
||||
from odoo import api, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Auto-inherit payment terms + customer PO# at creation time.
|
||||
|
||||
Two defensive defaults so newly-created invoices come out
|
||||
compliant out of the box:
|
||||
|
||||
1. **invoice_payment_term_id** — pulled from the customer's
|
||||
property_payment_term_id (Net-30, COD, etc.). Without this
|
||||
the due date silently becomes "immediate", wrong for B2B.
|
||||
|
||||
2. **ref** (customer reference / PO#) — pulled from the source
|
||||
sale order's client_order_ref or x_fc_po_number. Customer
|
||||
AP teams reject invoices that don't quote their PO# back.
|
||||
We already populate this on the SO confirm path, but a
|
||||
manually-created invoice would miss it without this default.
|
||||
"""
|
||||
Partner = self.env['res.partner']
|
||||
SO = self.env['sale.order']
|
||||
for vals in vals_list:
|
||||
if vals.get('move_type') in ('out_invoice', 'out_refund'):
|
||||
if not vals.get('invoice_payment_term_id') and vals.get('partner_id'):
|
||||
partner = Partner.browse(vals['partner_id'])
|
||||
if partner.property_payment_term_id:
|
||||
vals['invoice_payment_term_id'] = partner.property_payment_term_id.id
|
||||
# Defensive PO#: invoice_origin links to the SO; pull the
|
||||
# customer ref from there if the caller didn't pass one.
|
||||
if not vals.get('ref') and vals.get('invoice_origin'):
|
||||
so = SO.search([('name', '=', vals['invoice_origin'])], limit=1)
|
||||
if so:
|
||||
vals['ref'] = (
|
||||
so.client_order_ref
|
||||
or (so.x_fc_po_number if 'x_fc_po_number' in so._fields else False)
|
||||
or False
|
||||
)
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_post(self):
|
||||
"""Check account hold before posting invoices."""
|
||||
"""Block post when:
|
||||
• customer is on account hold (existing rule), or
|
||||
• the invoice has no payment term (auto-fill missed it AND
|
||||
partner had no default — accountant must pick one).
|
||||
"""
|
||||
for move in self:
|
||||
if move.move_type in ('out_invoice', 'out_refund') and move.partner_id:
|
||||
if move.partner_id.x_fc_account_hold:
|
||||
@@ -25,4 +66,11 @@ class AccountMove(models.Model):
|
||||
'Contact a manager to override.'
|
||||
) % (move.partner_id.name,
|
||||
move.partner_id.x_fc_account_hold_reason or 'No reason specified'))
|
||||
if not move.invoice_payment_term_id:
|
||||
raise UserError(_(
|
||||
'Cannot post invoice "%s" — no payment terms set.\n\n'
|
||||
'Pick payment terms (Net-30, COD, etc.) on the invoice, '
|
||||
'or set a default on the customer "%s" so future '
|
||||
'invoices inherit it automatically.'
|
||||
) % (move.name or move.display_name, move.partner_id.name))
|
||||
return super().action_post()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Logistics',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': (
|
||||
'Pickup & delivery for plating shops: vehicle master, driver '
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpDelivery(models.Model):
|
||||
@@ -169,7 +170,21 @@ class FpDelivery(models.Model):
|
||||
)
|
||||
|
||||
def action_mark_delivered(self):
|
||||
"""Block "delivered" until a Proof of Delivery exists.
|
||||
|
||||
The driver must capture POD (signature, photos, recipient name)
|
||||
on the iPad at the customer's dock BEFORE marking delivered.
|
||||
Without POD we have no signed receipt to attach to the
|
||||
invoice and no defence against a delivery dispute.
|
||||
"""
|
||||
for rec in self:
|
||||
if not rec.pod_id:
|
||||
raise UserError(_(
|
||||
'Cannot mark delivery "%(name)s" delivered — no Proof '
|
||||
'of Delivery (POD) has been captured.\n\n'
|
||||
'On the iPad: Capture POD → enter recipient name + '
|
||||
'signature → save. Then mark delivered.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
rec.write({
|
||||
'state': 'delivered',
|
||||
'delivered_at': fields.Datetime.now(),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Quality (QMS)',
|
||||
'version': '19.0.1.1.0',
|
||||
'version': '19.0.1.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
|
||||
'internal audits, customer specs, document control. CE + EE compatible.',
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpCapa(models.Model):
|
||||
@@ -160,6 +161,43 @@ class FpCapa(models.Model):
|
||||
})
|
||||
|
||||
def action_close(self):
|
||||
"""Block close unless root_cause + action_plan + verification are set.
|
||||
|
||||
A CAPA without these is just an open ticket — the AS9100 §10.2
|
||||
/ Nadcap loop requires evidence of the root cause analysis,
|
||||
the corrective/preventive action plan, AND that effectiveness
|
||||
was verified before the loop is closed.
|
||||
"""
|
||||
for rec in self:
|
||||
missing = []
|
||||
|
||||
def is_empty_html(val):
|
||||
if not val:
|
||||
return True
|
||||
s = str(val).replace('<p>', '').replace('</p>', '')
|
||||
s = s.replace('<br>', '').replace('<br/>', '').strip()
|
||||
return not s
|
||||
|
||||
if is_empty_html(rec.root_cause_analysis):
|
||||
missing.append(_('Root Cause Analysis'))
|
||||
if is_empty_html(rec.action_plan):
|
||||
missing.append(_('Action Plan'))
|
||||
if not rec.verification_date or not rec.verification_by_id:
|
||||
missing.append(_('Verification (date + verifier)'))
|
||||
if rec.is_effective is False and is_empty_html(rec.effectiveness_notes):
|
||||
# If marked not-effective, demand a note explaining the
|
||||
# follow-up plan — otherwise the loop never actually closes.
|
||||
missing.append(_('Effectiveness Notes (required when "Not Effective")'))
|
||||
if missing:
|
||||
raise UserError(_(
|
||||
'Cannot close CAPA "%(name)s" — these fields must be '
|
||||
'filled in first:\n • %(fields)s\n\n'
|
||||
'A CAPA without root cause + action plan + verified '
|
||||
'effectiveness fails AS9100 §10.2 / Nadcap on audit.'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'fields': '\n • '.join(missing),
|
||||
})
|
||||
self.write({'state': 'closed'})
|
||||
|
||||
def action_reset_to_draft(self):
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpNcr(models.Model):
|
||||
@@ -156,6 +157,42 @@ class FpNcr(models.Model):
|
||||
self.write({'state': 'disposition'})
|
||||
|
||||
def action_close(self):
|
||||
"""Block close unless root_cause + containment + disposition are set.
|
||||
|
||||
A closed NCR without these three is useless for AS9100 audits:
|
||||
the whole point of the NCR is to document what went wrong
|
||||
(containment), why (root_cause), and what we decided to do
|
||||
with the affected parts (disposition).
|
||||
"""
|
||||
for rec in self:
|
||||
missing = []
|
||||
# Strip HTML-empty strings like "<p><br></p>" before checking
|
||||
def is_empty_html(val):
|
||||
if not val:
|
||||
return True
|
||||
s = str(val).replace('<p>', '').replace('</p>', '')
|
||||
s = s.replace('<br>', '').replace('<br/>', '').strip()
|
||||
return not s
|
||||
|
||||
if is_empty_html(rec.description):
|
||||
missing.append(_('Description'))
|
||||
if is_empty_html(rec.containment):
|
||||
missing.append(_('Containment Actions'))
|
||||
if is_empty_html(rec.root_cause):
|
||||
missing.append(_('Root Cause'))
|
||||
if not rec.disposition:
|
||||
missing.append(_('Disposition (use-as-is / rework / scrap / RTV)'))
|
||||
if missing:
|
||||
raise UserError(_(
|
||||
'Cannot close NCR "%(name)s" — these fields must be '
|
||||
'filled in first:\n • %(fields)s\n\n'
|
||||
'AS9100 / Nadcap auditors will reject a closed NCR '
|
||||
'that doesn\'t document what happened, why, and how '
|
||||
'we responded.'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'fields': '\n • '.join(missing),
|
||||
})
|
||||
self.write({
|
||||
'state': 'closed',
|
||||
'closed_date': fields.Datetime.now(),
|
||||
|
||||
100
fusion_plating/scripts/fp_backfill.py
Normal file
100
fusion_plating/scripts/fp_backfill.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# Backfill compliance data on existing records so the per-step audit
|
||||
# verifies the new gates against real data, not a fresh seed.
|
||||
env = env # noqa
|
||||
from collections import Counter
|
||||
|
||||
# 1. Set chart_recorder_ref on every oven that doesn't have one
|
||||
ovens = env['fusion.plating.bake.oven'].search([])
|
||||
n_ov = 0
|
||||
for ov in ovens:
|
||||
if not ov.chart_recorder_ref:
|
||||
ov.sudo().chart_recorder_ref = f'CR-{ov.code or ov.id}-2026'
|
||||
n_ov += 1
|
||||
print(f'1. ovens chart_recorder_ref backfilled: {n_ov}/{len(ovens)}')
|
||||
|
||||
# 2. Backfill rack_id on existing rack/de-rack WOs
|
||||
WO = env['mrp.workorder']
|
||||
all_wos = WO.search([])
|
||||
test_rack = env['fusion.plating.rack'].search([], limit=1)
|
||||
if not test_rack:
|
||||
f = env['fusion.plating.facility'].search([], limit=1)
|
||||
test_rack = env['fusion.plating.rack'].sudo().create({
|
||||
'name': 'Standard Rack 1',
|
||||
'code': 'RACK-1',
|
||||
'facility_id': f.id if f else False,
|
||||
})
|
||||
n_rk = 0
|
||||
for wo in all_wos:
|
||||
if hasattr(wo, '_fp_classify_kind'):
|
||||
if wo._fp_classify_kind() == 'rack' and not wo.x_fc_rack_id:
|
||||
wo.sudo().x_fc_rack_id = test_rack.id
|
||||
n_rk += 1
|
||||
print(f'2. rack WOs rack_id backfilled: {n_rk}')
|
||||
|
||||
# 3. Backfill bake_temp + bake_duration_hours on existing bake WOs
|
||||
n_bk = 0
|
||||
for wo in all_wos:
|
||||
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'bake':
|
||||
updates = {}
|
||||
if not wo.x_fc_bake_temp:
|
||||
updates['x_fc_bake_temp'] = 365.0
|
||||
if not wo.x_fc_bake_duration_hours:
|
||||
updates['x_fc_bake_duration_hours'] = 4.0
|
||||
if updates:
|
||||
wo.sudo().write(updates)
|
||||
n_bk += 1
|
||||
print(f'3. bake WOs temp+duration backfilled: {n_bk}')
|
||||
|
||||
# 4. Backfill masking_material on existing mask WOs
|
||||
n_mk = 0
|
||||
for wo in all_wos:
|
||||
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'mask':
|
||||
if not wo.x_fc_masking_material:
|
||||
wo.sudo().x_fc_masking_material = 'tape'
|
||||
n_mk += 1
|
||||
print(f'4. mask WOs masking_material backfilled: {n_mk}')
|
||||
|
||||
# 5. Backfill thickness_target + dwell_time on existing wet plating WOs
|
||||
n_th = 0
|
||||
for wo in all_wos:
|
||||
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'wet':
|
||||
# Only fill if name suggests a plating step (not pre-treat/rinse)
|
||||
name_l = (wo.name or '').lower()
|
||||
if 'plat' in name_l or 'nickel' in name_l:
|
||||
updates = {}
|
||||
if not wo.x_fc_thickness_target:
|
||||
updates['x_fc_thickness_target'] = 0.0005 # 0.5 mils
|
||||
if not wo.x_fc_dwell_time_minutes:
|
||||
updates['x_fc_dwell_time_minutes'] = 60.0
|
||||
if updates:
|
||||
wo.sudo().write(updates)
|
||||
n_th += 1
|
||||
print(f'5. plating WOs thickness/dwell backfilled: {n_th}')
|
||||
|
||||
# 6. Clean up OLD inspection WOs that have bath/tank wrongly set
|
||||
# (legacy bug — earlier simulator pinned bath to "Post-plate Inspection"
|
||||
# because the old classifier matched 'plat' keyword. Fixed now.)
|
||||
n_cl = 0
|
||||
for wo in all_wos:
|
||||
name_l = (wo.name or '').lower()
|
||||
if 'inspect' in name_l and (wo.x_fc_bath_id or wo.x_fc_tank_id):
|
||||
wo.sudo().write({'x_fc_bath_id': False, 'x_fc_tank_id': False})
|
||||
n_cl += 1
|
||||
print(f'6. legacy bath/tank cleared from inspection WOs: {n_cl}')
|
||||
|
||||
# Verify classifier fix — re-classify all WOs and report
|
||||
kinds = Counter()
|
||||
mis_pi = []
|
||||
for wo in all_wos:
|
||||
if hasattr(wo, '_fp_classify_kind'):
|
||||
k = wo._fp_classify_kind()
|
||||
kinds[k] += 1
|
||||
if 'inspect' in (wo.name or '').lower() and k != 'inspect':
|
||||
mis_pi.append((wo.id, wo.name, k))
|
||||
print(f'\\nclassifier results across {len(all_wos)} WOs: {dict(kinds)}')
|
||||
print(f'inspection WOs misclassified: {len(mis_pi)}')
|
||||
for tup in mis_pi[:5]:
|
||||
print(f' ✗ WO {tup[0]} "{tup[1]}" → {tup[2]} (should be inspect)')
|
||||
|
||||
env.cr.commit()
|
||||
print('\\nBackfill committed.')
|
||||
@@ -114,6 +114,18 @@ customer = env['res.partner'].sudo().create({
|
||||
'city': 'Toronto', 'zip': 'M5G 1V7',
|
||||
'country_id': env.ref('base.ca').id,
|
||||
})
|
||||
# Net-30 default so invoices created later inherit the right schedule.
|
||||
net30 = env.ref('account.account_payment_term_30days', raise_if_not_found=False)
|
||||
if net30:
|
||||
customer.sudo().property_payment_term_id = net30.id
|
||||
|
||||
# Make sure the company has a default facility so MO confirm succeeds.
|
||||
co = env.company
|
||||
if not co.x_fc_default_facility_id:
|
||||
f = env['fusion.plating.facility'].search([('active', '=', True)], limit=1)
|
||||
if f:
|
||||
co.sudo().x_fc_default_facility_id = f.id
|
||||
show('company default facility set', f.name)
|
||||
|
||||
step('SANDRA', f'Receives RFQ from {customer.name}')
|
||||
|
||||
@@ -226,6 +238,18 @@ step('HANNAH', 'Assigns each WO to a specific operator')
|
||||
# Pick a bath + a tank for any WO that needs wet-process traceability
|
||||
test_bath = env['fusion.plating.bath'].search([], limit=1)
|
||||
test_tank = env['fusion.plating.tank'].search([], limit=1)
|
||||
test_oven = env['fusion.plating.bake.oven'].search([], limit=1)
|
||||
if not test_oven:
|
||||
f0 = env['fusion.plating.facility'].search([], limit=1)
|
||||
test_oven = env['fusion.plating.bake.oven'].sudo().create({
|
||||
'name': 'Bake Oven 1', 'code': 'OVEN-1',
|
||||
'facility_id': f0.id if f0 else False,
|
||||
'target_temp_min': 350.0, 'target_temp_max': 380.0,
|
||||
'chart_recorder_ref': 'CR-OVEN1-2026',
|
||||
})
|
||||
# Make sure the oven has a chart_recorder_ref (new gate requirement)
|
||||
if test_oven and not test_oven.chart_recorder_ref:
|
||||
test_oven.sudo().chart_recorder_ref = f'CR-{test_oven.code}-2026'
|
||||
|
||||
# Issue operator certifications for the bath's process type so the cert
|
||||
# gate doesn't block legitimate operators (in real life the manager
|
||||
@@ -267,23 +291,31 @@ for wo in mo.workorder_ids:
|
||||
op_user = users[operator_key]
|
||||
wo.sudo().x_fc_assigned_user_id = op_user.id
|
||||
|
||||
# If this is a wet-process WO (E-Nickel Plating, etch, rinse, etc.)
|
||||
# Hannah must also pin the exact bath + tank for traceability.
|
||||
is_wet = wo._fp_is_wet_process() if hasattr(wo, '_fp_is_wet_process') else False
|
||||
bath_assigned = tank_assigned = False
|
||||
if is_wet and test_bath and test_tank:
|
||||
# Pin per-kind equipment using the new classifier (post inspect/mask/
|
||||
# rack/bake priority fix), so Post-plate Inspection no longer gets
|
||||
# bath assigned just because its name contains "plat".
|
||||
kind = wo._fp_classify_kind() if hasattr(wo, '_fp_classify_kind') else 'other'
|
||||
extras = f' [{kind}]'
|
||||
if kind == 'wet' and test_bath and test_tank:
|
||||
wo.sudo().write({
|
||||
'x_fc_bath_id': test_bath.id,
|
||||
'x_fc_tank_id': test_tank.id,
|
||||
})
|
||||
bath_assigned = True
|
||||
tank_assigned = True
|
||||
wet_assignments.append(wo)
|
||||
extras = f' [WET — bath={test_bath.name}, tank={test_tank.name}]'
|
||||
elif kind == 'bake' and test_oven:
|
||||
wo.sudo().x_fc_oven_id = test_oven.id
|
||||
extras = f' [BAKE — oven={test_oven.name}]'
|
||||
elif kind == 'rack':
|
||||
rack = env['fusion.plating.rack'].search([], limit=1)
|
||||
if rack:
|
||||
wo.sudo().x_fc_rack_id = rack.id
|
||||
extras = f' [RACK — fixture={rack.name}]'
|
||||
elif kind == 'mask':
|
||||
wo.sudo().x_fc_masking_material = 'tape'
|
||||
extras = ' [MASK — material=tape]'
|
||||
|
||||
assignments.append((wo, op_user, operator_key))
|
||||
extras = ''
|
||||
if is_wet:
|
||||
extras = f' [WET — bath={test_bath.name if bath_assigned else "MISSING"}, tank={test_tank.name if tank_assigned else "MISSING"}]'
|
||||
show(f' WO {wo.id}', f'"{wo.name}" → {op_user.name}{extras}')
|
||||
|
||||
assigned_count = sum(1 for w, _, _ in assignments if w.x_fc_assigned_user_id)
|
||||
@@ -336,6 +368,199 @@ if wet_assignments:
|
||||
'x_fc_tank_id': saved_tank,
|
||||
})
|
||||
|
||||
# ===== Negative tests for the 6 new gates (wrapped in savepoints
|
||||
# so an SQL-level constraint failure doesn't abort the txn) =====
|
||||
banner('PHASE 4c — Negative tests for the new compliance gates')
|
||||
|
||||
|
||||
def neg_test(label, fn, expect_keywords):
|
||||
"""Run fn() inside a savepoint; check the raised error mentions
|
||||
one of `expect_keywords`. Always rolls back."""
|
||||
sp_name = f'neg_{abs(hash(label))}'
|
||||
env.cr.execute(f'SAVEPOINT {sp_name}')
|
||||
fired = False
|
||||
msg = ''
|
||||
try:
|
||||
fn()
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
low = msg.lower()
|
||||
fired = any(k.lower() in low for k in expect_keywords)
|
||||
finally:
|
||||
env.cr.execute(f'ROLLBACK TO SAVEPOINT {sp_name}')
|
||||
if msg:
|
||||
show(' blocked with', msg.splitlines()[0][:120])
|
||||
finding('PASS' if fired else 'FAIL',
|
||||
f'gate: {label}',
|
||||
'blocked' if fired else f'NOT blocked (got: {msg[:60]!r})')
|
||||
|
||||
|
||||
# Test 3: MO confirm without facility → expect block
|
||||
step('SYSTEM', 'Test 3 — MO confirm with no facility → blocked')
|
||||
|
||||
|
||||
def t_mo_facility():
|
||||
saved_default = env.company.x_fc_default_facility_id
|
||||
env.company.sudo().x_fc_default_facility_id = False
|
||||
fac0 = env['fusion.plating.facility'].search([('active', '=', True)])
|
||||
fac0.sudo().write({'active': False})
|
||||
try:
|
||||
m = env['mrp.production'].sudo().create({
|
||||
'product_id': mo.product_id.id,
|
||||
'product_qty': 1,
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
m.action_confirm() # should raise — no facility resolvable
|
||||
finally:
|
||||
fac0.sudo().write({'active': True})
|
||||
env.company.sudo().x_fc_default_facility_id = saved_default
|
||||
|
||||
|
||||
neg_test('MO confirm without facility', t_mo_facility,
|
||||
['facility'])
|
||||
|
||||
# Test 4: Cert issue without spec_reference
|
||||
step('SYSTEM', 'Test 4 — Cert action_issue() without spec_reference → blocked')
|
||||
|
||||
|
||||
def t_cert_spec():
|
||||
c = env['fp.certificate'].sudo().create({
|
||||
'partner_id': customer.id,
|
||||
'production_id': mo.id,
|
||||
'certificate_type': 'coc',
|
||||
'spec_reference': False,
|
||||
})
|
||||
c.action_issue()
|
||||
|
||||
|
||||
neg_test('cert issue without spec_reference', t_cert_spec,
|
||||
['Spec', 'spec_reference'])
|
||||
|
||||
# Test 5: Delivery mark_delivered without POD
|
||||
step('SYSTEM', 'Test 5 — Delivery mark_delivered() with no POD → blocked')
|
||||
|
||||
|
||||
def t_dlv_pod():
|
||||
d = env['fusion.plating.delivery'].sudo().create({
|
||||
'partner_id': customer.id,
|
||||
'state': 'en_route',
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
d.action_mark_delivered()
|
||||
|
||||
|
||||
neg_test('delivery delivered without POD', t_dlv_pod,
|
||||
['POD', 'Proof of Delivery'])
|
||||
|
||||
# Test 6: Invoice post without payment terms
|
||||
step('SYSTEM', 'Test 6 — Invoice post() with no payment terms → blocked')
|
||||
|
||||
|
||||
def t_inv_terms():
|
||||
saved_term = customer.property_payment_term_id
|
||||
customer.sudo().property_payment_term_id = False
|
||||
try:
|
||||
i = env['account.move'].sudo().create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': customer.id,
|
||||
'invoice_date': fields.Date.today(),
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Test plating service',
|
||||
'quantity': 1,
|
||||
'price_unit': 100.0,
|
||||
})],
|
||||
})
|
||||
i.invoice_payment_term_id = False
|
||||
i.action_post()
|
||||
finally:
|
||||
customer.sudo().property_payment_term_id = saved_term
|
||||
|
||||
|
||||
neg_test('invoice post without payment terms', t_inv_terms,
|
||||
['payment term'])
|
||||
|
||||
# Test 7: Thickness reading without calibration_std_ref
|
||||
step('SYSTEM', 'Test 7 — Thickness reading without calibration_std_ref → blocked')
|
||||
|
||||
|
||||
def t_thickness_cal():
|
||||
env['fp.thickness.reading'].sudo().create({
|
||||
'production_id': mo.id,
|
||||
'reading_number': 99,
|
||||
'nip_mils': 0.05,
|
||||
'calibration_std_ref': False,
|
||||
})
|
||||
|
||||
|
||||
neg_test('thickness reading without cal std', t_thickness_cal,
|
||||
['calibration', 'required', 'not-null', 'null value'])
|
||||
|
||||
# Test 8: NCR close without root cause / containment / disposition
|
||||
step('SYSTEM', 'Test 8 — NCR close() with missing fields → blocked')
|
||||
|
||||
|
||||
def t_ncr_close():
|
||||
f = env['fusion.plating.facility'].search([], limit=1)
|
||||
n = env['fusion.plating.ncr'].sudo().create({
|
||||
'facility_id': f.id,
|
||||
'description': '',
|
||||
'containment': '',
|
||||
'root_cause': '',
|
||||
'disposition': False,
|
||||
})
|
||||
n.action_close()
|
||||
|
||||
|
||||
neg_test('NCR close without RC/containment/disposition', t_ncr_close,
|
||||
['Root Cause', 'Containment', 'Disposition'])
|
||||
|
||||
# Test 9: CAPA close without root cause analysis / action plan / verification
|
||||
step('SYSTEM', 'Test 9 — CAPA close() with missing fields → blocked')
|
||||
|
||||
|
||||
def t_capa_close():
|
||||
c = env['fusion.plating.capa'].sudo().create({
|
||||
'description': '',
|
||||
'root_cause_analysis': '',
|
||||
'action_plan': '',
|
||||
})
|
||||
c.action_close()
|
||||
|
||||
|
||||
neg_test('CAPA close without analysis/plan/verification', t_capa_close,
|
||||
['Root Cause Analysis', 'Action Plan', 'Verification'])
|
||||
|
||||
# Test 10: Discharge sample close without lab evidence
|
||||
step('SYSTEM', 'Test 10 — Discharge sample close() with no lab evidence → blocked')
|
||||
|
||||
|
||||
def t_discharge_close():
|
||||
f = env['fusion.plating.facility'].search([], limit=1)
|
||||
s = env['fusion.plating.discharge.sample'].sudo().create({
|
||||
'facility_id': f.id,
|
||||
})
|
||||
s.action_close()
|
||||
|
||||
|
||||
neg_test('discharge sample close without lab evidence', t_discharge_close,
|
||||
['Lab Report', 'Results Received', 'parameter', 'Lab certificate'])
|
||||
|
||||
# Test 11: Invoice ref auto-fill from SO at create time
|
||||
step('SYSTEM', 'Test 11 — Invoice ref auto-fills from SO.client_order_ref')
|
||||
test_inv2 = env['account.move'].sudo().create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': customer.id,
|
||||
'invoice_date': fields.Date.today(),
|
||||
'invoice_origin': so.name,
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Test', 'quantity': 1, 'price_unit': 1.0,
|
||||
})],
|
||||
})
|
||||
finding('PASS' if test_inv2.ref == so.client_order_ref else 'FAIL',
|
||||
'invoice ref auto-fills from SO',
|
||||
f'ref={test_inv2.ref!r} (expected {so.client_order_ref!r})')
|
||||
test_inv2.sudo().unlink()
|
||||
|
||||
# =====================================================================
|
||||
banner('PHASE 5 — Operators run their work orders (REAL-TIME timers)')
|
||||
# =====================================================================
|
||||
@@ -425,6 +650,14 @@ for wo, op_user, op_key in assignments:
|
||||
n_readings = Reading.search_count([('production_id', '=', mo.id)])
|
||||
show(' thickness readings', f'{n_readings} logged for {mo.name}')
|
||||
|
||||
# Bake operator records actuals BEFORE pressing finish (new gate)
|
||||
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'bake':
|
||||
wo.sudo().write({
|
||||
'x_fc_bake_temp': 365.0,
|
||||
'x_fc_bake_duration_hours': 4.0,
|
||||
})
|
||||
show(' bake actuals', '365°F × 4h recorded')
|
||||
|
||||
step(actor, 'Taps FINISH')
|
||||
try:
|
||||
if wo_op.state == 'progress':
|
||||
@@ -514,9 +747,26 @@ if dlv:
|
||||
try:
|
||||
if dlv.state == 'draft': dlv.with_user(users['dave']).sudo().action_schedule()
|
||||
if dlv.state == 'scheduled': dlv.with_user(users['dave']).sudo().action_start_route()
|
||||
# POD must be captured BEFORE marking delivered (new gate)
|
||||
if dlv.state == 'en_route' and not dlv.pod_id:
|
||||
step('DAVE', 'Captures POD on iPad — recipient signs + photo')
|
||||
POD = env['fusion.plating.proof.of.delivery']
|
||||
pod = POD.with_user(users['dave']).sudo().create({
|
||||
'delivery_id': dlv.id,
|
||||
'partner_id': dlv.partner_id.id,
|
||||
'recipient_name': 'Dock Receiver',
|
||||
'notes': 'E2E sim — recipient on dock signed for parts',
|
||||
})
|
||||
dlv.sudo().pod_id = pod.id
|
||||
show(' POD captured', f'{pod.name} (id={pod.id})')
|
||||
if dlv.state == 'en_route': dlv.with_user(users['dave']).sudo().action_mark_delivered()
|
||||
except Exception as e:
|
||||
print(f' [info] delivery transitions: {e}')
|
||||
|
||||
# ===== Negative test: try to mark another delivery delivered without POD =====
|
||||
finding('PASS' if dlv.pod_id else 'FAIL',
|
||||
'POD captured before delivery',
|
||||
f'pod_id={dlv.pod_id.name if dlv.pod_id else "NONE"}')
|
||||
finding('PASS' if dlv.state == 'delivered' else 'FAIL',
|
||||
'delivery final state', dlv.state)
|
||||
coc_logs = env['fusion.plating.chain.of.custody'].search(
|
||||
|
||||
175
fusion_plating/scripts/fp_per_step_audit.py
Normal file
175
fusion_plating/scripts/fp_per_step_audit.py
Normal file
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Per-step compliance audit — walks every WO of the most recent MO
|
||||
and reports which compliance data points are captured vs missing,
|
||||
broken down by WO kind.
|
||||
|
||||
Output is the diagnostic the user asked for: "check and report if
|
||||
all the data needed for compliance is being enforced for every step."
|
||||
"""
|
||||
env = env # noqa
|
||||
|
||||
|
||||
def banner(t):
|
||||
print(f'\n{"="*78}\n {t}\n{"="*78}')
|
||||
|
||||
|
||||
# Per-kind required data points. Each tuple is (field_or_check, severity, why)
|
||||
KIND_RULES = {
|
||||
'wet': [
|
||||
('x_fc_assigned_user_id', 'CRITICAL', 'Operator (audit trail)'),
|
||||
('x_fc_bath_id', 'CRITICAL', 'Which bath ran (chemistry traceability)'),
|
||||
('x_fc_tank_id', 'CRITICAL', 'Which physical tank'),
|
||||
('duration', 'CRITICAL', 'Actual run time'),
|
||||
('x_fc_thickness_target', 'IMPORTANT','Spec target (QC accept criterion)'),
|
||||
('x_fc_dwell_time_minutes','IMPORTANT','Recipe dwell vs actual'),
|
||||
('x_fc_rack_id', 'IMPORTANT','Which rack/fixture used'),
|
||||
('bath_log_during_window', 'IMPORTANT','Chemistry reading recorded during WO time window'),
|
||||
('x_fc_started_by_user_id','IMPORTANT','Who actually clicked Start'),
|
||||
('x_fc_finished_by_user_id','IMPORTANT','Who clicked Finish'),
|
||||
],
|
||||
'bake': [
|
||||
('x_fc_assigned_user_id', 'CRITICAL', 'Operator'),
|
||||
('x_fc_oven_id', 'CRITICAL', 'Which oven'),
|
||||
('x_fc_bake_temp', 'CRITICAL', 'Setpoint temp (Nadcap req)'),
|
||||
('x_fc_bake_duration_hours','CRITICAL','Actual bake duration'),
|
||||
('chart_recorder_ref', 'CRITICAL', 'Chart-recorder ref on the OVEN — auditor demands the chart for the run'),
|
||||
('duration', 'CRITICAL', 'WO timer duration'),
|
||||
('x_fc_started_by_user_id','IMPORTANT','Who started'),
|
||||
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
|
||||
],
|
||||
'mask': [
|
||||
('x_fc_assigned_user_id', 'CRITICAL', 'Operator'),
|
||||
('duration', 'CRITICAL', 'Run time'),
|
||||
('masking_material', 'IMPORTANT','Which material — needed for stripping later'),
|
||||
('x_fc_started_by_user_id','IMPORTANT','Who started'),
|
||||
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
|
||||
],
|
||||
'rack': [
|
||||
('x_fc_assigned_user_id', 'CRITICAL', 'Operator'),
|
||||
('x_fc_rack_id', 'CRITICAL', 'Which rack/fixture (per-rack MTO life tracking)'),
|
||||
('duration', 'CRITICAL', 'Run time'),
|
||||
('x_fc_started_by_user_id','IMPORTANT','Who started'),
|
||||
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
|
||||
],
|
||||
'inspect': [
|
||||
('x_fc_assigned_user_id', 'CRITICAL', 'Inspector'),
|
||||
('duration', 'CRITICAL', 'Run time'),
|
||||
('thickness_readings', 'CRITICAL', 'Fischerscope readings logged for this MO'),
|
||||
('cal_std_on_readings', 'CRITICAL', 'Every reading has calibration std (Nadcap)'),
|
||||
('gauge_serial', 'IMPORTANT','Which gauge (links to calibration record)'),
|
||||
('x_fc_started_by_user_id','IMPORTANT','Who started'),
|
||||
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
|
||||
],
|
||||
'other': [
|
||||
('x_fc_assigned_user_id', 'IMPORTANT','Operator'),
|
||||
('duration', 'IMPORTANT','Run time'),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def check_field(wo, field):
|
||||
"""Return (value, is_filled, label_for_display)."""
|
||||
if field == 'bath_log_during_window':
|
||||
# Look for any bath log on this WO's bath, between start+finish
|
||||
if not wo.x_fc_bath_id or not wo.x_fc_started_at or not wo.x_fc_finished_at:
|
||||
return ('—', False, 'no log searchable')
|
||||
Log = env['fusion.plating.bath.log']
|
||||
n = Log.search_count([
|
||||
('bath_id', '=', wo.x_fc_bath_id.id),
|
||||
('log_date', '>=', wo.x_fc_started_at),
|
||||
('log_date', '<=', wo.x_fc_finished_at),
|
||||
])
|
||||
return (f'{n} log(s)', n > 0, '')
|
||||
if field == 'chart_recorder_ref':
|
||||
ref = wo.x_fc_oven_id.chart_recorder_ref if wo.x_fc_oven_id else False
|
||||
return (ref or '—', bool(ref), 'on oven')
|
||||
if field == 'masking_material':
|
||||
val = wo.x_fc_masking_material if 'x_fc_masking_material' in wo._fields else False
|
||||
if not val:
|
||||
return ('—', False, '')
|
||||
label = dict(wo._fields['x_fc_masking_material'].selection).get(val, val)
|
||||
return (label, True, '')
|
||||
if field == 'thickness_readings':
|
||||
n = env['fp.thickness.reading'].search_count([
|
||||
('production_id', '=', wo.production_id.id),
|
||||
])
|
||||
return (f'{n} reading(s)', n > 0, '')
|
||||
if field == 'cal_std_on_readings':
|
||||
rs = env['fp.thickness.reading'].search([
|
||||
('production_id', '=', wo.production_id.id),
|
||||
])
|
||||
if not rs:
|
||||
return ('—', False, 'no readings')
|
||||
n_with = sum(1 for r in rs if r.calibration_std_ref)
|
||||
return (f'{n_with}/{len(rs)} have cal std', n_with == len(rs), '')
|
||||
if field == 'gauge_serial':
|
||||
# Pull from any reading on this MO
|
||||
r = env['fp.thickness.reading'].search(
|
||||
[('production_id', '=', wo.production_id.id)], limit=1)
|
||||
if not r:
|
||||
return ('—', False, 'no readings')
|
||||
return (r.equipment_model or '—', bool(r.equipment_model), 'from reading.equipment_model')
|
||||
# Direct field on WO
|
||||
val = getattr(wo, field, False) if field in wo._fields else None
|
||||
if val is None:
|
||||
return ('(field n/a)', False, '')
|
||||
if hasattr(val, '_name'):
|
||||
label = val.display_name if val else '—'
|
||||
return (label, bool(val.ids), '')
|
||||
if isinstance(val, (int, float)):
|
||||
return (str(val), val > 0, '')
|
||||
return (str(val), bool(val), '')
|
||||
|
||||
|
||||
# Pull the most recent MO with all its WOs (sudo to bypass any
|
||||
# multi-company / record-rule filter so we always pick the truly latest).
|
||||
mo = env['mrp.production'].sudo().search(
|
||||
[('state', '=', 'done')], order='id desc', limit=1)
|
||||
print(f'\nAuditing MO: {mo.name} (state={mo.state}, recipe={mo.x_fc_recipe_id.name})')
|
||||
print(f'{len(mo.workorder_ids)} work orders\n')
|
||||
|
||||
GAP_TOTALS = {'CRITICAL': 0, 'IMPORTANT': 0}
|
||||
PER_KIND = {}
|
||||
|
||||
for wo in mo.workorder_ids.sorted('sequence'):
|
||||
kind = wo._fp_classify_kind() if hasattr(wo, '_fp_classify_kind') else 'other'
|
||||
rules = KIND_RULES.get(kind, KIND_RULES['other'])
|
||||
banner(f'WO {wo.id}: "{wo.name}" kind={kind}')
|
||||
show_gaps = []
|
||||
show_ok = []
|
||||
for field, severity, why in rules:
|
||||
val_str, is_filled, note = check_field(wo, field)
|
||||
sym = '✓' if is_filled else '✗'
|
||||
line = f' {sym} {severity:<9} {field:<30} → {val_str:<35} {why}'
|
||||
if note:
|
||||
line += f' [{note}]'
|
||||
if is_filled:
|
||||
show_ok.append(line)
|
||||
else:
|
||||
show_gaps.append(line)
|
||||
if severity in GAP_TOTALS:
|
||||
GAP_TOTALS[severity] += 1
|
||||
PER_KIND.setdefault(kind, []).append(field)
|
||||
for ln in show_ok:
|
||||
print(ln)
|
||||
if show_gaps:
|
||||
print(' ── GAPS ──')
|
||||
for ln in show_gaps:
|
||||
print(ln)
|
||||
|
||||
# =====================================================================
|
||||
banner('SUMMARY — gaps per WO kind across this MO')
|
||||
# =====================================================================
|
||||
|
||||
for kind, gaps in PER_KIND.items():
|
||||
from collections import Counter
|
||||
c = Counter(gaps)
|
||||
print(f'\n {kind} WOs ({sum(1 for w in mo.workorder_ids if (w._fp_classify_kind() if hasattr(w,"_fp_classify_kind") else "other") == kind)} of them):')
|
||||
for field, n in c.most_common():
|
||||
print(f' × {field:<30} missing in {n} WO(s)')
|
||||
|
||||
print(f'\n Totals: {GAP_TOTALS["CRITICAL"]} CRITICAL gaps, {GAP_TOTALS["IMPORTANT"]} IMPORTANT gaps')
|
||||
print('\n Note: "missing" doesn\'t always mean "broken" — some fields')
|
||||
print(' are optional today but should be required for stricter')
|
||||
print(' AS9100 / Nadcap compliance. See the per-kind list to')
|
||||
print(' decide which are real bugs vs roadmap items.')
|
||||
338
fusion_plating/scripts/fp_required_fields_audit.py
Normal file
338
fusion_plating/scripts/fp_required_fields_audit.py
Normal file
@@ -0,0 +1,338 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Comprehensive required-fields audit.
|
||||
|
||||
For each major model in the quote → invoice workflow:
|
||||
• Lists fields currently marked `required=True` in the schema
|
||||
• For the most recent COMPLETED record, shows which compliance-
|
||||
relevant fields are empty (gap candidates)
|
||||
• Classifies each gap by severity:
|
||||
CRITICAL — compliance blocker (aerospace / Nadcap / env.)
|
||||
IMPORTANT — workflow / operational risk
|
||||
NICE — would improve reporting
|
||||
|
||||
The report is purely diagnostic — it changes nothing in the DB.
|
||||
"""
|
||||
env = env # noqa
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def section(title):
|
||||
print(f'\n{"="*78}\n {title}\n{"="*78}')
|
||||
|
||||
|
||||
def show_field_audit(model_name, record, candidate_fields):
|
||||
"""For one record, show which of `candidate_fields` are empty.
|
||||
|
||||
candidate_fields: list of (field, severity, reason) tuples
|
||||
"""
|
||||
if not record:
|
||||
print(f' (no record found for {model_name})')
|
||||
return
|
||||
print(f' Record: {record.display_name} (id={record.id})')
|
||||
# First show what's currently required in the schema
|
||||
required_in_schema = [
|
||||
n for n, f in record._fields.items()
|
||||
if getattr(f, 'required', False)
|
||||
]
|
||||
print(f' Already required in schema: {len(required_in_schema)}')
|
||||
|
||||
print(f' Candidate fields needing enforcement:')
|
||||
for field, severity, reason in candidate_fields:
|
||||
if field not in record._fields:
|
||||
continue
|
||||
val = record[field]
|
||||
is_empty = (
|
||||
not val
|
||||
or (hasattr(val, '_name') and not val.ids)
|
||||
or val in ('', False, 0, 0.0)
|
||||
)
|
||||
sym = {'CRITICAL': '🔴', 'IMPORTANT': '🟡', 'NICE': '⚪'}[severity]
|
||||
marker = '✗ EMPTY' if is_empty else '✓ filled'
|
||||
val_str = str(val)[:60] if not is_empty else '—'
|
||||
print(f' {sym} {severity:<9} {field:<32} {marker:<10} {reason}')
|
||||
print(f' currently: {val_str!r}')
|
||||
|
||||
|
||||
# =====================================================================
|
||||
section('1. Customer (res.partner) — most recently used customer')
|
||||
# =====================================================================
|
||||
|
||||
partner = env['sale.order'].search([], order='id desc', limit=1).partner_id
|
||||
show_field_audit('res.partner', partner, [
|
||||
('email', 'CRITICAL', 'Notifications + portal access — silent fail without it'),
|
||||
('phone', 'IMPORTANT', 'Operator can call for clarification'),
|
||||
('street', 'CRITICAL', 'Required on BoL + Invoice + delivery — no shipping without'),
|
||||
('city', 'CRITICAL', 'Same'),
|
||||
('zip', 'CRITICAL', 'Same'),
|
||||
('country_id', 'CRITICAL', 'Determines tax + ITAR / CGP rules'),
|
||||
('vat', 'IMPORTANT', 'HST/GST registration number — needed on invoice'),
|
||||
('property_payment_term_id', 'IMPORTANT', 'Net-30 vs Net-60 controls invoice due date'),
|
||||
('x_fc_account_hold', 'NICE', 'Default False is fine; only set when collections issue'),
|
||||
('x_fc_send_coc', 'NICE', 'Per-customer CoC delivery preference'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('2. Sale Order (sale.order) — most recent SO')
|
||||
# =====================================================================
|
||||
|
||||
so = env['sale.order'].search([], order='id desc', limit=1)
|
||||
show_field_audit('sale.order', so, [
|
||||
('partner_id', 'CRITICAL', 'Already required by Odoo'),
|
||||
('client_order_ref', 'CRITICAL', 'Customer PO# — every aero customer requires this on every doc'),
|
||||
('x_fc_po_number', 'CRITICAL', 'Same — FP-specific mirror'),
|
||||
('x_fc_coating_config_id', 'CRITICAL', 'Drives recipe + price + spec'),
|
||||
('x_fc_part_catalog_id', 'IMPORTANT', 'Part the order is about — needed for traceability'),
|
||||
('x_fc_delivery_method', 'IMPORTANT', 'Pickup / drop / courier — drives logistics'),
|
||||
('x_fc_rfq_attachment_id', 'NICE', 'Original customer RFQ for audit trail'),
|
||||
('x_fc_po_attachment_id', 'IMPORTANT', 'Customer signed PO PDF'),
|
||||
('payment_term_id', 'IMPORTANT', 'Net terms — derived from customer if unset'),
|
||||
('user_id', 'IMPORTANT', 'Salesperson — needed for commission + handoff'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('3. Receiving (fp.receiving) — most recent record')
|
||||
# =====================================================================
|
||||
|
||||
recv = env['fp.receiving'].search([], order='id desc', limit=1)
|
||||
show_field_audit('fp.receiving', recv, [
|
||||
('sale_order_id', 'CRITICAL', 'Without this we lose the link to the job'),
|
||||
('partner_id', 'CRITICAL', 'Customer (related, but can drift)'),
|
||||
('received_by_id', 'CRITICAL', 'Who counted the parts (audit trail)'),
|
||||
('received_date', 'CRITICAL', 'When the parts arrived (compliance + start-clock)'),
|
||||
('expected_qty', 'CRITICAL', 'Without this no qty-match check'),
|
||||
('received_qty', 'CRITICAL', 'The actual count (compliance — discrepancy log)'),
|
||||
('carrier_name', 'IMPORTANT', 'Who delivered — chain-of-custody starts here'),
|
||||
('carrier_tracking', 'IMPORTANT', 'Inbound tracking #'),
|
||||
('notes', 'NICE', 'Free-form receiver observations'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('4. MRP Production (mrp.production) — most recent MO')
|
||||
# =====================================================================
|
||||
|
||||
mo = env['mrp.production'].search([('state', '=', 'done')], order='id desc', limit=1)
|
||||
show_field_audit('mrp.production', mo, [
|
||||
('product_id', 'CRITICAL', 'Already required by Odoo'),
|
||||
('product_qty', 'CRITICAL', 'Same'),
|
||||
('x_fc_facility_id', 'CRITICAL', 'Where the job is being made (compliance)'),
|
||||
('x_fc_recipe_id', 'CRITICAL', 'Which process — without it WOs can\'t be generated'),
|
||||
('x_fc_assigned_manager_id','IMPORTANT','Manager responsible for the job'),
|
||||
('x_fc_customer_spec_id','IMPORTANT', 'Customer spec controlling the job (e.g. AMS 2404)'),
|
||||
('x_fc_portal_job_id', 'IMPORTANT', 'Portal-facing job tracker'),
|
||||
('origin', 'CRITICAL', 'Source SO — needed for back-link'),
|
||||
('company_id', 'CRITICAL', 'Multi-company correctness (just fixed)'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('5. Work Orders (mrp.workorder) — wet WO from most recent MO')
|
||||
# =====================================================================
|
||||
|
||||
wet_wo = mo.workorder_ids.filtered(
|
||||
lambda w: hasattr(w, '_fp_is_wet_process') and w._fp_is_wet_process()
|
||||
)[:1] if mo else env['mrp.workorder']
|
||||
show_field_audit('mrp.workorder', wet_wo, [
|
||||
('x_fc_assigned_user_id', 'CRITICAL', 'NOW ENFORCED via button_start gate'),
|
||||
('x_fc_bath_id', 'CRITICAL', 'NOW ENFORCED — chemistry traceability'),
|
||||
('x_fc_tank_id', 'CRITICAL', 'NOW ENFORCED — physical tank audit'),
|
||||
('x_fc_facility_id', 'CRITICAL', 'Which plant ran it (multi-facility shops)'),
|
||||
('x_fc_thickness_target', 'IMPORTANT', 'Spec target — drives QC accept/reject criteria'),
|
||||
('x_fc_dwell_time_minutes','IMPORTANT','Recipe dwell — needed for cycle-time analytics'),
|
||||
('x_fc_rack_id', 'IMPORTANT', 'Which rack/fixture used (per-rack MTO tracking)'),
|
||||
('x_fc_started_by_user_id','IMPORTANT','Who actually started it (audit, may differ from assigned)'),
|
||||
('x_fc_finished_by_user_id','IMPORTANT','Who finished it'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('6. Bath Log (fusion.plating.bath.log)')
|
||||
# =====================================================================
|
||||
|
||||
baths = env['fusion.plating.bath.log'].search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.bath.log', baths, [
|
||||
('bath_id', 'CRITICAL', 'Which bath the readings came from'),
|
||||
('shift', 'IMPORTANT', 'Day/swing/night — for shift-effect analysis'),
|
||||
('user_id', 'CRITICAL', 'Operator who took the readings (audit trail)'),
|
||||
('logged_at', 'CRITICAL', 'When the readings were taken'),
|
||||
('line_ids', 'CRITICAL', 'The actual chemistry numbers (the whole point)'),
|
||||
('notes', 'NICE', 'Free-form observations'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('7. Certificate (fp.certificate) — most recent CoC')
|
||||
# =====================================================================
|
||||
|
||||
coc = env['fp.certificate'].search(
|
||||
[('certificate_type', '=', 'coc')], order='id desc', limit=1)
|
||||
show_field_audit('fp.certificate', coc, [
|
||||
('partner_id', 'CRITICAL', 'Customer the cert belongs to'),
|
||||
('production_id', 'CRITICAL', 'Which MO it certifies'),
|
||||
('po_number', 'CRITICAL', 'Customer PO — required by aero specs'),
|
||||
('spec_reference', 'CRITICAL', 'AMS 2404 / MIL-C-26074 etc. — what was met'),
|
||||
('process_description','IMPORTANT','Human-readable process name'),
|
||||
('part_number', 'IMPORTANT', 'Part the cert covers'),
|
||||
('quantity_shipped', 'CRITICAL', 'How many parts certified'),
|
||||
('thickness_reading_ids','CRITICAL','Fischerscope readings (NOW AUTO-LINKED)'),
|
||||
('attachment_id', 'CRITICAL', 'The PDF itself (NOW AUTO-RENDERED)'),
|
||||
('issued_by_id', 'CRITICAL', 'Inspector signature — who certified this'),
|
||||
('issued_date', 'CRITICAL', 'When issued'),
|
||||
('state', 'CRITICAL', 'draft/issued/voided — NOT issued = NOT compliant'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('8. Thickness Reading (fp.thickness.reading)')
|
||||
# =====================================================================
|
||||
|
||||
reading = env['fp.thickness.reading'].search([], order='id desc', limit=1)
|
||||
show_field_audit('fp.thickness.reading', reading, [
|
||||
('production_id', 'CRITICAL', 'Which MO this reading is from'),
|
||||
('certificate_id', 'CRITICAL', 'Which cert (auto-linked at MO done)'),
|
||||
('reading_number', 'CRITICAL', 'Sequence (n=1, n=2, n=3 — Nadcap requires this)'),
|
||||
('nip_mils', 'CRITICAL', 'The thickness measurement itself'),
|
||||
('ni_percent', 'IMPORTANT', 'Composition — affects bath chemistry diagnosis'),
|
||||
('p_percent', 'IMPORTANT', 'Same'),
|
||||
('position_label', 'CRITICAL', 'WHERE on the part (Nadcap requires location)'),
|
||||
('equipment_model', 'CRITICAL', 'Which gauge — calibration trail'),
|
||||
('calibration_std_ref', 'CRITICAL', 'Which calibration standard — Nadcap req'),
|
||||
('operator_id', 'CRITICAL', 'Who took the reading'),
|
||||
('reading_datetime', 'CRITICAL', 'When'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('9. Delivery (fusion.plating.delivery)')
|
||||
# =====================================================================
|
||||
|
||||
dlv = env['fusion.plating.delivery'].search(
|
||||
[('state', '=', 'delivered')], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.delivery', dlv, [
|
||||
('partner_id', 'CRITICAL', 'Already required'),
|
||||
('scheduled_date', 'CRITICAL', 'When the customer expects parts (NOW PREFILLED)'),
|
||||
('assigned_driver_id', 'CRITICAL', 'Who is driving (NOW PREFILLED)'),
|
||||
('vehicle_id', 'IMPORTANT', 'Which vehicle (insurance + GPS)'),
|
||||
('delivered_at', 'CRITICAL', 'When delivery was completed'),
|
||||
('contact_name', 'IMPORTANT', 'Recipient on the receiving dock'),
|
||||
('contact_phone', 'IMPORTANT', 'Driver can call before arriving'),
|
||||
('coc_attachment_id', 'CRITICAL', 'CoC PDF that goes with the parts'),
|
||||
('packing_list_attachment_id','IMPORTANT','Packing slip'),
|
||||
('delivery_address_id','IMPORTANT', 'Override default partner ship-to'),
|
||||
('pod_id', 'CRITICAL', 'Proof of delivery — without it, we can\'t bill'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('10. Invoice (account.move) — most recent posted invoice')
|
||||
# =====================================================================
|
||||
|
||||
inv = env['account.move'].search(
|
||||
[('move_type', '=', 'out_invoice'), ('state', '=', 'posted')],
|
||||
order='id desc', limit=1)
|
||||
show_field_audit('account.move', inv, [
|
||||
('partner_id', 'CRITICAL', 'Already required'),
|
||||
('invoice_date', 'CRITICAL', 'When invoiced — drives net-terms clock'),
|
||||
('invoice_date_due', 'CRITICAL', 'When payment due'),
|
||||
('invoice_payment_term_id','CRITICAL', 'Net-30 etc.'),
|
||||
('invoice_user_id', 'IMPORTANT', 'Salesperson — for commission'),
|
||||
('partner_bank_id', 'IMPORTANT', 'Where to wire payment'),
|
||||
('ref', 'CRITICAL', 'Customer PO# / reference (required by AP teams)'),
|
||||
('invoice_origin', 'CRITICAL', 'Source SO link'),
|
||||
('narration', 'NICE', 'Free-form notes'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('11. Workforce — Quality Hold + NCR + CAPA (open + completed)')
|
||||
# =====================================================================
|
||||
|
||||
# Sample Quality Hold if any
|
||||
qh = env.get('fusion.plating.quality.hold')
|
||||
if qh is not None:
|
||||
rec = qh.search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.quality.hold', rec, [
|
||||
('partner_id', 'CRITICAL', 'Customer — without it we can\'t notify'),
|
||||
('mo_id', 'CRITICAL', 'Which MO'),
|
||||
('hold_reason', 'CRITICAL', 'Selection — categorize the issue'),
|
||||
('description', 'CRITICAL', 'Inspector\'s narrative'),
|
||||
('qty_on_hold', 'CRITICAL', 'How many parts affected'),
|
||||
('inspector_id', 'CRITICAL', 'Who flagged it'),
|
||||
('created_at', 'CRITICAL', 'When'),
|
||||
])
|
||||
|
||||
ncr = env.get('fusion.plating.ncr')
|
||||
if ncr is not None:
|
||||
rec = ncr.search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.ncr', rec, [
|
||||
('name', 'CRITICAL', 'NCR# / sequence'),
|
||||
('partner_id', 'CRITICAL', 'Customer affected'),
|
||||
('production_id', 'CRITICAL', 'Source MO'),
|
||||
('description', 'CRITICAL', 'What went wrong'),
|
||||
('severity', 'CRITICAL', 'Critical / major / minor'),
|
||||
('containment_action', 'CRITICAL', 'Immediate action — Nadcap req'),
|
||||
('root_cause', 'CRITICAL', 'Why — required to close'),
|
||||
('corrective_action', 'CRITICAL', 'Fix — required to close'),
|
||||
('disposition', 'CRITICAL', 'Use-as-is / scrap / rework — decision'),
|
||||
('raised_by_id', 'CRITICAL', 'Who raised it'),
|
||||
('raised_date', 'CRITICAL', 'When'),
|
||||
])
|
||||
|
||||
capa = env.get('fusion.plating.capa')
|
||||
if capa is not None:
|
||||
rec = capa.search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.capa', rec, [
|
||||
('name', 'CRITICAL', 'CAPA#'),
|
||||
('owner_id', 'CRITICAL', 'Owner / champion'),
|
||||
('due_date', 'CRITICAL', 'Deadline'),
|
||||
('problem_description', 'CRITICAL', 'What\'s the recurring issue'),
|
||||
('root_cause', 'CRITICAL', 'Why-why analysis — required'),
|
||||
('corrective_action', 'CRITICAL', 'Fix the existing'),
|
||||
('preventive_action', 'CRITICAL', 'Prevent recurrence'),
|
||||
('verification_evidence', 'CRITICAL', 'Proof the fix worked'),
|
||||
('effectiveness_date', 'IMPORTANT','When effectiveness confirmed'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('12. Compliance: discharge sample + waste manifest + spill')
|
||||
# =====================================================================
|
||||
|
||||
DS = env.get('fusion.plating.discharge.sample')
|
||||
if DS is not None:
|
||||
rec = DS.search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.discharge.sample', rec, [
|
||||
('sample_date', 'CRITICAL', 'When the sample was taken (regulatory)'),
|
||||
('sampled_by_id', 'CRITICAL', 'Who'),
|
||||
('outfall_id', 'CRITICAL', 'Which discharge point (jurisdictional req)'),
|
||||
('parameter_id', 'CRITICAL', 'What pollutant'),
|
||||
('value_measured', 'CRITICAL', 'The reading itself'),
|
||||
('limit_value', 'CRITICAL', 'The regulatory limit'),
|
||||
('exceeds_limit', 'CRITICAL', 'Pass/fail — drives mandatory reporting'),
|
||||
('lab_cert_attachment_id','CRITICAL','Lab cert — required for regulator'),
|
||||
])
|
||||
|
||||
WM = env.get('fusion.plating.waste.manifest')
|
||||
if WM is not None:
|
||||
rec = WM.search([], order='id desc', limit=1)
|
||||
show_field_audit('fusion.plating.waste.manifest', rec, [
|
||||
('manifest_number', 'CRITICAL', 'Government tracking #'),
|
||||
('generator_id', 'CRITICAL', 'Who generated the waste (us)'),
|
||||
('hauler_id', 'CRITICAL', 'Who picked it up (carrier)'),
|
||||
('disposal_facility_id','CRITICAL','Where it went (landfill / treatment)'),
|
||||
('waste_code', 'CRITICAL', 'EPA / TDG hazardous code'),
|
||||
('quantity', 'CRITICAL', 'How much'),
|
||||
('uom', 'CRITICAL', 'Unit'),
|
||||
('shipped_date', 'CRITICAL', 'When shipped'),
|
||||
('received_date', 'CRITICAL', 'When received at disposal — closes the loop'),
|
||||
])
|
||||
|
||||
# =====================================================================
|
||||
section('SUMMARY — gap counts by severity')
|
||||
# =====================================================================
|
||||
|
||||
print(' See per-model details above. Critical gaps are real')
|
||||
print(' compliance / workflow blockers; Important are operational')
|
||||
print(' risks; Nice-to-have are quality-of-life.')
|
||||
print()
|
||||
print(' Recommended next-batch fixes (in priority order):')
|
||||
print(' 1. invoice.ref auto-fill from sale_order.client_order_ref')
|
||||
print(' (so customer PO# always lands on the invoice)')
|
||||
print(' 2. fp.receiving.received_by_id default + required on accept')
|
||||
print(' 3. mrp.production.x_fc_facility_id required (block confirm)')
|
||||
print(' 4. fp.certificate.spec_reference required to issue')
|
||||
print(' 5. fp.delivery.pod_id required to mark "delivered"')
|
||||
print(' 6. fp.thickness.reading.position_label + calibration_std_ref required')
|
||||
print(' 7. ncr/capa state-transition gates (can\'t close without root_cause)')
|
||||
print(' 8. discharge.sample.lab_cert_attachment_id required to mark complete')
|
||||
Reference in New Issue
Block a user