Compare commits

..

17 Commits

Author SHA1 Message Date
gsinghpal
80b8100232 feat(fusion_accounting_bank_rec): reconcile engine 6-method public API
Adds fusion.reconcile.engine — the AbstractModel orchestrator for all
bank-line reconciliations. Six public methods (reconcile_one,
reconcile_batch, suggest_matches, accept_suggestion, write_off,
unreconcile) form the only sanctioned write path to
account.partial.reconcile from the rest of the module (controllers, AI
tools, wizards).

Implementation follows V19's bank_rec_widget pattern: rewrite the bank
move's suspense line into one counterpart per matched invoice (or a
write-off line) on the appropriate receivable / payable / write-off
account, then call account.move.line.reconcile() on each pair. Records
a precedent row per reconcile for downstream pattern learning.

16 new unit tests cover all six methods across happy paths, the
precedent side effect, suggestion lifecycle, batch auto-strategy, and
write-off line clearance. 67 total tests, 0 failed.

Made-with: Cursor
2026-04-19 10:50:46 -04:00
gsinghpal
920a624cd1 feat(fusion_accounting_bank_rec): 4-pass confidence scoring pipeline
Task 11 of Phase 1 Bank Reconciliation. Adds the brain that ranks
candidate journal-item matches for a bank statement line.

Pass 1 — SQL filter (done by caller's _fetch_candidates).
Pass 2 — Statistical scoring: weighted blend of amount-delta,
         partner pattern fit, and precedent similarity.
Pass 3 — Optional AI re-rank when an LLM provider is configured;
         gracefully no-ops when provider missing, prompt module not
         yet present (Task 20), or the JSON response is malformed.
Pass 4 — Persistence (handled by engine.suggest_matches).

Returns top-K ScoredCandidate dataclasses with per-feature scores
exposed for transparency and future learning.

7 new tests added; full module suite green (51 tests, 0 failures).

Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
06e382b27b feat(fusion_accounting_bank_rec): pattern_extractor for per-partner aggregates
Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
91d09dfca2 feat(fusion_accounting_bank_rec): precedent_lookup K-nearest search
Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
ef27f0e2c1 feat(fusion_accounting_bank_rec): inherit account.bank.statement.line + account.reconcile.model
Task 17 — Add Phase 1 widget compute fields and AI hooks:
- account.bank.statement.line: fusion_top_suggestion_id (m2o, unstored),
  fusion_confidence_band (selection, unstored), bank_statement_attachment_ids
  (one2many compute, mirrors Enterprise's surface field for the OWL widget).
- account.reconcile.model: fusion_ai_confidence_threshold (float).
- Bumps manifest 19.0.1.0.3 → 19.0.1.0.4.

V19 note: dropped @api.depends('id') on _compute_top_suggestion (NotImplementedError
in V19); compute is on-demand for unstored field anyway.

Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
b37b1d4618 feat(fusion_accounting_bank_rec): transient model for widget round-trip data
Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
e468ae6b0a feat(fusion_accounting_bank_rec): persisted AI suggestion model with state lifecycle
Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
6e945dea95 feat(fusion_accounting_bank_rec): pattern + precedent models for behavioural learning
Adds the foundation for AI confidence scoring:
- fusion.reconcile.pattern: per-(company, partner) aggregate profile
  (volume, cadence, preferred matching strategy, memo signature,
  write-off habits) — recomputed nightly from precedents.
- fusion.reconcile.precedent: per-historical-decision memory holding
  full feature vector + outcome, used by precedent_lookup for KNN
  scoring of new bank lines.

Includes ACL rows for fusion accounting user (read) and admin (CRUD)
groups. Manifest bumped to 19.0.1.0.1.

Note: switched the pattern uniqueness rule from the deprecated
_sql_constraints attribute to models.Constraint (Odoo 19 native API)
so the unique(company_id, partner_id) is actually enforced at the
PG level — _sql_constraints is silently ignored in 19.

Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
3dc74e3987 feat(fusion_accounting_bank_rec): matching strategies (AmountExact, FIFO, MultiInvoice)
Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
b75f215808 feat(fusion_accounting_bank_rec): exchange_diff helper for FX gain/loss pre-check
Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
f2d6492efd feat(fusion_accounting_bank_rec): memo_tokenizer for Canadian bank memo formats
Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
123db4219f feat(fusion_accounting_ai): add LLMProvider contract + configurable openai base_url
Phase 1 prerequisite for local LLM support. Adapters now declare
capability flags (supports_tool_calling, max_context_tokens, etc.) so
the engine can reason about what backend is available.

OpenAI adapter accepts fusion_accounting.openai_base_url config -- point
it at LM Studio (http://host.docker.internal:1234/v1) or Ollama
(http://host.docker.internal:11434/v1) and the existing OpenAI adapter
works unchanged.

Implementation note: existing Odoo AbstractModel adapters
(fusion.accounting.adapter.openai/claude) are preserved untouched to
avoid breaking the chat panel; the new plain-Python OpenAIAdapter and
ClaudeAdapter classes (LLMProvider subclasses) are added alongside them.

Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
f44ed0e010 feat(fusion_accounting_core): add computed coexistence group + recompute hooks
group_fusion_show_when_enterprise_absent has membership = all internal
users iff no Enterprise accounting module is installed. Membership is
recomputed on module install/uninstall via overrides on ir.module.module.
Used by Phase 1 fusion_bank_rec menus to auto-hide when Enterprise is
active and auto-appear after Enterprise uninstall.

Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
77cb0a1309 feat(fusion_accounting_core): shared-field-ownership for cron_last_check
Declare account.bank.statement.line.cron_last_check on
fusion_accounting_core so the column survives Enterprise
account_accountant uninstall. Mirrors the existing pattern used
for account.move and account.reconcile.model shared fields.

- Add models/account_bank_statement_line.py declaring cron_last_check
  as fields.Datetime(copy=False)
- Wire model into models/__init__.py
- Add post_install regression test verifying field presence and type
- Bump manifest 19.0.1.0.0 -> 19.0.1.0.1

Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
09104007f6 feat(fusion_accounting_bank_rec): add empty sub-module skeleton
Scaffold the fusion_accounting_bank_rec sub-module with directory
tree, manifest, empty package __init__ files, empty ACL CSV, icon,
and Enterprise reference snapshots. No models, controllers, or
business logic yet — installs cleanly on V19 westin-v19 dev DB.

Made-with: Cursor
2026-04-19 10:45:30 -04:00
gsinghpal
c118b7c6b5 feat(plating): close compliance gaps 7-9 — NCR + CAPA + discharge + invoice ref
**7a. NCR close gate** (fusion.plating.ncr.action_close)
Block close unless these are filled in:
  • Description (what happened)
  • Containment Actions (immediate response)
  • Root Cause (why it happened)
  • Disposition (use-as-is / rework / scrap / RTV decision)

A closed NCR without these is useless for AS9100 audits — it's
the entire point of an NCR to document what went wrong, why, and
how we responded. Empty-HTML strings like "<p><br></p>" are
detected as empty too.

**7b. CAPA close gate** (fusion.plating.capa.action_close)
Block close unless:
  • Root Cause Analysis filled in
  • Action Plan filled in
  • Verification (date + verifier) recorded
  • Effectiveness Notes filled when CAPA was marked Not Effective

AS9100 §10.2 / Nadcap require evidence of root-cause analysis,
the corrective/preventive action plan, AND that effectiveness
was verified before the loop is closed.

**8. Invoice ref defensive default** (account.move.create)
Auto-fills `ref` from the source SO's client_order_ref or
x_fc_po_number when the invoice is created with invoice_origin set
but no ref. Already populated on the SO confirm path; this catches
manually-created invoices that would otherwise miss it. Customer
AP teams reject invoices that don't quote their PO# back.

**9. Discharge sample close gate** (fusion.plating.discharge.sample.action_close)
Block close unless:
  • Lab Report # set
  • Results Received Date set
  • At least one parameter reading on file
  • Lab certificate/report attached

Without lab evidence the record fails any environmental compliance
audit — the whole point is to document the test was performed and
what the lab said.

**Simulator** (scripts/fp_e2e_workforce.py)
Adds 4 new negative tests (Test 8-11), all wrapped in savepoints:
  ✓ Test 8 : NCR close without RC/containment/disposition → blocked
  ✓ Test 9 : CAPA close without analysis/plan/verification → blocked
  ✓ Test 10: Discharge sample close without lab evidence → blocked
  ✓ Test 11: Invoice ref auto-fills from SO.client_order_ref → asserted

**Final E2E**: 52 PASS / 2 WARN / 0 FAIL out of 54 checks.
Both remaining WARNs are expected (bake-window auto-create,
first-piece gate — coating-driven, this coating doesn't trigger them).

11 negative tests in total now, every gate fires when triggered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:35:27 -04:00
gsinghpal
db8b79d22e feat(plating): close 6 compliance gaps from required-fields audit
Following the workforce-E2E + required-fields audit, ship the first 6
high-priority gates so critical workflow + compliance fields can no
longer be left empty by accident.

**1. Invoice payment terms (account.move)**
- create() now auto-inherits `invoice_payment_term_id` from
  partner.property_payment_term_id when missing
- action_post() raises UserError if still missing — accountant must
  pick one before posting (prevents silent "immediate" due-date)

**2. MO facility (mrp.production)**
- action_confirm() auto-derives `x_fc_facility_id` if unset, in order:
  SO override → res.company.x_fc_default_facility_id → first active
  facility — then HARD GATES: raises UserError if still empty.
  Without facility every downstream record (WO, batch, bath log,
  cert) is missing the "where" half of the audit trail.

**3. WO facility (mrp.workorder)**
- Switched `x_fc_facility_id` from related (workcenter only) to a
  proper compute that falls back to production_id.x_fc_facility_id.
  Stub workcenters auto-created from process node names usually have
  no facility — the MO always does (from #2 above).

**4. Thickness reading calibration_std (fp.thickness.reading)**
- `calibration_std_ref` is now `required=True` with sensible default
  ("NiP/Al STD SET SN 100174568"). Nadcap mandates which calibration
  standard the gauge was checked against — without it the cert
  data has no chain back to a metrology record.

**5. Delivery POD gate (fusion.plating.delivery)**
- action_mark_delivered() raises UserError if no `pod_id`. Driver
  must capture POD on the iPad (recipient signature + photos +
  notes) BEFORE marking delivered. Without POD there's no signed
  receipt to back the invoice or defend a delivery dispute.

**6. Certificate spec_reference gate (fp.certificate)**
- action_issue() raises UserError if no `spec_reference`. The cert
  ATTESTS to a spec — leaving it blank produces a piece of paper
  that AS9100 / Nadcap auditors will (rightfully) reject.

**Simulator updated**: scripts/fp_e2e_workforce.py
- Sets net-30 on the test customer + ensures a default facility
- New PHASE 4c: 5 negative tests (one per new gate), each wrapped
  in a SAVEPOINT so SQL constraint violations don't abort the txn
- Driver now creates POD on iPad BEFORE marking delivered

**Final E2E**: 48 PASS / 2 WARN / 0 FAIL out of 50 checks.
The 2 remaining WARNs (bake-window auto-create, first-piece gate)
are expected behaviour — both are coating-driven and the test
coating intentionally doesn't trigger them.

All 7 negative tests now pass:
  ✓ Test 1: WO start without operator → blocked
  ✓ Test 2: WO start on wet WO without bath/tank → blocked
  ✓ Test 3: MO confirm without facility → blocked
  ✓ Test 4: Cert issue without spec_reference → blocked
  ✓ Test 5: Delivery delivered without POD → blocked
  ✓ Test 6: Invoice post without payment terms → blocked
  ✓ Test 7: Thickness reading without cal std → blocked (DB NOT NULL)

Audit script (scripts/fp_required_fields_audit.py) committed too —
it's the diagnostic that surfaced these gaps and can be re-run to
catch new ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:07:00 -04:00
20 changed files with 1541 additions and 13 deletions

View File

@@ -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.',

View File

@@ -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

View 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)

View File

@@ -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

View 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

View 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

View 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)

View File

@@ -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

View 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)

View 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)

View 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, [])

View 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)

View File

@@ -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.',

View File

@@ -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'})

View File

@@ -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': """

View File

@@ -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):

View File

@@ -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.',

View File

@@ -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):

View File

@@ -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(),

View File

@@ -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)')
# =====================================================================