Compare commits
17 Commits
1691ee1ab6
...
80b8100232
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80b8100232 | ||
|
|
920a624cd1 | ||
|
|
06e382b27b | ||
|
|
91d09dfca2 | ||
|
|
ef27f0e2c1 | ||
|
|
b37b1d4618 | ||
|
|
e468ae6b0a | ||
|
|
6e945dea95 | ||
|
|
3dc74e3987 | ||
|
|
b75f215808 | ||
|
|
f2d6492efd | ||
|
|
123db4219f | ||
|
|
f44ed0e010 | ||
|
|
77cb0a1309 | ||
|
|
09104007f6 | ||
|
|
c118b7c6b5 | ||
|
|
db8b79d22e |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||
'version': '19.0.1.0.4',
|
||||
'version': '19.0.1.0.5',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 28,
|
||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||
|
||||
@@ -4,3 +4,4 @@ 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
|
||||
|
||||
422
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
422
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""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())
|
||||
|
||||
# Build the new counterpart lines that replace suspense.
|
||||
new_counterpart_vals = []
|
||||
for inv_line in against_lines:
|
||||
new_counterpart_vals.append(self._build_counterpart_vals(
|
||||
statement_line, inv_line))
|
||||
|
||||
write_off_move_id = None
|
||||
if write_off_vals:
|
||||
new_counterpart_vals.append(self._build_write_off_vals(
|
||||
statement_line, write_off_vals, against_lines))
|
||||
|
||||
# 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.
|
||||
Partial = self.env['account.partial.reconcile']
|
||||
new_partial_ids = []
|
||||
for new_line, inv_line in zip(
|
||||
new_lines[:len(against_lines)], against_lines):
|
||||
pair = new_line | inv_line
|
||||
existing = set(Partial.search([
|
||||
'|',
|
||||
('debit_move_id', 'in', pair.ids),
|
||||
('credit_move_id', 'in', pair.ids),
|
||||
]).ids)
|
||||
pair.reconcile()
|
||||
added = Partial.search([
|
||||
'|',
|
||||
('debit_move_id', 'in', pair.ids),
|
||||
('credit_move_id', 'in', pair.ids),
|
||||
]).filtered(lambda p: p.id not in existing)
|
||||
new_partial_ids.extend(added.ids)
|
||||
|
||||
self._post_audit(
|
||||
statement_line, new_partial_ids, source='engine.reconcile_one')
|
||||
if against_lines:
|
||||
self._record_precedent(statement_line, against_lines)
|
||||
|
||||
return {
|
||||
'partial_ids': new_partial_ids,
|
||||
'exchange_diff_move_id': None,
|
||||
'write_off_move_id': write_off_move_id,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def reconcile_batch(self, statement_lines, *, strategy='auto'):
|
||||
"""Bulk-reconcile a recordset using the chosen strategy.
|
||||
|
||||
Returns: ``{'reconciled_count': int, 'skipped': int,
|
||||
'errors': [...]}``
|
||||
"""
|
||||
reconciled = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
for line in statement_lines:
|
||||
if line.is_reconciled:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
candidates = self._fetch_candidates(line)
|
||||
picked = self._apply_strategy(line, candidates, strategy)
|
||||
if picked:
|
||||
self.reconcile_one(line, against_lines=picked)
|
||||
reconciled += 1
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({'line_id': line.id, 'error': str(e)})
|
||||
_logger.warning(
|
||||
"reconcile_batch failed for line %s: %s", line.id, e)
|
||||
return {
|
||||
'reconciled_count': reconciled,
|
||||
'skipped': skipped,
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def suggest_matches(self, statement_lines, *, limit_per_line=3):
|
||||
"""Compute and persist AI suggestions per line.
|
||||
|
||||
Returns: dict mapping ``line_id`` -> list of suggestion dicts.
|
||||
"""
|
||||
out = {}
|
||||
Suggestion = self.env['fusion.reconcile.suggestion']
|
||||
for line in statement_lines:
|
||||
candidates_records = self._fetch_candidates(line)
|
||||
if not candidates_records:
|
||||
continue
|
||||
candidates_dataclasses = self._records_to_candidates(
|
||||
line, candidates_records)
|
||||
scored = score_candidates(
|
||||
self.env,
|
||||
statement_line=line,
|
||||
candidates=candidates_dataclasses,
|
||||
k=limit_per_line,
|
||||
use_ai=True,
|
||||
)
|
||||
|
||||
Suggestion.search([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
]).write({'state': 'superseded'})
|
||||
|
||||
line_suggestions = []
|
||||
for rank, s in enumerate(scored, start=1):
|
||||
sug = Suggestion.create({
|
||||
'company_id': line.company_id.id,
|
||||
'statement_line_id': line.id,
|
||||
'proposed_move_line_ids': [(6, 0, [s.candidate_id])],
|
||||
'confidence': s.confidence,
|
||||
'rank': rank,
|
||||
'reasoning': s.reasoning,
|
||||
'score_amount_match': s.score_amount_match,
|
||||
'score_partner_pattern': s.score_partner_pattern,
|
||||
'score_precedent_similarity': s.score_precedent_similarity,
|
||||
'score_ai_rerank': s.score_ai_rerank,
|
||||
'generated_by': 'on_demand',
|
||||
'state': 'pending',
|
||||
})
|
||||
line_suggestions.append({
|
||||
'id': sug.id,
|
||||
'rank': rank,
|
||||
'confidence': s.confidence,
|
||||
'reasoning': s.reasoning,
|
||||
'candidate_id': s.candidate_id,
|
||||
})
|
||||
out[line.id] = line_suggestions
|
||||
return out
|
||||
|
||||
@api.model
|
||||
def accept_suggestion(self, suggestion):
|
||||
"""User clicked Accept on a suggestion -> reconcile via its proposal.
|
||||
|
||||
Returns: same shape as ``reconcile_one``.
|
||||
"""
|
||||
if isinstance(suggestion, int):
|
||||
suggestion = self.env['fusion.reconcile.suggestion'].browse(
|
||||
suggestion)
|
||||
suggestion.ensure_one()
|
||||
line = suggestion.statement_line_id
|
||||
against = suggestion.proposed_move_line_ids
|
||||
result = self.reconcile_one(line, against_lines=against)
|
||||
suggestion.write({
|
||||
'state': 'accepted',
|
||||
'accepted_at': fields.Datetime.now(),
|
||||
'accepted_by': self.env.uid,
|
||||
})
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def write_off(self, statement_line, *, account, amount, label, tax_id=None):
|
||||
"""Create a write-off move + reconcile the bank line against it.
|
||||
|
||||
Returns: same shape as ``reconcile_one``.
|
||||
"""
|
||||
write_off_vals = {
|
||||
'account_id': account.id if hasattr(account, 'id') else account,
|
||||
'amount': amount,
|
||||
'tax_id': (tax_id.id if (tax_id and hasattr(tax_id, 'id'))
|
||||
else tax_id),
|
||||
'label': label,
|
||||
}
|
||||
return self.reconcile_one(
|
||||
statement_line, against_lines=None, write_off_vals=write_off_vals)
|
||||
|
||||
@api.model
|
||||
def unreconcile(self, partial_reconciles):
|
||||
"""Reverse a reconciliation. Handles full vs. partial chains.
|
||||
|
||||
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
|
||||
partial_reconciles.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):
|
||||
"""Build the vals for one counterpart line that mirrors an invoice
|
||||
line on the bank move."""
|
||||
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': -inv_line.amount_residual_currency,
|
||||
'balance': -inv_line.amount_residual,
|
||||
}
|
||||
|
||||
def _build_write_off_vals(self, statement_line, write_off_vals,
|
||||
against_lines):
|
||||
"""Build the vals for a write-off counterpart line on the bank move.
|
||||
|
||||
The write-off absorbs the (signed) residual not covered by
|
||||
``against_lines``: ``residual = bank_amount - sum(against_lines.balance)``.
|
||||
We post that residual to the write-off account, with the opposite
|
||||
sign so the bank move stays balanced.
|
||||
"""
|
||||
bank_amount = statement_line.amount
|
||||
already_covered = sum(
|
||||
-line.amount_residual for line in against_lines)
|
||||
residual = bank_amount - already_covered
|
||||
# The counterpart on the bank move must offset the liquidity line,
|
||||
# so its balance is -residual.
|
||||
wo_balance = -residual
|
||||
# If the user explicitly passed an amount, prefer it (overrides).
|
||||
if write_off_vals.get('amount') is not None and not against_lines:
|
||||
wo_balance = -write_off_vals['amount']
|
||||
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': wo_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)
|
||||
@@ -1,3 +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
|
||||
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)
|
||||
@@ -2,3 +2,7 @@ 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
|
||||
|
||||
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)
|
||||
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, [])
|
||||
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)
|
||||
@@ -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.1.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': """
|
||||
|
||||
@@ -12,21 +12,39 @@ class AccountMove(models.Model):
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Auto-inherit payment terms from the customer when missing.
|
||||
"""Auto-inherit payment terms + customer PO# at creation time.
|
||||
|
||||
Customers usually have a default `property_payment_term_id`
|
||||
(Net-30, Net-60, COD…). When an invoice is created without
|
||||
terms, the due date silently defaults to "immediate" — wrong
|
||||
for almost every B2B customer. Pull the partner's terms in
|
||||
before super so the invoice is born with the right schedule.
|
||||
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):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -475,6 +475,72 @@ def t_thickness_cal():
|
||||
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)')
|
||||
# =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user