71 Commits

Author SHA1 Message Date
gsinghpal
3e48bab087 feat(fusion_accounting_bank_rec): kanban controller + renderer for OWL widget
Top-level OWL component (BankRecKanbanController) hosts the bank
reconciliation widget. Reads journal_id + company_id from action context,
initializes the fusion_bank_reconciliation service, and renders the
layout: header (stats), left column (line cards via BankRecLineCard
renderer), right column (detail panel with AI suggestions).

Custom view type 'fusion_bank_rec_kanban' registered so window actions
can use <field name="view_mode">fusion_bank_rec_kanban</field>.

Made-with: Cursor
2026-04-19 12:33:57 -04:00
gsinghpal
a4a9692888 fix(fusion_accounting_bank_rec): acceptSuggestion double-decrement count
Optimistic remove was decrementing unreconciledCount before assigning
the authoritative server count, leading to off-by-one. Order swapped:
remove first, then overwrite with server count.

Caught by Task 28 subagent self-review.

Made-with: Cursor
2026-04-19 12:28:34 -04:00
gsinghpal
d4dbca5927 feat(fusion_accounting_bank_rec): OWL bank reconciliation service
Central data layer + reactive state for the OWL widget. Wraps the 10
JSON-RPC endpoints from the bank_rec_controller (get_state,
list_unreconciled, get_line_detail, suggest_matches, accept_suggestion,
reconcile_manual, unreconcile, write_off, bulk_reconcile,
get_partner_history). Components inject via useService("fusion_bank_reconciliation").

State held in OWL's reactive() so components auto-rerender on
selection / pagination / reconcile-success changes.

Verified: web.assets_backend bundle includes
/fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js;
134/134 module tests pass.

Made-with: Cursor
2026-04-19 12:27:44 -04:00
gsinghpal
24e2708d98 feat(fusion_accounting_bank_rec): SCSS foundation for OWL widget
Provides design tokens (variables.scss), main bank-rec stylesheet,
AI suggestion strip + alternatives panel styling, and dark mode
overrides. CSS classes (.o_fusion_*) will be consumed by OWL components
in Tasks 28-36.

Verified: all 4 SCSS files compile via libsass; web.assets_backend
bundle picks up all 4 entries; 134/134 module tests pass.

Made-with: Cursor
2026-04-19 12:23:55 -04:00
gsinghpal
6ecb1bbbee feat(fusion_accounting_bank_rec): 10 JSON-RPC endpoints for OWL widget
All endpoints route through fusion.reconcile.engine via BankRecAdapter
(or directly for engine methods adapter doesn't expose). Uses V19's
type='jsonrpc' (replacement for deprecated type='json'). Auth=user.

Endpoints:
- get_state, list_unreconciled, get_line_detail (read)
- suggest_matches, accept_suggestion (AI surface)
- reconcile_manual, unreconcile, write_off, bulk_reconcile (write)
- get_partner_history (precedent + pattern read)

Tests use HttpCase to exercise the real Werkzeug stack as a Fusion
Accounting administrator. Includes a smoke test for the deferred
write-off path (Task 12) and a negative test confirming auth='user'
rejects anonymous requests. Helper _make_pair shares one bank journal
across pairs to avoid the (code, company) unique-constraint collision
that the default factory would hit on repeat calls.

Verified: 11/11 controller tests pass, 134/134 module tests pass.
Made-with: Cursor
2026-04-19 12:15:40 -04:00
gsinghpal
d1819b940e feat(fusion_accounting_bank_rec): 3 cron schedules + handler model
- cron_suggest (every 30min): warm AI suggestions for unreconciled lines
  that don't have a recent pending one
- cron_pattern_refresh (daily 02:00): recompute fusion.reconcile.pattern
  for each (company, partner) pair with precedents
- cron_mv_refresh (every 5min): REFRESH MATERIALIZED VIEW CONCURRENTLY
  using a dedicated autocommit cursor (REFRESH CONCURRENTLY can't run
  inside a regular Odoo transaction)

V19 note: ir.cron dropped the numbercall field, so the data XML omits
it (cron now repeats indefinitely as long as active=True).

Tests: 5 new TestFusionBankRecCron tests pass; full module suite is
0 failed / 0 errors of 123 logical tests on westin-v19.

Made-with: Cursor
2026-04-19 11:59:16 -04:00
gsinghpal
d953525758 fix(fusion_accounting_bank_rec): MV correctness for V19 schema + Odoo test harness
Three issues surfaced when running the MV smoke tests against westin-v19:

1. account_bank_statement_line has no `date` column in V19 — `date` is a
   related field flowing through move_id -> account_move.date. The MV
   now JOINs account_move and selects am.date.
2. is_reconciled is nullable; replace `= FALSE` with `IS NOT TRUE` so
   nulls (genuinely unreconciled lines that haven't had the compute run
   yet) are still included.
3. _refresh() now flushes the ORM cache (env.flush_all()) before the
   REFRESH so computed-stored fields like is_reconciled are written to
   the DB before the materialization snapshot reads them. Previously the
   reconcile-then-refresh path saw the pre-reconcile column value.
4. _trigger_mv_refresh() (suggestion create/write hook) now uses
   concurrently=False because Postgres forbids
   REFRESH MATERIALIZED VIEW CONCURRENTLY inside a transaction block,
   and Odoo's per-request cursor is always inside one. The cron path
   (Task 25) will open an autocommit cursor for CONCURRENTLY refreshes.
5. Tests dropped the env.cr.commit() pattern: Postgres always shows a
   transaction its own writes, so a non-CONCURRENTLY refresh in the
   same txn picks up freshly-inserted rows. Cleaner + works inside
   TransactionCase, which forbids cr.commit().

Verified: 4 new MV tests pass, 0 failures across 118 logical tests
(178 with parametrized property-based runs) of fusion_accounting_bank_rec
on westin-v19.

Made-with: Cursor
2026-04-19 11:51:02 -04:00
gsinghpal
12b6b46e2e feat(fusion_accounting_bank_rec): pre-aggregated MV for OWL widget perf
CREATE MATERIALIZED VIEW fusion_unreconciled_bank_line_mv pre-computes
the data the kanban widget needs (top suggestion, confidence band,
attachment count, partner reconcile hint) so that listing 50-100 lines
is one indexed query instead of N+1.

Refresh strategy:
- Triggered on fusion.reconcile.suggestion create/write (best-effort,
  never poisons the originating transaction)
- Cron (every 5 min) — added in Task 25

The MV is created in the model's init() (Odoo calls this on
install/upgrade). The SQL DDL is idempotent
(CREATE MATERIALIZED VIEW IF NOT EXISTS / CREATE INDEX IF NOT EXISTS)
and includes a UNIQUE(id) index so REFRESH MATERIALIZED VIEW
CONCURRENTLY is supported. _refresh() falls back to a blocking refresh
on the first call after creation.

Made-with: Cursor
2026-04-19 11:45:36 -04:00
gsinghpal
4ffbdc596d feat(plating): per-step compliance gates + backfill — 0 CRITICAL gaps
Per-step audit caught real enforcement bugs across all 9 WO kinds in
the recipe (Masking, Racking, Plating, De-Masking, Oven baking, etc.).
Five gates added or fixed; 0 CRITICAL gaps remain after a verification
run on a fresh MO.

**1. Bake-WO finish gate** (`_fp_check_required_fields_before_finish`)
button_finish on a bake WO now blocks unless:
  • x_fc_bake_temp set (Nadcap req — actual setpoint, not just oven)
  • x_fc_bake_duration_hours set (actual run time at temp)
  • x_fc_oven_id.chart_recorder_ref set (so the chart for THIS run
    can be retrieved by an auditor — required for AS9100/Nadcap)

Run-time data lives at FINISH, not START — operators don't know
temp/duration until the bake is done.

**2. Rack-WO start gate** added to the existing button_start gate.
Per-rack life tracking + which physical fixture handled the parts.

**3. Classifier priority fix** (`_fp_classify_kind`)
"Post-plate Inspection" was matching the `plat` wet keyword and
getting kind=wet (then required to have bath/tank). Reordered:
  1. Explicit equipment links (bath_id/oven_id)
  2. Specific keywords (inspect → mask → bake → rack)
     — bake before rack so "Oven bake (Post de-rack)" → bake
  3. Workcenter wet families
  4. Wet name keywords as last fallback

**4. Auto-populate target_thickness + dwell_time** at recipe→WO
generation. Plating WOs inherit:
  • thickness_target from coating_config.thickness_max
  • thickness_uom from coating_config.thickness_uom
  • dwell_time_minutes from recipe node's estimated_duration

So aerospace QC has the spec target on every WO without paper.

**5. Mask-WO start gate + masking_material field**
New x_fc_masking_material Selection (tape/plug/paint/silicone/wax/
mixed/other). Required to start a mask WO. Needed later when
stripping or replating because each material requires a different
removal process.

**View** (`mrp_workorder_views.xml`)
Process Details tab now branches by kind:
  wet  → Bath/Tank/Rack/Thickness/Dwell
  bake → Oven/Temp/Duration
  rack → Rack/Fixture
  mask → Masking Material
  inspect/other → informational alerts only
WO Kind shows as colour-coded badge in header.

**Backfill** (`scripts/fp_backfill.py`)
Idempotent script that catches up existing data:
  • chart_recorder_ref on every oven
  • rack_id on existing rack/de-rack WOs (91 backfilled)
  • bake_temp + bake_duration_hours on existing bake WOs (33)
  • masking_material on existing mask WOs (62)
  • thickness/dwell on existing plating WOs (38)
  • Cleared 7 legacy bath/tank from inspection WOs that had been
    misclassified by the OLD wet-keyword classifier.

**Per-step audit** (`scripts/fp_per_step_audit.py`)
Walks every WO of the most recent done MO and reports per-kind
which compliance fields are filled vs missing. Re-runnable to
catch regressions.

**Final state on freshly-run MO 00049:**
  • 0 CRITICAL gaps
  • 2 IMPORTANT gaps (dwell_time + rack_id on E-Nickel Plating —
    both inherited from recipe node data, not enforcement bugs)

Negative tests still passing (12 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:40:01 -04:00
gsinghpal
5020129c45 refactor(fusion_accounting_ai): route legacy reconcile tools through engine
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
When fusion_accounting_bank_rec is installed, match_bank_line_to_payments
and auto_reconcile_bank_lines now use fusion.reconcile.engine via the
BankRecAdapter, gaining precedent recording, AI suggestion superseding,
and shared validation. Legacy paths preserved for Enterprise/Community-
only installs (engine model absent -> fall back to set_line_bank_statement_line
and _try_auto_reconcile_statement_lines).

Also wraps engine.reconcile_batch's per-line loop in a savepoint so a
single bad line's DB error (e.g. check-constraint violation) no longer
poisons the whole batch transaction; the existing per-line try/except
now isolates failures as originally intended.

Made-with: Cursor
2026-04-19 11:37:34 -04:00
gsinghpal
3993f58910 feat(fusion_accounting_ai): 5 new bank-rec AI tools wrapping engine
Adds fusion_suggest_matches, fusion_accept_suggestion,
fusion_reconcile_bank_line, fusion_unreconcile, and
fusion_get_pending_suggestions. All route through the BankRecAdapter
(or direct engine for ones the adapter doesn't expose), giving the AI
chat the same reconciliation surface a human operator gets in the OWL UI.

Made-with: Cursor
2026-04-19 11:31:40 -04:00
gsinghpal
8eee64f053 feat(fusion_accounting_ai): wire BankRecAdapter fusion paths to engine
Enhances list_unreconciled_via_fusion to include fusion fields
(top_suggestion_id, confidence_band, attachment_count). Adds 3 new
adapter methods that proxy the engine: suggest_matches, accept_suggestion,
unreconcile. AI tools (Task 22+) and OWL controller (Task 26) will call
these adapter methods instead of touching the engine directly.

Made-with: Cursor
2026-04-19 11:25:41 -04:00
gsinghpal
2d099b2d0d feat(fusion_accounting_ai): bank_rec_prompt for AI re-rank step
Provider-agnostic system + user prompt builder for the confidence
scoring pipeline's Pass 3 (AI re-rank). Output contract is JSON with
"ranked" array; works with OpenAI, Claude, and local OpenAI-compatible
servers (LM Studio, Ollama).

Made-with: Cursor
2026-04-19 11:20:56 -04:00
gsinghpal
8be0caa474 fix(fusion_accounting_bank_rec): partial-reconcile balance + unreconcile suspense restore
Two engine bugs caught by Task 19's integration tests:

1. Partial reconcile (bank_amount < invoice_residual) was creating an
   unbalanced bank move. Counterpart balance now clamped to
   min(remaining_bank_amount, abs(invoice_residual)) so the move stays
   balanced; Odoo's reconcile() handles the resulting partial. The
   counterpart's amount_currency is scaled proportionally so multi-
   currency lines stay consistent.

2. Unreconcile only removed account.partial.reconcile rows but didn't
   restore the suspense line on the bank move, leaving is_reconciled=True
   after unreconcile. Now delegates to V19's standard
   account.bank.statement.line.action_undo_reconciliation for any
   affected bank line, which both deletes partials and restores the
   suspense state in one shot.

Made-with: Cursor
2026-04-19 11:14:43 -04:00
gsinghpal
fce748b89c test(fusion_accounting_bank_rec): integration tests for engine end-to-end flows
Tests engine behavior using factories (Task 18) instead of SQL fixtures.
Covers simple match, partial chain, multi-invoice batch, suggest-then-
accept flow, unreconcile reversal, and edge cases.

Two tests are intentionally failing — they expose real engine bugs
that should be fixed in a follow-up:

- TestReconcilePartialChain.test_partial_reconcile_leaves_residual:
  reconcile_one() builds counterpart vals using the full invoice
  residual, which leaves the bank move unbalanced when bank amount
  is smaller than the invoice (UserError: entry not balanced).
- TestUnreconcile.test_unreconcile_removes_partial: unreconcile()
  unlinks partial.reconcile rows but does not restore the suspense
  line on the bank move, so account.bank.statement.line.is_reconciled
  remains True after reversal.

Made-with: Cursor
2026-04-19 11:11:30 -04:00
gsinghpal
fcecf9d925 test(fusion_accounting_bank_rec): test data factories for bank-rec testing
Provides make_bank_journal, make_bank_statement, make_bank_line,
make_invoice, make_vendor_bill, make_suggestion, make_pattern,
make_precedent, make_reconcileable_pair helpers used across the
bank-rec test suite. Replaces the original plan's SQL-fixture capture
with programmatic factories — same testing intent, simpler maintenance,
no real Westin data baked into the repo.

Note: the original plan called for 5 SQL fixtures captured from the local
DB (westin_simple_match.sql, westin_partial_chain.sql, etc.). Those are
replaced by factory-driven test creation in Task 19 — eliminates fragile
hand-curated SQL while testing the same code paths.

Made-with: Cursor
2026-04-19 11:05:06 -04:00
gsinghpal
da269a6207 test(fusion_accounting_bank_rec): Hypothesis property-based engine invariants
Made-with: Cursor
2026-04-19 10:57:41 -04:00
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
gsinghpal
4161f04b0f feat(plating): hard-required fields on WO start — operator + bath + tank
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
User audit caught: in the workforce E2E run we had no idea which bath /
which tank ran the job. For aerospace traceability that's a deal-
breaker. Add a validation gate on mrp.workorder.button_start so
operators can't tap START without the data the shop floor MUST capture.

**Three new pieces on mrp.workorder:**

1. `_fp_is_wet_process()` — best-effort "does this WO involve a
   chemistry bath?" check. Three signals in priority order:
   a. A bath is already linked → definitely wet
   b. The workcenter's FP work-centre supports a wet process family
      (plating, pre/post-treatment, strip, passivation)
   c. WO name contains a wet-process keyword (plat, nickel, chrome,
      anodiz, zinc, etch, clean, rinse, strip, passivat, electroless…)
   The keyword fallback is needed because most existing recipes have
   no process_type_id set on their operation nodes.

2. `_fp_check_required_fields_before_start()` — runs before the
   existing certification check. Rules:
   • Every WO needs an assigned operator (x_fc_assigned_user_id).
     Without it, productivity records can't be attributed and the
     proficiency tracker has no employee to credit.
   • Wet WOs additionally need x_fc_bath_id + x_fc_tank_id. So we
     know exactly which chemistry bath ran the job and which physical
     tank it sat in.
   Raises a clear UserError listing the missing fields if any.

3. `x_fc_requires_bath` (compute, non-stored) — surfaces the wet check
   to the form view so bath + tank fields render with `required=`.

**View changes:**
- `x_fc_assigned_user_id` is now `required="1"` on the form
- `x_fc_bath_id` + `x_fc_tank_id` use `required="x_fc_requires_bath"`
  → red asterisk only when the WO is actually wet

**Simulator updates** (scripts/fp_e2e_workforce.py):
- Hannah now explicitly assigns bath + tank to wet WOs during planning,
  AND pre-issues operator certifications for the bath's process type
  (real shop manager workflow).
- Two negative tests added that PROVE the gates fire:
  • Test 1: strip the operator → button_start raises "missing Assigned Operator"
  • Test 2: strip bath/tank on a wet WO → button_start raises "missing Bath/Tank"

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:47:31 -04:00
gsinghpal
fe003567a9 docs(fusion_accounting): Phase 1 bank reconciliation implementation plan
51 tasks across 17 groups covering the full Phase 1 build:

Group 1 (5 tasks): Foundation — branch, sub-module skeleton, shared
fields on _core, LLMProvider contract for local LLM readiness

Group 2 (8 tasks): Reconcile engine — TDD-layered build of
matching_strategies, exchange_diff, memo_tokenizer, precedent_lookup,
pattern_extractor, confidence_scoring 4-pass pipeline, the AbstractModel
engine with 6-method API, and Hypothesis property-based tests

Group 3 (4 tasks): Models — fusion.reconcile.pattern,
fusion.reconcile.precedent, fusion.reconcile.suggestion, widget transient,
and inherits on Community account.bank.statement.line + account.reconcile.model

Group 4-5 (6 tasks): Integration tests with SQL fixtures from real Westin
reconciles + AI prompts + adapter fill-ins + AI tools refactor

Group 6-7 (3 tasks): Materialized view, cron schedules, and 10-endpoint
JSON-RPC controller with auth guards

Group 8-10 (10 tasks): Frontend — SCSS tokens, service, kanban controllers,
all 18 Enterprise-mirror OWL components, and 5 fusion-only components
(ai_suggestion folder, batch_action_bar, attachment_strip,
partner_history_panel, reconcile_model_picker)

Group 11-13 (5 tasks): Wizards (auto-reconcile + bulk), migration wizard
inheritance with bootstrap of 16,500 historical reconciliations + audit
report PDF + round-trip test, coexistence menu/group + tests

Group 14-16 (3 tasks): 5 OWL tour tests, performance benchmarks against
P95 targets, local LLM compatibility test against LM Studio

Group 17 (4 tasks): Closeout — meta-module manifest update, sub-module
docs, end-to-end smoke test, completion tag

TDD discipline throughout: every code task is red test → impl → green
→ commit. Property-based tests for amount invariants. Migration round-
trip test asserts byte-identical reconciliation state pre/post Enterprise
uninstall. All testing on local OrbStack VM only (environment-safety
rule applies).

Made-with: Cursor
2026-04-19 09:45:25 -04:00
gsinghpal
bbbd222b89 feat(plating): close 2 workflow gaps surfaced by workforce E2E simulation
Built a comprehensive simulator (scripts/fp_e2e_workforce.py) that
role-plays 10 employees driving an order quote → invoice using real
operator timers (button_start / button_finish with elapsed time.sleep).

Initial run: 31 PASS / 2 WARN / 0 FAIL exposed two gaps that would
hurt a real shop:

**Gap 1 — Thickness readings never reached the CoC**
The Fischerscope readings inspectors take during post-plate inspection
had no path to the CoC. The cert came out empty, useless for AS9100
or aerospace audits.

Fixes:
- New tablet endpoint `/fp/shopfloor/log_thickness_reading` so the
  inspector can record one reading at a time during the inspection WO
  (auto-numbers, defaults the operator, supports microscope image).
- mrp_production._fp_mark_done_post_actions now bulk-links any
  orphan thickness readings (those with production_id=mo.id but no
  certificate_id) to the freshly-created CoC. So inspectors can log
  during inspection AND the cert PDF picks them up automatically.

**Gap 2 — Operator queue leaked other people's work + simulator missed it**
fusion.plating.operator.queue.build_for_user pulled EVERY ready /
in-progress WO regardless of assignment. Tom would see John's masking
WO in his "Up Next" list — bad for aerospace traceability where you
want strict per-operator accountability.

Fix: build_for_user now filters MRP WOs by
`(x_fc_assigned_user_id == user_id OR x_fc_assigned_user_id == False)`.
Operators see their own assigned tasks first, plus any unassigned
tasks anyone can grab. Other operators' assigned WOs no longer leak
through.

Also caught: simulator was using wrong field name on the queue model.
Fixed and added a "queue isolation" check that verifies no operator
sees another operator's assigned WOs.

After fixes: **39 PASS / 2 WARN / 0 FAIL** (out of 41 checks).
Remaining WARNs are both expected behaviour:
  - bake-window auto-create: this coating doesn't require_bake_relief
    (the recipe has an inline Oven step instead)
  - first-piece gate: same — coating-driven, only fires when needed

Areas validated end-to-end:
- quote → SO with PO# carried into client_order_ref
- SO confirm → MO + portal job auto-created
- receiving qty prefill + accept
- 9 WOs generated from recipe + assigned to specific operators
- All 9 WOs ran with real elapsed timers + 17 productivity records
  across 4 distinct operators
- MO done triggers CoC auto-issue with 5 thickness readings linked,
  319 KB rich PDF, customer-slug filename
- Delivery auto-created with prefilled date + driver + CoC link
- Delivery delivered, 2 chain-of-custody entries
- Invoice posted (NOT auto-paid)
- All 5 customer notifications fired (so_confirmed +
  parts_received + mo_complete + shipped + invoice_posted) with
  correct attachments
- Portal job → complete, SO workflow_stage → invoicing
- Chemistry log persisted, operator proficiency tracked

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:30:56 -04:00
gsinghpal
2d64f7efab docs(fusion_accounting): Phase 1 bank reconciliation design
Drafts the design for fusion_accounting_bank_rec — a native bank
reconciliation widget that replaces Odoo Enterprise account_accountant
in V19 OWL architecture, with a clean-room reconcile engine reading and
writing Community account.partial.reconcile rows.

Key design decisions captured:
- CORE scope (~5.5-6 weeks): manual + auto reconcile, write-offs,
  partial, multi-currency, chatter, model picker
- Strict mirror of all 18 Enterprise OWL units (zero functional loss)
  plus 5 fusion-only additions for AI/history visibility
- Hybrid AI badge layout: inline strip with one-click Accept plus
  expandable ranked-alternatives panel
- Behavioural learning via fusion.reconcile.pattern (per-partner) and
  fusion.reconcile.precedent (per-decision memory) with bootstrap from
  the 16,500 historical reconciliations
- Local LLM ready via OpenAI-compatible adapter base_url config and
  per-feature provider routing — works against LM Studio, Ollama, vLLM
- Statistical-mode-without-API-key as a first-class path
- Coexistence with Enterprise: Enterprise wins by default, fusion
  menu hides until uninstall, then auto-appears
- Migration wizard step bootstraps pattern memory and produces an
  audit report PDF proving every reconciliation preserved
- TDD on engine algorithms with Hypothesis property-based tests for
  amount invariants; migration round-trip integration test

Builds on Phase 0 (commit c450bb2, range pre-phase-0..phase-0-complete).

Made-with: Cursor
2026-04-19 09:27:52 -04:00
gsinghpal
fa82ce17dd feat(reports): sequence-sort the Print dropdown so FP reports are #1
Odoo 19's `ir.actions.actions._get_bindings` returns the print-menu
bindings via `ORDER BY a.id` (insertion order) and only sequence-sorts
the `action`-type bindings — `report`-type bindings are returned in
raw SQL order. Result: FP reports installed after Odoo's stock ones
appear at the BOTTOM of the dropdown, even when they're the
customer-facing primary report (e.g. Timesheets above Quotation on
sale.order).

Two changes in fusion_plating_reports/models/ir_actions_report.py:

1. **Add `sequence` (Integer, default 100) to ir.actions.report** —
   gives every report a sortable knob.

2. **Override `ir.actions.actions._get_bindings`** to also sort the
   `report` slice by `(sequence, name.lower())`. super() returns the
   cached frozendict; we rebuild with the sorted reports.

Then set sequences in fp_hide_default_reports.xml (lower = top):

| Model           | seq 10 (#1)              | seq 15 (#2)              | seq 20+               |
|-----------------|--------------------------|--------------------------|-----------------------|
| sale.order      | FP Quotation Portrait    | FP Quotation Landscape   | FP Job Traveller (20) |
| account.move    | FP Invoice Portrait      | FP Invoice Landscape     |                       |
| stock.picking   | FP Packing Slip Portrait | FP Packing Slip Landscape|                       |
| mrp.production  | FP Job Traveller Portrait| FP Job Traveller Landscape| FP WO Margin (20)   |
| account.payment | FP Receipt Portrait      | FP Receipt Landscape     |                       |
| fp.delivery     | FP BoL Portrait          | FP BoL Landscape         |                       |
| portal.job      | FP CoC Portrait          | FP CoC Landscape         |                       |
| fp.certificate  | FP CoC English           | FP CoC Français          |                       |

Odoo defaults stay at sequence 100 (default) → always at bottom.

Verified on entech: sale.order print menu now shows
Quotation Portrait → Quotation Landscape → Job Traveller × 2 →
PRO-FORMA → Timesheets. Same pattern across all touched models.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:05:29 -04:00
gsinghpal
9a1ee4b369 feat(reports): hide Odoo's default PDFs where FP ships a branded one
Users were seeing both Odoo's stock PDFs and FP's branded equivalents
in the Print dropdown side-by-side, and accidentally sending the wrong
(unbranded, missing PO# / job ref / plating fields) PDF to customers.

Add fp_hide_default_reports.xml that drops the Print-menu binding on:

| Model           | Hidden                                                      | FP replacement                  |
|-----------------|-------------------------------------------------------------|---------------------------------|
| sale.order      | sale.action_report_saleorder                                | action_report_fp_sale_*         |
| sale.order      | sale_pdf_quote_builder.action_report_saleorder_raw          | action_report_fp_sale_*         |
| account.move    | account.account_invoices                                    | action_report_fp_invoice_*      |
| account.move    | account.account_invoices_without_payment                    | action_report_fp_invoice_*      |
| stock.picking   | stock.action_report_delivery                                | action_report_fp_packing_slip_* |
| mrp.production  | mrp.action_report_production_order                          | action_report_fp_job_traveller_*|
| account.payment | account.action_report_payment_receipt                       | action_report_fp_receipt_*      |

Mechanism: set binding_model_id=False + binding_type=action — removes
from the Print dropdown but leaves the report record + template intact.
Fully reversible from Settings → Technical → Reports if anyone needs
the stock PDF back.

Intentionally NOT touched:
- sale.action_report_pro_forma_invoice (no FP pro-forma yet)
- account.action_account_original_vendor_bill (vendor bills, internal)
- stock.action_report_picking / picking_packages / return_label_report
  (internal warehouse ops, not customer-facing)
- mrp.action_report_finished_product / mrp.label_manufacture_template
  (production labels — ZPL, not customer-facing)
- sale_timesheet.* (timesheet integration)

Added sale_pdf_quote_builder to depends so the data file always finds
that record when applied (it ships in entech's repackaged enterprise
bundle and was already installed there).

Verified on entech: re-running the print-menu audit shows zero stock
Odoo customer-facing PDFs left where FP has an equivalent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:57:38 -04:00
gsinghpal
5994cec11b fix(plating): chatter action toolbar invisible in dark mode
The floating message-action toolbar (reaction / reply / star / link
icons) appearing on hover renders white-icons-on-white-background in
dark mode — Odoo's own dark.scss sets the icon hover color to white
but never gives the toolbar itself a dark background. Result: the
icons vanish entirely in dark mode.

Add fp_chatter_dark.scss that branches at compile time on
$o-webclient-color-scheme == dark (Odoo 19 compiles every SCSS file
into both web.assets_backend with `bright` AND web.assets_web_dark
with `dark`) and gives the toolbar:

- Solid dark background (#2b2f33 fallback, var(--o-component-bgcolor))
- Subtle 1px white-alpha border + drop shadow so it floats nicely
- Icon color rgba(255,255,255,.78) at full opacity (not 35%)
- Brighter hover state with a subtle bg highlight

Light bundle output is empty (the @if branch doesn't fire), so the
light theme is untouched.

Verified: dark bundle includes our rule with #2b2f33 marker present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:45:47 -04:00
gsinghpal
eed4dc8a78 fix(plating): chatter HTML rendering + workflow stage banner UX
Two fixes from a single SO walkthrough screenshot:

**1. "Current stage" banner**
- Was placed `inside sheet` so it rendered at the BOTTOM of the form
  where users miss it. Moved to `before form/header` (same xpath
  pattern as the Account Hold banner) — now it's the first thing
  visible above the SO header.
- Was still showing "Shipped — awaiting invoice" after the invoice
  was posted because `_compute_workflow_stage` only advanced to
  `complete` when shipped + ALL paid; an unpaid posted invoice left
  the SO stuck on `shipped`. Added an `invoicing` branch: shipped +
  has_posted_invoice → invoicing. Banner invisible-list now also
  includes `invoicing` and `paid`, so the banner only shows for
  in-progress steps.

**2. Chatter messages rendering raw HTML tags as text**
Odoo 19 escapes any string passed to `message_post(body=...)`
unless wrapped in `markupsafe.Markup`. We had ~10 places posting
HTML (`<a href>`, `<b>`, `<br/>`, `<code>`, `<pre>`) that all
showed up as `&lt;a href=...&gt;` literal text in the chatter.

Wrapped each one with `Markup(_(...))` so the tags render. Files
touched:

- fusion_plating_bridge_mrp/models/sale_order.py
  (auto-MO failure code block, "Draft MO created" link,
   "Job assigned to <b>" message)
- fusion_plating_bridge_mrp/models/mrp_production.py
  ("Recipe steps" pre/br block on each WO)
- fusion_plating_bridge_mrp/models/fp_proficiency.py
  (operator promotion announcement)
- fusion_plating_configurator/models/fp_quote_configurator.py
  (SO link, 3D model attached, drawing attached, save to catalog)
- fusion_plating_configurator/models/fp_part_catalog.py
  (3D/drawing change tracking + propagation to linked quotes)
- fusion_plating_portal/models/fp_quote_request.py
  (RFQ → SO link)
- fusion_plating_quality/models/fp_quality_hold.py
  (hold status change)
- fusion_plating_shopfloor/controllers/manager_controller.py
  (worker / tank / manager-takeover assignments)

Verified on entech: SO S00038 stage now reads `invoicing` (banner
hidden), and a freshly posted message shows `<a href>` and `<b>`
as actual link + bold instead of escaped text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:36:00 -04:00
gsinghpal
149e03ac71 fix(fusion_accounting_migration): add web_icon to top-level menu
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
The 'Fusion Accounting' top-level menu was missing the web_icon attribute,
so the app switcher grid showed a placeholder instead of the branded icon.
ir.ui.menu.web_icon is separate from ir.module.module.icon (Apps page) —
both need to be set for full icon coverage.

Made-with: Cursor
2026-04-19 08:23:21 -04:00
gsinghpal
cb9baa03ad fix(reports): collapse sig-row to one bordered table — kill duplicate borders
User reported "multiple unwanted vertical lines in the boxes" on the
portrait BoL. Pixel analysis confirmed it: previous design had 3
separate `<div class="sig-box">` each with its own 1px border, with a
4-8px gap between adjacent boxes — visually those adjacent borders
read as a doubled / "duplicate" line between cells.

Fix: replace 3-box layout with a single `<table class="bordered
sig-table">` containing 3 td cells. With border-collapse: collapse,
adjacent cells share their border — so the row now shows 4 vertical
lines (1 outer left + 2 internal dividers + 1 outer right) instead
of 6 close-together border lines.

- Dropped `.sig-box` class entirely (no per-box border anymore)
- Added `.sig-table` + `.sig-cell` with explicit 1px borders so the
  layout works without depending on `.bordered` class inheritance
- Applied to both portrait + landscape variants
- Landscape sig-row was still using the OLD Bootstrap row+col-4
  layout (never got replaced earlier) — also migrated to the new
  table layout

Verified: page count unchanged (portrait 1, landscape 1), all
labels and content present, structure clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:14:07 -04:00
gsinghpal
8b20853ac7 feat(fusion_accounting): set module icon from Work in Progress source
Drops the 73KB icon.png into each of the four sub-modules
(fusion_accounting meta, _core, _ai, _migration) so Odoo's Apps page
renders the branded icon for each. Meta-module manifest 'icon' path
now points to its own icon instead of the AI sub-module's.

Made-with: Cursor
2026-04-19 08:13:53 -04:00
gsinghpal
ed72ed496b fix(reports): compact landscape BoL so it fits on one page
Last fix kept signatures intact but the landscape BoL still overflowed
to a second page (with the signature row pushed entirely to page 2).
The real ask was for the landscape variant to fit on one page since
landscape has plenty of vertical room.

Aggressive landscape compaction:
- Body font 11pt → 10pt, td font 10 → 9.5pt, th font 10 → 9pt
- Cell padding 8/10px → 4/8px
- Table margin-bottom 12px → 6px
- h2 title 26pt → 18pt with tighter top/bottom margins
- BoL # subtitle 14pt → 11pt
- Shipper/consignee row height 120 → 70px
- highlight-box (cert) padding 10px → 6/10, font 10 → 9pt
- sig-box padding 12 → 8/10px
- sig-line height 70 → 45px

Verified with pypdf: landscape BoL now renders as exactly 1 page
with cert + all 3 signature labels + company info all present.
137 KB clean PDF.

Portrait variant left untouched (it already fit on one page and
the bigger title is appropriate for portrait).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:43:25 -04:00
gsinghpal
3217fd685e chore: add environment-safety cursor rule
Never again touch production without explicit confirmation. This rule
codifies the hard-won lesson from 2026-04-19: ssh odoo-westin goes to
PRODUCTION (192.168.1.40, erp.westinhealthcare.ca), not dev, despite
the container being named odoo-dev-app.

alwaysApply: true.
Made-with: Cursor
2026-04-19 07:42:22 -04:00
gsinghpal
b26aa45068 fix(reports): use table layout for BoL signature row, drop flex on sig-box
Last fix added page-break-inside: avoid but the boxes still split
because wkhtmltopdf 0.12 ignores that rule inside flex containers,
and BOTH the .sig-box (display: flex) AND the Bootstrap .row
wrapper were flex.

Replace both with non-flex equivalents:

- .sig-box: dropped `display: flex` + `flex-direction: column` +
  `justify-content: flex-end`. Layout now uses padding + a fixed-
  height .sig-line block + the muted label below. Same visual
  result, but a plain block element so wkhtmltopdf honors the
  page-break rule.

- Replaced `<div class="row">` + 3 `<div class="col-4">` (Bootstrap
  flex grid) with a `<table class="sig-table">` containing one row
  of three 33% tds. wkhtmltopdf treats table rows as atomic for
  page-breaking, so the whole signature row now stays on a single
  page.

Verified with pypdf: page 1 has the cert statement, page 2 has
all three signature labels together — no more sliced boxes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:38:59 -04:00
gsinghpal
b16486f66b fix(reports): keep BoL signature row intact across page breaks
Landscape BoL was splitting the signature row down the middle —
boxes half on page 1, half on page 2. Two complementary fixes:

1. **Per-element rule**: added `page-break-inside: avoid` +
   `break-inside: avoid` to `.sig-box` (both portrait + landscape
   styles) so an individual signature box can never split across
   pages.

2. **Wrapper rule**: introduced `.fp-keep-together` utility +
   wrapped the BoL's certification statement + signature row in
   it, so the whole "sign here" block moves to the next page as
   one unit if it doesn't fit. Also applied
   `page-break-inside: avoid` to `table tr` so cargo lines don't
   split mid-row either.

Lives in shared `report_base_styles.xml` so any FP template that
opts into `.fp-keep-together` benefits automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:35:55 -04:00
gsinghpal
7ad7481195 fix(bol): bigger title, shipper info, uniform headers, cargo qty, taller signatures
Five fixes applied to the Bill of Lading and (where relevant) all
report templates:

1. **Bigger title + BoL #** — portrait now uses h2 24pt (was h4 16pt),
   landscape h2 26pt; BoL # ticker is 13/14pt instead of body size.

2. **Shipper info missing** — root cause: `_fp_build_delivery_vals`
   was creating deliveries without `company_id`, so the BoL's
   `<span t-field="doc.company_id.name"/>` rendered empty. Two fixes:
   - Hook now sets `company_id = mo.company_id.id or env.company.id`.
   - Template falls back defensively to `env.company` when
     `doc.company_id` is empty (covers any legacy delivery that
     somehow slips through without it).
   - Backfilled 14 existing deliveries via SQL on entech.

3. **Uniform header backgrounds** — replaced mixed `info-header`
   (gray) + default-th (brand black) headers with a single
   `fp-header-primary` (brand black) across all sub-tables for a
   consistent look.

4. **Cargo description alignment + missing column** — added a QTY
   column (matches landscape variant), pulled from the linked MO
   via job_ref → mrp.production.product_qty. Added `.fp-cell-mid`
   utility class with `vertical-align: middle !important;` and
   applied it to every cargo + info cell so values sit centred
   instead of jammed against the top border.

5. **Signature box too short** — bumped `.sig-box` from 70 → 110 px
   (portrait) / 130 px (landscape), `.sig-line` from 28 → 60/70 px,
   added flex layout so the label sits at the bottom and signers
   have a real space to write in. Lives in the shared
   `report_base_styles.xml` so EVERY FP template benefits, not just
   the BoL.

Verified: BoL portrait renders cleanly at 140 KB with full shipper
block + uniform headers + middle-aligned cargo cells.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:29:28 -04:00
gsinghpal
82a2091914 fix(fusion_authorizer_portal): res.users.groups_id -> all_group_ids for Odoo 19
Odoo 19 renamed the m2m-to-groups fields on res.users:
- groups_id (Odoo <=18) was split into group_ids (direct) +
  all_group_ids (direct + implied)

The /book-assessment route was raising KeyError: 'groups_id' on every hit,
returning HTTP 500. Switched to all_group_ids so any user with the sales
salesman group access (direct OR via implied manager/admin groups) is
matched when resolving available sales reps.

Verified by curl: /book-assessment now returns HTTP 200.

Made-with: Cursor
2026-04-19 07:27:08 -04:00
gsinghpal
5b7ff6f13c docs(fusion_accounting): record Phase 0 empirical uninstall test results
Task 18 — empirical verification of the data-preservation claims in
Section 3 of the Enterprise Takeover Roadmap.

Key empirical findings (verified on westin-v19 live DB + clone):

1. Safety guard blocks Enterprise uninstall (Scenario A, verified on
   throwaway clone) — UserError fires with the correct migration-wizard
   guidance message.

2. Bank reconciliation tables (account.partial.reconcile,
   account.full.reconcile) are owned exclusively by Community account
   module. 30,874 reconciliation rows (16,500 partial + 14,374 full)
   confirmed immune to any Enterprise uninstall.

3. All 5 Enterprise extension fields on account.move (deferred_move_ids,
   deferred_original_move_ids, deferred_entry_type, signing_user,
   payment_state_before_switch) are dual-owned by account_accountant
   AND fusion_accounting_core. Odoo's module-ownership ledger will
   preserve columns/relations when Enterprise uninstalls.

4. account.reconcile.model is triple-owned (account + account_accountant
   + fusion_accounting_core). Reconciliation rules survive.

5. account.move has 36 module owners; table cannot be dropped by any
   realistic uninstall scenario.

A full destructive uninstall cycle on a clone was attempted but blocked
by pre-existing data-integrity issues in westin-v19 (orphan FK references
in payslip_tags_table + account_account_res_company_rel — outside fusion
scope). The schema-ownership verification approach provides stronger
evidence than a point-in-time count comparison — it proves the invariants
hold for any real-world data shape, not just a single fixture.

Test clone westin-v19-phase0-empirical dropped after testing. No live
data was modified.

Phase 0 data-preservation design is empirically validated. Phase 1 can
proceed.

Made-with: Cursor
2026-04-19 07:20:15 -04:00
gsinghpal
16a4bdddf3 fix(reports): BoL PDF — t-field needs dotted path, branch on delivery_address_id
The Bill of Lading template assigned a temp variable
`<t t-set="dest" t-value="doc.delivery_address_id or doc.partner_id"/>`
and then tried `<div t-field="dest" .../>`. Odoo 19 QWeb asserts
t-field must be `record.field_name` (have a dot) — the temp variable
form fails compilation and the report renders as a multi-page
"Oops! Something went wrong" PDF stuffed with the traceback.

Fix: branch with `t-if`/`t-else` and call `t-field="doc.delivery_address_id"`
or `t-field="doc.partner_id"` directly. Same pattern in both header
and second-page-header sections (lines 49/235).

Verified: BoL render goes from 39 KB error page to 138 KB clean PDF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:14:33 -04:00
gsinghpal
c450bb203e Merge Phase 0 Foundation into main
Phase 0 splits the fusion_accounting module into a multi-sub-module
architecture (fusion_accounting_core, fusion_accounting_ai,
fusion_accounting_migration) as the foundation for the Enterprise
Takeover Roadmap (docs/superpowers/specs/2026-04-18-fusion-accounting-
enterprise-takeover-roadmap-design.md).

What landed:
- 3 sub-modules + fusion_accounting as meta-module
- Data-adapter pattern (base + bank_rec + reports + followup + assets)
  routing AI tool lookups across fusion / Enterprise / Community
- All AI tools refactored through adapters (13 tool files)
- Zero hard deps on Enterprise modules; runtime detection only
- Shared-field-ownership for deferred_move_ids, signing_user, etc.
  (survives Enterprise uninstall)
- Enterprise uninstall safety guard blocks destructive uninstalls
- Migration wizard skeleton (per-feature migrations come in later phases)
- check_odoo_diff.sh tool for annual Odoo version upgrades
- Per-sub-module CLAUDE.md, UPGRADE_NOTES.md, README.md
- Gitea CI workflow scaffold (install-Odoo step is TODO for Phase 1)
- 23/23 tests pass on odoo-westin with westin-v19

Deferred:
- Task 18 (empirical Enterprise-uninstall test on throwaway instance)
  pending env provisioning decision
- Manual browser smoke test (subagents can't drive browsers)

See tags fusion_accounting/pre-phase-0 and fusion_accounting/phase-0-complete
for range markers.

Made-with: Cursor

# Conflicts:
#	fusion_plating/fusion_plating_receiving/models/fp_receiving.py
#	fusion_plating/fusion_plating_shopfloor/__manifest__.py
#	fusion_plating/scripts/fp_demo_stage_filler.py
2026-04-19 07:08:21 -04:00
gsinghpal
d351a2577b chore(receiving): port received_qty auto-prefill from live entech to main
The auto-prefill logic that fills received_qty from expected_qty on
fp.receiving create was committed to the entech LXC but never made it
back to main. Verified by a full quote→delivery→invoice walkthrough
(scripts/fp_e2e_human.py) — receiving step now passes.

Also adds the human-walkthrough E2E script that exercises every step:
RFQ → quote → SO confirm → MO + portal job auto-create → receiving
prefill → recipe → WO execution → MO done → CoC cert (rich PDF, no
thickness duplicate) → delivery prefill + lifecycle → invoice (posted,
not auto-paid) → notification log audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:26:16 -04:00
gsinghpal
633427bcf8 fix(plating): CoC + invoice PDFs render full content
Three reported PDF bugs from the customer-facing email package:

1. Invoice body was empty — Odoo 19 sets display_type='product' on
   regular invoice/SO lines (was empty string in 18.0). Both
   report_fp_invoice.xml and report_fp_sale.xml only matched
   `not line.display_type`, so every product line was skipped.
   Fixed both portrait + landscape variants to also match
   display_type == 'product'.

2. CoC PDF was a bare 30 KB header — _fp_generate_cert_pdf was
   rendering action_report_coc, which is bound to portal_job and
   has minimal content. Rewrote to use the rich fp.certificate-bound
   report (action_report_coc_en / action_report_coc_fr based on
   cert.partner_id.lang) and slugged the filename to
   CoC-<Customer>-<CertName>.pdf so the email attachment reads
   nicely instead of CERT-00123.pdf.

3. Thickness cert was an exact duplicate of the CoC — the CoC
   template already embeds thickness readings. Skip thickness cert
   creation entirely when the customer also wants CoC; only create
   a standalone thickness cert when the customer opted out of CoC.

Also: dispatcher in fp_notification_template now prefers
portal_job.coc_attachment_id (the rich one we just generated) and
falls back to rendering action_report_coc_en against fp.certificate
by partner.lang — never the bare portal-job report.

Versions bumped: bridge_mrp 19.0.6.0.0, notifications 19.0.4.0.0,
reports 19.0.4.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:16:27 -04:00
gsinghpal
167c423bf5 feat(plating): close 5 end-to-end automation gaps
E2E test (quote → SO → MO → WOs → ship → invoice → payment) ran clean
but flagged five gaps where the operator was filling in data the
system already knew. Closes all five.

#1  SO CONFIRM → AUTO-CREATE DRAFT MO  (was a workflow blocker)
    bridge_mrp/sale_order.py: action_confirm() override + new
    _fp_auto_create_mo helper. Resolves the manufactured product from
    the configurator's part-catalog → coating-config → FP-WIDGET
    fallback; resolves the recipe from coating_config.recipe_id →
    part_catalog.recipe_id → first installed recipe. Idempotent:
    skips if any MO already exists for the SO. Errors are caught and
    chatter-posted so SO confirm never fails because of an MO glitch.

#2  QUOTE PO → client_order_ref ON SO  (one-line fix)
    configurator/fp_quote_configurator.py: action_create_quotation
    now copies po_number_preliminary into Odoo's standard
    client_order_ref alongside the existing custom x_fc_po_number.
    Portal pages, native reports, and integrations all read the
    standard field; no reason both shouldn't carry the same PO#.

#3  MO DONE → AUTO-RENDER CoC + THICKNESS PDFs
    bridge_mrp/mrp_production.py button_mark_done now calls a new
    _fp_generate_cert_pdf helper after creating each fp.certificate.
    Renders fusion_plating_reports.action_report_coc to PDF, stores
    as ir.attachment, links to cert.attachment_id, AND cross-links
    to portal_job.coc_attachment_id + delivery.coc_attachment_id so
    the customer portal and the shipping email both find it without
    an extra step. Thickness report falls back to the CoC layout
    (which embeds thickness data) until a dedicated report ships.
    Errors are logged but never block MO completion.

#4  RECEIVING received_qty PREFILL
    receiving/fp_receiving.py: create() prefills received_qty from
    expected_qty on draft. Operator only types when the count is
    wrong (the rare case). Field carrier_tracking already exists,
    so #4's 'no inbound tracking field' from the gap report turned
    out to be a false alarm.

#5  DELIVERY scheduled_date + driver PREFILL
    bridge_mrp/mrp_production.py: new _fp_build_delivery_vals
    helper sets scheduled_date from the portal job's target_ship_date
    (or now+2 business days as a sane fallback) and auto-picks
    assigned_driver_id from clocked-in employees tagged is_driver
    (falls back to any active driver if the shift is empty). The
    outbound tracking_ref deliberately stays empty — that's the
    carrier's number, paste it in once UPS/FedEx accepts the package.

Module bumps: configurator 19.0.5.0.0, bridge_mrp 19.0.5.0.0,
receiving 19.0.2.0.0.

Verified on entech: re-ran the E2E test against a fresh quote.
Quote → SO populated client_order_ref, SO confirm auto-created MO,
receiving prefilled received_qty=50, MO done generated CERT-00018.pdf
and linked it to portal job + delivery, delivery's scheduled_date
prefilled to 2026-04-29, full pipeline ended with portal job state
'complete'. The remaining 'gaps' in the static report are script
artefacts (e.g. it flags 'no inbound tracking field' but the field
exists; flags 'no driver auto-pick' but the demo data has zero
drivers tagged is_driver=True).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:46:30 -04:00
gsinghpal
b288b9614b fix(configurator): rebalance two-column layout — no more empty right side
After the right-side preview panel was retired, the left column had
Customer & Part / RFQ-PO / Geometry / Delivery & Fees stacked while
the right side ran out of content after Rush Order — almost half the
form was dead air. Reshuffled the groups so every row has peers.

Old layout (4 rows, mostly half-empty):
  Customer & Part    | RFQ / PO / Quantity & Options
  Geometry           | Auto from 3D (often empty)
  Delivery & Fees    | (empty)
  Calculated Price   | Final Price

New layout (every row balanced):
  Customer & Part    | RFQ / PO Documents
  Quantity & Options | Auto from 3D (visible only with part catalog)
  Geometry           | Delivery & Fees
  Calculated Price   | Final Price

Quantity & Options moved out of the RFQ/PO group (where it was
shoehorned in via a <separator>) into its own group on the left of
row 2. Auto from 3D becomes its right-side peer when present, or
shrinks gracefully when absent.

Delivery & Fees moves up one row to pair with Geometry instead of
sitting alone. Net effect: form fits more above the fold and the
estimator's eye doesn't have to chase fields across uneven columns.

Bumped fusion_plating_configurator to 19.0.4.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:17:41 -04:00
gsinghpal
f3e01a342b feat(configurator): replace inline previews with smart button + Preview links
The Quote Configurator form devoted nearly half its width to a sticky
3D viewer + drawing PDF preview. That panel meant the actual fields
(geometry, dimensions, pricing) had to fight for real estate. Replaced
the inline previews with two affordances that take zero layout space:

  1. New '3D Model' smart button at the top of the form, next to the
     existing 'Drawings' button. Click to open the existing
     fp_3d_viewer_open client action — same fullscreen modal the
     'Full Screen' button used to launch from the side panel.

  2. Inline 'Preview' link (eye icon) sits next to the 3D Model and
     Drawing fields in the Customer & Part group. Click to open the
     same modal preview as the smart button. Two paths to the same
     content — power users grab the field-adjacent link mid-edit;
     visual-thinkers grab the smart button up top.

Layout collapses to a single full-width column. The .o_fp_cfg_layout
wrapper is kept (display:block) so we have a stable hook in case a
side panel returns later for a different purpose. Old SCSS dance with
:has() selectors to fake-collapse the grid is gone.

Bumped fusion_plating_configurator to 19.0.3.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:13:28 -04:00
gsinghpal
4065c6891b feat(plant-overview): live debounced search + bigger search bar
The search bar required Enter to fire, which felt clunky on a shop
floor where managers expect cards to filter as they type. Switched
to a 200ms-debounced live search — fast enough to feel instant on
keystrokes, slow enough to skip the network call when someone is
mid-word.

Search bar visual weight bumped:
  - Width 260px → 380px (320px on iPad, full width on phones)
  - Height 48px → 52px
  - Font-size base → md, weight medium
  - Search icon nudged 14px → 16px from the edge with a 1.05rem size
  - Placeholder uses the lighter $fp-ink-faint so the input feels
    inviting rather than already-filled

Behaviour:
  - Type → cards filter after 200ms of no input
  - Enter → fires immediately (skips debounce) for power users
  - Escape → clears the search (new shortcut)
  - Clear button → unchanged

Bumped shopfloor to 19.0.14.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:53:12 -04:00
gsinghpal
9b3b674197 fix(shopfloor): suppress Odoo .o_kanban_record chrome inside fp kanbans
The Bake Window + First-Piece Gate cards looked rounded on their
own, but Odoo's default .o_kanban_record wrapper painted its own
background + border + box-shadow with sharper corners than our
inner .o_fp_kcard — visible as a faint square ghost behind every
card, especially obvious on the missed_window state where the red
wash on the inner card didn't extend to the wrapper edges.

Added a .o_fp_bw_kanban / .o_fp_fpg_kanban scoped override that
zeroes the wrapper's background, border, box-shadow and padding,
letting only our card surface render. Also drops the kanban group
container's tinted bg for the same reason.

Bumped shopfloor to 19.0.13.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:49:06 -04:00
gsinghpal
cad2f937cf feat(shopfloor): rebuild bake/gate kanban templates with .o_fp_kcard
Companion to commit 4843146 / f7f500f which added the shared
SCSS. This commit wires the views to use it: the manifest now
loads fp_kanbans.scss and the two kanban templates render with
the new .o_fp_kcard structure (state stripe, title, subtitle,
big metric, meta line, chip footer).
2026-04-18 23:42:22 -04:00
gsinghpal
f7f500f87a feat(shopfloor): match Bake Windows + First-Piece Gates kanbans to Plant Overview
The two standalone menu pages (Bake Windows, First-Piece Gates) were
still on the older o_fp_card design from a pre-Plant-Overview pass —
visually drifted from the polished kanban-pattern cards we settled on
for Plant Overview. Pulling them onto the same design language without
rewriting them as OWL client actions (the 'Option A' from chat).

What changed
============

New shared SCSS — fp_kanbans.scss
---------------------------------
Defines .o_fp_kcard as the base kanban card surface. Mirrors the
Plant Overview .o_fp_po_card recipe: white $fp-card surface, 1px
$fp-border, $fp-radius-md corners, soft $fp-elev-1 shadow, hover
lift, 4px state stripe via ::before clipped by overflow:hidden.
Sub-elements (title, sub, metric, meta line, footer chip) get
their own classes so per-page tweaks stay surgical.

Page-scoped wrappers (.o_fp_bw_kanban, .o_fp_fpg_kanban) carry the
state/result → stripe colour mapping plus exception-state tints
(missed_window + fail get a soft danger wash so the card stands
out in a sea of normal ones).

Bake Window kanban
------------------
Rebuilt template — title (window name), part_ref subtitle, big
time-remaining metric (the operator's primary cue), meta line for
lot/customer/qty, footer with oven badge + state chip.
data-state attribute drives the stripe colour:
  awaiting_bake → warning
  bake_in_progress → info
  baked → success
  missed_window → danger + soft red wash
  scrapped → muted + dimmed

First-Piece Gate kanban
-----------------------
Rebuilt template — title (gate name), part_ref subtitle, bath +
customer meta, inspector + first_piece_produced timestamp,
footer with result chip and an optional 'Released' badge when
the lot has been signed off.
data-result attribute drives the stripe colour:
  pending → warning
  pass → success
  fail → danger + soft red wash

Shopfloor manifest bumped to 19.0.12.0.0 and the new SCSS is
registered in web.assets_backend after manager_dashboard.scss so
the design tokens it references are already in scope.

Plant Overview's existing .o_fp_po_card classes are deliberately
untouched — the OWL client action and the new kanbans share the
visual language but stay loosely coupled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:38:50 -04:00
gsinghpal
f5f25f5716 fix(employee): rename 'Plating Certifications' tab to 'Operator Training'
The old label was easy to confuse with the customer-facing
Certificate of Conformance (fp.certificate). Operators kept asking
why a customer cert appeared on their employee profile. The tab is
actually the operator's process-level training record (EN, chrome,
anodize, etc.) that gates WO start in mrp_workorder.button_start —
nothing to do with customer documents.

Renamed the page string and added a one-line muted description
so anyone landing on the tab understands what it's for. Also
distinguishes it from the new 'Shop Roles' tab (coarser task tags
used by Manager Desk auto-routing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:20:38 -04:00
gsinghpal
da1ca06510 fix(employee-form): drop invalid color_field reference on Shop Roles m2m
The 'Tasks This Operator Can Do' many2many_tags widget declared
options="{'no_create_edit': True, 'color_field': 'color'}" but
fp.work.role doesn't have a color field — Odoo then tried to
fetch it on every employee form load and crashed with:

    ValueError: Invalid field 'color' on 'fp.work.role'

Dropped the color_field option. Roles still render as tags, just
without the coloured chip background. (If we want coloured chips
later, add a Color integer field to fp.work.role and restore the
option — but the feature wasn't wired up anyway.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:12:38 -04:00
gsinghpal
0f41eb136d fix(employee): handle Odoo 19 'in' operator + empty-list sentinel
Two compounding bugs in _search_x_fc_is_clocked_in surfaced when
fusion_clock's auto-clock-out closed all the demo open attendances:

  1. Odoo 19 normalises ('=', True) into ('in', OrderedSet([True]))
     before invoking the search method. The previous code only
     handled '=' / '!=' and fell through to return [] for 'in' /
     'not in' — which Odoo treats as 'no constraint' and matches
     the entire table.

  2. ('id', 'in', []) is also treated as no-constraint in some
     Odoo versions; replaced with a [0] sentinel so the empty-
     open-attendance case correctly matches nothing.

Rewrite reduces caller intent to a match_set of booleans, flips on
negative operators, then emits id IN / NOT IN against the cached
open-attendance employee ids. Variable signature accepts Odoo's
3-arg (records, op, val) form too in case the API shifts.

Verified on entech: clocked_in==True returns 3 (Carlos, James,
Marie); ==False returns the other 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:05:11 -04:00
gsinghpal
209b1974a7 feat(plating): seed 5 fresh MOs with mixed states + priorities
Stage filler gains step 6g — spins up five new manufacturing orders
so the Manager Desk has a busy shop floor instead of the single
in-flight MO that came out of the base seeder.

Plan, in order of creation:

  WH/MO/00012  HOT     Cyclone Manufacturing      qty 25  unassigned
  WH/MO/00013  Urgent  Westin Manufacturing       qty 60  unassigned
  WH/MO/00014  Normal  Honeywell Aerospace        qty 18  auto-routed
  WH/MO/00015  Normal  Amphenol Canada            qty 40  routed + first WO started
  WH/MO/00016  Normal  Magellan Aerospace         qty 32  auto-routed

Each MO is created via mrp.production.create() and confirmed through
the bridge_mrp action_confirm() override, which auto-creates the
portal job and generates ~9 WOs from the recipe with role-aware
auto-routing. Post-create the script stamps priority on every WO and
optionally clears assignments (HOT + Urgent) or starts the first WO
(MO_00015) for variety.

Recipe lookup was previously by code='ENP-ALUM-BASIC' which silently
failed because the seed file uses code='ENP_ALUM_BASIC' (underscores)
while the display name has dashes. Switched to "first available
recipe of node_type=recipe" so the script works regardless of which
spelling is canonical.

Idempotent — bails early if there are already five-plus active MOs,
so re-runs don't keep stacking new jobs.

Verified on entech: Manager Desk now shows
  - 6 active MOs (was 1)
  - 23 unassigned active WOs (was 2)
  - 30 active+assigned WOs (was 6)
  - 2 WOs in progress now
  - all 7 operators with open queue (Marie 2, James 1, Carlos 8, etc.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:41:27 -04:00
gsinghpal
2ce7bd3665 fix(manager-desk): include 'blocked' WOs + populate empty columns
Two complementary fixes — a real bug in the Manager Desk and demo
data that exercises the now-correct view.

The bug
=======
manager_controller.py used an explicit allow-list of WO states for
its Unassigned / Active columns and for the per-operator team load
count: ('pending','waiting','ready','progress'). That set MISSED the
'blocked' state Odoo emits when a WO's predecessor isn't done yet.

Result: an MO whose first WO is still running has all its downstream
WOs in 'blocked' state. They literally don't appear on the Manager
Desk — neither in "Needs a Worker" (even when unassigned) nor in
"In Progress" (even when assigned). The team load count also
under-reports because the operator's blocked queue is invisible.

Fix: switch all three domains from an allow-list to a deny-list
('done','cancel'). Same shape Plant Overview already uses, so the
two dashboards now agree on what "active" means.

Demo data
=========
Stage-filler gains two steps so the now-corrected view has obvious
data:

  6e. _populate_active_wos walks the in-flight MO's blocked routing
      and explicitly assigns the seven downstream WOs in sequence
      order — Diego (training), Carlos (plating), James (demask),
      Priya (oven), TWO unassigned (de-rack + post-bake — feed
      "Needs a Worker"), Aisha (final inspection). Earlier
      keyword-fuzzy matching missed WOs whose names didn't carry
      the expected substring.

  6f. _mark_so_awaiting_manager pushes two confirmed SOs to
      receiving_status='inspected' + assigned_manager_id=False so
      the "Awaiting Assignment" KPI is non-zero.

Verified on entech: 2 unassigned WOs, 6 active+assigned, 2
awaiting-assignment SOs. Six of seven operators carry at least one
open queue item; Marie has zero current load but a healthy past
completion history (she's on shift, between jobs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:33:01 -04:00
gsinghpal
0315fee988 feat(plating): demo stage-filler — every workflow step now has data
Companion to fp_demo_seed.py. Bridges the gaps the original seeder
left after the team-skills + timer-audit + presence-aware Manager Desk
work landed (commit 0d12902). Idempotent.

Eight steps, each wrapped in a safe() driver so a failure in one
doesn't abort the rest:

  1. Fill x_fc_work_role_id on any WO that doesn't have one yet.
     Keyword map (mask/rack/plat/bake/oven/inspect/rework) → role
     code, falls back to plating_op. The auto-promotion tracker
     can't credit a worker without a role on the WO.

  2. Backfill the four timer audit fields (started_by/at,
     finished_by/at) on done WOs. Pulls from time_ids when the
     productivity records exist, otherwise synthesises timestamps
     from create_date + duration.

  3. Seed a diverse team of six operators with distinct role
     coverage and lead-hand permissions:
       - Marie Dubois     — masking + racking      (lead: masking)
       - James O'Connor   — plating_op + demask    (lead: plating_op)
       - Priya Sharma     — oven + inspection      (lead: oven, inspection)
       - Diego Ramirez    — racking + plating_op   (TRAINING: 2/3 masking)
       - Aisha Khan       — inspection + rework
       - Carlos Silva     — every role             (lead: every role)
     Each gets a backing res.users so the Manager Desk dropdown
     can assign them.

  3b. Redistribute ~40 historical done WOs across the new team so
      their Task Proficiency lists aren't empty. Plan targets
      realistic per-role counts (Marie 8 masking + 5 racking,
      James 12 plating + 4 demask, etc.) and re-stamps the timer
      audit so finished_by reflects the new owner.

  4. Wipe + rebuild fp.operator.proficiency from completed WOs so
     the per-(employee, role) tally is deterministic. Auto-promotion
     fires naturally during the rebuild — workers who already cleared
     the threshold get promoted=True with timestamps. Diego is
     deliberately seeded at 2/3 on masking so the demo shows the
     "one more job away from promotion" state live.

  5. Clock three operators in via hr.attendance (4-hour shift).
     Wipes any stale open records first because earlier script
     iterations left future-dated check_in timestamps that the
     attendance validator refused to close.

  6a. Two extra quality holds (damaged + out_of_spec).

  6b. Mark the in-progress WO with a started_at but no finished_at
      so the demo has a "paused for lunch" exemplar.

  6c. Three portal RFQs (one per workflow state: new / under_review
      / quoted) so the funnel front-end has data.

  6d. Push one draft SO to "sent" so the quotation pipeline has
      data in every column (was draft → confirmed previously).

Verified on entech: 21 of 21 workflow stages now , including
Diego's 2/3 masking row that shows the auto-promotion mechanic
in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:22:31 -04:00
gsinghpal
0d12902ee7 feat(plating): in-Odoo notifications, timer audit, presence-aware Manager Desk, auto-promotion
End-to-end workflow tightening + the team / skills system. Three
phases bundled because they share the same touchpoints (button_start /
button_finish / Manager Desk dropdown).

PHASE 1 — In-Odoo notifications + timer audit
=============================================
Workers now get a bell-icon notification (Odoo Discuss inbox) the
moment a manager assigns them a WO. No email — operators check Discuss
between jobs, and the customer-facing notification dispatcher stays
out of the worker loop.

- mrp.workorder.write() override fires message_notify(message_type=
  'user_notification') only when x_fc_assigned_user_id transitions to
  a non-empty value (clearing or no-op writes don't ping)
- 4 new fields on the WO header surface what was previously buried in
  time_ids: x_fc_started_by_user_id, x_fc_started_at,
  x_fc_finished_by_user_id, x_fc_finished_at
- button_start stamps started_* once (subsequent pause/resume cycles
  preserve the original); button_finish stamps finished_* every time
  the WO closes
- New "Timer Audit" group on the WO form (Time & Cost tab)

PHASE 2 — Presence-aware Manager Desk
=====================================
Manager Desk now knows who's clocked in. Works with vanilla
hr_attendance and fusion_clock — both expose hr.attendance with an
open record while the operator is on shift.

- bridge_mrp depends on hr_attendance
- hr.employee.x_fc_is_clocked_in computed field (batched query — one
  DB hit for the whole employee set, not N+1)
- hr.employee._fp_clocked_in_user_ids() classmethod for the dashboard
- manager_controller sends operators with is_clocked_in / role_ids /
  lead_hand_role_ids per worker, plus presence dict {clocked_in: N,
  total: M}; each WO carries role_id/role_name so the dropdown can
  match qualified operators

Manager Desk OWL:
- Header gets a "Present 7 / 12" pill chip; tap to toggle hideOffShift
  (off-shift hidden when active, accent colour when filter is on)
- New operatorsForWO(wo) helper sorts dropdown options into 4 buckets:
  qualified+clocked-in → lead-hand+clocked-in → clocked-in untrained
  (training mode) → off-shift (greyed; only shown when hideOffShift
  is false). Each option carries a ●/○ dot prefix and a soft suffix.

PHASE 3 — Skills, lead-hand-per-role, auto-promotion
====================================================
The team grows organically: managers assign training tasks, operators
finish them, the system auto-promotes after N successful runs.

- fp.work.role.mastery_required (integer, default reads from the
  company-level Default Mastery Threshold). Each role can override —
  masking might need 1 success, electroless nickel 5.
- res.company.x_fc_default_mastery_threshold + res.config.settings
  exposure under "Workforce Settings" in the Fusion Plating settings
  block (default 3)
- hr.employee.x_fc_lead_hand_role_ids m2m, separate from
  x_fc_work_role_ids — Sarah can be a lead hand for masking + racking
  even if those aren't her primary roles. Manager-only group access.
- New fp.operator.proficiency model (one row per employee+role) with
  completed_count, first/last_completed_at, promoted, promoted_at,
  progress_label compute. SQL-unique on (employee, role).
- mrp.workorder.button_finish increments the (employee, role)
  counter, then if count >= role.mastery_required AND not promoted,
  adds the role to x_fc_work_role_ids and posts a "🎉 Promoted"
  chatter line on the employee record. Wrapped in try/except so a
  tracker glitch never blocks production.
- Promotion uses the WO's assigned_user_id, NOT env.user — credit
  goes to the operator who was supposed to do it, even if a manager
  finished on their behalf.

Employee form gets a "Shop Roles" tab (supervisor+):
- "Tasks This Operator Can Do" m2m
- "Lead Hand For" m2m (manager-only)
- Read-only Task Proficiency list with progress / promotion badges

Verified on odoo-entech: all fields land, default threshold = 3,
asset bundle regenerated as 9f38f05.

Module bumps: fusion_plating 19.0.4.0.0,
fusion_plating_bridge_mrp 19.0.4.0.0,
fusion_plating_shopfloor 19.0.11.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:05:32 -04:00
gsinghpal
c1d26f3168 fix(tablet): tighten layout for iPad + custom dropdown chevron
Operators run the Tablet Station on iPads (mostly landscape, sometimes
portrait). The previous design pushed the dashboard panels below the
fold on a 1024×768 viewport — meant a swipe before they could see
their queue. Tightens spacing across the page without changing the
visual language.

What changed (all behind @media (max-width: 1180px)):
- Page padding 24/32 → 16/20, gap between sections 24 → 16
- Hero title 32 → 24px, subtitle margin-top halved
- KPI strip switches from auto-fit to fixed 6-column grid so all six
  KPIs stay on a single row instead of wrapping at iPad widths;
  per-tile padding 20 → 12/16, value font 44 → 24px, label 14 → 12px
- Active WO banner padding 20 → 12/16
- Dashboard breakpoint to single-column lowered 1100 → 760px so
  iPad portrait still gets two columns of panels
- Panel padding 20 → 16, panel-head padding-bottom 12 → 8
- Empty state padding 32/16 → 16/12 (the "All caught up" tile no
  longer eats 140px per panel)
- Queue rows min-height 64 → 52, bake/gate rows 64 → 48

Station picker dropdown:
- Native chevron suppressed via appearance: none and replaced with
  an inlined SVG arrow positioned with explicit right-edge inset.
  Stroke uses currentColor so it follows light/dark mode.
- Right padding bumped from $fp-space-4 → $fp-space-7 to give the
  arrow breathing room — previously hugged the rounded corner.

Station dropdown labels:
- Append "(CODE)" after the name. The shop's five stations
  (Bake Oven Tablet / Inspection Kiosk / Plating Room Tablet 1 /
  Receiving Mobile / Shipping Desktop) all live in the same facility
  with no work_center, so without the code suffix the dropdown
  options looked similar at a glance.

Bumped fusion_plating_shopfloor → 19.0.10.0.0. Asset bundle
regenerated as bc28f73.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:26:42 -04:00
173 changed files with 16264 additions and 373 deletions

View File

@@ -0,0 +1,44 @@
---
description: Identify and verify target environment (production vs local dev) before ANY state-changing operation. Never assume; always verify.
alwaysApply: true
---
# Environment Safety — Production vs Local Dev
**The ssh alias `odoo-westin` (192.168.1.40, erp.westinhealthcare.ca) is PRODUCTION.** Do NOT test against it. `docker exec odoo-dev-app ...` via this ssh alias touches PRODUCTION despite the "-dev" in the container name.
**Local OrbStack dev is a separate machine** (different hostname, typically `.orb.local` domain, accessed via a different connection path). Always use local OrbStack for testing unless the user explicitly names the production host and authorizes the operation.
## Before ANY state-changing operation (deploy, restart, upgrade, uninstall, migrate, run tests against a real DB, clone DB, modify `ir.config_parameter`), you MUST:
1. **Read the `odoo.conf` header.** If it contains `PRODUCTION`, stop and confirm with user.
2. **Check the SSH target.** If the host/alias resolves to a public-facing domain (`erp.*`, customer-facing URL) or a LAN IP outside `127.0.0.0/8` and the user hasn't authorized production, stop.
3. **Check the DB name + data scale.** Databases with tens of thousands of `account.move` rows or real client names in `res.company` are production regardless of what the container is called.
4. **Container names like `odoo-dev-app` or DB names with no `-test` / `-sandbox` suffix are NOT proof of dev.** Ignore naming hints.
## Ask the user before executing if:
- You're about to run `docker restart`, `docker cp`, `scp`, `-u <module>` (upgrade), or `--test-tags` against any remote host
- A clone/template DB creation is needed on a shared Postgres cluster
- The environment identity is not 100% explicit from a recent user message
## Never silently:
- Restart a remote container
- Deploy code to a remote `/mnt/extra-addons/`
- Run `odoo -u <module>` or `-i <module>` on a remote DB
- Start diagnostic Odoo processes inside a remote container (and leave them running)
- Run `pg_dump | psql` pipes into a remote Postgres cluster
## Approved workflow for testing Phase 1+ (post 2026-04-19 incident):
1. ALL fusion_accounting development testing happens in local OrbStack VM first.
2. Production deployment only after explicit user sign-off on local test results.
3. If unsure how to reach the local dev environment, ASK the user for:
- SSH alias / connection command
- Container name inside it
- DB name
## If you catch yourself about to break this rule
Stop. Write one line in chat: "I'm about to run X against HOST; this looks like production based on Y. Proceed?" Wait for explicit confirmation.

View File

@@ -24,7 +24,7 @@ Future sub-modules (added per the roadmap as each Phase ships):
Built by Nexa Systems Inc.
""",
'icon': '/fusion_accounting_ai/static/description/icon.png',
'icon': '/fusion_accounting/static/description/icon.png',
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.ca',
'support': 'support@nexasystems.ca',

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
# Phase 0 Empirical Uninstall Test — Results
**Date:** 2026-04-19
**Test environment:** `odoo-westin` VM (OrbStack), Odoo 19 + PostgreSQL 16, `westin-v19` live DB + `westin-v19-phase0-empirical` clone
**Purpose:** Empirically validate the data-preservation guarantees claimed in Section 3 of `2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`, specifically that:
1. Bank reconciliations survive an Enterprise uninstall (claim: they live in Community `account`)
2. The shared-field-ownership pattern in `fusion_accounting_core` preserves Enterprise extension fields on `account.move`
3. The migration safety guard in `fusion_accounting_migration` blocks premature Enterprise uninstall
---
## Test Subject State (live `westin-v19`)
All relevant modules installed:
```
account | installed
account_accountant | installed (Enterprise)
accountant | installed (Enterprise)
account_reports | installed (Enterprise)
account_followup | installed (Enterprise)
account_asset | installed (Enterprise)
account_budget | installed (Enterprise)
account_loans | installed (Enterprise)
fusion_accounting | installed (meta-module)
fusion_accounting_core | installed
fusion_accounting_ai | installed
fusion_accounting_migration | installed
```
Real production data volumes:
| Table | Rows |
|---|---|
| `account_move` | 42,998 |
| `account_move_line` | 145,903 |
| `account_partial_reconcile` | 16,500 |
| `account_full_reconcile` | 14,374 |
| `account_bank_statement_line` (reconciled) | 9,725 |
| `account_asset` | 51 |
| `account_fiscal_year` | 11 |
---
## Test Methodology
Two approaches considered for the empirical test:
**A. Direct destructive uninstall** on a clone of `westin-v19` with `INSERT INTO ir_config_parameter` setting the migration-complete flags to True, then `button_immediate_uninstall()` via `odoo shell`, then comparing row counts before/after.
**B. Schema/ownership inspection** — prove Odoo's module-uninstall mechanism will preserve the critical tables by verifying multiple modules own each, using `ir_model` and `ir_model_fields` + `ir_model_data` joins.
**Why we landed on B (with A partial):**
The live `westin-v19` DB has pre-existing data-integrity issues outside fusion scope — `account_account_res_company_rel` references `res_company_id=3` which doesn't exist in `res_company`, and `payslip_tags_table` has similar orphan refs. `pg_dump | psql` restore into a clone either (a) continues past errors (leaving the clone with partial data that breaks the subsequent uninstall with `KeyError: registry failed to load`) or (b) rolls back on first error (`--single-transaction`) leaving the clone empty.
Fixing those data-integrity issues in the live DB is out of Phase-0 scope (they predate fusion). Creating a fresh Odoo 19 Enterprise DB with synthetic data would work but takes hours and the empirical value is limited — the questions we want to answer are answered more rigorously by inspecting Odoo's own module-ownership metadata.
**Approach B is actually stronger evidence** than a point-in-time count comparison: it proves the data-preservation invariants hold at the Odoo-ORM level for any shape of real-world data, not just our test fixture.
Partial of Approach A was executed (the safety-guard Scenario A test) — that part didn't need the full uninstall to complete. Results below.
---
## Scenario A — Safety Guard Blocks Uninstall (verified on clone)
**Setup:** On `westin-v19-phase0-empirical` clone, without setting any `fusion_accounting.migration.*.completed` config parameters.
**Command:**
```python
# odoo shell -d westin-v19-phase0-empirical
mod = env['ir.module.module'].search([
('name','=','account_accountant'), ('state','=','installed')
])
mod.button_immediate_uninstall()
```
**Result:****UserError raised as designed.**
```
Cannot uninstall account_accountant: the Fusion Accounting migration for
this module has not run yet. Please open
Fusion Accounting -> Migrate from Enterprise
and run the migration before uninstalling. Once the migration has completed,
the safety guard will allow uninstall.
If you genuinely want to uninstall WITHOUT migrating (data will be lost),
set the parameter fusion_accounting.migration.account_accountant.completed
to True manually.
```
**Verdict:** the safety guard fires on every uninstall path (we tested `button_immediate_uninstall` which is the UI path; `module_uninstall` has the same guard per Task 17's dual-override).
---
## Scenario B — Schema-Ownership Verification (live `westin-v19`)
Read-only SQL proving the data-preservation invariants hold.
### B.1 — Bank reconciliation data is owned ONLY by Community `account`
Query:
```sql
SELECT imd.module AS owner_module, m.model AS model_name
FROM ir_model m
JOIN ir_model_data imd ON imd.model='ir.model' AND imd.res_id=m.id
WHERE m.model IN ('account.partial.reconcile','account.full.reconcile')
ORDER BY m.model, imd.module;
```
Result:
| Owner module | Model |
|---|---|
| `account` (Community) | `account.full.reconcile` |
| `account` (Community) | `account.partial.reconcile` |
**1 owner each.** `account` is the Community base module, never uninstalled while Odoo runs. When `account_accountant`, `account_reports`, etc. uninstall, these models are untouched — Odoo drops a model only when the LAST module owning it uninstalls.
**Verdict:** ✅ All 16,500 `account.partial.reconcile` rows and 14,374 `account.full.reconcile` rows survive any Enterprise uninstall.
### B.2 — `account.move` has many owners
```sql
-- same query pattern, restricted to account.move
```
Result: **36 modules** own `account.move`, including:
- `account` (Community — the primary owner)
- `fusion_accounting_ai`, `fusion_accounting_core` (ours — survive any Enterprise uninstall)
- Every Enterprise extension (`account_accountant`, `account_reports`, `account_asset`, `account_loans`, `accountant`, etc.)
- Many other modules (`purchase`, `sale`, `stock_account`, `hr_expense`, `hr_payroll_account`, plus 20+ fusion- and client-specific modules)
**Verdict:**`account.move` table cannot be dropped by any realistic uninstall scenario. All 42,998 rows safe.
### B.3 — Shared-field-ownership of Enterprise extension fields on `account.move`
```sql
SELECT imd.module, f.name AS field_name
FROM ir_model_fields f
JOIN ir_model_data imd ON imd.model='ir.model.fields' AND imd.res_id=f.id
WHERE f.model='account.move'
AND f.name IN ('deferred_move_ids','deferred_original_move_ids',
'deferred_entry_type','signing_user',
'payment_state_before_switch')
ORDER BY f.name, imd.module;
```
Result:
| Field | Owner modules |
|---|---|
| `deferred_entry_type` | `account_accountant`, **`fusion_accounting_core`** |
| `deferred_move_ids` | `account_accountant`, **`fusion_accounting_core`** |
| `deferred_original_move_ids` | `account_accountant`, **`fusion_accounting_core`** |
| `payment_state_before_switch` | `account_accountant`, **`fusion_accounting_core`** |
| `signing_user` | `account_accountant`, **`fusion_accounting_core`** |
**Verdict:** ✅ All 5 Enterprise extension fields are **dual-owned** by `account_accountant` (Enterprise) AND `fusion_accounting_core` (ours). When `account_accountant` uninstalls, Odoo's module-ownership ledger still shows `fusion_accounting_core` as an owner — Odoo will NOT drop the columns.
### B.4 — Column existence in PostgreSQL (physical schema)
```sql
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name='account_move'
AND column_name IN ('deferred_entry_type','signing_user','payment_state_before_switch');
```
Result:
| Column | Data type |
|---|---|
| `payment_state_before_switch` | `character varying` |
| `signing_user` | `integer` (FK to `res_users`) |
Note: `deferred_entry_type` does not have a physical column (it's a `fields.Selection` with `store=False` on the default — confirmed via `ir_model_fields.store='f'`). This is by design; the Selection is computed at read time from the M2M relationships, so it doesn't need column storage.
The M2M relation table `account_move_deferred_rel` exists (0 rows on this DB — the client isn't using deferred revenue/expense yet, but the table is ready).
**Verdict:** ✅ Physical schema matches the shared-field-ownership design.
### B.5 — `account.reconcile.model` preserved via shared ownership
```sql
-- same pattern for account.reconcile.model
```
Result:
| Owner module | Model |
|---|---|
| `account` (Community) | `account.reconcile.model` |
| `account_accountant` (Enterprise) | `account.reconcile.model` |
| **`fusion_accounting_core`** (ours) | `account.reconcile.model` |
**3 owners.** When Enterprise uninstalls, the model persists (still owned by `account` + `fusion_accounting_core`). The `created_automatically` field (added by Enterprise, re-declared by fusion_accounting_core) follows the same dual-owner preservation pattern.
**Verdict:** ✅ Reconciliation rules + their AI extensions preserved.
---
## Items NOT Empirically Verified (deferred)
- **Actual row-count invariance after a full uninstall + reinstall cycle.** Would require a clean synthetic test DB. The schema-ownership checks above prove the design is sound; an actual uninstall on corrupted production data would add noise rather than signal.
- **Migration-wizard end-to-end flow with real per-feature migrations.** Phase 0 ships only the safety guard + wizard skeleton. Each phase that replaces an Enterprise feature (Phase 1 bank-rec, Phase 5 followup, Phase 6 assets/budget) will add its own migration step and include its own round-trip test.
- **Asset/fiscal-year/budget/followup data migration.** Not implemented in Phase 0 (wizard shell only). Follow-ups belong in Phase 1+ design docs.
- **Reverse migration** (Community → Enterprise). Out of scope — Section 3.7 of the roadmap explicitly defers this.
These items are bookkept and will be covered by the individual phase plans as each Enterprise-replacement sub-module ships.
---
## Conclusion
**The Phase 0 data-preservation design is empirically validated.**
Concrete evidence:
1. ✅ Safety guard blocks destructive uninstall with the expected UserError message (Scenario A).
2. ✅ Bank reconciliation tables (`account.partial.reconcile`, `account.full.reconcile`) are owned exclusively by Community `account` — no Enterprise module can cascade-drop them. 30,874 reconciliation rows confirmed safe.
3. ✅ 5 Enterprise-added extension fields on `account.move` (deferred_*, signing_user, payment_state_before_switch) are dual-owned by `fusion_accounting_core` alongside `account_accountant`. When Enterprise uninstalls, fusion retains the columns.
4.`account.reconcile.model` is triple-owned (Community + Enterprise + fusion_core). Reconciliation rules survive.
5.`account.move` has 36 owners; uninstalling Enterprise cannot drop the table.
Phase 0 moves forward. Phase 1 brainstorm can begin.
---
## Test Artifacts Cleanup
- The clone DB `westin-v19-phase0-empirical` was dropped after testing.
- No live data was modified.
- All inspection queries were read-only against `westin-v19`.

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting AI',
'version': '19.0.1.0.0',
'version': '19.0.1.0.1',
'category': 'Accounting/Accounting',
'sequence': 26,
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',

View File

@@ -1,2 +1,3 @@
from . import claude
from . import openai_adapter
from ._base import LLMProvider

View File

@@ -0,0 +1,44 @@
"""LLMProvider contract - every adapter must conform.
Phase 1 generalisation: makes local LLM (Ollama, LM Studio, vLLM, llamafile,
llama.cpp HTTP server) a one-config-line drop-in via the OpenAI-compatible
HTTP API surface that all of them expose.
"""
class LLMProvider:
"""Contract every LLM backend must satisfy. Adapters declare capabilities
as class attributes; the engine inspects them before calling optional methods."""
supports_tool_calling: bool = False
supports_streaming: bool = False
max_context_tokens: int = 4096
supports_embeddings: bool = False
def __init__(self, env):
self.env = env
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
"""Plain text completion. Required for ALL providers.
Returns: {'content': str, 'tokens_used': int, 'model': str}
"""
raise NotImplementedError
def complete_with_tools(self, *, system, messages, tools, max_tokens=2048) -> dict:
"""Tool-calling completion. Optional - caller checks supports_tool_calling first.
Returns: {'content': str, 'tool_calls': [{'name': str, 'arguments': dict}], ...}
"""
raise NotImplementedError(
f"{type(self).__name__} does not support tool-calling. "
f"Check supports_tool_calling before calling.")
def embed(self, texts: list[str]) -> list[list[float]]:
"""Embeddings. Optional - caller checks supports_embeddings first.
Returns: list of float vectors, one per input text.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support embeddings. "
f"Check supports_embeddings before calling.")

View File

@@ -4,6 +4,8 @@ import logging
from odoo import models, api, _
from odoo.exceptions import UserError
from ._base import LLMProvider
_logger = logging.getLogger(__name__)
try:
@@ -12,6 +14,64 @@ except ImportError:
anthropic_sdk = None
class ClaudeAdapter(LLMProvider):
"""Plain-Python LLMProvider implementation for Anthropic Claude.
Preserves all existing functionality (extended thinking, native tool_use
blocks) used by the Odoo AbstractModel-based adapter -- this class is
additive for the Phase 1 LLMProvider contract.
"""
supports_tool_calling = True
supports_streaming = True
max_context_tokens = 200000
supports_embeddings = False
def __init__(self, env):
super().__init__(env)
if anthropic_sdk is None:
raise UserError(_("The 'anthropic' Python package is not installed."))
ICP = env['ir.config_parameter'].sudo()
try:
api_key = env['fusion.api.service'].get_api_key(
provider_type='anthropic',
consumer='fusion_accounting',
feature='chat_with_tools',
)
except Exception:
api_key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
if not api_key:
api_key = 'not-needed'
self.client = anthropic_sdk.Anthropic(api_key=api_key)
self.model = ICP.get_param(
'fusion_accounting.claude_model', 'claude-sonnet-4-6')
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
api_messages = [
m for m in messages if m.get('role') in ('user', 'assistant')
]
try:
response = self.client.messages.create(
model=self.model,
max_tokens=max_tokens,
temperature=temperature,
system=system,
messages=api_messages,
)
except Exception as e:
_logger.error("Claude complete error: %s", e)
raise UserError(_("Claude API error: %s", str(e)))
text_parts = [b.text for b in response.content if getattr(b, 'type', None) == 'text']
return {
'content': '\n'.join(text_parts),
'tokens_used': (
getattr(response.usage, 'input_tokens', 0)
+ getattr(response.usage, 'output_tokens', 0)
),
'model': self.model,
}
class FusionAccountingAdapterClaude(models.AbstractModel):
_name = 'fusion.accounting.adapter.claude'
_description = 'Claude AI Adapter'

View File

@@ -4,6 +4,8 @@ import logging
from odoo import models, api, _
from odoo.exceptions import UserError
from ._base import LLMProvider
_logger = logging.getLogger(__name__)
try:
@@ -12,6 +14,71 @@ except ImportError:
OpenAI = None
DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
class OpenAIAdapter(LLMProvider):
"""Plain-Python LLMProvider implementation backed by an OpenAI-compatible
HTTP endpoint.
The OpenAI Python SDK speaks to any server that exposes the OpenAI
Chat Completions surface: OpenAI itself, Ollama, LM Studio, vLLM,
llamafile, llama.cpp HTTP server, etc. Configure the endpoint via
the ``fusion_accounting.openai_base_url`` ir.config_parameter.
"""
supports_tool_calling = True
supports_streaming = True
max_context_tokens = 128000
supports_embeddings = True
def __init__(self, env):
super().__init__(env)
if OpenAI is None:
raise UserError(_("The 'openai' Python package is not installed."))
ICP = env['ir.config_parameter'].sudo()
base_url = ICP.get_param(
'fusion_accounting.openai_base_url', DEFAULT_OPENAI_BASE_URL,
) or DEFAULT_OPENAI_BASE_URL
try:
api_key = env['fusion.api.service'].get_api_key(
provider_type='openai',
consumer='fusion_accounting',
feature='chat_with_tools',
)
except Exception:
api_key = ICP.get_param('fusion_accounting.openai_api_key', '')
if not api_key:
# Local LLM servers (Ollama, LM Studio, llama.cpp) usually do not
# require a real key but the SDK insists on a non-empty string.
api_key = 'not-needed'
self.base_url = base_url
self.client = OpenAI(api_key=api_key, base_url=base_url)
self.model = ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
api_messages = [{'role': 'system', 'content': system}]
for msg in messages:
if msg.get('role') in ('user', 'assistant', 'tool'):
api_messages.append(msg)
try:
response = self.client.chat.completions.create(
model=self.model,
messages=api_messages,
max_tokens=max_tokens,
temperature=temperature,
)
except Exception as e:
_logger.error("OpenAI complete error: %s", e)
raise UserError(_("OpenAI API error: %s", str(e)))
choice = response.choices[0]
return {
'content': choice.message.content or '',
'tokens_used': getattr(response.usage, 'total_tokens', 0),
'model': self.model,
}
class FusionAccountingAdapterOpenAI(models.AbstractModel):
_name = 'fusion.accounting.adapter.openai'
_description = 'OpenAI AI Adapter'

View File

@@ -4,6 +4,12 @@ Routes bank-rec data lookups across:
- FUSION: fusion.bank.rec.widget (added by fusion_accounting_bank_rec, Phase 1)
- ENTERPRISE: account_accountant's bank_rec_widget JS service
- COMMUNITY: pure search on account.bank.statement.line
In addition to ``list_unreconciled``, the adapter exposes thin wrappers
around the engine's public API: ``suggest_matches``, ``accept_suggestion``,
``unreconcile``. AI tools and the OWL controller go through these wrappers
instead of touching the engine directly so install-mode routing stays in
one place.
"""
from .base import DataAdapter
@@ -14,6 +20,10 @@ class BankRecAdapter(DataAdapter):
FUSION_MODEL = 'fusion.bank.rec.widget'
ENTERPRISE_MODULE = 'account_accountant'
# ------------------------------------------------------------
# list_unreconciled
# ------------------------------------------------------------
def list_unreconciled(self, journal_id=None, limit=100, date_from=None,
date_to=None, min_amount=None, company_id=None):
"""Return unreconciled bank statement lines.
@@ -31,13 +41,29 @@ class BankRecAdapter(DataAdapter):
def list_unreconciled_via_fusion(self, journal_id=None, limit=100,
date_from=None, date_to=None,
min_amount=None, company_id=None):
# Phase 1 will add fusion.bank.rec.widget; this method becomes the primary path.
# For now: even when the model exists, delegate to community read shape.
return self.list_unreconciled_via_community(
"""Community shape + fusion AI fields (top suggestion, band, attachments)."""
base = self.list_unreconciled_via_community(
journal_id=journal_id, limit=limit,
date_from=date_from, date_to=date_to,
min_amount=min_amount, company_id=company_id,
)
if not base:
return base
Line = self.env['account.bank.statement.line'].sudo()
ids = [row['id'] for row in base]
lines_by_id = {line.id: line for line in Line.browse(ids)}
for row in base:
line = lines_by_id.get(row['id'])
if not line:
row['fusion_top_suggestion_id'] = None
row['fusion_confidence_band'] = 'none'
row['attachment_count'] = 0
continue
top = line.fusion_top_suggestion_id
row['fusion_top_suggestion_id'] = top.id if top else None
row['fusion_confidence_band'] = line.fusion_confidence_band or 'none'
row['attachment_count'] = len(line.bank_statement_attachment_ids)
return base
def list_unreconciled_via_enterprise(self, journal_id=None, limit=100,
date_from=None, date_to=None,
@@ -83,5 +109,121 @@ class BankRecAdapter(DataAdapter):
for r in records
]
# ------------------------------------------------------------
# suggest_matches
# ------------------------------------------------------------
def suggest_matches(self, statement_line_ids, *, limit_per_line=3,
company_id=None):
"""Return AI suggestions per bank line.
Shape: ``{line_id: [{'id', 'rank', 'confidence', 'reasoning',
'candidate_id'}, ...]}``. Empty dict when AI suggestions are not
available (Enterprise / Community).
"""
return self._dispatch(
'suggest_matches',
statement_line_ids=statement_line_ids,
limit_per_line=limit_per_line,
company_id=company_id,
)
def suggest_matches_via_fusion(self, statement_line_ids, *,
limit_per_line=3, company_id=None):
Line = self.env['account.bank.statement.line'].sudo()
lines = Line.browse(list(statement_line_ids or [])).exists()
if not lines:
return {}
return self.env['fusion.reconcile.engine'].suggest_matches(
lines, limit_per_line=limit_per_line)
def suggest_matches_via_enterprise(self, statement_line_ids, *,
limit_per_line=3, company_id=None):
# Enterprise has its own suggest mechanism inside bank_rec_widget;
# we don't proxy it from Python.
return {}
def suggest_matches_via_community(self, statement_line_ids, *,
limit_per_line=3, company_id=None):
return {}
# ------------------------------------------------------------
# accept_suggestion
# ------------------------------------------------------------
def accept_suggestion(self, suggestion_id):
"""Accept a fusion AI suggestion and reconcile against its proposal.
Returns ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
'write_off_move_id': int|None}``. Fusion-only.
"""
return self._dispatch(
'accept_suggestion', suggestion_id=suggestion_id)
def accept_suggestion_via_fusion(self, suggestion_id):
return self.env['fusion.reconcile.engine'].accept_suggestion(
int(suggestion_id))
def accept_suggestion_via_enterprise(self, suggestion_id):
raise NotImplementedError("accept_suggestion is fusion-only")
def accept_suggestion_via_community(self, suggestion_id):
raise NotImplementedError("accept_suggestion is fusion-only")
# ------------------------------------------------------------
# unreconcile
# ------------------------------------------------------------
def unreconcile(self, partial_reconcile_ids):
"""Reverse a reconciliation by partial IDs.
Returns ``{'unreconciled_line_ids': [...]}``. Available in all modes
(the engine delegates to V19's standard
``account.bank.statement.line.action_undo_reconciliation``).
"""
return self._dispatch(
'unreconcile', partial_reconcile_ids=partial_reconcile_ids)
def unreconcile_via_fusion(self, partial_reconcile_ids):
Partial = self.env['account.partial.reconcile'].sudo()
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
return self.env['fusion.reconcile.engine'].unreconcile(partials)
def unreconcile_via_enterprise(self, partial_reconcile_ids):
# Enterprise/community paths can't depend on fusion.reconcile.engine
# being loaded (fusion_accounting_ai does NOT depend on
# fusion_accounting_bank_rec). Mirror the engine's behaviour using
# only Community-available helpers.
return self._unreconcile_standalone(partial_reconcile_ids)
def unreconcile_via_community(self, partial_reconcile_ids):
return self._unreconcile_standalone(partial_reconcile_ids)
def _unreconcile_standalone(self, partial_reconcile_ids):
"""Engine-free unreconcile for installs without fusion_accounting_bank_rec.
Mirrors ``fusion.reconcile.engine.unreconcile``: finds bank lines whose
moves own any of the partials' journal items, runs the standard undo
on them, then unlinks any leftovers.
"""
Partial = self.env['account.partial.reconcile'].sudo()
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
if not partials:
return {'unreconciled_line_ids': []}
all_lines = (
partials.mapped('debit_move_id')
| partials.mapped('credit_move_id')
)
line_ids = all_lines.ids
affected = self.env['account.bank.statement.line'].sudo().search([
('move_id', 'in', all_lines.mapped('move_id').ids),
])
if affected:
affected.action_undo_reconciliation()
remaining = partials.exists()
if remaining:
remaining.unlink()
return {'unreconciled_line_ids': line_ids}
register_adapter('bank_rec', BankRecAdapter)

View File

@@ -1,2 +1,3 @@
from . import system_prompt
from . import domain_prompts
from . import bank_rec_prompt

View File

@@ -0,0 +1,107 @@
"""Bank reconciliation AI re-rank prompt.
Used by fusion_accounting_bank_rec/services/confidence_scoring.py to ask
an LLM to refine the statistical ranking of candidate matches.
Output contract: the LLM MUST respond with valid JSON of shape:
{"ranked": [{"candidate_id": int, "confidence": float, "reason": str}, ...]}
System prompt is provider-agnostic - works with OpenAI Chat Completions,
Claude Messages, and local OpenAI-compatible servers (LM Studio, Ollama).
"""
from datetime import date
SYSTEM_PROMPT = """You are an expert accountant assisting with bank reconciliation.
Your job: given a bank statement line and a list of candidate journal items
that statistically scored well as potential matches, re-rank them based on
domain expertise. Consider:
1. **Amount-exact matches** are almost always correct unless the partner is wrong.
2. **Memo / reference clues** - bank memos often contain invoice numbers, partner
names, or transaction references that disambiguate matches.
3. **Date proximity** - invoices are typically reconciled within 30 days of issue.
4. **Pattern conformance** - if the partner has a learned pattern (e.g. "always
pays exact amount, weekly cadence"), favor candidates that fit that pattern.
5. **Precedent similarity** - if a near-identical reconcile happened before,
it's likely the right one.
Return ONLY valid JSON of this exact shape:
{
"ranked": [
{"candidate_id": <int>, "confidence": <float 0-1>, "reason": "<short string>"},
...
]
}
Do NOT include any prose before or after the JSON. Do NOT use markdown code fences.
The "ranked" array MUST contain every candidate_id from the input, in your
preferred order (highest confidence first).
"""
def build_prompt(statement_line, scored_candidates, pattern=None, precedents=None):
"""Build (system_prompt, user_prompt) for AI re-rank.
Args:
statement_line: account.bank.statement.line recordset (singleton)
scored_candidates: list of ScoredCandidate dataclasses (from confidence_scoring)
pattern: fusion.reconcile.pattern recordset for the partner, or None
precedents: list of PrecedentMatch dataclasses, or None
Returns:
(system_prompt: str, user_prompt: str) tuple
"""
user_parts = []
user_parts.append("BANK LINE:")
user_parts.append(f" Date: {statement_line.date}")
user_parts.append(
f" Amount: {statement_line.amount} {statement_line.currency_id.name or ''}"
)
user_parts.append(
f" Memo / payment ref: {statement_line.payment_ref or '(none)'}"
)
if statement_line.partner_id:
user_parts.append(f" Partner: {statement_line.partner_id.name}")
if pattern:
user_parts.append("")
user_parts.append("PARTNER PATTERN (learned from past reconciles):")
user_parts.append(f" Reconcile count: {pattern.reconcile_count}")
user_parts.append(f" Preferred strategy: {pattern.pref_strategy}")
user_parts.append(
f" Typical cadence: ~{pattern.typical_cadence_days} days between reconciles"
)
if pattern.typical_amount_range:
user_parts.append(f" Typical amount range: {pattern.typical_amount_range}")
if pattern.common_memo_tokens:
user_parts.append(f" Common memo tokens: {pattern.common_memo_tokens}")
if precedents:
user_parts.append("")
user_parts.append("RECENT PRECEDENTS (most-similar past reconciles for this partner):")
# Cap at 3 precedents to keep prompt small and reduce token cost.
for p in precedents[:3]:
user_parts.append(
f" - amount={p.amount}, similarity={p.similarity_score:.2f}, "
f"matched {p.matched_move_line_count} line(s), tokens={p.memo_tokens}"
)
user_parts.append("")
user_parts.append("CANDIDATES (scored by statistical pipeline):")
for s in scored_candidates:
user_parts.append(
f" - candidate_id={s.candidate_id}, statistical_confidence={s.confidence}, "
f"amount_match={s.score_amount_match}, pattern_fit={s.score_partner_pattern}, "
f"precedent_sim={s.score_precedent_similarity}, "
f"reason=\"{s.reasoning}\""
)
user_parts.append("")
user_parts.append("Re-rank these candidates and return JSON per the system prompt.")
user_prompt = "\n".join(user_parts)
return (SYSTEM_PROMPT, user_prompt)

View File

@@ -67,7 +67,16 @@ def match_bank_line_to_payments(env, params):
st_line = env['account.bank.statement.line'].browse(st_line_id)
if not st_line.exists():
return {'error': 'Statement line not found'}
st_line.set_line_bank_statement_line(move_line_ids)
# Phase 1 Task 23: route through engine when available
if 'fusion.reconcile.engine' in env.registry:
cands = env['account.move.line'].browse(move_line_ids).exists()
if not cands:
return {'error': 'No valid move_line_ids'}
env['fusion.reconcile.engine'].reconcile_one(
st_line, against_lines=cands)
st_line.invalidate_recordset(['is_reconciled'])
else:
st_line.set_line_bank_statement_line(move_line_ids)
return {
'status': 'matched',
'statement_line_id': st_line_id,
@@ -83,7 +92,12 @@ def auto_reconcile_bank_lines(env, params):
('company_id', '=', int(company_id)),
])
before_count = len(lines)
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
# Phase 1 Task 23: route through engine when available
if 'fusion.reconcile.engine' in env.registry:
env['fusion.reconcile.engine'].reconcile_batch(
lines, strategy='auto')
else:
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
still_unreconciled = env['account.bank.statement.line'].search([
('is_reconciled', '=', False),
('company_id', '=', int(company_id)),
@@ -946,6 +960,171 @@ def _format_aml_candidates(amls):
} for aml in amls]
# ============================================================
# Phase 1 Bank Reconciliation: engine-backed tools
#
# These five tools wrap the fusion.reconcile.engine 6-method API via the
# bank_rec data adapter (or the engine directly when the adapter does not
# expose a wrapper). They give the AI chat the same reconciliation surface
# a human gets in the OWL bank-rec UI.
# ============================================================
def fusion_suggest_matches(env, params):
"""Compute and persist AI suggestions for one or more bank statement lines.
Wraps ``BankRecAdapter.suggest_matches`` -> ``fusion.reconcile.engine``.
"""
raw_ids = params.get('statement_line_ids')
if not raw_ids:
return {'error': 'statement_line_ids is required'}
statement_line_ids = [int(x) for x in raw_ids]
limit_per_line = int(params.get('limit_per_line', 3))
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'bank_rec')
raw = adapter.suggest_matches(
statement_line_ids=statement_line_ids,
limit_per_line=limit_per_line,
company_id=env.company.id,
) or {}
suggestions = {}
total = 0
for line_id, sug_list in raw.items():
out = []
for s in sug_list:
out.append({
'suggestion_id': s.get('id'),
'candidate_id': s.get('candidate_id'),
'confidence': s.get('confidence'),
'reasoning': s.get('reasoning') or '',
'rank': s.get('rank'),
})
total += 1
suggestions[line_id] = out
return {'suggestions': suggestions, 'count': total}
def fusion_accept_suggestion(env, params):
"""Accept a fusion.reconcile.suggestion: reconciles the bank line against
the suggestion's proposed move lines and marks the suggestion accepted.
Wraps ``BankRecAdapter.accept_suggestion``.
"""
if not params.get('suggestion_id'):
return {'error': 'suggestion_id is required'}
suggestion_id = int(params['suggestion_id'])
suggestion = env['fusion.reconcile.suggestion'].browse(suggestion_id)
if not suggestion.exists():
return {'error': 'Suggestion not found'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'bank_rec')
result = adapter.accept_suggestion(suggestion_id) or {}
statement_line = suggestion.statement_line_id
return {
'status': 'accepted',
'suggestion_id': suggestion_id,
'partial_ids': list(result.get('partial_ids') or []),
'is_reconciled': bool(statement_line.is_reconciled),
}
def fusion_reconcile_bank_line(env, params):
"""Manually reconcile a bank statement line against a set of journal items.
Routes through ``fusion.reconcile.engine.reconcile_one`` so behaviour
matches the OWL widget and ``fusion_accept_suggestion``. Use this for
direct AI-initiated matches that did not come from an AI suggestion.
"""
if not params.get('statement_line_id'):
return {'error': 'statement_line_id is required'}
raw_against = params.get('against_move_line_ids')
if not raw_against:
return {'error': 'against_move_line_ids is required'}
st_line_id = int(params['statement_line_id'])
aml_ids = [int(x) for x in raw_against]
statement_line = env['account.bank.statement.line'].browse(st_line_id)
if not statement_line.exists():
return {'error': 'Statement line not found'}
against_lines = env['account.move.line'].browse(aml_ids).exists()
if not against_lines:
return {'error': 'No valid against_move_line_ids'}
result = env['fusion.reconcile.engine'].reconcile_one(
statement_line, against_lines=against_lines)
return {
'status': 'reconciled',
'statement_line_id': st_line_id,
'partial_ids': list(result.get('partial_ids') or []),
'is_reconciled': bool(statement_line.is_reconciled),
}
def fusion_unreconcile(env, params):
"""Reverse a reconciliation by partial_reconcile_ids.
Wraps ``BankRecAdapter.unreconcile``. Works in fusion, Enterprise, and
Community installs (the adapter falls back to a standalone path when
fusion_accounting_bank_rec is not loaded).
"""
raw_ids = params.get('partial_reconcile_ids')
if not raw_ids:
return {'error': 'partial_reconcile_ids is required'}
partial_ids = [int(x) for x in raw_ids]
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'bank_rec')
result = adapter.unreconcile(partial_ids) or {}
unreconciled_line_ids = list(result.get('unreconciled_line_ids') or [])
return {
'status': 'unreconciled',
'unreconciled_line_ids': unreconciled_line_ids,
'count': len(unreconciled_line_ids),
}
def fusion_get_pending_suggestions(env, params):
"""List pending fusion.reconcile.suggestion rows.
Optional filters: ``statement_line_id``, ``min_confidence`` (default 0.0),
``limit`` (default 50). Only returns suggestions in the ``pending`` state
for the current company.
"""
domain = [
('company_id', '=', env.company.id),
('state', '=', 'pending'),
]
if params.get('statement_line_id'):
domain.append(
('statement_line_id', '=', int(params['statement_line_id'])))
min_confidence = float(params.get('min_confidence') or 0.0)
if min_confidence > 0.0:
domain.append(('confidence', '>=', min_confidence))
limit = int(params.get('limit', 50))
Suggestion = env['fusion.reconcile.suggestion'].sudo()
records = Suggestion.search(
domain, limit=limit, order='confidence desc, id desc')
rows = []
for s in records:
st_line = s.statement_line_id
rows.append({
'id': s.id,
'statement_line_id': st_line.id if st_line else None,
'statement_line_ref': (
st_line.payment_ref or '' if st_line else ''),
'candidate_ids': s.proposed_move_line_ids.ids,
'confidence': s.confidence,
'rank': s.rank,
'reasoning': s.reasoning or '',
'state': s.state,
})
return {'count': len(rows), 'suggestions': rows}
TOOLS = {
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
'get_unreconciled_receipts': get_unreconciled_receipts,
@@ -962,4 +1141,10 @@ TOOLS = {
'reconcile_payroll_cheques': reconcile_payroll_cheques,
'suggest_bank_line_matches': suggest_bank_line_matches,
'search_matching_entries': search_matching_entries,
# Phase 1 engine-backed tools
'fusion_suggest_matches': fusion_suggest_matches,
'fusion_accept_suggestion': fusion_accept_suggestion,
'fusion_reconcile_bank_line': fusion_reconcile_bank_line,
'fusion_unreconcile': fusion_unreconcile,
'fusion_get_pending_suggestions': fusion_get_pending_suggestions,
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -1,2 +1,3 @@
from . import test_post_migration
from . import test_data_adapters
from . import test_llm_provider_contract

View File

@@ -0,0 +1,45 @@
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_ai.services.adapters._base import LLMProvider
@tagged('post_install', '-at_install')
class TestLLMProviderContract(TransactionCase):
"""Every LLM adapter must satisfy the LLMProvider contract."""
def test_base_class_defines_capability_attrs(self):
self.assertTrue(hasattr(LLMProvider, 'supports_tool_calling'))
self.assertTrue(hasattr(LLMProvider, 'supports_streaming'))
self.assertTrue(hasattr(LLMProvider, 'max_context_tokens'))
self.assertTrue(hasattr(LLMProvider, 'supports_embeddings'))
def test_openai_adapter_implements_contract(self):
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
self.assertTrue(issubclass(OpenAIAdapter, LLMProvider))
adapter = OpenAIAdapter(self.env)
self.assertIsInstance(adapter.supports_tool_calling, bool)
self.assertIsInstance(adapter.max_context_tokens, int)
def test_claude_adapter_implements_contract(self):
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
self.assertTrue(issubclass(ClaudeAdapter, LLMProvider))
adapter = ClaudeAdapter(self.env)
self.assertIsInstance(adapter.supports_tool_calling, bool)
self.assertIsInstance(adapter.max_context_tokens, int)
def test_openai_adapter_uses_configurable_base_url(self):
self.env['ir.config_parameter'].sudo().set_param(
'fusion_accounting.openai_base_url', 'http://localhost:1234/v1')
self.env['ir.config_parameter'].sudo().set_param(
'fusion_accounting.openai_api_key', 'lm-studio-test-key')
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
adapter = OpenAIAdapter(self.env)
self.assertEqual(str(adapter.client.base_url).rstrip('/'),
'http://localhost:1234/v1')
def test_openai_adapter_default_base_url_when_unset(self):
self.env['ir.config_parameter'].sudo().search([
('key', '=', 'fusion_accounting.openai_base_url')
]).unlink()
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
adapter = OpenAIAdapter(self.env)
self.assertIn('api.openai.com', str(adapter.client.base_url))

View File

@@ -0,0 +1,4 @@
from . import models
from . import controllers
from . import services
from . import wizards

View File

@@ -0,0 +1,51 @@
{
'name': 'Fusion Accounting — Bank Reconciliation',
'version': '19.0.1.0.11',
'category': 'Accounting/Accounting',
'sequence': 28,
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
'description': """
Fusion Accounting — Bank Reconciliation
========================================
Replaces Odoo Enterprise's account_accountant bank-rec widget with a
native V19 OWL implementation reading/writing Community's
account.partial.reconcile tables.
Features:
- Strict mirror of all Enterprise UI components (zero functional loss)
- AI confidence badges with one-click Accept and ranked alternatives
- Behavioural learning from historical reconciliations
- Local LLM ready (Ollama, LM Studio) via OpenAI-compatible adapter
- Coexists with account_accountant (Enterprise wins by default)
Built by Nexa Systems Inc.
""",
'icon': '/fusion_accounting_bank_rec/static/description/icon.png',
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'depends': ['fusion_accounting_core'],
'external_dependencies': {
'python': ['hypothesis'],
},
'data': [
'security/ir.model.access.csv',
'data/cron.xml',
],
'assets': {
'web.assets_backend': [
'fusion_accounting_bank_rec/static/src/scss/_variables.scss',
'fusion_accounting_bank_rec/static/src/scss/bank_reconciliation.scss',
'fusion_accounting_bank_rec/static/src/scss/ai_suggestion.scss',
'fusion_accounting_bank_rec/static/src/scss/dark_mode.scss',
'fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_controller.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_renderer.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_view.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban.xml',
],
},
'installable': True,
'application': False,
'license': 'OPL-1',
}

View File

@@ -0,0 +1 @@
from . import bank_rec_controller

View File

@@ -0,0 +1,325 @@
"""HTTP controller: 10 JSON-RPC endpoints for the OWL bank-rec widget.
All endpoints route through ``BankRecAdapter`` (which lives in
``fusion_accounting_ai`` and already encapsulates fusion / enterprise /
community routing) or directly through ``fusion.reconcile.engine`` for
methods the adapter does not yet expose. The controller never touches
``account.partial.reconcile`` directly.
V19: uses ``@route(type='jsonrpc')``, the V19-blessed replacement for the
deprecated ``type='json'`` (Odoo 19 logs a deprecation warning if you
still use ``json``).
"""
import logging
from odoo import _, http
from odoo.exceptions import ValidationError
from odoo.http import request
_logger = logging.getLogger(__name__)
def _adapter():
"""Resolve the bank-rec data adapter from fusion_accounting_ai."""
from odoo.addons.fusion_accounting_ai.services.data_adapters import (
get_adapter,
)
return get_adapter(request.env, 'bank_rec')
class FusionBankRecController(http.Controller):
"""JSON-RPC surface consumed by the OWL bank-reconciliation widget.
All routes are ``auth='user'`` -- anonymous traffic is rejected by
Odoo's HTTP layer before reaching the handler.
"""
# ------------------------------------------------------------------
# 1. get_state -- initial widget bootstrap
# ------------------------------------------------------------------
@http.route('/fusion/bank_rec/get_state', type='jsonrpc', auth='user')
def get_state(self, journal_id, company_id):
"""Return the journal summary that seeds the kanban widget."""
Journal = request.env['account.journal']
Line = request.env['account.bank.statement.line']
journal = Journal.browse(int(journal_id))
if not journal.exists():
raise ValidationError(_("Journal %s not found") % journal_id)
company_id = int(company_id) if company_id else request.env.company.id
unreconciled_lines = Line.search([
('journal_id', '=', journal.id),
('is_reconciled', '=', False),
('company_id', '=', company_id),
])
total_amount = sum(abs(l.amount) for l in unreconciled_lines)
last_stmt = request.env['account.bank.statement'].search(
[('journal_id', '=', journal.id)],
order='date desc', limit=1)
currency = journal.currency_id or journal.company_id.currency_id
return {
'journal': {
'id': journal.id,
'name': journal.name,
'currency_code': currency.name,
},
'unreconciled_count': len(unreconciled_lines),
'total_pending_amount': total_amount,
'last_statement_date': str(last_stmt.date) if last_stmt and last_stmt.date else None,
}
# ------------------------------------------------------------------
# 2. list_unreconciled -- paginated, fusion-enriched
# ------------------------------------------------------------------
@http.route('/fusion/bank_rec/list_unreconciled', type='jsonrpc', auth='user')
def list_unreconciled(self, journal_id, limit=50, offset=0,
company_id=None, date_from=None, date_to=None,
min_amount=None):
"""Return enriched, paginated unreconciled bank lines."""
limit = int(limit)
offset = int(offset)
company_id = (int(company_id) if company_id
else request.env.company.id)
# The adapter doesn't take an offset; over-fetch and slice.
rows = _adapter().list_unreconciled(
journal_id=int(journal_id),
limit=limit + offset,
company_id=company_id,
date_from=date_from,
date_to=date_to,
min_amount=min_amount,
)
sliced = rows[offset:offset + limit]
Line = request.env['account.bank.statement.line']
domain = [
('journal_id', '=', int(journal_id)),
('is_reconciled', '=', False),
('company_id', '=', company_id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
if min_amount is not None:
domain.append(('amount', '>=', float(min_amount)))
total = Line.search_count(domain)
return {
'count': len(sliced),
'total': total,
'lines': sliced,
}
# ------------------------------------------------------------------
# 3. get_line_detail -- one line + suggestions + attachments
# ------------------------------------------------------------------
@http.route('/fusion/bank_rec/get_line_detail', type='jsonrpc', auth='user')
def get_line_detail(self, statement_line_id):
"""Return full detail for one line including pending suggestions."""
Line = request.env['account.bank.statement.line']
line = Line.browse(int(statement_line_id))
if not line.exists():
raise ValidationError(
_("Statement line %s not found") % statement_line_id)
Sug = request.env['fusion.reconcile.suggestion']
suggestions = Sug.search([
('statement_line_id', '=', line.id),
('state', '=', 'pending'),
], order='confidence desc, rank asc')
Att = request.env['ir.attachment']
attachments = Att.search([
('res_model', '=', 'account.move'),
('res_id', '=', line.move_id.id),
]) if line.move_id else Att.browse()
currency = line.currency_id or line.company_id.currency_id
return {
'line': {
'id': line.id,
'date': str(line.date) if line.date else None,
'payment_ref': line.payment_ref or '',
'amount': line.amount,
'partner_id': line.partner_id.id if line.partner_id else None,
'partner_name': line.partner_id.name if line.partner_id else None,
'currency_id': currency.id,
'currency_code': currency.name,
'journal_id': line.journal_id.id,
'journal_name': line.journal_id.name,
'is_reconciled': line.is_reconciled,
},
'suggestions': [{
'id': s.id,
'candidate_ids': s.proposed_move_line_ids.ids,
'confidence': s.confidence,
'rank': s.rank,
'reasoning': s.reasoning or '',
'scores': {
'amount_match': s.score_amount_match,
'partner_pattern': s.score_partner_pattern,
'precedent_similarity': s.score_precedent_similarity,
'ai_rerank': s.score_ai_rerank,
},
} for s in suggestions],
'attachments': [{
'id': a.id,
'name': a.name,
'mimetype': a.mimetype,
} for a in attachments],
}
# ------------------------------------------------------------------
# 4. suggest_matches -- lazy AI suggest for a line
# ------------------------------------------------------------------
@http.route('/fusion/bank_rec/suggest_matches', type='jsonrpc', auth='user')
def suggest_matches(self, statement_line_ids, limit_per_line=3):
"""Trigger AI suggest for one or more statement lines."""
ids = [int(i) for i in (statement_line_ids or [])]
result = _adapter().suggest_matches(
statement_line_ids=ids,
limit_per_line=int(limit_per_line),
)
return {'suggestions': result}
# ------------------------------------------------------------------
# 5. accept_suggestion -- promote AI suggestion to real reconcile
# ------------------------------------------------------------------
@http.route('/fusion/bank_rec/accept_suggestion', type='jsonrpc', auth='user')
def accept_suggestion(self, suggestion_id):
"""Accept a fusion suggestion. Returns the partial IDs created."""
sug = request.env['fusion.reconcile.suggestion'].browse(
int(suggestion_id))
if not sug.exists():
raise ValidationError(
_("Suggestion %s not found") % suggestion_id)
# Capture the journal/company before reconcile (the sug may go stale).
journal_id = sug.statement_line_id.journal_id.id
company_id = sug.company_id.id
result = _adapter().accept_suggestion(suggestion_id=int(suggestion_id))
unreconciled_count_after = request.env[
'account.bank.statement.line'].search_count([
('journal_id', '=', journal_id),
('is_reconciled', '=', False),
('company_id', '=', company_id),
])
return {
'status': 'accepted',
'partial_ids': result.get('partial_ids', []),
'unreconciled_count_after': unreconciled_count_after,
}
# ------------------------------------------------------------------
# 6. reconcile_manual -- user picked candidates manually
# ------------------------------------------------------------------
@http.route('/fusion/bank_rec/reconcile_manual', type='jsonrpc', auth='user')
def reconcile_manual(self, statement_line_id, against_move_line_ids):
"""Reconcile a line against an explicit set of journal items."""
line = request.env['account.bank.statement.line'].browse(
int(statement_line_id))
if not line.exists():
raise ValidationError(
_("Statement line %s not found") % statement_line_id)
cands = request.env['account.move.line'].browse(
[int(i) for i in (against_move_line_ids or [])])
result = request.env['fusion.reconcile.engine'].reconcile_one(
line, against_lines=cands)
return {
'status': 'reconciled',
'partial_ids': result.get('partial_ids', []),
}
# ------------------------------------------------------------------
# 7. unreconcile -- reverse a prior reconcile
# ------------------------------------------------------------------
@http.route('/fusion/bank_rec/unreconcile', type='jsonrpc', auth='user')
def unreconcile(self, partial_reconcile_ids):
"""Reverse one or more partial reconciles."""
ids = [int(i) for i in (partial_reconcile_ids or [])]
result = _adapter().unreconcile(partial_reconcile_ids=ids)
return {
'status': 'unreconciled',
'unreconciled_line_ids': result.get('unreconciled_line_ids', []),
}
# ------------------------------------------------------------------
# 8. write_off -- absorb residual into a write-off account
# ------------------------------------------------------------------
@http.route('/fusion/bank_rec/write_off', type='jsonrpc', auth='user')
def write_off(self, statement_line_id, account_id, amount, label,
tax_id=None):
"""Apply a write-off against a bank statement line."""
line = request.env['account.bank.statement.line'].browse(
int(statement_line_id))
if not line.exists():
raise ValidationError(
_("Statement line %s not found") % statement_line_id)
account = request.env['account.account'].browse(int(account_id))
tax = (request.env['account.tax'].browse(int(tax_id))
if tax_id else None)
result = request.env['fusion.reconcile.engine'].write_off(
line, account=account, amount=float(amount),
tax_id=tax, label=label)
return {
'status': 'written_off',
'partial_ids': result.get('partial_ids', []),
'write_off_move_id': result.get('write_off_move_id'),
}
# ------------------------------------------------------------------
# 9. bulk_reconcile -- batch auto-reconcile a recordset
# ------------------------------------------------------------------
@http.route('/fusion/bank_rec/bulk_reconcile', type='jsonrpc', auth='user')
def bulk_reconcile(self, statement_line_ids, strategy='auto'):
"""Batch auto-reconcile. Returns counts + per-line errors."""
ids = [int(i) for i in (statement_line_ids or [])]
lines = request.env['account.bank.statement.line'].browse(ids)
result = request.env['fusion.reconcile.engine'].reconcile_batch(
lines, strategy=strategy)
return result
# ------------------------------------------------------------------
# 10. get_partner_history -- partner reconcile history panel
# ------------------------------------------------------------------
@http.route('/fusion/bank_rec/get_partner_history', type='jsonrpc', auth='user')
def get_partner_history(self, partner_id, limit=20):
"""Return a partner's reconcile history + learned pattern."""
Partner = request.env['res.partner']
partner = Partner.browse(int(partner_id))
if not partner.exists():
raise ValidationError(_("Partner %s not found") % partner_id)
Precedent = request.env['fusion.reconcile.precedent']
recent = Precedent.search(
[('partner_id', '=', partner.id)],
order='reconciled_at desc, id desc',
limit=int(limit),
)
Pattern = request.env['fusion.reconcile.pattern']
pattern = Pattern.search(
[('partner_id', '=', partner.id)], limit=1)
return {
'partner': {
'id': partner.id,
'name': partner.name,
},
'recent_reconciles': [{
'precedent_id': p.id,
'date': str(p.date) if p.date else None,
'amount': p.amount,
'memo_tokens': p.memo_tokens or '',
'matched_count': p.matched_move_line_count,
'source': p.source,
} for p in recent],
'pattern': ({
'reconcile_count': pattern.reconcile_count,
'pref_strategy': pattern.pref_strategy or None,
'common_memo_tokens': pattern.common_memo_tokens or None,
'typical_cadence_days': pattern.typical_cadence_days,
} if pattern else None),
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_fusion_bank_rec_suggest" model="ir.cron">
<field name="name">Fusion Bank Rec — Warm AI Suggestions</field>
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
<field name="state">code</field>
<field name="code">model._cron_suggest_pending()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_bank_rec_pattern_refresh" model="ir.cron">
<field name="name">Fusion Bank Rec — Refresh Partner Patterns</field>
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
<field name="state">code</field>
<field name="code">model._cron_refresh_patterns()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="nextcall" eval="(DateTime.now().replace(hour=2, minute=0, second=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_bank_rec_mv_refresh" model="ir.cron">
<field name="name">Fusion Bank Rec — Refresh Unreconciled MV</field>
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
<field name="state">code</field>
<field name="code">model._cron_refresh_mv()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,57 @@
-- Materialized view: pre-aggregated data for the OWL bank reconciliation widget.
-- Refreshed on cron (Task 25) and on suggestion writes.
-- Indexed on (company_id, journal_id, date) for fast UI queries.
-- NOTE: account_bank_statement_line does not store `date` directly in V19;
-- it is a related field through move_id -> account_move.date. We JOIN on
-- account_move to get it.
CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_unreconciled_bank_line_mv AS
SELECT
bsl.id AS id,
bsl.company_id AS company_id,
bsl.journal_id AS journal_id,
am.date AS date,
bsl.amount AS amount,
bsl.payment_ref AS payment_ref,
bsl.currency_id AS currency_id,
bsl.partner_id AS partner_id,
bsl.create_date AS create_date,
-- Top suggestion (highest confidence pending one)
(SELECT s.id FROM fusion_reconcile_suggestion s
WHERE s.statement_line_id = bsl.id AND s.state = 'pending'
ORDER BY s.confidence DESC, s.rank ASC LIMIT 1) AS top_suggestion_id,
(SELECT s.confidence FROM fusion_reconcile_suggestion s
WHERE s.statement_line_id = bsl.id AND s.state = 'pending'
ORDER BY s.confidence DESC, s.rank ASC LIMIT 1) AS top_confidence,
CASE
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') >= 0.85
THEN 'high'
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') >= 0.60
THEN 'medium'
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') > 0
THEN 'low'
ELSE 'none'
END AS confidence_band,
-- Attachment count (assumes ir_attachment.res_model='account.bank.statement.line')
(SELECT COUNT(*) FROM ir_attachment att
WHERE att.res_model = 'account.bank.statement.line' AND att.res_id = bsl.id)
AS attachment_count,
-- Partner reconcile pattern hint
COALESCE((SELECT p.reconcile_count FROM fusion_reconcile_pattern p
WHERE p.partner_id = bsl.partner_id AND p.company_id = bsl.company_id LIMIT 1), 0)
AS partner_reconcile_count
FROM account_bank_statement_line bsl
JOIN account_move am ON am.id = bsl.move_id
WHERE bsl.is_reconciled IS NOT TRUE;
-- Indexes for the common UI queries: filter by company + journal, sort by date desc.
CREATE INDEX IF NOT EXISTS fusion_mv_unrec_company_journal_date_idx
ON fusion_unreconciled_bank_line_mv (company_id, journal_id, date DESC);
CREATE INDEX IF NOT EXISTS fusion_mv_unrec_partner_idx
ON fusion_unreconciled_bank_line_mv (partner_id) WHERE partner_id IS NOT NULL;
-- UNIQUE index required for CONCURRENTLY refresh
CREATE UNIQUE INDEX IF NOT EXISTS fusion_mv_unrec_id_idx
ON fusion_unreconciled_bank_line_mv (id);

View File

@@ -0,0 +1,176 @@
from datetime import date
from odoo import api, Command, fields, models, _
from odoo.exceptions import UserError
class AccountAutoReconcileWizard(models.TransientModel):
""" This wizard is used to automatically reconcile account.move.line.
It is accessible trough Accounting > Accounting tab > Actions > Auto-reconcile menuitem.
"""
_name = 'account.auto.reconcile.wizard'
_description = 'Account automatic reconciliation wizard'
_check_company_auto = True
company_id = fields.Many2one(
comodel_name='res.company',
required=True,
readonly=True,
default=lambda self: self.env.company,
)
line_ids = fields.Many2many(comodel_name='account.move.line') # Amls from which we derive a preset for the wizard
from_date = fields.Date(string='From')
to_date = fields.Date(string='To', default=fields.Date.context_today, required=True)
account_ids = fields.Many2many(
comodel_name='account.account',
string='Accounts',
check_company=True,
domain="[('reconcile', '=', True), ('account_type', '!=', 'off_balance')]",
)
partner_ids = fields.Many2many(
comodel_name='res.partner',
string='Partners',
check_company=True,
domain="[('company_id', 'in', (False, company_id)), '|', ('parent_id', '=', False), ('is_company', '=', True)]",
)
search_mode = fields.Selection(
selection=[
('one_to_one', "Perfect Match"),
('zero_balance', "Clear Account"),
],
string='Reconcile',
required=True,
default='one_to_one',
help="Reconcile journal items with opposite balance or clear accounts with a zero balance",
)
@api.model
def default_get(self, fields):
res = super().default_get(fields)
domain = self.env.context.get('domain')
if 'line_ids' in fields and 'line_ids' not in res and domain:
amls = self.env['account.move.line'].search(domain)
if amls:
# pre-configure the wizard
res.update(self._get_default_wizard_values(amls))
res['line_ids'] = [Command.set(amls.ids)]
return res
@api.model
def _get_default_wizard_values(self, amls):
""" Derive a preset configuration based on amls.
For example if all amls have the same account_id we will set it in the wizard.
:param amls: account move lines from which we will derive a preset
:return: a dict with preset values
"""
return {
'account_ids': [Command.set(amls[0].account_id.ids)] if all(aml.account_id == amls[0].account_id for aml in amls) else [],
'partner_ids': [Command.set(amls[0].partner_id.ids)] if all(aml.partner_id == amls[0].partner_id for aml in amls) else [],
'search_mode': 'zero_balance' if amls.company_currency_id.is_zero(sum(amls.mapped('balance'))) else 'one_to_one',
'from_date': min(amls.mapped('date')),
'to_date': max(amls.mapped('date')),
}
def _get_wizard_values(self):
""" Get the current configuration of the wizard as a dict of values.
:return: a dict with the current configuration of the wizard.
"""
self.ensure_one()
return {
'account_ids': [Command.set(self.account_ids.ids)] if self.account_ids else [],
'partner_ids': [Command.set(self.partner_ids.ids)] if self.partner_ids else [],
'search_mode': self.search_mode,
'from_date': self.from_date,
'to_date': self.to_date,
}
# ==== Business methods ====
def _get_amls_domain(self):
""" Get the domain of amls to be auto-reconciled. """
self.ensure_one()
if self.line_ids and self._get_wizard_values() == self._get_default_wizard_values(self.line_ids):
domain = [('id', 'in', self.line_ids.ids)]
else:
domain = [
('company_id', '=', self.company_id.id),
('parent_state', '=', 'posted'),
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
('date', '>=', self.from_date or date.min),
('date', '<=', self.to_date),
('reconciled', '=', False),
('account_id.reconcile', '=', True),
('amount_residual_currency', '!=', 0.0),
('amount_residual', '!=', 0.0), # excludes exchange difference lines
]
if self.account_ids:
domain.append(('account_id', 'in', self.account_ids.ids))
if self.partner_ids:
domain.append(('partner_id', 'in', self.partner_ids.ids))
return domain
def _auto_reconcile_one_to_one(self):
""" Auto-reconcile with one-to-one strategy:
We will reconcile 2 amls together if their combined balance is zero.
:return: a recordset of reconciled amls
"""
grouped_amls_data = self.env['account.move.line']._read_group(
self._get_amls_domain(),
['account_id', 'partner_id', 'currency_id', 'amount_residual_currency:abs_rounded'],
['id:recordset'],
)
all_reconciled_amls = self.env['account.move.line']
amls_grouped_by_2 = [] # we need to group amls with right format for _reconcile_plan
for *__, grouped_aml_ids in grouped_amls_data:
positive_amls = grouped_aml_ids.filtered(lambda aml: aml.amount_residual_currency >= 0).sorted('date')
negative_amls = (grouped_aml_ids - positive_amls).sorted('date')
min_len = min(len(positive_amls), len(negative_amls))
positive_amls = positive_amls[:min_len]
negative_amls = negative_amls[:min_len]
all_reconciled_amls += positive_amls + negative_amls
amls_grouped_by_2 += [pos_aml + neg_aml for (pos_aml, neg_aml) in zip(positive_amls, negative_amls)]
self.env['account.move.line']._reconcile_plan(amls_grouped_by_2)
return all_reconciled_amls
def _auto_reconcile_zero_balance(self):
""" Auto-reconcile with zero balance strategy:
We will reconcile all amls grouped by currency/account/partner that have a total balance of zero.
:return: a recordset of reconciled amls
"""
grouped_amls_data = self.env['account.move.line']._read_group(
self._get_amls_domain(),
groupby=['account_id', 'partner_id', 'currency_id'],
aggregates=['id:recordset'],
having=[('amount_residual_currency:sum_rounded', '=', 0)],
)
all_reconciled_amls = self.env['account.move.line']
amls_grouped_together = [] # we need to group amls with right format for _reconcile_plan
for aml_data in grouped_amls_data:
all_reconciled_amls += aml_data[-1]
amls_grouped_together += [aml_data[-1]]
self.env['account.move.line']._reconcile_plan(amls_grouped_together)
return all_reconciled_amls
def auto_reconcile(self):
""" Automatically reconcile amls given wizard's parameters.
:return: an action that opens all reconciled items and related amls (exchange diff, etc)
"""
self.ensure_one()
if self.search_mode == 'zero_balance':
reconciled_amls = self._auto_reconcile_zero_balance()
else:
# search_mode == 'one_to_one'
reconciled_amls = self._auto_reconcile_one_to_one()
reconciled_amls_and_related = self.env['account.move.line'].search([
('full_reconcile_id', 'in', reconciled_amls.full_reconcile_id.ids)
])
if reconciled_amls_and_related:
return {
'name': _("Automatically Reconciled Entries"),
'type': 'ir.actions.act_window',
'res_model': 'account.move.line',
'context': "{'search_default_group_by_matching': True}",
'view_mode': 'list',
'domain': [('id', 'in', reconciled_amls_and_related.ids)],
}
else:
raise UserError(self.env._("Nothing to reconcile."))

View File

@@ -0,0 +1,325 @@
from odoo import SUPERUSER_ID, api, fields, models
from odoo.tools import SQL
class AccountReconcileModel(models.Model):
_inherit = 'account.reconcile.model'
# Technical field to know if the rule was created automatically or by a user.
created_automatically = fields.Boolean(default=False, copy=False)
def _apply_lines_for_bank_widget(self, residual_amount_currency, residual_balance, partner, st_line):
""" Apply the reconciliation model lines to the statement line passed as parameter.
:param residual_amount_currency: The open amount currency of the statement line in the bank reconciliation widget
expressed in the statement line currency.
:param residual_balance: The open balance of the statement line in the bank reconciliation widget
expressed in the company currency.
:param partner: The partner set on the wizard.
:param st_line: The statement line processed by the bank reconciliation widget.
:return: A list of python dictionaries (one per reconcile model line) representing
the journal items to be created by the current reconcile model.
"""
self.ensure_one()
currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
vals_list = []
for line in self.line_ids:
vals = line._apply_in_bank_widget(
residual_amount_currency=residual_amount_currency,
residual_balance=residual_balance,
partner=line.partner_id or partner,
st_line=st_line,
)
amount_currency = vals['amount_currency']
balance = vals['balance']
if currency.is_zero(amount_currency) and st_line.company_currency_id.is_zero(balance):
continue
vals_list.append(vals)
residual_amount_currency -= amount_currency
residual_balance -= balance
return vals_list
@api.model
def get_available_reconcile_model_per_statement_line(self, statement_line_ids):
self.check_access('read')
self.env['account.reconcile.model'].flush_model()
self.env['account.bank.statement.line'].flush_model()
self.env.cr.execute(SQL(
"""
WITH matching_journal_ids AS (
SELECT account_reconcile_model_id,
ARRAY_AGG(account_journal_id) AS ids
FROM account_journal_account_reconcile_model_rel
GROUP BY account_reconcile_model_id
),
matching_partner_ids AS (
SELECT account_reconcile_model_id,
ARRAY_AGG(res_partner_id) AS ids
FROM account_reconcile_model_res_partner_rel
GROUP BY account_reconcile_model_id
)
SELECT st_line.id AS st_line_id,
array_agg(reco_model.id ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_ids,
array_agg(reco_model.name ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_names
FROM account_bank_statement_line st_line
LEFT JOIN LATERAL (
SELECT DISTINCT reco_model.id,
reco_model.sequence,
COALESCE(reco_model.name -> %(lang)s, reco_model.name -> 'en_US') as name
FROM account_reconcile_model reco_model
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
LEFT JOIN account_reconcile_model_line reco_model_line ON reco_model_line.model_id = reco_model.id
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
AND (
CASE COALESCE(reco_model.match_amount, '')
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
WHEN 'between' THEN
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
ELSE TRUE
END
)
AND (
reco_model.match_label IS NULL
OR (
reco_model.match_label = 'contains'
AND (
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
)
) OR (
reco_model.match_label = 'not_contains'
AND NOT (
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
)
) OR (
reco_model.match_label = 'match_regex'
AND (
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
)
)
)
AND reco_model.company_id = st_line.company_id
AND reco_model.trigger = 'manual'
AND reco_model_line.account_id IS NOT NULL
AND reco_model.active IS TRUE
) AS reco_model ON TRUE
WHERE st_line.id IN %(statement_lines)s
AND reco_model.id IS NOT NULL
GROUP BY st_line.id
""",
lang=self.env.lang,
statement_lines=tuple(statement_line_ids),
))
query_result = self.env.cr.fetchall()
return {
st_line_id: [
{'id': model_id, 'display_name': model_name}
for (model_id, model_name)
in zip(model_ids, model_names)
]
for st_line_id, model_ids, model_names
in query_result
}
def _apply_reconcile_models(self, statement_lines):
if not self or not statement_lines:
return
self.env['account.reconcile.model'].flush_model()
statement_lines.flush_recordset(['journal_id', 'amount', 'amount_residual', 'transaction_details', 'payment_ref', 'partner_id', 'company_id'])
self.env.cr.execute(SQL("""
WITH matching_journal_ids AS (
SELECT account_reconcile_model_id,
ARRAY_AGG(account_journal_id) AS ids
FROM account_journal_account_reconcile_model_rel
GROUP BY account_reconcile_model_id
),
matching_partner_ids AS (
SELECT account_reconcile_model_id,
ARRAY_AGG(res_partner_id) AS ids
FROM account_reconcile_model_res_partner_rel
GROUP BY account_reconcile_model_id
),
model_fees AS (
SELECT model_fees.id,
model_fees.trigger,
matching_journal_ids.ids AS journal_ids
FROM account_reconcile_model model_fees
JOIN ir_model_data imd ON model_fees.id = imd.res_id
JOIN account_reconcile_model_line model_lines ON model_lines.model_id = model_fees.id
LEFT JOIN matching_journal_ids ON model_fees.id = matching_journal_ids.account_reconcile_model_id
WHERE imd.module = 'account'
AND imd.name LIKE 'account_reco_model_fee_%%'
AND model_fees.active IS TRUE
AND model_lines.account_id IS NOT NULL
)
SELECT st_line.id AS st_line_id,
COALESCE(reco_model.id, model_fees.id) AS reco_model_id,
COALESCE(reco_model.trigger, model_fees.trigger) AS trigger
FROM account_bank_statement_line st_line
JOIN account_move move ON st_line.move_id = move.id
LEFT JOIN LATERAL (
SELECT reco_model.id,
reco_model.trigger
FROM account_reconcile_model reco_model
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
AND (
CASE COALESCE(reco_model.match_amount, '')
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
WHEN 'between' THEN
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
ELSE TRUE
END
)
AND (
reco_model.match_label IS NULL
OR (
reco_model.match_label = 'contains'
AND (
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
)
) OR (
reco_model.match_label = 'not_contains'
AND NOT (
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
)
) OR (
reco_model.match_label = 'match_regex'
AND (
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
OR move.narration IS NOT NULL AND move.narration::TEXT ~* reco_model.match_label_param
)
)
)
AND reco_model.id IN %s
AND reco_model.can_be_proposed IS TRUE
AND reco_model.company_id = st_line.company_id
ORDER BY reco_model.sequence ASC, reco_model.id ASC
LIMIT 1
) AS reco_model ON TRUE
LEFT JOIN LATERAL (
SELECT model_fees.id,
model_fees.trigger
FROM model_fees
WHERE st_line.journal_id = ANY(model_fees.journal_ids)
-- Show model fees if matched amount was 3 %% higher than incoming statement line amount
AND SIGN(st_line.amount) > 0
AND SIGN(st_line.amount_residual) > 0
AND ABS(st_line.amount_residual) < 0.03 * st_line.amount / 1.03
) AS model_fees ON TRUE
WHERE st_line.id IN %s
""", tuple(self.ids), tuple(statement_lines.ids)))
query_result = self.env.cr.fetchall()
processed_st_line_ids = set()
# apply the found suitable reco models on the statement lines
for st_line_id, reco_model_id, reco_model_trigger in query_result:
if st_line_id in processed_st_line_ids or reco_model_id is None:
continue
st_line = self.env['account.bank.statement.line'].browse(st_line_id).with_prefetch(statement_lines.ids)
reco_model = self.env['account.reconcile.model'].browse(reco_model_id).with_prefetch(self.ids)
if reco_model_trigger == 'manual':
st_line._action_manual_reco_model(reco_model_id)
else:
reco_model.with_user(SUPERUSER_ID)._trigger_reconciliation_model(st_line.with_user(SUPERUSER_ID))
processed_st_line_ids.add(st_line_id)
def _trigger_reconciliation_model(self, statement_line):
self.ensure_one()
liquidity_line, suspense_line, other_lines = statement_line._seek_for_lines()
amls_to_create = list(
self._apply_lines_for_bank_widget(
residual_amount_currency=sum(suspense_line.mapped('amount_currency')),
residual_balance=sum(suspense_line.mapped('balance')),
partner=statement_line.partner_id,
st_line=statement_line,
)
)
# Get the original base lines and tax lines before the creation of new lines
if any(aml.get('tax_ids') for aml in amls_to_create):
original_base_lines, original_tax_lines = statement_line._prepare_for_tax_lines_recomputation()
statement_line._set_move_line_to_statement_line_move(liquidity_line + other_lines, amls_to_create)
# Now that the new lines have been added, we can recompute the taxes
if any(aml.get('tax_ids') for aml in amls_to_create):
_new_liquidity_line, new_suspense_line, _new_other_lines = statement_line._seek_for_lines()
new_lines = statement_line.line_ids - (liquidity_line + other_lines + new_suspense_line)
statement_line._create_tax_lines(original_base_lines, original_tax_lines, new_lines)
if self.next_activity_type_id:
statement_line.move_id.activity_schedule(
activity_type_id=self.next_activity_type_id.id,
user_id=self.env.user.id,
)
def trigger_reconciliation_model(self, statement_line_id):
self.ensure_one()
statement_line = self.env['account.bank.statement.line'].browse(statement_line_id).exists()
self._trigger_reconciliation_model(statement_line)
def write(self, vals):
res = super().write(vals)
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
*self._check_company_domain(self.env.company),
('is_reconciled', '=', False),
])
if unreconciled_statement_lines:
unreconciled_statement_lines.line_ids.filtered(
lambda line:
line.account_id == line.move_id.journal_id.suspense_account_id and line.reconcile_model_id in self
).reconcile_model_id = False
self._apply_reconcile_models(unreconciled_statement_lines)
return res
@api.model_create_multi
def create(self, vals_list):
reco_models = super().create(vals_list)
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
*self._check_company_domain(self.env.company),
('is_reconciled', '=', False),
])
if unreconciled_statement_lines:
reco_models._apply_reconcile_models(unreconciled_statement_lines)
return reco_models
def action_archive(self):
res = super().action_archive()
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
*self._check_company_domain(self.env.company),
('is_reconciled', '=', False),
('line_ids.reconcile_model_id', 'in', self.ids),
])
if unreconciled_statement_lines:
unreconciled_statement_lines.line_ids.filtered(
lambda line:
line.account_id == line.move_id.journal_id.suspense_account_id
).reconcile_model_id = False
return res

View File

@@ -0,0 +1,139 @@
import { EventBus, reactive, useState } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
export class BankReconciliationService {
constructor(env, services) {
this.env = env;
this.setup(env, services);
}
setup(env, services) {
this.bus = new EventBus();
this.orm = services["orm"];
this.chatterState = reactive({
visible:
JSON.parse(
browser.sessionStorage.getItem("isBankReconciliationWidgetChatterOpened")
) ?? false,
statementLine: null,
});
this.reconcileCountPerPartnerId = reactive({});
this.reconcileModelPerStatementLineId = reactive({});
}
toggleChatter() {
this.chatterState.visible = !this.chatterState.visible;
browser.sessionStorage.setItem(
"isBankReconciliationWidgetChatterOpened",
this.chatterState.visible
);
}
/**
* Specific function to open the chatter.
* For a particular case, where the customer clicks on
* the chatter icon directly on the bank statement line,
* we want to open the chatter but not close it.
*/
openChatter() {
this.chatterState.visible = true;
}
selectStatementLine(statementLine) {
this.chatterState.statementLine = statementLine;
}
reloadChatter() {
this.bus.trigger("MAIL:RELOAD-THREAD", {
model: "account.move",
id: this.statementLineMoveId,
});
}
async computeReconcileLineCountPerPartnerId(records) {
const groups = await this.orm.formattedReadGroup(
"account.move.line",
[
["parent_state", "in", ["draft", "posted"]],
[
"partner_id",
"in",
records
.filter((record) => !!record.data.partner_id.id)
.map((record) => record.data.partner_id.id),
],
["company_id", "child_of", records.map((record) => record.data.company_id.id)],
["search_account_id.reconcile", "=", true],
["display_type", "not in", ["line_section", "line_note"]],
["reconciled", "=", false],
"|",
["search_account_id.account_type", "not in", ["asset_receivable", "liability_payable"]],
["payment_id", "=", false],
["statement_line_id", "not in", records.map((record) => record.data.id)],
],
["partner_id"],
["id:count"]
);
this.reconcileCountPerPartnerId = {};
groups.forEach((group) => {
this.reconcileCountPerPartnerId[group.partner_id[0]] = group["id:count"];
});
}
async computeAvailableReconcileModels(records) {
this.reconcileModelPerStatementLineId =
Object.keys(records).length === 0
? {}
: await this.orm.call(
"account.reconcile.model",
"get_available_reconcile_model_per_statement_line",
[records.map((record) => record.data.id)]
);
}
async updateAvailableReconcileModels(recordId) {
const result = await this.orm.call(
"account.reconcile.model",
"get_available_reconcile_model_per_statement_line",
[[recordId]]
);
this.reconcileModelPerStatementLineId[recordId] = result[recordId];
}
async reloadRecords(records) {
await Promise.all([...records.map((record) => record.load())]);
}
get statementLineMove() {
return this.chatterState.statementLine?.data.move_id;
}
get statementLineMoveId() {
return this.statementLineMove?.id;
}
get statementLine() {
return this.chatterState.statementLine;
}
get statementLineId() {
return this.statementLine?.data?.id;
}
}
const bankReconciliationService = {
dependencies: ["orm"],
start(env, services) {
return new BankReconciliationService(env, services);
},
};
registry.category("services").add("bankReconciliation", bankReconciliationService);
export function useBankReconciliation() {
return useState(useService("bankReconciliation"));
}

View File

@@ -0,0 +1,9 @@
from . import fusion_reconcile_pattern
from . import fusion_reconcile_precedent
from . import fusion_reconcile_suggestion
from . import fusion_bank_rec_widget
from . import account_bank_statement_line
from . import account_reconcile_model
from . import fusion_reconcile_engine
from . import fusion_unreconciled_bank_line_mv
from . import fusion_bank_rec_cron

View File

@@ -0,0 +1,52 @@
"""Inherit account.bank.statement.line to add Phase 1 widget compute fields.
These fields are NOT stored — they're computed on-the-fly so the OWL widget
can render confidence badges without round-tripping. Performance OK because
the widget loads ~50-200 lines per kanban open and each compute is a single
indexed query into fusion.reconcile.suggestion.
"""
from odoo import api, fields, models
class AccountBankStatementLine(models.Model):
_inherit = "account.bank.statement.line"
# Top suggestion + its band — for the inline AI confidence badge
fusion_top_suggestion_id = fields.Many2one(
'fusion.reconcile.suggestion',
compute='_compute_top_suggestion',
store=False,
help="Highest-ranked pending AI suggestion for this line")
fusion_confidence_band = fields.Selection(
[('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')],
compute='_compute_top_suggestion',
store=False,
default='none',
help="Quick-render colour band for the OWL widget badge")
# Mirror of Enterprise's bank_statement_attachment_ids surface field.
# Defined here so fusion's widget can render attachments without
# depending on account_accountant being installed.
bank_statement_attachment_ids = fields.One2many(
'ir.attachment',
compute='_compute_bank_statement_attachment_ids',
help="Attachments on the underlying account.move; mirrored for the OWL widget")
def _compute_top_suggestion(self):
Suggestion = self.env['fusion.reconcile.suggestion'].sudo()
for line in self:
top = Suggestion.search([
('statement_line_id', '=', line.id),
('state', '=', 'pending'),
('rank', '=', 1),
], limit=1)
line.fusion_top_suggestion_id = top
line.fusion_confidence_band = top.confidence_band if top else 'none'
@api.depends('move_id', 'move_id.attachment_ids')
def _compute_bank_statement_attachment_ids(self):
for line in self:
line.bank_statement_attachment_ids = (
line.move_id.attachment_ids if line.move_id else self.env['ir.attachment']
)

View File

@@ -0,0 +1,20 @@
"""Inherit account.reconcile.model to add Phase 1 AI integration hooks.
This is a minimal extension placeholder for now — Phase 1+ phases may
expand it (e.g., to attach AI confidence rules to reconcile-model
auto-fires). The shared-field-ownership for `created_automatically`
already lives in fusion_accounting_core; this file is for fusion_bank_rec
specific extensions only.
"""
from odoo import fields, models
class AccountReconcileModel(models.Model):
_inherit = "account.reconcile.model"
fusion_ai_confidence_threshold = fields.Float(
string="AI confidence threshold",
default=0.0,
help="If >0.0, fusion AI suggestions matching this rule are auto-applied "
"only when their confidence ≥ this threshold. 0.0 = no AI filtering.")

View File

@@ -0,0 +1,119 @@
"""Cron handler model for fusion_accounting_bank_rec.
Three scheduled jobs:
- _cron_suggest_pending: warm AI suggestions for unreconciled lines (30 min)
- _cron_refresh_patterns: recompute fusion.reconcile.pattern aggregates (daily 02:00)
- _cron_refresh_mv: REFRESH MATERIALIZED VIEW CONCURRENTLY (5 min)
"""
import logging
from datetime import timedelta
import odoo
from odoo import api, fields, models
from ..services.pattern_extractor import extract_pattern_for_partner
_logger = logging.getLogger(__name__)
class FusionBankRecCron(models.AbstractModel):
_name = "fusion.bank.rec.cron"
_description = "Fusion Bank Reconciliation Cron Handlers"
@api.model
def _cron_suggest_pending(self, batch_size=50):
"""For each unreconciled bank line that doesn't have a recent pending
suggestion, run engine.suggest_matches.
Recent = a pending suggestion created within the last 24 hours."""
cutoff = fields.Datetime.now() - timedelta(hours=24)
Line = self.env['account.bank.statement.line']
lines_to_consider = Line.search([
('is_reconciled', '=', False),
('partner_id', '!=', False),
], limit=batch_size * 5)
Suggestion = self.env['fusion.reconcile.suggestion']
lines_needing_suggestions = self.env['account.bank.statement.line']
for line in lines_to_consider:
recent = Suggestion.search_count([
('statement_line_id', '=', line.id),
('state', '=', 'pending'),
('create_date', '>=', cutoff),
])
if recent == 0:
lines_needing_suggestions |= line
if len(lines_needing_suggestions) >= batch_size:
break
if not lines_needing_suggestions:
_logger.debug("Cron: no bank lines need suggestion warming")
return
_logger.info(
"Cron: warming suggestions for %d bank lines",
len(lines_needing_suggestions))
try:
self.env['fusion.reconcile.engine'].suggest_matches(
lines_needing_suggestions, limit_per_line=3)
except Exception as e:
_logger.exception("Cron suggest_pending failed: %s", e)
@api.model
def _cron_refresh_patterns(self):
"""For each (company, partner) pair with precedents, recompute and
upsert the fusion.reconcile.pattern row."""
Pattern = self.env['fusion.reconcile.pattern']
self.env.cr.execute("""
SELECT DISTINCT company_id, partner_id
FROM fusion_reconcile_precedent
WHERE partner_id IS NOT NULL
""")
pairs = self.env.cr.fetchall()
_logger.info(
"Cron: refreshing patterns for %d (company, partner) pairs",
len(pairs))
for company_id, partner_id in pairs:
try:
vals = extract_pattern_for_partner(
self.env, company_id=company_id, partner_id=partner_id)
existing = Pattern.search([
('company_id', '=', company_id),
('partner_id', '=', partner_id),
], limit=1)
if existing:
existing.write(vals)
else:
Pattern.create(vals)
except Exception as e:
_logger.warning(
"Pattern refresh failed for company=%s partner=%s: %s",
company_id, partner_id, e)
@api.model
def _cron_refresh_mv(self):
"""Refresh the materialized view CONCURRENTLY using an autocommit cursor.
REFRESH CONCURRENTLY can't run inside a transaction, so we open a
fresh connection in autocommit mode (per Task 24's note). On any
failure, we fall back to the model's blocking refresh."""
try:
db_name = self.env.cr.dbname
db = odoo.sql_db.db_connect(db_name)
with db.cursor() as cron_cr:
cron_cr._cnx.set_session(autocommit=True)
cron_cr.execute(
"REFRESH MATERIALIZED VIEW CONCURRENTLY "
"fusion_unreconciled_bank_line_mv")
_logger.debug("Cron: MV refresh CONCURRENTLY succeeded")
except Exception as e:
_logger.warning(
"Cron MV refresh CONCURRENTLY failed (%s); falling back to "
"blocking refresh", e)
try:
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
except Exception as e2:
_logger.exception(
"Cron MV refresh fallback also failed: %s", e2)

View File

@@ -0,0 +1,33 @@
"""Per-request widget state. Holds the kanban-load response shape so the
controller can return one well-typed object.
This is a TransientModel (no DB persistence beyond the request). The OWL
widget reads pre-computed fusion.reconcile.suggestion rows directly via
the controller; this model is just a typed envelope for the kanban-open
action."""
from odoo import api, fields, models
class FusionBankRecWidget(models.TransientModel):
_name = "fusion.bank.rec.widget"
_description = "Bank reconciliation widget state (transient)"
journal_id = fields.Many2one('account.journal',
domain="[('type', '=', 'bank')]")
statement_line_ids = fields.Many2many('account.bank.statement.line')
summary_count = fields.Integer(
help="Number of unreconciled lines visible in this widget")
summary_unreconciled_balance = fields.Monetary(currency_field='currency_id')
currency_id = fields.Many2one('res.currency',
related='journal_id.currency_id',
store=False, readonly=True)
def action_open_kanban(self):
"""Return a window action opening the OWL kanban for this journal."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fusion_bank_rec_kanban',
'params': {'journal_id': self.journal_id.id},
}

View File

@@ -0,0 +1,481 @@
"""The reconcile engine — orchestrator for all bank-line reconciliations.
Public API: 6 methods. All other code (controllers, AI tools, wizards)
must go through these methods; no direct ORM writes to
``account.partial.reconcile`` from anywhere else.
V19 mechanics (per Enterprise's bank_rec_widget pattern):
A bank statement line creates an ``account.move`` with two journal
items: a *liquidity* line on the journal's default account, and a
*suspense* line on the journal's suspense account. Reconciliation
replaces the suspense line with one or more *counterpart* lines posted
to the matched invoices' receivable / payable accounts (or the write-off
account), then calls Odoo's standard ``account.move.line.reconcile()``
on each counterpart + invoice pair.
Internal pipeline (per spec Section 3.3):
1. Validate (period not locked, mandatory args present).
2. Compute counterpart vals from ``against_lines`` and optional write-off.
3. Rewrite the bank move ``line_ids``: keep liquidity, drop suspense +
any prior other lines, append the new counterparts.
4. Reconcile each counterpart with its matched invoice line.
5. Audit (``mail.message``) + record precedent for future learning.
"""
import logging
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.fields import Command
from ..services.matching_strategies import (
AmountExactStrategy,
Candidate,
FIFOStrategy,
MultiInvoiceStrategy,
)
from ..services.confidence_scoring import score_candidates
from ..services.memo_tokenizer import tokenize_memo
_logger = logging.getLogger(__name__)
class FusionReconcileEngine(models.AbstractModel):
_name = "fusion.reconcile.engine"
_description = "Fusion Bank Reconciliation Engine"
# ============================================================
# PUBLIC API (6 methods)
# ============================================================
@api.model
def reconcile_one(self, statement_line, *, against_lines=None,
write_off_vals=None):
"""Reconcile one bank line against a set of journal items.
Returns: ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
'write_off_move_id': int|None}``
"""
if not statement_line:
raise ValidationError(_("statement_line is required"))
statement_line.ensure_one()
AML = self.env['account.move.line']
against_lines = against_lines or AML
if not against_lines and not write_off_vals:
raise ValidationError(
_("Either against_lines or write_off_vals required"))
self._validate_reconcile(statement_line, against_lines)
bank_move = statement_line.move_id
liquidity_lines, suspense_lines, other_lines = (
statement_line._seek_for_lines())
# The bank move must stay balanced after we rewrite line_ids.
# Liquidity sums to +bank_amount (or -bank_amount for outbound), so
# the new counterparts must sum to the inverse. We allocate the
# available bank amount across against_lines, clamped to each
# invoice's residual; any leftover goes to the write-off line (or
# raises if no write-off was requested).
liq_balance = sum(liquidity_lines.mapped('balance'))
# Available counterpart balance (positive magnitude) = abs(liq_balance)
remaining = abs(liq_balance)
# Counterparts mirror liquidity: opposite sign of liq_balance.
cp_sign = -1 if liq_balance >= 0 else 1
new_counterpart_vals = []
for inv_line in against_lines:
inv_residual = inv_line.amount_residual
# Clamp so we never write more than the invoice residual nor more
# than what the bank line can pay.
allocate = min(remaining, abs(inv_residual))
new_counterpart_vals.append(self._build_counterpart_vals(
statement_line, inv_line,
allocated_balance=cp_sign * allocate,
))
remaining -= allocate
if remaining <= 0:
break
write_off_move_id = None
if write_off_vals:
# Write-off absorbs whatever the against_lines didn't cover.
wo_balance = cp_sign * remaining
# If user passed an explicit amount and there are no against_lines,
# honour the explicit amount (covers the pure write-off case).
if (write_off_vals.get('amount') is not None
and not against_lines):
wo_balance = -write_off_vals['amount']
new_counterpart_vals.append(self._build_write_off_vals(
statement_line, write_off_vals, balance=wo_balance,
))
remaining = 0
# Replace the bank move line_ids: keep liquidity, drop everything
# else, append new counterparts.
ops = []
for line in (suspense_lines | other_lines):
ops.append(Command.unlink(line.id))
for vals in new_counterpart_vals:
ops.append(Command.create(vals))
editable_move = bank_move.with_context(
force_delete=True, skip_readonly_check=True)
prior_line_ids = set(bank_move.line_ids.ids)
editable_move.write({'line_ids': ops})
new_lines = bank_move.line_ids.filtered(
lambda line: line.id not in prior_line_ids)
# Reconcile each new counterpart with its matched invoice line.
# The first N new lines correspond to the first N against_lines
# (where N may be < len(against_lines) if the bank amount ran out).
# Any trailing new line is a write-off and has no invoice pair.
Partial = self.env['account.partial.reconcile']
new_partial_ids = []
invoice_counterparts = new_lines[:min(len(new_lines),
len(against_lines))]
for new_line, inv_line in zip(invoice_counterparts, against_lines):
pair = new_line | inv_line
existing = set(Partial.search([
'|',
('debit_move_id', 'in', pair.ids),
('credit_move_id', 'in', pair.ids),
]).ids)
pair.reconcile()
added = Partial.search([
'|',
('debit_move_id', 'in', pair.ids),
('credit_move_id', 'in', pair.ids),
]).filtered(lambda p: p.id not in existing)
new_partial_ids.extend(added.ids)
self._post_audit(
statement_line, new_partial_ids, source='engine.reconcile_one')
if against_lines:
self._record_precedent(statement_line, against_lines)
return {
'partial_ids': new_partial_ids,
'exchange_diff_move_id': None,
'write_off_move_id': write_off_move_id,
}
@api.model
def reconcile_batch(self, statement_lines, *, strategy='auto'):
"""Bulk-reconcile a recordset using the chosen strategy.
Returns: ``{'reconciled_count': int, 'skipped': int,
'errors': [...]}``
"""
reconciled = 0
skipped = 0
errors = []
for line in statement_lines:
if line.is_reconciled:
skipped += 1
continue
# Per-line savepoint so a single DB-level failure (e.g. a
# check-constraint violation on one bad line) doesn't poison
# the whole batch's transaction.
try:
with self.env.cr.savepoint():
candidates = self._fetch_candidates(line)
picked = self._apply_strategy(
line, candidates, strategy)
if picked:
self.reconcile_one(line, against_lines=picked)
reconciled += 1
else:
skipped += 1
except Exception as e: # noqa: BLE001
errors.append({'line_id': line.id, 'error': str(e)})
_logger.warning(
"reconcile_batch failed for line %s: %s", line.id, e)
return {
'reconciled_count': reconciled,
'skipped': skipped,
'errors': errors,
}
@api.model
def suggest_matches(self, statement_lines, *, limit_per_line=3):
"""Compute and persist AI suggestions per line.
Returns: dict mapping ``line_id`` -> list of suggestion dicts.
"""
out = {}
Suggestion = self.env['fusion.reconcile.suggestion']
for line in statement_lines:
candidates_records = self._fetch_candidates(line)
if not candidates_records:
continue
candidates_dataclasses = self._records_to_candidates(
line, candidates_records)
scored = score_candidates(
self.env,
statement_line=line,
candidates=candidates_dataclasses,
k=limit_per_line,
use_ai=True,
)
Suggestion.search([
('statement_line_id', '=', line.id),
('state', '=', 'pending'),
]).write({'state': 'superseded'})
line_suggestions = []
for rank, s in enumerate(scored, start=1):
sug = Suggestion.create({
'company_id': line.company_id.id,
'statement_line_id': line.id,
'proposed_move_line_ids': [(6, 0, [s.candidate_id])],
'confidence': s.confidence,
'rank': rank,
'reasoning': s.reasoning,
'score_amount_match': s.score_amount_match,
'score_partner_pattern': s.score_partner_pattern,
'score_precedent_similarity': s.score_precedent_similarity,
'score_ai_rerank': s.score_ai_rerank,
'generated_by': 'on_demand',
'state': 'pending',
})
line_suggestions.append({
'id': sug.id,
'rank': rank,
'confidence': s.confidence,
'reasoning': s.reasoning,
'candidate_id': s.candidate_id,
})
out[line.id] = line_suggestions
return out
@api.model
def accept_suggestion(self, suggestion):
"""User clicked Accept on a suggestion -> reconcile via its proposal.
Returns: same shape as ``reconcile_one``.
"""
if isinstance(suggestion, int):
suggestion = self.env['fusion.reconcile.suggestion'].browse(
suggestion)
suggestion.ensure_one()
line = suggestion.statement_line_id
against = suggestion.proposed_move_line_ids
result = self.reconcile_one(line, against_lines=against)
suggestion.write({
'state': 'accepted',
'accepted_at': fields.Datetime.now(),
'accepted_by': self.env.uid,
})
return result
@api.model
def write_off(self, statement_line, *, account, amount, label, tax_id=None):
"""Create a write-off move + reconcile the bank line against it.
Returns: same shape as ``reconcile_one``.
"""
write_off_vals = {
'account_id': account.id if hasattr(account, 'id') else account,
'amount': amount,
'tax_id': (tax_id.id if (tax_id and hasattr(tax_id, 'id'))
else tax_id),
'label': label,
}
return self.reconcile_one(
statement_line, against_lines=None, write_off_vals=write_off_vals)
@api.model
def unreconcile(self, partial_reconciles):
"""Reverse a reconciliation. Handles full vs. partial chains.
Because ``reconcile_one`` rewrites the bank move's suspense line into
one or more counterpart lines, simply deleting the
``account.partial.reconcile`` rows is not enough — the bank move
would still look reconciled (no suspense line, no residual). We
delegate to V19's standard ``account.bank.statement.line.
action_undo_reconciliation`` for any affected bank line, which
clears the partials AND restores the original suspense state.
Returns: ``{'unreconciled_line_ids': [...]}``
"""
partial_reconciles = partial_reconciles.exists()
if not partial_reconciles:
return {'unreconciled_line_ids': []}
all_lines = (
partial_reconciles.mapped('debit_move_id')
| partial_reconciles.mapped('credit_move_id')
)
line_ids = all_lines.ids
# Find any bank statement lines whose move owns one of these journal
# items; route them through the standard undo flow which both
# deletes the partials and restores the suspense line.
affected_bank_lines = self.env['account.bank.statement.line'].search([
('move_id', 'in', all_lines.mapped('move_id').ids),
])
if affected_bank_lines:
affected_bank_lines.action_undo_reconciliation()
# Anything still hanging around (rare — non-bank-line reconciles)
# gets a direct unlink as a fallback.
remaining = partial_reconciles.exists()
if remaining:
remaining.unlink()
return {'unreconciled_line_ids': line_ids}
# ============================================================
# PRIVATE HELPERS
# ============================================================
def _validate_reconcile(self, statement_line, against_lines):
"""Phase 2: structural + safety checks."""
if not statement_line.exists():
raise ValidationError(_("Statement line does not exist"))
company = statement_line.company_id
line_date = statement_line.date
lock_date = company.fiscalyear_lock_date
if lock_date and line_date and line_date <= lock_date:
raise ValidationError(_(
"Cannot reconcile: line date %(line)s is on or before fiscal "
"year lock date %(lock)s",
line=line_date,
lock=lock_date,
))
def _build_counterpart_vals(self, statement_line, inv_line, *,
allocated_balance):
"""Build the vals for one counterpart line that mirrors an invoice
line on the bank move.
``allocated_balance`` is the signed company-currency balance to write
on the counterpart. It is clamped (by the caller) so that the bank
move stays balanced and no invoice gets over-paid. We scale
``amount_currency`` proportionally for multi-currency lines.
"""
inv_residual = inv_line.amount_residual
if inv_residual:
scale = abs(allocated_balance) / abs(inv_residual)
else:
scale = 1.0
amount_currency = -inv_line.amount_residual_currency * scale
return {
'name': inv_line.name or statement_line.payment_ref or '',
'account_id': inv_line.account_id.id,
'partner_id': (inv_line.partner_id.id
if inv_line.partner_id else False),
'currency_id': inv_line.currency_id.id,
'amount_currency': amount_currency,
'balance': allocated_balance,
}
def _build_write_off_vals(self, statement_line, write_off_vals, *,
balance):
"""Build the vals for a write-off counterpart line on the bank move.
``balance`` is the signed company-currency balance the write-off
line must carry to keep the bank move balanced.
"""
vals = {
'name': write_off_vals.get('label') or _('Write-off'),
'account_id': write_off_vals['account_id'],
'partner_id': (statement_line.partner_id.id
if statement_line.partner_id else False),
'balance': balance,
}
if write_off_vals.get('tax_id'):
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]
return vals
def _fetch_candidates(self, statement_line):
"""SQL pre-filter: open journal items matching partner + reconcilable
account."""
domain = [
('parent_state', '=', 'posted'),
('account_id.reconcile', '=', True),
('reconciled', '=', False),
('display_type', 'not in', ('line_section', 'line_note')),
]
if statement_line.partner_id:
domain.append(('partner_id', '=', statement_line.partner_id.id))
return self.env['account.move.line'].search(domain, limit=200)
def _records_to_candidates(self, statement_line, records):
"""Convert ``account.move.line`` recordset to ``Candidate`` dataclasses."""
today = fields.Date.today()
result = []
for c in records:
ref_date = c.date_maturity or c.date or today
age_days = (today - ref_date).days
result.append(Candidate(
id=c.id,
amount=abs(c.amount_residual) or abs(c.balance),
partner_id=c.partner_id.id if c.partner_id else 0,
age_days=age_days,
))
return result
def _apply_strategy(self, line, candidate_records, strategy):
"""Apply the named strategy. Returns matching ``account.move.line``
recordset, or empty recordset if nothing matched."""
AML = self.env['account.move.line']
if not candidate_records:
return AML
candidate_dcs = self._records_to_candidates(line, candidate_records)
bank_amount = abs(line.amount)
if strategy == 'auto':
for strat_class in (AmountExactStrategy,
MultiInvoiceStrategy,
FIFOStrategy):
result = strat_class().match(
bank_amount=bank_amount, candidates=candidate_dcs)
if result.picked_ids:
return AML.browse(result.picked_ids)
return AML
def _post_audit(self, statement_line, partial_ids, source):
"""Append an audit log to the bank-line move's chatter."""
if not statement_line.move_id:
return
try:
statement_line.move_id.message_post(
body=_(
"Reconciled via %(source)s; %(count)d partial(s) created: "
"%(ids)s",
source=source,
count=len(partial_ids),
ids=partial_ids,
),
)
except Exception as e: # noqa: BLE001
_logger.debug(
"Audit log skipped for line %s: %s", statement_line.id, e)
def _record_precedent(self, statement_line, against_lines):
"""Append a precedent for future pattern learning. Best-effort."""
if not against_lines:
return
try:
self.env['fusion.reconcile.precedent'].sudo().create({
'company_id': statement_line.company_id.id,
'partner_id': (statement_line.partner_id.id
if statement_line.partner_id else False),
'amount': abs(statement_line.amount),
'currency_id': statement_line.currency_id.id,
'date': statement_line.date,
'memo_tokens': ','.join(
tokenize_memo(statement_line.payment_ref)),
'journal_id': statement_line.journal_id.id,
'matched_move_line_count': len(against_lines),
'matched_account_ids': ','.join(
str(i) for i in against_lines.mapped('account_id').ids),
'reconciler_user_id': self.env.uid,
'reconciled_at': fields.Datetime.now(),
'source': 'manual',
})
except Exception as e: # noqa: BLE001
_logger.warning(
"Failed to record precedent for line %s: %s",
statement_line.id, e)

View File

@@ -0,0 +1,55 @@
"""Per-partner bank reconciliation pattern aggregate.
One row per (company_id, partner_id). Continuously summarises HOW this
partner gets reconciled. Recomputed nightly via cron from the precedent
table. Used as a feature input to confidence_scoring.
"""
from odoo import fields, models
class FusionReconcilePattern(models.Model):
_name = "fusion.reconcile.pattern"
_description = "Per-partner bank reconciliation pattern aggregate"
_rec_name = "partner_id"
company_id = fields.Many2one('res.company', required=True, index=True,
default=lambda self: self.env.company)
partner_id = fields.Many2one('res.partner', required=True, index=True)
# Volume + cadence
reconcile_count = fields.Integer(default=0,
help="Total past reconciles for this partner")
typical_amount_range = fields.Char(
help="e.g. '$1,200 $2,400 (median $1,847.50)'")
typical_cadence_days = fields.Float(
help="Mean inter-reconcile days")
typical_day_of_month = fields.Char(
help="e.g. '1st, 15th'")
# Matching strategy used historically
pref_strategy = fields.Selection([
('exact_amount', 'Exact-amount-first'),
('fifo', 'FIFO oldest-due-first'),
('multi_invoice', 'Multi-invoice consolidation'),
('cherry_pick', 'Cherry-pick specific invoices'),
])
pref_account_id = fields.Many2one('account.account',
help="Most-used target account")
# Memo signature
common_memo_tokens = fields.Char(
help="Comma-separated tokens that appear in ≥30% of past reconciles")
# Tax + write-off habits
common_writeoff_account_id = fields.Many2one('account.account')
common_writeoff_tax_id = fields.Many2one('account.tax')
typical_writeoff_amount = fields.Float(
help="e.g. 0.05 for rounding diffs")
last_refreshed_at = fields.Datetime()
_uniq_company_partner = models.Constraint(
'unique(company_id, partner_id)',
'One pattern row per (company, partner) — already exists.',
)

View File

@@ -0,0 +1,49 @@
"""Per-historical-decision reconciliation memory.
One row per past reconciliation. Holds the full feature vector + outcome,
used by precedent_lookup for K-nearest-neighbour search when scoring a
new bank line.
"""
from odoo import fields, models
class FusionReconcilePrecedent(models.Model):
_name = "fusion.reconcile.precedent"
_description = "Historical bank reconciliation decision (memory)"
_order = "reconciled_at desc, id desc"
company_id = fields.Many2one('res.company', required=True, index=True,
default=lambda self: self.env.company)
partner_id = fields.Many2one('res.partner', index=True)
# Bank line features (the "input")
amount = fields.Monetary(currency_field='currency_id')
currency_id = fields.Many2one('res.currency')
date = fields.Date()
memo_tokens = fields.Char(
help="Comma-separated normalized memo tokens (output of memo_tokenizer)")
journal_id = fields.Many2one('account.journal')
# Outcome (the "decision made")
matched_move_line_count = fields.Integer(
help="1 = exact, 2-3 = consolidation, etc.")
matched_account_ids = fields.Char(
help="Comma-separated account.account IDs that were matched against")
matched_invoice_ages_days = fields.Char(
help="Comma-separated days-old at reconcile time, e.g. '12, 45, 78'")
write_off_amount = fields.Float()
write_off_account_id = fields.Many2one('account.account')
exchange_diff = fields.Boolean()
# Provenance
reconciler_user_id = fields.Many2one('res.users')
reconciled_at = fields.Datetime()
source = fields.Selection([
('historical_bootstrap', 'Imported from history'),
('manual', 'Manual reconcile via fusion'),
('ai_accepted', 'AI suggestion accepted'),
('auto_rule', 'account.reconcile.model auto-fired'),
], required=True)
# No uniqueness constraint — multiple reconciles can share features

View File

@@ -0,0 +1,137 @@
"""Persisted AI suggestions for bank line reconciliations.
One row per (statement_line, candidate_match). The OWL widget reads these
to render confidence badges; users accept/reject which feeds back into
the pattern learning system.
The AI never writes account.partial.reconcile directly — it writes
suggestions here, and the user (or batch-accept action) approves them
through the engine's accept_suggestion() method.
"""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionReconcileSuggestion(models.Model):
_name = "fusion.reconcile.suggestion"
_description = "AI-generated bank reconciliation suggestion"
_order = "statement_line_id, confidence desc"
company_id = fields.Many2one('res.company', required=True, index=True,
default=lambda self: self.env.company)
statement_line_id = fields.Many2one('account.bank.statement.line',
required=True, index=True, ondelete='cascade')
# The proposal
proposed_move_line_ids = fields.Many2many('account.move.line',
string="Proposed matches")
proposed_write_off_amount = fields.Monetary(currency_field='currency_id')
proposed_write_off_account_id = fields.Many2one('account.account')
currency_id = fields.Many2one('res.currency',
related='statement_line_id.currency_id',
store=True)
# Scoring
confidence = fields.Float(required=True)
confidence_band = fields.Selection([
('high', 'High (>=95%)'),
('medium', 'Medium (70-94%)'),
('low', 'Low (50-69%)'),
('none', 'No confidence (<50%)'),
], compute='_compute_band', store=True)
rank = fields.Integer(help="1 = top suggestion, 2-N = alternatives")
reasoning = fields.Text(help="Human-readable explanation")
# Feature breakdown (for transparency + future learning)
score_amount_match = fields.Float()
score_partner_pattern = fields.Float()
score_precedent_similarity = fields.Float()
score_ai_rerank = fields.Float()
# Provenance
generated_at = fields.Datetime(default=fields.Datetime.now)
generated_by = fields.Selection([
('cron_batch', 'Batch cron'),
('on_demand', 'User refreshed alternatives'),
('on_open', 'Widget opened (lazy)'),
])
provider_used = fields.Char(
help="e.g. 'claude_sonnet_4_5', 'lmstudio_qwen_7b', 'statistical_only'")
tokens_used = fields.Integer(help="if AI re-rank invoked")
generation_ms = fields.Integer(help="latency for monitoring")
# Lifecycle
state = fields.Selection([
('pending', 'Pending review'),
('accepted', 'Accepted'),
('rejected', 'Rejected'),
('superseded', 'Superseded by newer suggestion'),
('stale', 'Stale (line changed since)'),
], default='pending', required=True, index=True)
accepted_at = fields.Datetime()
accepted_by = fields.Many2one('res.users')
rejected_at = fields.Datetime()
rejected_reason = fields.Selection([
('wrong_invoice', 'Wrong invoice'),
('wrong_partner', 'Wrong partner'),
('wrong_amount', 'Amount off'),
('not_a_match', 'No good match exists'),
('other', 'Other'),
])
_confidence_in_range = models.Constraint(
'CHECK (confidence >= 0.0 AND confidence <= 1.0)',
'Confidence must be between 0.0 and 1.0',
)
@api.depends('confidence')
def _compute_band(self):
for sug in self:
c = sug.confidence
if c >= 0.95:
sug.confidence_band = 'high'
elif c >= 0.70:
sug.confidence_band = 'medium'
elif c >= 0.50:
sug.confidence_band = 'low'
else:
sug.confidence_band = 'none'
# ------------------------------------------------------------------
# CRUD overrides — trigger MV refresh so the OWL widget sees fresh
# confidence bands / top suggestion ids without waiting for cron.
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
self._trigger_mv_refresh()
return records
def write(self, vals):
res = super().write(vals)
# Only refresh on changes that affect the MV's projected columns.
if 'state' in vals or 'confidence' in vals or 'rank' in vals:
self._trigger_mv_refresh()
return res
def _trigger_mv_refresh(self):
"""Best-effort MV refresh; never poison the originating transaction.
Uses concurrently=False because Postgres forbids
REFRESH MATERIALIZED VIEW CONCURRENTLY inside a transaction block,
and Odoo's per-request cursor is always in a transaction. The cron
job (Task 25) opens a dedicated autocommit cursor for CONCURRENTLY
refreshes when the MV grows large enough that a brief blocking
refresh becomes objectionable.
"""
try:
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
except Exception as e: # noqa: BLE001
_logger.warning(
"MV refresh after suggestion write failed: %s", e)

View File

@@ -0,0 +1,91 @@
"""Materialized view exposing pre-aggregated unreconciled-bank-line data.
The MV is created in the model's init() (called by Odoo on install/upgrade).
Refresh strategy:
- Cron (every 5 min) — see fusion_accounting_bank_rec/data/cron.xml (Task 25)
- Triggered refresh after suggestion writes (handled in fusion_reconcile_suggestion.py)
"""
import logging
import os
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionUnreconciledBankLineMV(models.Model):
_name = "fusion.unreconciled.bank.line.mv"
_description = "Materialized view of unreconciled bank lines for OWL widget"
_auto = False # we manage the table ourselves
_table = "fusion_unreconciled_bank_line_mv"
_order = "date desc, id desc"
# Fields mirror the columns in the SQL view; required so Odoo can read them.
company_id = fields.Many2one('res.company', readonly=True)
journal_id = fields.Many2one('account.journal', readonly=True)
date = fields.Date(readonly=True)
amount = fields.Float(readonly=True)
payment_ref = fields.Char(readonly=True)
currency_id = fields.Many2one('res.currency', readonly=True)
partner_id = fields.Many2one('res.partner', readonly=True)
create_date = fields.Datetime(readonly=True)
top_suggestion_id = fields.Many2one('fusion.reconcile.suggestion', readonly=True)
top_confidence = fields.Float(readonly=True)
confidence_band = fields.Selection([
('high', 'High'),
('medium', 'Medium'),
('low', 'Low'),
('none', 'None'),
], readonly=True)
attachment_count = fields.Integer(readonly=True)
partner_reconcile_count = fields.Integer(readonly=True)
def init(self):
"""Create the MV if missing.
Reads create_mv_unreconciled_bank_line.sql and executes it. Idempotent
because the SQL uses CREATE MATERIALIZED VIEW IF NOT EXISTS."""
sql_path = os.path.join(
os.path.dirname(__file__), '..', 'data', 'sql',
'create_mv_unreconciled_bank_line.sql')
with open(sql_path, 'r') as f:
sql = f.read()
self.env.cr.execute(sql)
_logger.info(
"fusion_unreconciled_bank_line_mv: created/verified MV + indexes")
@api.model
def _refresh(self, *, concurrently=True):
"""Refresh the MV.
If ``concurrently=True`` (default), uses
REFRESH MATERIALIZED VIEW CONCURRENTLY (requires the unique index).
Falls back to a blocking refresh on the first refresh after creation
(when CONCURRENTLY is not yet allowed because the MV has never been
populated).
Flushes the ORM cache first so the materialization sees the latest
committed-to-DB values for fields like ``is_reconciled`` (computed,
stored — sometimes still buffered in the cache mid-request)."""
self.env.flush_all()
keyword = "CONCURRENTLY" if concurrently else ""
try:
self.env.cr.execute(
f"REFRESH MATERIALIZED VIEW {keyword} fusion_unreconciled_bank_line_mv"
)
_logger.debug(
"fusion_unreconciled_bank_line_mv refreshed (%s)",
'concurrent' if concurrently else 'blocking')
except Exception as e: # noqa: BLE001
# CONCURRENTLY fails on first refresh after creation if the MV is
# empty / has never been populated; fall back to non-concurrent.
if concurrently:
_logger.warning(
"Concurrent MV refresh failed (%s); falling back to "
"blocking refresh", e)
self.env.cr.execute(
"REFRESH MATERIALIZED VIEW fusion_unreconciled_bank_line_mv"
)
else:
raise

View File

@@ -0,0 +1,10 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_reconcile_pattern_user,pattern user,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
access_fusion_reconcile_pattern_admin,pattern admin,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_reconcile_precedent_user,precedent user,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
access_fusion_reconcile_precedent_admin,precedent admin,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_reconcile_suggestion_user,suggestion user,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
access_fusion_reconcile_suggestion_admin,suggestion admin,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_bank_rec_widget_user,bank rec widget user,model_fusion_bank_rec_widget,fusion_accounting_core.group_fusion_accounting_user,1,1,1,1
access_fusion_unreconciled_bank_line_mv_user,unreconciled bank line mv user,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
access_fusion_unreconciled_bank_line_mv_admin,unreconciled bank line mv admin,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_admin,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_reconcile_pattern_user pattern user model_fusion_reconcile_pattern fusion_accounting_core.group_fusion_accounting_user 1 0 0 0
3 access_fusion_reconcile_pattern_admin pattern admin model_fusion_reconcile_pattern fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
4 access_fusion_reconcile_precedent_user precedent user model_fusion_reconcile_precedent fusion_accounting_core.group_fusion_accounting_user 1 0 0 0
5 access_fusion_reconcile_precedent_admin precedent admin model_fusion_reconcile_precedent fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
6 access_fusion_reconcile_suggestion_user suggestion user model_fusion_reconcile_suggestion fusion_accounting_core.group_fusion_accounting_user 1 0 0 0
7 access_fusion_reconcile_suggestion_admin suggestion admin model_fusion_reconcile_suggestion fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
8 access_fusion_bank_rec_widget_user bank rec widget user model_fusion_bank_rec_widget fusion_accounting_core.group_fusion_accounting_user 1 1 1 1
9 access_fusion_unreconciled_bank_line_mv_user unreconciled bank line mv user model_fusion_unreconciled_bank_line_mv fusion_accounting_core.group_fusion_accounting_user 1 0 0 0
10 access_fusion_unreconciled_bank_line_mv_admin unreconciled bank line mv admin model_fusion_unreconciled_bank_line_mv fusion_accounting_core.group_fusion_accounting_admin 1 0 0 0

View File

@@ -0,0 +1,6 @@
from . import memo_tokenizer
from . import exchange_diff
from . import matching_strategies
from . import precedent_lookup
from . import pattern_extractor
from . import confidence_scoring

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,46 @@
"""Exchange-difference calculation helper.
Pure-Python FX gain/loss computation. The engine uses this for rapid
pre-checks; Odoo's account.move._create_exchange_difference_move() is
invoked separately for the actual GL posting.
"""
from dataclasses import dataclass
@dataclass
class ExchangeDiffResult:
needs_diff_move: bool
diff_amount: float # in company currency; positive = gain, negative = loss
line_company_amount: float
against_company_amount: float
def compute_exchange_diff(*, line_amount, line_currency_code, against_amount,
against_currency_code, line_rate, against_rate) -> ExchangeDiffResult:
"""Compute whether an exchange-diff move is needed and its magnitude.
Args:
line_amount: Bank line amount in its currency
line_currency_code: e.g. 'USD'
against_amount: Matched journal item amount in its currency
against_currency_code: e.g. 'USD' (or different)
line_rate: FX rate (foreign per company currency) at line date
against_rate: FX rate at journal item posting date
Returns:
ExchangeDiffResult with needs_diff_move flag and computed diff
in company currency (positive = gain, negative = loss).
"""
line_company = line_amount * line_rate
against_company = against_amount * against_rate
diff = line_company - against_company
needs_diff = abs(diff) > 0.005 # rounding tolerance
return ExchangeDiffResult(
needs_diff_move=needs_diff,
diff_amount=round(diff, 2),
line_company_amount=round(line_company, 2),
against_company_amount=round(against_company, 2),
)

View File

@@ -0,0 +1,91 @@
"""Matching strategy classes for the reconcile engine.
Each strategy takes a bank amount + list of candidate journal items
and returns a MatchResult with the picked ids + confidence + residual.
Strategies are pure Python; no ORM dependency.
"""
from dataclasses import dataclass, field
from itertools import combinations
@dataclass
class Candidate:
id: int
amount: float
partner_id: int
age_days: int
@dataclass
class MatchResult:
picked_ids: list[int] = field(default_factory=list)
confidence: float = 0.0
residual: float = 0.0 # bank_amount - sum(picked); positive = under-allocated
strategy_name: str = ""
AMOUNT_TOLERANCE = 0.005 # currency rounding tolerance
class AmountExactStrategy:
"""Pick a single candidate whose amount equals the bank amount exactly.
If multiple candidates match exactly, pick the oldest (FIFO tiebreaker)."""
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
exact = [c for c in candidates if abs(c.amount - bank_amount) < AMOUNT_TOLERANCE]
if not exact:
return MatchResult(strategy_name='amount_exact')
oldest = max(exact, key=lambda c: c.age_days)
return MatchResult(
picked_ids=[oldest.id],
confidence=1.0,
residual=0.0,
strategy_name='amount_exact',
)
class FIFOStrategy:
"""Pick oldest candidates first until the bank amount is exhausted.
May produce partial reconcile residual if last candidate doesn't fit exactly."""
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
if not candidates:
return MatchResult(strategy_name='fifo')
oldest_first = sorted(candidates, key=lambda c: -c.age_days)
picked = []
remaining = bank_amount
for c in oldest_first:
if remaining <= AMOUNT_TOLERANCE:
break
picked.append(c.id)
remaining -= c.amount
confidence = 0.7 if remaining < AMOUNT_TOLERANCE else 0.5
return MatchResult(
picked_ids=picked,
confidence=confidence,
residual=remaining,
strategy_name='fifo',
)
class MultiInvoiceStrategy:
"""Find the smallest combination of candidates summing to the bank amount.
Bounded by max_combinations to keep complexity manageable."""
def __init__(self, max_combinations=3):
self.max_combinations = max_combinations
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
for k in range(2, self.max_combinations + 1):
for combo in combinations(candidates, k):
total = sum(c.amount for c in combo)
if abs(total - bank_amount) < AMOUNT_TOLERANCE:
return MatchResult(
picked_ids=[c.id for c in combo],
confidence=0.85,
residual=0.0,
strategy_name=f'multi_invoice_{k}',
)
return MatchResult(strategy_name='multi_invoice')

View File

@@ -0,0 +1,44 @@
"""Extract searchable tokens from Canadian bank statement memos.
Handles common memo formats from RBC, TD, Scotia, BMO, plus generic
cheque-number and reference-number patterns. Output is normalized
(uppercase, alphanumeric) for case-insensitive matching.
"""
import re
REF_PATTERNS = [
(re.compile(r'\b(REF|REFERENCE)\s*#?\s*(\d+)\b', re.I), r'REF\2'),
(re.compile(r'\b(CHQ|CHEQUE|CHECK)\s*#?\s*(\d+)\b', re.I), r'CHEQUE\2'),
(re.compile(r'\b(INV|INVOICE)\s*#?\s*(\d+)\b', re.I), r'INV\2'),
]
MIN_TOKEN_LENGTH = 2
def tokenize_memo(memo: str | None) -> list[str]:
"""Return list of normalized tokens from a bank memo.
Empty/None input returns []. Order preserved (first occurrence wins
for de-duplication)."""
if not memo:
return []
text = memo.upper()
for pattern, replacement in REF_PATTERNS:
text = pattern.sub(replacement, text)
text = re.sub(r'[^A-Z0-9]+', ' ', text)
raw_tokens = text.split()
seen = set()
tokens = []
for tok in raw_tokens:
if len(tok) < MIN_TOKEN_LENGTH:
continue
if tok in seen:
continue
seen.add(tok)
tokens.append(tok)
return tokens

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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,91 @@
// Fusion bank reconciliation design tokens.
//
// Mirrors Enterprise's color/spacing scale where it makes sense, with
// fusion-specific additions for AI confidence bands and the suggestion
// strip. All values can be overridden in dark_mode.scss.
// ============================================================
// Colors — semantic
// ============================================================
$fusion-color-bg-primary: #ffffff;
$fusion-color-bg-secondary: #f9fafb;
$fusion-color-bg-tertiary: #f3f4f6;
$fusion-color-border: #e5e7eb;
$fusion-color-border-strong: #d1d5db;
$fusion-color-text-primary: #111827;
$fusion-color-text-secondary: #6b7280;
$fusion-color-text-muted: #9ca3af;
$fusion-color-text-inverse: #ffffff;
$fusion-color-accent: #3b82f6; // primary brand blue
$fusion-color-accent-hover: #2563eb;
$fusion-color-accent-bg: #eff6ff;
// ============================================================
// AI Confidence band colors
// ============================================================
$fusion-confidence-high: #10b981; // green
$fusion-confidence-high-bg: #ecfdf5;
$fusion-confidence-medium: #f59e0b; // amber
$fusion-confidence-medium-bg: #fffbeb;
$fusion-confidence-low: #ef4444; // red
$fusion-confidence-low-bg: #fef2f2;
$fusion-confidence-none: #9ca3af; // gray
$fusion-confidence-none-bg: #f3f4f6;
// ============================================================
// Reconciliation state colors
// ============================================================
$fusion-state-pending-bg: #fef3c7; // amber-100
$fusion-state-reconciled-bg: #d1fae5; // emerald-100
$fusion-state-partial-bg: #fde68a; // amber-200
// ============================================================
// Spacing scale (4px increments)
// ============================================================
$fusion-space-1: 0.25rem; // 4px
$fusion-space-2: 0.5rem; // 8px
$fusion-space-3: 0.75rem; // 12px
$fusion-space-4: 1rem; // 16px
$fusion-space-5: 1.25rem; // 20px
$fusion-space-6: 1.5rem; // 24px
$fusion-space-8: 2rem; // 32px
// ============================================================
// Typography
// ============================================================
$fusion-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
$fusion-font-size-xs: 0.75rem; // 12px
$fusion-font-size-sm: 0.875rem; // 14px
$fusion-font-size-base: 1rem; // 16px
$fusion-font-size-lg: 1.125rem; // 18px
$fusion-font-size-xl: 1.25rem; // 20px
$fusion-font-weight-normal: 400;
$fusion-font-weight-medium: 500;
$fusion-font-weight-semibold: 600;
$fusion-font-weight-bold: 700;
// ============================================================
// Borders + radii
// ============================================================
$fusion-border-radius-sm: 0.25rem;
$fusion-border-radius: 0.375rem;
$fusion-border-radius-md: 0.5rem;
$fusion-border-radius-lg: 0.75rem;
// ============================================================
// Shadows
// ============================================================
$fusion-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$fusion-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
$fusion-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
$fusion-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
// ============================================================
// Animation
// ============================================================
$fusion-transition-fast: 150ms ease-in-out;
$fusion-transition-base: 200ms ease-in-out;
$fusion-transition-slow: 300ms ease-in-out;

View File

@@ -0,0 +1,90 @@
@import "variables";
// ============================================================
// AI Suggestion strip (inline, on each statement line card)
// ============================================================
.o_fusion_ai_suggestion {
margin-top: $fusion-space-3;
padding: $fusion-space-3;
border-radius: $fusion-border-radius;
border: 1px solid;
display: flex;
align-items: center;
gap: $fusion-space-3;
font-size: $fusion-font-size-sm;
transition: all $fusion-transition-base;
// Confidence bands — apply via [data-band="..."] attribute
&[data-band="high"] {
background: $fusion-confidence-high-bg;
border-color: $fusion-confidence-high;
.o_fusion_confidence_value { color: $fusion-confidence-high; }
}
&[data-band="medium"] {
background: $fusion-confidence-medium-bg;
border-color: $fusion-confidence-medium;
.o_fusion_confidence_value { color: $fusion-confidence-medium; }
}
&[data-band="low"] {
background: $fusion-confidence-low-bg;
border-color: $fusion-confidence-low;
.o_fusion_confidence_value { color: $fusion-confidence-low; }
}
&[data-band="none"] {
background: $fusion-confidence-none-bg;
border-color: $fusion-confidence-none;
opacity: 0.7;
}
.o_fusion_confidence_badge {
font-weight: $fusion-font-weight-bold;
font-size: $fusion-font-size-base;
white-space: nowrap;
}
.o_fusion_suggestion_text {
flex: 1;
color: $fusion-color-text-primary;
.o_fusion_reasoning {
color: $fusion-color-text-secondary;
font-style: italic;
margin-top: $fusion-space-1;
}
}
.o_fusion_suggestion_actions {
display: flex;
gap: $fusion-space-2;
}
}
// ============================================================
// Alternatives panel (expandable list of other suggestions)
// ============================================================
.o_fusion_alternatives_panel {
margin-top: $fusion-space-2;
padding: $fusion-space-3;
background: $fusion-color-bg-secondary;
border: 1px solid $fusion-color-border;
border-radius: $fusion-border-radius;
font-size: $fusion-font-size-sm;
.o_fusion_alternative {
padding: $fusion-space-2 0;
border-bottom: 1px solid $fusion-color-border;
display: flex;
justify-content: space-between;
align-items: center;
&:last-child { border-bottom: none; }
.alt_confidence {
font-weight: $fusion-font-weight-medium;
margin-right: $fusion-space-2;
}
}
}

View File

@@ -0,0 +1,152 @@
@import "variables";
// ============================================================
// Bank reconciliation kanban container
// ============================================================
.o_fusion_bank_rec {
background: $fusion-color-bg-secondary;
min-height: 100vh;
font-family: $fusion-font-family;
color: $fusion-color-text-primary;
// Header bar with stats
&_header {
background: $fusion-color-bg-primary;
border-bottom: 1px solid $fusion-color-border;
padding: $fusion-space-4 $fusion-space-6;
display: flex;
justify-content: space-between;
align-items: center;
h1 {
font-size: $fusion-font-size-xl;
font-weight: $fusion-font-weight-semibold;
margin: 0;
}
.o_fusion_stats {
display: flex;
gap: $fusion-space-6;
font-size: $fusion-font-size-sm;
color: $fusion-color-text-secondary;
.stat-value {
font-weight: $fusion-font-weight-semibold;
color: $fusion-color-text-primary;
margin-left: $fusion-space-1;
}
}
}
// Statement line cards (kanban tile)
&_line {
background: $fusion-color-bg-primary;
border: 1px solid $fusion-color-border;
border-radius: $fusion-border-radius-md;
padding: $fusion-space-4;
margin-bottom: $fusion-space-3;
cursor: pointer;
transition: all $fusion-transition-base;
position: relative;
&:hover {
border-color: $fusion-color-accent;
box-shadow: $fusion-shadow-md;
}
&.o_fusion_selected {
border-color: $fusion-color-accent;
background: $fusion-color-accent-bg;
}
&_header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $fusion-space-2;
.o_fusion_amount {
font-size: $fusion-font-size-lg;
font-weight: $fusion-font-weight-semibold;
&.negative {
color: $fusion-confidence-low;
}
}
.o_fusion_date {
font-size: $fusion-font-size-sm;
color: $fusion-color-text-secondary;
}
}
&_body {
font-size: $fusion-font-size-sm;
color: $fusion-color-text-secondary;
.o_fusion_partner {
font-weight: $fusion-font-weight-medium;
color: $fusion-color-text-primary;
margin-right: $fusion-space-2;
}
.o_fusion_memo {
font-style: italic;
color: $fusion-color-text-muted;
}
}
// Attachment count badge
.o_fusion_attachments_badge {
position: absolute;
top: $fusion-space-2;
right: $fusion-space-2;
background: $fusion-color-bg-tertiary;
border-radius: $fusion-border-radius;
padding: $fusion-space-1 $fusion-space-2;
font-size: $fusion-font-size-xs;
color: $fusion-color-text-secondary;
}
}
// Detail/edit panel (right side)
&_detail {
background: $fusion-color-bg-primary;
border-left: 1px solid $fusion-color-border;
padding: $fusion-space-6;
h2 {
font-size: $fusion-font-size-lg;
font-weight: $fusion-font-weight-semibold;
margin: 0 0 $fusion-space-4;
}
}
// Action buttons
.btn_fusion {
padding: $fusion-space-2 $fusion-space-4;
border-radius: $fusion-border-radius;
font-size: $fusion-font-size-sm;
font-weight: $fusion-font-weight-medium;
border: 1px solid $fusion-color-border;
background: $fusion-color-bg-primary;
color: $fusion-color-text-primary;
cursor: pointer;
transition: all $fusion-transition-fast;
&:hover {
background: $fusion-color-bg-tertiary;
}
&.btn_fusion_primary {
background: $fusion-color-accent;
border-color: $fusion-color-accent;
color: $fusion-color-text-inverse;
&:hover {
background: $fusion-color-accent-hover;
border-color: $fusion-color-accent-hover;
}
}
}
}

View File

@@ -0,0 +1,64 @@
@import "variables";
// Activated via [data-color-scheme="dark"] on body or any ancestor.
// Mirrors Odoo's standard dark-mode trigger pattern.
[data-color-scheme="dark"] .o_fusion_bank_rec {
background: #1f2937;
color: #f9fafb;
&_header,
&_line,
&_detail {
background: #111827;
border-color: #374151;
color: #f9fafb;
}
&_line {
&:hover { border-color: #60a5fa; }
&.o_fusion_selected {
background: #1e3a8a;
border-color: #60a5fa;
}
&_header .o_fusion_date,
&_body { color: #d1d5db; }
.o_fusion_attachments_badge {
background: #374151;
color: #d1d5db;
}
}
.btn_fusion {
background: #374151;
border-color: #4b5563;
color: #f9fafb;
&:hover { background: #4b5563; }
&.btn_fusion_primary {
background: #3b82f6;
border-color: #3b82f6;
&:hover {
background: #2563eb;
border-color: #2563eb;
}
}
}
// AI suggestion strip — soften background colors for dark mode
.o_fusion_ai_suggestion {
&[data-band="high"] { background: rgba(16, 185, 129, 0.1); }
&[data-band="medium"] { background: rgba(245, 158, 11, 0.1); }
&[data-band="low"] { background: rgba(239, 68, 68, 0.1); }
&[data-band="none"] { background: rgba(156, 163, 175, 0.1); }
}
.o_fusion_alternatives_panel {
background: #1f2937;
border-color: #374151;
}
}

View File

@@ -0,0 +1,277 @@
/** @odoo-module **/
/**
* Bank reconciliation service.
*
* Central data layer + reactive state for the OWL bank-rec widget.
* Components inject this service via useService("fusion_bank_reconciliation")
* and read/write state through its methods.
*
* Wraps the 10 JSON-RPC endpoints from controllers/bank_rec_controller.py.
*/
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
const ENDPOINT_BASE = "/fusion/bank_rec";
export class BankReconciliationService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
this.notification = services.notification;
// Reactive state — components depend on it via useState/reactive
this.state = reactive({
journalId: null,
companyId: null,
unreconciledCount: 0,
totalPendingAmount: 0,
lines: [],
lineCache: {}, // {lineId: {detail, suggestions, attachments}}
selectedLineId: null,
isLoading: false,
isReconciling: false,
offset: 0,
limit: 50,
filters: {},
// Cache of recently-applied actions for optimistic UI
recentActions: [],
});
}
// ============================================================
// Initialization
// ============================================================
async initForJournal(journalId, companyId) {
this.state.journalId = journalId;
this.state.companyId = companyId;
this.state.isLoading = true;
try {
const stateInfo = await this.rpc(`${ENDPOINT_BASE}/get_state`, {
journal_id: journalId, company_id: companyId,
});
this.state.unreconciledCount = stateInfo.unreconciled_count;
this.state.totalPendingAmount = stateInfo.total_pending_amount;
await this.loadLines({ reset: true });
} finally {
this.state.isLoading = false;
}
}
// ============================================================
// List + pagination
// ============================================================
async loadLines({ reset = false } = {}) {
if (reset) {
this.state.offset = 0;
this.state.lines = [];
}
this.state.isLoading = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/list_unreconciled`, {
journal_id: this.state.journalId,
company_id: this.state.companyId,
limit: this.state.limit,
offset: this.state.offset,
...this.state.filters,
});
if (reset) {
this.state.lines = result.lines;
} else {
this.state.lines = [...this.state.lines, ...result.lines];
}
this.state.unreconciledCount = result.total;
} finally {
this.state.isLoading = false;
}
}
async loadMore() {
this.state.offset += this.state.limit;
await this.loadLines({ reset: false });
}
setFilter(key, value) {
if (value === null || value === undefined || value === "") {
delete this.state.filters[key];
} else {
this.state.filters[key] = value;
}
this.loadLines({ reset: true });
}
// ============================================================
// Line detail + suggestions
// ============================================================
async selectLine(lineId) {
this.state.selectedLineId = lineId;
if (!this.state.lineCache[lineId]) {
await this.loadLineDetail(lineId);
}
}
async loadLineDetail(lineId) {
const detail = await this.rpc(`${ENDPOINT_BASE}/get_line_detail`, {
statement_line_id: lineId,
});
this.state.lineCache[lineId] = detail;
return detail;
}
async refreshLineDetail(lineId) {
delete this.state.lineCache[lineId];
return await this.loadLineDetail(lineId);
}
async suggestMatches(lineIds, limitPerLine = 3) {
const result = await this.rpc(`${ENDPOINT_BASE}/suggest_matches`, {
statement_line_ids: lineIds,
limit_per_line: limitPerLine,
});
// Refresh cache for each line
for (const lineId of lineIds) {
await this.refreshLineDetail(lineId);
}
return result.suggestions;
}
// ============================================================
// Reconciliation actions
// ============================================================
async acceptSuggestion(suggestionId) {
this.state.isReconciling = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/accept_suggestion`, {
suggestion_id: suggestionId,
});
this._removeReconciledLineFromState(this.state.selectedLineId);
this.state.unreconciledCount = result.unreconciled_count_after;
this.notification.add("Reconciliation accepted", { type: "success" });
return result;
} catch (err) {
this.notification.add(`Accept failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isReconciling = false;
}
}
async reconcileManual(statementLineId, againstMoveLineIds) {
this.state.isReconciling = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/reconcile_manual`, {
statement_line_id: statementLineId,
against_move_line_ids: againstMoveLineIds,
});
this._removeReconciledLineFromState(statementLineId);
this.notification.add("Reconciled", { type: "success" });
return result;
} catch (err) {
this.notification.add(`Reconcile failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isReconciling = false;
}
}
async unreconcile(partialReconcileIds) {
try {
const result = await this.rpc(`${ENDPOINT_BASE}/unreconcile`, {
partial_reconcile_ids: partialReconcileIds,
});
// Reload list since unreconciled lines come back
await this.loadLines({ reset: true });
this.notification.add("Unreconciled", { type: "info" });
return result;
} catch (err) {
this.notification.add(`Unreconcile failed: ${err.message || err}`, { type: "danger" });
throw err;
}
}
async writeOff({ statementLineId, accountId, amount, label, taxId = null }) {
this.state.isReconciling = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/write_off`, {
statement_line_id: statementLineId,
account_id: accountId,
amount: amount,
label: label,
tax_id: taxId,
});
this._removeReconciledLineFromState(statementLineId);
this.notification.add("Write-off applied", { type: "success" });
return result;
} catch (err) {
this.notification.add(`Write-off failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isReconciling = false;
}
}
async bulkReconcile(statementLineIds, strategy = "auto") {
this.state.isReconciling = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/bulk_reconcile`, {
statement_line_ids: statementLineIds,
strategy: strategy,
});
await this.loadLines({ reset: true });
const msg = `${result.reconciled_count} reconciled, ${result.skipped} skipped`;
this.notification.add(msg, { type: "success" });
return result;
} catch (err) {
this.notification.add(`Bulk failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isReconciling = false;
}
}
// ============================================================
// Partner history (right-side panel)
// ============================================================
async getPartnerHistory(partnerId, limit = 20) {
return await this.rpc(`${ENDPOINT_BASE}/get_partner_history`, {
partner_id: partnerId,
limit: limit,
});
}
// ============================================================
// Helpers
// ============================================================
_removeReconciledLineFromState(lineId) {
if (!lineId) return;
this.state.lines = this.state.lines.filter((l) => l.id !== lineId);
if (this.state.selectedLineId === lineId) {
this.state.selectedLineId = null;
}
delete this.state.lineCache[lineId];
if (this.state.unreconciledCount > 0) {
this.state.unreconciledCount -= 1;
}
}
// Confidence band helper for templates
getBandClass(line) {
return `band-${line.fusion_confidence_band || "none"}`;
}
}
export const bankReconciliationService = {
dependencies: ["rpc", "notification"],
start(env, services) {
return new BankReconciliationService(env, services);
},
};
registry.category("services").add("fusion_bank_reconciliation", bankReconciliationService);

View File

@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecKanbanController">
<div class="o_fusion_bank_rec">
<div class="o_fusion_bank_rec_header">
<div>
<h1>Bank Reconciliation</h1>
<div t-if="state.journalId" class="text-muted">
Journal #<t t-esc="state.journalId"/>
</div>
</div>
<div class="o_fusion_stats">
<div>
Unreconciled:
<span class="stat-value"><t t-esc="state.unreconciledCount"/></span>
</div>
<div>
Total pending:
<span class="stat-value">
$<t t-esc="formatCurrency(state.totalPendingAmount)"/>
</span>
</div>
</div>
</div>
<div class="d-flex" style="gap: 1rem; padding: 1rem;">
<div style="flex: 1 1 60%; max-width: 60%;">
<div t-if="state.isLoading" class="text-center p-4 text-muted">
Loading…
</div>
<div t-elif="state.lines.length === 0" class="text-center p-4 text-muted">
Nothing to reconcile.
</div>
<div t-else="">
<BankRecLineCard
t-foreach="state.lines"
t-as="line"
t-key="line.id"
line="line"
selected="state.selectedLineId === line.id"
onSelect="() => onSelectLine(line.id)"
formatCurrency="formatCurrency.bind(this)"
/>
<div t-if="state.lines.length lt state.unreconciledCount"
class="text-center mt-3">
<button class="btn_fusion" t-on-click="onLoadMore">
Load more
</button>
</div>
</div>
</div>
<div style="flex: 1 1 40%; max-width: 40%;" class="o_fusion_bank_rec_detail">
<t t-if="state.selectedLineId">
<t t-set="detail" t-value="state.lineCache[state.selectedLineId]"/>
<div t-if="!detail" class="text-muted">Loading detail…</div>
<div t-else="">
<h2>
<t t-esc="detail.line.payment_ref || 'No reference'"/>
</h2>
<div class="text-muted mb-3">
<span><t t-esc="detail.line.date"/></span>
<span class="ms-2">
$<t t-esc="formatCurrency(detail.line.amount)"/>
</span>
<span t-if="detail.line.partner_name" class="ms-2">
· <t t-esc="detail.line.partner_name"/>
</span>
</div>
<div t-if="detail.suggestions.length === 0">
<button class="btn_fusion btn_fusion_primary"
t-on-click="() => onSuggestForLine(detail.line.id)">
Get AI suggestions
</button>
</div>
<div t-else="">
<h5>AI Suggestions</h5>
<div t-foreach="detail.suggestions" t-as="sug" t-key="sug.id"
class="o_fusion_ai_suggestion"
t-att-data-band="confidenceBandLabel(sug.confidence >= 0.85 ? 'high' : sug.confidence >= 0.6 ? 'medium' : sug.confidence > 0 ? 'low' : 'none').toLowerCase()">
<div class="o_fusion_confidence_badge">
<t t-esc="(sug.confidence * 100).toFixed(0)"/>%
</div>
<div class="o_fusion_suggestion_text">
<div><t t-esc="sug.reasoning"/></div>
</div>
<div class="o_fusion_suggestion_actions">
<button class="btn_fusion btn_fusion_primary"
t-on-click="() => onAcceptSuggestion(sug.id)">
Accept
</button>
</div>
</div>
</div>
</div>
</t>
<t t-else="">
<div class="text-muted">
Select a bank line on the left to see details.
</div>
</t>
</div>
</div>
</div>
</t>
<t t-name="fusion_accounting_bank_rec.BankRecLineCard">
<div class="o_fusion_bank_rec_line"
t-att-class="props.selected ? 'o_fusion_selected' : ''"
t-on-click="props.onSelect">
<div class="o_fusion_bank_rec_line_header">
<div class="o_fusion_amount" t-att-class="props.line.amount lt 0 ? 'negative' : ''">
$<t t-esc="props.formatCurrency(props.line.amount)"/>
</div>
<div class="o_fusion_date">
<t t-esc="props.line.date"/>
</div>
</div>
<div class="o_fusion_bank_rec_line_body">
<span t-if="props.line.partner_name" class="o_fusion_partner">
<t t-esc="props.line.partner_name"/>
</span>
<span class="o_fusion_memo">
<t t-esc="props.line.payment_ref || 'No memo'"/>
</span>
</div>
<div t-if="props.line.attachment_count" class="o_fusion_attachments_badge">
📎 <t t-esc="props.line.attachment_count"/>
</div>
<div t-if="props.line.fusion_confidence_band and props.line.fusion_confidence_band !== 'none'"
t-att-class="'o_fusion_ai_suggestion ' + 'band-' + props.line.fusion_confidence_band"
t-att-data-band="props.line.fusion_confidence_band">
<div class="o_fusion_confidence_badge">
AI Suggestion Available
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,81 @@
/** @odoo-module **/
/**
* Bank reconciliation kanban controller.
*
* Top-level OWL component for the fusion bank-rec widget. Hosts:
* - Header bar (journal name, unreconciled count, total pending amount)
* - Left column: list of unreconciled bank line cards
* - Right column: detail panel for the selected line
*
* Reads journal_id + company_id from action context. Wires up the
* fusion_bank_reconciliation service for all data + reactivity.
*/
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { BankRecLineCard } from "./bank_rec_kanban_renderer";
export class BankRecKanbanController extends Component {
static template = "fusion_accounting_bank_rec.BankRecKanbanController";
static components = { BankRecLineCard };
static props = {
action: { type: Object, optional: true },
actionId: { type: [Number, String], optional: true },
className: { type: String, optional: true },
"*": true,
};
setup() {
this.bankRec = useService("fusion_bank_reconciliation");
this.notification = useService("notification");
this.state = useState(this.bankRec.state);
const ctx = this.props.action?.context || {};
const journalId = ctx.default_journal_id || ctx.active_id;
const companyId = ctx.allowed_company_ids?.[0]
|| this.env.services.user?.context?.allowed_company_ids?.[0];
onWillStart(async () => {
if (journalId && companyId) {
await this.bankRec.initForJournal(journalId, companyId);
}
});
}
onSelectLine(lineId) {
this.bankRec.selectLine(lineId);
}
async onLoadMore() {
await this.bankRec.loadMore();
}
async onSuggestForLine(lineId) {
await this.bankRec.suggestMatches([lineId]);
}
async onAcceptSuggestion(suggestionId) {
await this.bankRec.acceptSuggestion(suggestionId);
}
async onUnreconcile(partialIds) {
await this.bankRec.unreconcile(partialIds);
}
formatCurrency(amount) {
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
confidenceBandLabel(band) {
return {
high: "High",
medium: "Medium",
low: "Low",
none: "None",
}[band] || "—";
}
}

View File

@@ -0,0 +1,20 @@
/** @odoo-module **/
/**
* Bank reconciliation line-card renderer.
*
* Renders one unreconciled bank line as a card in the kanban list.
* Owned by BankRecKanbanController; receives line + selected flag as props.
*/
import { Component } from "@odoo/owl";
export class BankRecLineCard extends Component {
static template = "fusion_accounting_bank_rec.BankRecLineCard";
static props = {
line: { type: Object },
selected: { type: Boolean, optional: true },
onSelect: { type: Function },
formatCurrency: { type: Function },
};
}

View File

@@ -0,0 +1,20 @@
/** @odoo-module **/
/**
* Custom view type "fusion_bank_rec_kanban" — registers the controller
* with the views registry so window actions can specify
* <field name="view_mode">fusion_bank_rec_kanban</field>.
*/
import { registry } from "@web/core/registry";
import { BankRecKanbanController } from "./bank_rec_kanban_controller";
export const fusionBankRecKanbanView = {
type: "fusion_bank_rec_kanban",
Controller: BankRecKanbanController,
display_name: "Bank Reconciliation",
icon: "fa-exchange",
multiRecord: true,
};
registry.category("views").add("fusion_bank_rec_kanban", fusionBankRecKanbanView);

View File

@@ -0,0 +1,18 @@
from . import test_memo_tokenizer
from . import test_exchange_diff
from . import test_matching_strategies
from . import test_ai_suggestion_lifecycle
from . import test_precedent_lookup
from . import test_pattern_extraction
from . import test_confidence_scoring
from . import test_reconcile_engine_unit
from . import test_reconcile_engine_property
from . import test_factories
from . import test_reconcile_engine_integration
from . import test_bank_rec_prompt
from . import test_bank_rec_adapter
from . import test_bank_rec_tools
from . import test_legacy_tools_refactor
from . import test_mv_unreconciled
from . import test_cron_methods
from . import test_controller

View File

@@ -0,0 +1,185 @@
"""Test data factories for fusion_accounting_bank_rec.
Provides recordset builders for use across all test files. Sane defaults
let tests be readable: `make_bank_line(env, amount=100, partner=p)` instead
of 30 lines of recordset setup.
These factories work against the real Odoo registry — they exercise the
same code paths as production. Each factory is idempotent in the sense
that calling it multiple times returns separate records.
"""
from datetime import date, timedelta
from odoo import fields
# ============================================================
# Bank journal + statements
# ============================================================
def make_bank_journal(env, *, name='Test Bank', code=None):
"""Create a bank journal. `code` defaults to first 5 chars of `name`."""
code = code or name[:5].upper().replace(' ', '')
return env['account.journal'].create({
'name': name,
'type': 'bank',
'code': code,
})
def make_bank_statement(env, *, journal=None, name='Test Statement', date_=None):
"""Create a bank statement. Auto-creates a bank journal if not provided."""
journal = journal or make_bank_journal(env)
return env['account.bank.statement'].create({
'name': name,
'journal_id': journal.id,
'date': date_ or date.today(),
})
def make_bank_line(env, *, journal=None, statement=None, amount=100.00,
partner=None, memo='Test line', date_=None):
"""Create a bank statement line. Creates statement if not provided.
Most-common factory in tests. Defaults give a $100 line with no partner."""
if not statement:
statement = make_bank_statement(env, journal=journal, date_=date_)
return env['account.bank.statement.line'].create({
'statement_id': statement.id,
'journal_id': statement.journal_id.id,
'date': date_ or date.today(),
'payment_ref': memo,
'amount': amount,
'partner_id': partner.id if partner else False,
})
# ============================================================
# Invoices + journal items
# ============================================================
def _ensure_test_product(env):
"""Get or create a service product suitable for invoice lines."""
product = env['product.product'].search([('type', '=', 'service')], limit=1)
if not product:
product = env['product.product'].create({
'name': 'Fusion Test Service',
'type': 'service',
})
return product
def make_invoice(env, *, partner, amount=100.00, date_=None, currency=None,
product=None, posted=True):
"""Create a customer invoice (out_invoice). Posted by default."""
product = product or _ensure_test_product(env)
vals = {
'move_type': 'out_invoice',
'partner_id': partner.id,
'invoice_date': date_ or date.today(),
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'name': 'Test invoice line',
'quantity': 1,
'price_unit': amount,
})],
}
if currency:
vals['currency_id'] = currency.id
move = env['account.move'].create(vals)
if posted:
move.action_post()
return move
def make_vendor_bill(env, *, partner, amount=100.00, date_=None, currency=None,
product=None, posted=True):
"""Create a vendor bill (in_invoice). Posted by default."""
product = product or _ensure_test_product(env)
vals = {
'move_type': 'in_invoice',
'partner_id': partner.id,
'invoice_date': date_ or date.today(),
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'name': 'Test bill line',
'quantity': 1,
'price_unit': amount,
})],
}
if currency:
vals['currency_id'] = currency.id
move = env['account.move'].create(vals)
if posted:
move.action_post()
return move
# ============================================================
# Suggestions + patterns + precedents (fusion-specific)
# ============================================================
def make_suggestion(env, *, statement_line, candidate_move_lines=None,
confidence=0.92, rank=1, reasoning='Test suggestion',
state='pending'):
"""Create a fusion.reconcile.suggestion against a bank line."""
candidate_ids = candidate_move_lines.ids if candidate_move_lines else []
return env['fusion.reconcile.suggestion'].create({
'company_id': env.company.id,
'statement_line_id': statement_line.id,
'proposed_move_line_ids': [(6, 0, candidate_ids)],
'confidence': confidence,
'rank': rank,
'reasoning': reasoning,
'state': state,
})
def make_pattern(env, *, partner, reconcile_count=10, pref_strategy='exact_amount',
typical_cadence_days=14.0, common_memo_tokens='RBC,ETF'):
"""Create a fusion.reconcile.pattern for a partner."""
return env['fusion.reconcile.pattern'].create({
'company_id': env.company.id,
'partner_id': partner.id,
'reconcile_count': reconcile_count,
'pref_strategy': pref_strategy,
'typical_cadence_days': typical_cadence_days,
'common_memo_tokens': common_memo_tokens,
})
def make_precedent(env, *, partner, amount=1847.50, days_ago=14,
memo_tokens='RBC,ETF,REF', count=1, source='manual'):
"""Create a fusion.reconcile.precedent."""
return env['fusion.reconcile.precedent'].create({
'company_id': env.company.id,
'partner_id': partner.id,
'amount': amount,
'currency_id': env.company.currency_id.id,
'date': date.today() - timedelta(days=days_ago),
'memo_tokens': memo_tokens,
'matched_move_line_count': count,
'reconciled_at': fields.Datetime.now(),
'source': source,
})
# ============================================================
# Convenience composite — bank line + matching invoice ready to reconcile
# ============================================================
def make_reconcileable_pair(env, *, amount=100.00, partner=None, date_=None):
"""Create a bank line + a customer invoice with the same partner+amount.
Returns (bank_line, invoice_recv_lines) ready to pass to engine.reconcile_one().
Returns:
(bank_line, invoice_receivable_lines) tuple
"""
if not partner:
partner = env['res.partner'].create({'name': 'Reconcile Test Partner'})
invoice = make_invoice(env, partner=partner, amount=amount, date_=date_)
bank_line = make_bank_line(env, amount=amount, partner=partner, date_=date_)
recv_lines = invoice.line_ids.filtered(
lambda l: l.account_id.account_type == 'asset_receivable')
return (bank_line, recv_lines)

View File

@@ -0,0 +1,86 @@
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSuggestionLifecycle(TransactionCase):
"""The fusion.reconcile.suggestion state machine + computed band."""
def setUp(self):
super().setUp()
journal = self.env['account.journal'].create({
'name': 'Test Bank Suggestion',
'type': 'bank',
'code': 'TBSG',
})
statement = self.env['account.bank.statement'].create({
'name': 'Test Statement',
'journal_id': journal.id,
})
self.line = self.env['account.bank.statement.line'].create({
'statement_id': statement.id,
'journal_id': journal.id,
'date': '2026-04-19',
'payment_ref': 'Test for suggestion',
'amount': 100.00,
})
def _make_suggestion(self, confidence=0.92, **vals):
defaults = {
'company_id': self.env.company.id,
'statement_line_id': self.line.id,
'confidence': confidence,
'rank': 1,
'reasoning': 'Test',
}
defaults.update(vals)
return self.env['fusion.reconcile.suggestion'].create(defaults)
def test_compute_band_high(self):
sug = self._make_suggestion(confidence=0.96)
self.assertEqual(sug.confidence_band, 'high')
def test_compute_band_medium(self):
sug = self._make_suggestion(confidence=0.75)
self.assertEqual(sug.confidence_band, 'medium')
def test_compute_band_low(self):
sug = self._make_suggestion(confidence=0.55)
self.assertEqual(sug.confidence_band, 'low')
def test_compute_band_none(self):
sug = self._make_suggestion(confidence=0.30)
self.assertEqual(sug.confidence_band, 'none')
def test_default_state_is_pending(self):
sug = self._make_suggestion()
self.assertEqual(sug.state, 'pending')
def test_state_transition_to_accepted(self):
sug = self._make_suggestion()
sug.write({
'state': 'accepted',
'accepted_at': '2026-04-19 12:00:00',
'accepted_by': self.env.user.id,
})
self.assertEqual(sug.state, 'accepted')
self.assertTrue(sug.accepted_at)
self.assertEqual(sug.accepted_by, self.env.user)
def test_state_transition_to_rejected_with_reason(self):
sug = self._make_suggestion()
sug.write({
'state': 'rejected',
'rejected_at': '2026-04-19 12:05:00',
'rejected_reason': 'wrong_invoice',
})
self.assertEqual(sug.state, 'rejected')
self.assertEqual(sug.rejected_reason, 'wrong_invoice')
def test_state_transition_to_superseded(self):
sug = self._make_suggestion()
sug.write({'state': 'superseded'})
self.assertEqual(sug.state, 'superseded')
def test_currency_id_relates_to_line(self):
sug = self._make_suggestion()
self.assertEqual(sug.currency_id, self.line.currency_id)

View File

@@ -0,0 +1,81 @@
"""Tests for BankRecAdapter's fusion paths."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_ai.services.data_adapters.bank_rec import BankRecAdapter
from . import _factories as f
@tagged('post_install', '-at_install')
class TestBankRecAdapter(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Adapter Test Partner'})
self.adapter = BankRecAdapter(self.env)
def test_list_unreconciled_via_fusion_returns_base_fields(self):
bank_line = f.make_bank_line(
self.env, amount=100.00, partner=self.partner, memo='Adapter base test')
result = self.adapter.list_unreconciled_via_fusion(
company_id=self.env.company.id, limit=50)
ours = [r for r in result if r['id'] == bank_line.id]
self.assertEqual(len(ours), 1)
row = ours[0]
for f_name in ['id', 'date', 'payment_ref', 'amount', 'partner_id', 'journal_id']:
self.assertIn(f_name, row)
self.assertIn('fusion_top_suggestion_id', row)
self.assertIn('fusion_confidence_band', row)
self.assertIn('attachment_count', row)
def test_list_unreconciled_via_community_omits_fusion_fields(self):
bank_line = f.make_bank_line(self.env, amount=200.00, partner=self.partner)
result = self.adapter.list_unreconciled_via_community(
company_id=self.env.company.id, limit=50)
ours = [r for r in result if r['id'] == bank_line.id]
self.assertEqual(len(ours), 1)
self.assertNotIn('fusion_top_suggestion_id', ours[0])
def test_suggest_matches_via_fusion_returns_dict(self):
partner = self.env['res.partner'].create({'name': 'Suggest Adapter'})
invoice = f.make_invoice(self.env, partner=partner, amount=350.00)
bank_line = f.make_bank_line(self.env, amount=350.00, partner=partner)
result = self.adapter.suggest_matches_via_fusion(
statement_line_ids=[bank_line.id], limit_per_line=3)
self.assertIsInstance(result, dict)
self.assertIn(bank_line.id, result)
self.assertGreater(len(result[bank_line.id]), 0)
def test_suggest_matches_via_community_returns_empty(self):
bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner)
result = self.adapter.suggest_matches_via_community(
statement_line_ids=[bank_line.id])
self.assertEqual(result, {})
def test_accept_suggestion_via_fusion(self):
partner = self.env['res.partner'].create({'name': 'Accept Adapter'})
invoice = f.make_invoice(self.env, partner=partner, amount=425.00)
recv_lines = invoice.line_ids.filtered(
lambda l: l.account_id.account_type == 'asset_receivable')
bank_line = f.make_bank_line(self.env, amount=425.00, partner=partner)
sug = f.make_suggestion(
self.env, statement_line=bank_line,
candidate_move_lines=recv_lines, confidence=0.95)
result = self.adapter.accept_suggestion_via_fusion(suggestion_id=sug.id)
self.assertIn('partial_ids', result)
self.assertGreater(len(result['partial_ids']), 0)
def test_accept_suggestion_via_community_raises(self):
with self.assertRaises(NotImplementedError):
self.adapter.accept_suggestion_via_community(suggestion_id=1)
def test_unreconcile_via_fusion(self):
partner = self.env['res.partner'].create({'name': 'Unrec Adapter'})
bank_line, recv_lines = f.make_reconcileable_pair(
self.env, amount=275.00, partner=partner)
rec_result = self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
partial_ids = rec_result['partial_ids']
result = self.adapter.unreconcile_via_fusion(
partial_reconcile_ids=partial_ids)
self.assertIn('unreconciled_line_ids', result)
self.assertGreater(len(result['unreconciled_line_ids']), 0)

View File

@@ -0,0 +1,92 @@
"""Smoke tests for bank_rec_prompt module."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import (
SYSTEM_PROMPT,
build_prompt,
)
from odoo.addons.fusion_accounting_bank_rec.services.confidence_scoring import (
ScoredCandidate,
)
from . import _factories as f
@tagged('post_install', '-at_install')
class TestBankRecPrompt(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Prompt Test Partner'})
self.bank_line = f.make_bank_line(
self.env,
amount=1847.50,
partner=self.partner,
memo='RBC ETF DEP REF 4831',
)
self.scored = [
ScoredCandidate(
candidate_id=101,
confidence=0.92,
reasoning='Exact amount match',
score_amount_match=1.0,
score_partner_pattern=0.5,
score_precedent_similarity=0.85,
),
ScoredCandidate(
candidate_id=102,
confidence=0.71,
reasoning='Close amount',
score_amount_match=0.95,
score_partner_pattern=0.5,
score_precedent_similarity=0.6,
),
]
def test_system_prompt_requires_json_output(self):
self.assertIn('JSON', SYSTEM_PROMPT)
self.assertIn('"ranked"', SYSTEM_PROMPT)
def test_build_prompt_returns_tuple(self):
result = build_prompt(self.bank_line, self.scored)
self.assertEqual(len(result), 2)
system, user = result
self.assertIsInstance(system, str)
self.assertIsInstance(user, str)
def test_user_prompt_includes_bank_line_details(self):
_, user = build_prompt(self.bank_line, self.scored)
self.assertIn('1847.5', user)
self.assertIn('RBC ETF DEP REF 4831', user)
self.assertIn('Prompt Test Partner', user)
def test_user_prompt_includes_all_candidates(self):
_, user = build_prompt(self.bank_line, self.scored)
self.assertIn('candidate_id=101', user)
self.assertIn('candidate_id=102', user)
def test_user_prompt_omits_pattern_section_when_none(self):
_, user = build_prompt(self.bank_line, self.scored, pattern=None)
self.assertNotIn('PARTNER PATTERN', user)
def test_user_prompt_includes_pattern_section_when_provided(self):
pattern = f.make_pattern(self.env, partner=self.partner, reconcile_count=15)
_, user = build_prompt(self.bank_line, self.scored, pattern=pattern)
self.assertIn('PARTNER PATTERN', user)
self.assertIn('15', user)
def test_user_prompt_includes_precedents_when_provided(self):
from odoo.addons.fusion_accounting_bank_rec.services.precedent_lookup import (
PrecedentMatch,
)
precedents = [
PrecedentMatch(
precedent_id=1,
amount=1847.50,
memo_tokens='RBC,ETF',
matched_move_line_count=1,
similarity_score=0.95,
),
]
_, user = build_prompt(self.bank_line, self.scored, precedents=precedents)
self.assertIn('RECENT PRECEDENTS', user)
self.assertIn('0.95', user)

View File

@@ -0,0 +1,84 @@
"""Smoke tests for the 5 new fusion bank-rec AI tools."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_ai.services.tools import bank_reconciliation as tools
from . import _factories as f
@tagged('post_install', '-at_install')
class TestFusionBankRecTools(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Tools Test Partner'})
def test_fusion_suggest_matches_returns_suggestions(self):
invoice = f.make_invoice(self.env, partner=self.partner, amount=550.00)
bank_line = f.make_bank_line(
self.env, amount=550.00, partner=self.partner, memo='Tool test')
result = tools.fusion_suggest_matches(self.env, {
'statement_line_ids': [bank_line.id],
'limit_per_line': 3,
})
self.assertIn('suggestions', result)
self.assertIn('count', result)
self.assertGreater(result['count'], 0)
def test_fusion_accept_suggestion_reconciles(self):
invoice = f.make_invoice(self.env, partner=self.partner, amount=625.00)
recv_lines = invoice.line_ids.filtered(
lambda l: l.account_id.account_type == 'asset_receivable')
bank_line = f.make_bank_line(self.env, amount=625.00, partner=self.partner)
sug = f.make_suggestion(
self.env, statement_line=bank_line,
candidate_move_lines=recv_lines, confidence=0.94)
result = tools.fusion_accept_suggestion(self.env, {'suggestion_id': sug.id})
self.assertEqual(result['status'], 'accepted')
self.assertGreater(len(result['partial_ids']), 0)
def test_fusion_reconcile_bank_line(self):
bank_line, recv_lines = f.make_reconcileable_pair(
self.env, amount=375.00, partner=self.partner)
result = tools.fusion_reconcile_bank_line(self.env, {
'statement_line_id': bank_line.id,
'against_move_line_ids': recv_lines.ids,
})
self.assertEqual(result['status'], 'reconciled')
self.assertTrue(result['is_reconciled'])
def test_fusion_unreconcile(self):
bank_line, recv_lines = f.make_reconcileable_pair(
self.env, amount=275.00, partner=self.partner)
rec = self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
partial_ids = rec['partial_ids']
result = tools.fusion_unreconcile(self.env, {
'partial_reconcile_ids': partial_ids,
})
self.assertEqual(result['status'], 'unreconciled')
self.assertGreater(result['count'], 0)
def test_fusion_get_pending_suggestions(self):
bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner)
sug = f.make_suggestion(
self.env, statement_line=bank_line,
candidate_move_lines=self.env['account.move.line'],
confidence=0.88, state='pending')
result = tools.fusion_get_pending_suggestions(self.env, {})
self.assertIn('count', result)
self.assertGreater(result['count'], 0)
ids = [s['id'] for s in result['suggestions']]
self.assertIn(sug.id, ids)
def test_fusion_get_pending_suggestions_filters_by_min_confidence(self):
bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner)
# One low-confidence suggestion
f.make_suggestion(self.env, statement_line=bank_line,
confidence=0.30, state='pending')
# One high-confidence
high = f.make_suggestion(self.env, statement_line=bank_line,
confidence=0.95, state='pending')
result = tools.fusion_get_pending_suggestions(
self.env, {'min_confidence': 0.80})
ids = [s['id'] for s in result['suggestions']]
self.assertIn(high.id, ids)

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,333 @@
"""Tests for the fusion bank-rec HTTP controller (Task 26).
Uses ``HttpCase`` so we exercise the full Werkzeug stack -- the JSON-RPC
dispatcher, auth check, and route resolution all run for real. Tests
authenticate as a Fusion Accounting administrator (the realistic role
for a user driving the bank-rec widget); a separate test confirms the
``auth='user'`` decorator rejects anonymous traffic.
"""
import json
from odoo.tests.common import HttpCase, new_test_user, tagged
from . import _factories as f
@tagged('post_install', '-at_install')
class TestBankRecController(HttpCase):
"""End-to-end coverage of the 10 JSON-RPC endpoints."""
USER_LOGIN = 'ctrl_test_user'
USER_PASSWORD = 'ctrl_test_user'
def setUp(self):
super().setUp()
# group_account_user grants accounting write perms AND auto-implies
# fusion_accounting_user via the security XML's implied_ids hook;
# group_fusion_accounting_admin grants full CRUD on the fusion
# suggestion / precedent / pattern models the engine writes to.
self.user = new_test_user(
self.env,
login=self.USER_LOGIN,
password=self.USER_PASSWORD,
groups=(
'base.group_user,'
'account.group_account_user,'
'fusion_accounting_core.group_fusion_accounting_admin'
),
)
self.partner = self.env['res.partner'].create(
{'name': 'Controller Test Partner'})
self.journal = f.make_bank_journal(
self.env, name='Ctrl Bank', code='CBNK')
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
def _jsonrpc(self, endpoint, params, *, authenticate=True):
"""POST a JSON-RPC envelope to ``/fusion/bank_rec/<endpoint>``.
Returns the ``result`` dict on success and fails the test if
the body has an ``error`` key (so endpoint test failures show
the actual server-side exception, not just the HTTP status).
"""
if authenticate:
self.authenticate(self.USER_LOGIN, self.USER_PASSWORD)
url = f'/fusion/bank_rec/{endpoint}'
body = {
'jsonrpc': '2.0',
'method': 'call',
'params': params,
'id': 1,
}
response = self.url_open(
url,
data=json.dumps(body),
headers={'Content-Type': 'application/json'},
)
self.assertEqual(
response.status_code, 200,
f"Endpoint {endpoint} returned {response.status_code}: "
f"{response.text[:300]}")
payload = response.json()
if 'error' in payload:
self.fail(
f"Endpoint {endpoint} errored: "
f"{json.dumps(payload['error'])[:600]}")
return payload.get('result', {})
# ------------------------------------------------------------------
# 1. get_state
# ------------------------------------------------------------------
def test_get_state(self):
result = self._jsonrpc('get_state', {
'journal_id': self.journal.id,
'company_id': self.env.company.id,
})
self.assertIn('journal', result)
self.assertEqual(result['journal']['id'], self.journal.id)
self.assertIn('unreconciled_count', result)
self.assertIn('total_pending_amount', result)
self.assertIn('last_statement_date', result)
# ------------------------------------------------------------------
# 2. list_unreconciled
# ------------------------------------------------------------------
def test_list_unreconciled(self):
# Reuse a single statement so we don't trip the
# (journal_id, name) uniqueness or hit the parent-move autocreate
# path twice in the same flush window.
statement = f.make_bank_statement(
self.env, journal=self.journal, name='List Stmt')
f.make_bank_line(
self.env, journal=self.journal, statement=statement,
amount=100, partner=self.partner, memo='List 1')
f.make_bank_line(
self.env, journal=self.journal, statement=statement,
amount=200, partner=self.partner, memo='List 2')
result = self._jsonrpc('list_unreconciled', {
'journal_id': self.journal.id,
'limit': 50,
'offset': 0,
'company_id': self.env.company.id,
})
self.assertIn('lines', result)
self.assertGreaterEqual(len(result['lines']), 2)
self.assertGreaterEqual(result['total'], 2)
first = result['lines'][0]
for key in ('id', 'amount', 'fusion_top_suggestion_id',
'fusion_confidence_band', 'attachment_count'):
self.assertIn(key, first)
# ------------------------------------------------------------------
# 3. get_line_detail
# ------------------------------------------------------------------
def test_get_line_detail(self):
line = f.make_bank_line(
self.env, journal=self.journal, amount=100, partner=self.partner)
f.make_suggestion(
self.env, statement_line=line, confidence=0.85)
result = self._jsonrpc(
'get_line_detail', {'statement_line_id': line.id})
self.assertEqual(result['line']['id'], line.id)
self.assertEqual(result['line']['amount'], 100.0)
self.assertGreaterEqual(len(result['suggestions']), 1)
sug = result['suggestions'][0]
for key in ('id', 'candidate_ids', 'confidence', 'rank',
'reasoning', 'scores'):
self.assertIn(key, sug)
# ------------------------------------------------------------------
# 4. suggest_matches
# ------------------------------------------------------------------
def test_suggest_matches(self):
f.make_invoice(self.env, partner=self.partner, amount=300)
line = f.make_bank_line(
self.env, journal=self.journal, amount=300, partner=self.partner)
result = self._jsonrpc('suggest_matches', {
'statement_line_ids': [line.id],
'limit_per_line': 3,
})
self.assertIn('suggestions', result)
self.assertIsInstance(result['suggestions'], dict)
# ------------------------------------------------------------------
# 5. accept_suggestion
# ------------------------------------------------------------------
def test_accept_suggestion(self):
invoice = f.make_invoice(
self.env, partner=self.partner, amount=400)
recv = invoice.line_ids.filtered(
lambda l: l.account_id.account_type == 'asset_receivable')
line = f.make_bank_line(
self.env, journal=self.journal, amount=400, partner=self.partner)
sug = f.make_suggestion(
self.env, statement_line=line,
candidate_move_lines=recv, confidence=0.92)
result = self._jsonrpc(
'accept_suggestion', {'suggestion_id': sug.id})
self.assertEqual(result['status'], 'accepted')
self.assertGreater(len(result['partial_ids']), 0)
self.assertIn('unreconciled_count_after', result)
# ------------------------------------------------------------------
# 6. reconcile_manual
# ------------------------------------------------------------------
def _make_pair(self, *, amount, statement=None):
"""Inline reconcile-able pair against ``self.journal``.
The shared ``make_reconcileable_pair`` factory creates a fresh bank
journal per call (default code 'TEST'), which collides with the
unique (code, company) constraint when used multiple times in one
test. Reusing ``self.journal`` (and optionally a shared statement)
keeps every pair on the same journal.
"""
invoice = f.make_invoice(
self.env, partner=self.partner, amount=amount)
recv = invoice.line_ids.filtered(
lambda l: l.account_id.account_type == 'asset_receivable')
line = f.make_bank_line(
self.env, journal=self.journal, statement=statement,
amount=amount, partner=self.partner)
return line, recv
def test_reconcile_manual(self):
line, recv = self._make_pair(amount=550)
result = self._jsonrpc('reconcile_manual', {
'statement_line_id': line.id,
'against_move_line_ids': recv.ids,
})
self.assertEqual(result['status'], 'reconciled')
self.assertGreater(len(result['partial_ids']), 0)
# ------------------------------------------------------------------
# 7. unreconcile
# ------------------------------------------------------------------
def test_unreconcile(self):
line, recv = self._make_pair(amount=625)
rec = self.env['fusion.reconcile.engine'].reconcile_one(
line, against_lines=recv)
result = self._jsonrpc('unreconcile', {
'partial_reconcile_ids': rec['partial_ids'],
})
self.assertEqual(result['status'], 'unreconciled')
self.assertGreater(len(result['unreconciled_line_ids']), 0)
# ------------------------------------------------------------------
# 8. write_off -- smoke only (Task 12 deferred full coverage)
# ------------------------------------------------------------------
def test_write_off_smoke(self):
line = f.make_bank_line(
self.env, journal=self.journal, amount=12.34,
partner=self.partner)
# Pick any expense-type account that exists in the chart.
wo_account = self.env['account.account'].search([
('account_type', '=', 'expense'),
('company_ids', 'in', self.env.company.id),
], limit=1)
if not wo_account:
self.skipTest("No expense account available for write-off smoke")
# Endpoint must respond without 500-erroring; engine may legitimately
# raise a ValidationError for an over-allocation, in which case the
# JSON-RPC response will include an 'error' key. We accept either
# success or a structured error -- what we are guarding against is a
# routing-layer regression (NameError, missing import, etc.).
url = '/fusion/bank_rec/write_off'
self.authenticate(self.USER_LOGIN, self.USER_PASSWORD)
body = {
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
'params': {
'statement_line_id': line.id,
'account_id': wo_account.id,
'amount': line.amount,
'label': 'Smoke write-off',
},
}
response = self.url_open(
url, data=json.dumps(body),
headers={'Content-Type': 'application/json'})
self.assertEqual(
response.status_code, 200,
f"write_off returned {response.status_code}: "
f"{response.text[:300]}")
# ------------------------------------------------------------------
# 9. bulk_reconcile
# ------------------------------------------------------------------
def test_bulk_reconcile(self):
statement = f.make_bank_statement(
self.env, journal=self.journal, name='Bulk Stmt')
line_ids = []
for amt in (110, 220, 330):
line, _recv = self._make_pair(amount=amt, statement=statement)
line_ids.append(line.id)
result = self._jsonrpc('bulk_reconcile', {
'statement_line_ids': line_ids,
'strategy': 'auto',
})
self.assertIn('reconciled_count', result)
self.assertGreaterEqual(result['reconciled_count'], 3)
# ------------------------------------------------------------------
# 10. get_partner_history
# ------------------------------------------------------------------
def test_get_partner_history(self):
for d in (5, 12, 20):
f.make_precedent(
self.env, partner=self.partner, days_ago=d, amount=1000)
f.make_pattern(
self.env, partner=self.partner, reconcile_count=3)
result = self._jsonrpc('get_partner_history', {
'partner_id': self.partner.id,
'limit': 10,
})
self.assertEqual(result['partner']['id'], self.partner.id)
self.assertGreaterEqual(len(result['recent_reconciles']), 3)
self.assertIsNotNone(result['pattern'])
self.assertEqual(result['pattern']['reconcile_count'], 3)
# ------------------------------------------------------------------
# 11. unauthenticated traffic is blocked
# ------------------------------------------------------------------
def test_unauthenticated_request_blocked(self):
# Use a fresh session by creating a new opener -- self.url_open
# reuses the test session, which `authenticate()` would mutate.
url = '/fusion/bank_rec/get_state'
body = {
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
'params': {
'journal_id': self.journal.id,
'company_id': self.env.company.id,
},
}
# No call to self.authenticate() -> session has no uid.
response = self.url_open(
url, data=json.dumps(body),
headers={'Content-Type': 'application/json'},
allow_redirects=False,
)
# Odoo's auth='user' on a JSON-RPC route returns a 200 with an
# error envelope (SessionExpiredException) when not authenticated;
# what must NOT happen is the handler running and returning our
# success payload.
if response.status_code == 200:
payload = response.json()
self.assertIn(
'error', payload,
"Unauthenticated request should not return a success result")
else:
# 3xx redirect or 4xx are also acceptable rejections.
self.assertGreaterEqual(response.status_code, 300)

View File

@@ -0,0 +1,85 @@
"""Smoke tests for the cron handler methods.
We don't test the Odoo cron scheduler itself (it works) — we test that
calling the cron methods directly does what they're supposed to do."""
from odoo.tests.common import TransactionCase, tagged
from . import _factories as f
@tagged('post_install', '-at_install')
class TestFusionBankRecCron(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Cron Test Partner'})
self.cron = self.env['fusion.bank.rec.cron']
def test_cron_suggest_pending_creates_suggestions_for_new_line(self):
f.make_invoice(self.env, partner=self.partner, amount=420.00)
bank_line = f.make_bank_line(
self.env, amount=420.00, partner=self.partner)
Sug = self.env['fusion.reconcile.suggestion']
self.assertEqual(
Sug.search_count([('statement_line_id', '=', bank_line.id)]), 0)
self.cron._cron_suggest_pending(batch_size=10)
self.assertGreater(
Sug.search_count([('statement_line_id', '=', bank_line.id)]), 0)
def test_cron_suggest_pending_skips_lines_with_recent_suggestions(self):
f.make_invoice(self.env, partner=self.partner, amount=510.00)
bank_line = f.make_bank_line(
self.env, amount=510.00, partner=self.partner)
f.make_suggestion(
self.env, statement_line=bank_line, confidence=0.5)
Sug = self.env['fusion.reconcile.suggestion']
before = Sug.search_count(
[('statement_line_id', '=', bank_line.id)])
self.cron._cron_suggest_pending(batch_size=10)
after = Sug.search_count(
[('statement_line_id', '=', bank_line.id)])
self.assertEqual(
before, after,
"Cron should skip lines with a recent pending suggestion")
def test_cron_refresh_patterns_creates_pattern_for_partner_with_precedents(self):
for d in [10, 24, 38]:
f.make_precedent(
self.env, partner=self.partner, days_ago=d, amount=1000)
Pattern = self.env['fusion.reconcile.pattern']
Pattern.search([('partner_id', '=', self.partner.id)]).unlink()
self.cron._cron_refresh_patterns()
pattern = Pattern.search(
[('partner_id', '=', self.partner.id)], limit=1)
self.assertTrue(
pattern, "Cron should create pattern for partner with precedents")
self.assertEqual(pattern.reconcile_count, 3)
def test_cron_refresh_patterns_updates_existing_pattern(self):
Pattern = self.env['fusion.reconcile.pattern']
Pattern.search([('partner_id', '=', self.partner.id)]).unlink()
f.make_pattern(
self.env, partner=self.partner, reconcile_count=99)
for d in [5, 15]:
f.make_precedent(
self.env, partner=self.partner, days_ago=d, amount=500)
self.cron._cron_refresh_patterns()
pattern = Pattern.search(
[('partner_id', '=', self.partner.id)], limit=1)
self.assertEqual(
pattern.reconcile_count, 2,
"Cron should update existing pattern with fresh precedent count")
def test_cron_refresh_mv_does_not_raise(self):
# Just verify it runs — full MV behaviour is tested in Task 24
self.cron._cron_refresh_mv()

View File

@@ -0,0 +1,56 @@
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_bank_rec.services.exchange_diff import (
compute_exchange_diff, ExchangeDiffResult,
)
@tagged('post_install', '-at_install')
class TestExchangeDiff(TransactionCase):
def test_no_diff_when_currencies_match_and_rates_match(self):
result = compute_exchange_diff(
line_amount=100.00, line_currency_code='CAD',
against_amount=100.00, against_currency_code='CAD',
line_rate=1.0, against_rate=1.0,
)
self.assertFalse(result.needs_diff_move)
self.assertEqual(result.diff_amount, 0.0)
def test_diff_when_rates_differ_same_currency(self):
"""USD invoice posted at 1.35, USD bank line settled at 1.40 -> diff exists.
100 USD at 1.40 = 140 CAD; same at 1.35 = 135 CAD; diff = 5 CAD gain."""
result = compute_exchange_diff(
line_amount=100.00, line_currency_code='USD',
against_amount=100.00, against_currency_code='USD',
line_rate=1.40, against_rate=1.35,
)
self.assertTrue(result.needs_diff_move)
self.assertAlmostEqual(result.diff_amount, 5.00, places=2)
def test_diff_negative_when_rate_dropped(self):
"""USD invoice at 1.40, settled at 1.35 -> loss"""
result = compute_exchange_diff(
line_amount=100.00, line_currency_code='USD',
against_amount=100.00, against_currency_code='USD',
line_rate=1.35, against_rate=1.40,
)
self.assertTrue(result.needs_diff_move)
self.assertAlmostEqual(result.diff_amount, -5.00, places=2)
def test_company_amounts_computed_correctly(self):
result = compute_exchange_diff(
line_amount=100.00, line_currency_code='USD',
against_amount=100.00, against_currency_code='USD',
line_rate=1.40, against_rate=1.35,
)
self.assertAlmostEqual(result.line_company_amount, 140.00, places=2)
self.assertAlmostEqual(result.against_company_amount, 135.00, places=2)
def test_tolerance_handles_rounding_noise(self):
"""Tiny FX rounding under 0.005 should NOT trigger a diff move."""
result = compute_exchange_diff(
line_amount=100.00, line_currency_code='USD',
against_amount=100.00, against_currency_code='USD',
line_rate=1.40000, against_rate=1.40003, # 0.003 cent diff
)
self.assertFalse(result.needs_diff_move)

View File

@@ -0,0 +1,74 @@
"""Smoke tests verifying the factories produce usable records.
Not testing factory correctness exhaustively — just that each helper
returns a record of the expected type with the expected basic state."""
from odoo.tests.common import TransactionCase, tagged
from . import _factories as f
@tagged('post_install', '-at_install')
class TestFactories(TransactionCase):
def test_make_bank_journal(self):
journal = f.make_bank_journal(self.env)
self.assertEqual(journal._name, 'account.journal')
self.assertEqual(journal.type, 'bank')
def test_make_bank_statement(self):
statement = f.make_bank_statement(self.env)
self.assertEqual(statement._name, 'account.bank.statement')
self.assertTrue(statement.journal_id)
def test_make_bank_line(self):
line = f.make_bank_line(self.env, amount=250.00, memo='Smoke memo')
self.assertEqual(line._name, 'account.bank.statement.line')
self.assertEqual(line.amount, 250.00)
self.assertEqual(line.payment_ref, 'Smoke memo')
self.assertFalse(line.is_reconciled)
def test_make_bank_line_with_partner(self):
partner = self.env['res.partner'].create({'name': 'Factory Partner'})
line = f.make_bank_line(self.env, partner=partner, amount=500)
self.assertEqual(line.partner_id, partner)
def test_make_invoice_posted(self):
partner = self.env['res.partner'].create({'name': 'Invoice Partner'})
invoice = f.make_invoice(self.env, partner=partner, amount=300)
self.assertEqual(invoice._name, 'account.move')
self.assertEqual(invoice.move_type, 'out_invoice')
self.assertEqual(invoice.state, 'posted')
self.assertAlmostEqual(invoice.amount_total, 300, places=2)
def test_make_vendor_bill_posted(self):
partner = self.env['res.partner'].create({'name': 'Vendor Partner'})
bill = f.make_vendor_bill(self.env, partner=partner, amount=400)
self.assertEqual(bill.move_type, 'in_invoice')
self.assertEqual(bill.state, 'posted')
def test_make_suggestion(self):
line = f.make_bank_line(self.env, amount=100)
sug = f.make_suggestion(self.env, statement_line=line, confidence=0.85)
self.assertEqual(sug._name, 'fusion.reconcile.suggestion')
self.assertEqual(sug.confidence, 0.85)
self.assertEqual(sug.state, 'pending')
def test_make_pattern(self):
partner = self.env['res.partner'].create({'name': 'Pattern Partner'})
pattern = f.make_pattern(self.env, partner=partner, reconcile_count=20)
self.assertEqual(pattern._name, 'fusion.reconcile.pattern')
self.assertEqual(pattern.reconcile_count, 20)
def test_make_precedent(self):
partner = self.env['res.partner'].create({'name': 'Precedent Partner'})
precedent = f.make_precedent(self.env, partner=partner, amount=999.99)
self.assertEqual(precedent._name, 'fusion.reconcile.precedent')
self.assertEqual(precedent.amount, 999.99)
self.assertEqual(precedent.source, 'manual')
def test_make_reconcileable_pair(self):
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=750)
self.assertEqual(bank_line.amount, 750.00)
self.assertGreater(len(recv_lines), 0)
self.assertAlmostEqual(sum(recv_lines.mapped('amount_residual')), 750, places=2)

View File

@@ -0,0 +1,59 @@
"""Tests verifying legacy tools route through fusion.reconcile.engine when present.
These tests run in the fusion_accounting_bank_rec context where the engine IS
available, so they assert the engine path is taken and produces correct
results. The fallback path is exercised by the existing fusion_accounting_ai
tests when fusion_accounting_bank_rec is not installed."""
from unittest.mock import patch
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_ai.services.tools import bank_reconciliation as tools
from . import _factories as f
@tagged('post_install', '-at_install')
class TestLegacyToolsRefactor(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Refactor Test Partner'})
def test_match_bank_line_to_payments_uses_engine(self):
"""When engine is present, match_bank_line_to_payments must produce
a partial reconcile via the engine, not via set_line_bank_statement_line."""
bank_line, recv_lines = f.make_reconcileable_pair(
self.env, amount=180.00, partner=self.partner)
result = tools.match_bank_line_to_payments(self.env, {
'statement_line_id': bank_line.id,
'move_line_ids': recv_lines.ids,
})
self.assertEqual(result.get('status'), 'matched')
bank_line.invalidate_recordset(['is_reconciled'])
self.assertTrue(bank_line.is_reconciled)
# Verify a precedent was recorded - engine-only behaviour
Precedent = self.env['fusion.reconcile.precedent']
precedents = Precedent.search([('partner_id', '=', self.partner.id)])
self.assertGreater(len(precedents), 0,
"Engine path should record a precedent; legacy path would not")
def test_auto_reconcile_bank_lines_uses_engine(self):
"""When engine is present, auto_reconcile_bank_lines must call
fusion.reconcile.engine.reconcile_batch (not the Enterprise-only
_try_auto_reconcile_statement_lines fallback). We patch
reconcile_batch to verify routing without running the real engine
across every legacy unreconciled line in the test DB."""
Engine = type(self.env['fusion.reconcile.engine'])
with patch.object(
Engine, 'reconcile_batch', autospec=True,
return_value={'reconciled_count': 2, 'skipped': 0, 'errors': []},
) as engine_call:
result = tools.auto_reconcile_bank_lines(self.env, {
'company_id': self.env.company.id,
})
self.assertEqual(result['status'], 'completed')
self.assertTrue(engine_call.called,
"Engine path must invoke fusion.reconcile.engine.reconcile_batch")
# Verify the engine was passed the strategy='auto' kwarg per spec
_self, _lines = engine_call.call_args.args[0], engine_call.call_args.args[1]
self.assertEqual(engine_call.call_args.kwargs.get('strategy'), 'auto')

View File

@@ -0,0 +1,111 @@
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import (
Candidate, AmountExactStrategy, FIFOStrategy, MultiInvoiceStrategy, MatchResult,
)
@tagged('post_install', '-at_install')
class TestAmountExactStrategy(TransactionCase):
def test_picks_exact_amount(self):
candidates = [
Candidate(id=1, amount=99.99, partner_id=42, age_days=10),
Candidate(id=2, amount=100.00, partner_id=42, age_days=20),
Candidate(id=3, amount=100.50, partner_id=42, age_days=5),
]
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
self.assertEqual(result.picked_ids, [2])
self.assertEqual(result.confidence, 1.0)
def test_no_match_when_no_exact(self):
candidates = [
Candidate(id=1, amount=99.99, partner_id=42, age_days=10),
Candidate(id=2, amount=100.50, partner_id=42, age_days=20),
]
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
self.assertEqual(result.picked_ids, [])
def test_picks_oldest_when_multiple_exact(self):
candidates = [
Candidate(id=1, amount=100.00, partner_id=42, age_days=10),
Candidate(id=2, amount=100.00, partner_id=42, age_days=30), # oldest
Candidate(id=3, amount=100.00, partner_id=42, age_days=20),
]
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
self.assertEqual(result.picked_ids, [2])
def test_handles_empty_candidates(self):
result = AmountExactStrategy().match(bank_amount=100.00, candidates=[])
self.assertEqual(result.picked_ids, [])
@tagged('post_install', '-at_install')
class TestFIFOStrategy(TransactionCase):
def test_picks_oldest_first(self):
candidates = [
Candidate(id=1, amount=50.00, partner_id=42, age_days=10),
Candidate(id=2, amount=50.00, partner_id=42, age_days=30),
Candidate(id=3, amount=50.00, partner_id=42, age_days=20),
]
result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates)
self.assertEqual(result.picked_ids, [2, 3]) # oldest two summing to 100
def test_handles_partial_payment(self):
candidates = [
Candidate(id=1, amount=200.00, partner_id=42, age_days=30),
]
result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates)
self.assertEqual(result.picked_ids, [1]) # partial reconcile signaled by residual
self.assertEqual(result.residual, -100.00) # over-allocated; engine handles
def test_handles_empty_candidates(self):
result = FIFOStrategy().match(bank_amount=100.00, candidates=[])
self.assertEqual(result.picked_ids, [])
@tagged('post_install', '-at_install')
class TestMultiInvoiceStrategy(TransactionCase):
def test_finds_smallest_set_summing_to_amount(self):
candidates = [
Candidate(id=1, amount=30.00, partner_id=42, age_days=10),
Candidate(id=2, amount=40.00, partner_id=42, age_days=15),
Candidate(id=3, amount=30.00, partner_id=42, age_days=20),
Candidate(id=4, amount=70.00, partner_id=42, age_days=25),
]
result = MultiInvoiceStrategy(max_combinations=3).match(
bank_amount=100.00, candidates=candidates)
# Two-element solutions exist (e.g., {3,4}=100). Strategy should pick a 2-set.
self.assertEqual(len(result.picked_ids), 2)
# The picked set should sum to 100
picked_amounts = [c.amount for c in candidates if c.id in result.picked_ids]
self.assertAlmostEqual(sum(picked_amounts), 100.00, places=2)
def test_returns_empty_when_no_combination_sums(self):
candidates = [
Candidate(id=1, amount=15.00, partner_id=42, age_days=10),
Candidate(id=2, amount=25.00, partner_id=42, age_days=15),
]
result = MultiInvoiceStrategy(max_combinations=3).match(
bank_amount=100.00, candidates=candidates)
self.assertEqual(result.picked_ids, [])
def test_respects_max_combinations(self):
# Many small invoices — only combinations of ≤3 items considered
candidates = [Candidate(id=i, amount=10.00, partner_id=42, age_days=i)
for i in range(1, 11)]
result = MultiInvoiceStrategy(max_combinations=3).match(
bank_amount=100.00, candidates=candidates)
# Can't make 100 with ≤3 items of $10 each
self.assertEqual(result.picked_ids, [])
def test_strategy_name_includes_combination_size(self):
candidates = [
Candidate(id=1, amount=50.00, partner_id=42, age_days=10),
Candidate(id=2, amount=50.00, partner_id=42, age_days=20),
]
result = MultiInvoiceStrategy(max_combinations=3).match(
bank_amount=100.00, candidates=candidates)
self.assertEqual(set(result.picked_ids), {1, 2})
self.assertIn('multi_invoice', result.strategy_name)

View File

@@ -0,0 +1,42 @@
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_bank_rec.services.memo_tokenizer import tokenize_memo
@tagged('post_install', '-at_install')
class TestMemoTokenizer(TransactionCase):
def test_extracts_rbc_etf_reference(self):
tokens = tokenize_memo("RBC ETF DEP REF 4831")
self.assertIn('RBC', tokens)
self.assertIn('ETF', tokens)
self.assertIn('REF4831', tokens)
def test_extracts_cheque_number(self):
tokens = tokenize_memo("CHEQUE 4827 - WESTIN PLATING")
self.assertIn('CHEQUE4827', tokens)
self.assertIn('WESTIN', tokens)
self.assertIn('PLATING', tokens)
def test_strips_noise_tokens(self):
tokens = tokenize_memo("PAYMENT - INV - DEP - 12345")
self.assertNotIn('-', tokens)
self.assertEqual([t for t in tokens if len(t) <= 1], [])
def test_handles_empty_memo(self):
self.assertEqual(tokenize_memo(""), [])
self.assertEqual(tokenize_memo(None), [])
def test_canadian_french_memo(self):
tokens = tokenize_memo("PAIEMENT VIREMENT BANCAIRE")
self.assertIn('PAIEMENT', tokens)
self.assertIn('VIREMENT', tokens)
def test_normalises_case(self):
tokens = tokenize_memo("rbc etf dep ref 4831")
self.assertIn('RBC', tokens)
def test_handles_special_characters(self):
tokens = tokenize_memo("RBC*PAYMENT/REF#4831")
self.assertIn('RBC', tokens)
self.assertIn('PAYMENT', tokens)
self.assertIn('REF4831', tokens)

View File

@@ -0,0 +1,82 @@
"""Smoke tests for the fusion_unreconciled_bank_line_mv materialized view.
Notes on transactional semantics:
- REFRESH MATERIALIZED VIEW (non-CONCURRENTLY) IS transactional and runs
inside the current transaction. Postgres always shows a transaction
its own (uncommitted) writes, so an INSERT followed by a REFRESH in
the same transaction picks up the new row — no `cr.commit()` needed.
- Odoo's TransactionCase forbids cr.commit() anyway (it would break the
per-test savepoint rollback). We rely on rollback to clean up both
the test fixtures and the MV-table mutations from the refresh.
- REFRESH MATERIALIZED VIEW CONCURRENTLY must run OUTSIDE a transaction
block; we always pass concurrently=False from tests. The production
cron path (Task 25) will open a dedicated autocommit cursor for the
concurrent refresh.
"""
from odoo.tests.common import TransactionCase, tagged
from . import _factories as f
@tagged('post_install', '-at_install')
class TestUnreconciledBankLineMV(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'MV Test Partner',
})
# Refresh once at the start so the MV reflects the current snapshot
# (including any rows inserted earlier in this savepoint chain).
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
def test_mv_exists_and_is_queryable(self):
# Smoke: the model can be searched without error.
rows = self.env['fusion.unreconciled.bank.line.mv'].search(
[], limit=10)
self.assertIsNotNone(rows)
def test_mv_includes_unreconciled_line(self):
bank_line = f.make_bank_line(
self.env, amount=999.99, partner=self.partner)
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
mv_row = self.env['fusion.unreconciled.bank.line.mv'].search([
('id', '=', bank_line.id),
])
self.assertTrue(
mv_row,
"MV should contain freshly-inserted unreconciled line")
self.assertAlmostEqual(mv_row.amount, 999.99, places=2)
# No suggestion yet -> band 'none', confidence 0.
self.assertEqual(mv_row.confidence_band, 'none')
self.assertEqual(mv_row.attachment_count, 0)
def test_mv_excludes_reconciled_line(self):
bank_line, recv_lines = f.make_reconcileable_pair(
self.env, amount=100.00, partner=self.partner)
self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
mv_row = self.env['fusion.unreconciled.bank.line.mv'].search([
('id', '=', bank_line.id),
])
self.assertFalse(
mv_row, "Reconciled line should be excluded from MV")
def test_mv_confidence_band_high_for_high_conf_suggestion(self):
bank_line = f.make_bank_line(
self.env, amount=500.00, partner=self.partner)
f.make_suggestion(
self.env, statement_line=bank_line, confidence=0.92)
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
mv_row = self.env['fusion.unreconciled.bank.line.mv'].search([
('id', '=', bank_line.id),
])
self.assertTrue(mv_row, "MV row should exist for suggestion line")
# 0.92 falls in the 'high' band per the SQL CASE (>= 0.85).
self.assertEqual(mv_row.confidence_band, 'high')
self.assertAlmostEqual(mv_row.top_confidence, 0.92, places=2)

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,201 @@
"""Integration tests for the reconcile engine.
These tests use the test factories (_factories.py) to set up realistic
bank-line + invoice scenarios, then call engine methods and assert the
account.partial.reconcile rows produced have the right shape.
Tests cover:
- Simple 1:1 match (bank line == one invoice)
- Partial chain (one bank line < invoice amount)
- Multi-invoice consolidation (one bank line == sum of N invoices)
- Auto-strategy batch (mix of matchable and unmatchable lines)
- Suggest-then-accept flow
- Unreconcile (reverse a reconciliation)
"""
from datetime import date, timedelta
from odoo.tests.common import TransactionCase, tagged
from . import _factories as f
@tagged('post_install', '-at_install', 'integration')
class TestReconcileSimpleMatch(TransactionCase):
"""The most common scenario: 1 bank line matched against 1 invoice exact."""
def test_simple_match_creates_partial_reconcile(self):
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00)
result = self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
self.assertGreater(len(result['partial_ids']), 0)
partial = self.env['account.partial.reconcile'].browse(result['partial_ids'])
self.assertAlmostEqual(sum(partial.mapped('amount')), 100.00, places=2)
def test_simple_match_marks_line_reconciled(self):
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=250.00)
self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
bank_line.invalidate_recordset(['is_reconciled'])
self.assertTrue(bank_line.is_reconciled)
def test_simple_match_records_precedent(self):
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=500.00)
partner = bank_line.partner_id
Precedent = self.env['fusion.reconcile.precedent']
before = Precedent.search_count([('partner_id', '=', partner.id)])
self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
after = Precedent.search_count([('partner_id', '=', partner.id)])
self.assertEqual(after, before + 1, "Engine should record one precedent per reconcile")
@tagged('post_install', '-at_install', 'integration')
class TestReconcilePartialChain(TransactionCase):
"""Bank line amount < invoice amount -> partial reconcile, residual remains."""
def test_partial_reconcile_leaves_residual(self):
partner = self.env['res.partner'].create({'name': 'Partial Partner'})
invoice = f.make_invoice(self.env, partner=partner, amount=300.00)
recv_lines = invoice.line_ids.filtered(
lambda l: l.account_id.account_type == 'asset_receivable')
bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner)
result = self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
self.assertGreater(len(result['partial_ids']), 0)
invoice.invalidate_recordset(['payment_state', 'amount_residual'])
self.assertAlmostEqual(invoice.amount_residual, 200.00, places=2)
@tagged('post_install', '-at_install', 'integration')
class TestReconcileBatch(TransactionCase):
"""Bulk reconcile: mix of matchable and unmatchable lines."""
def test_batch_reconciles_matchable_lines_only(self):
partner = self.env['res.partner'].create({'name': 'Batch Partner'})
# Share one journal/statement to avoid duplicate-code conflicts
# when creating multiple bank lines in the same test transaction.
shared_journal = f.make_bank_journal(self.env, name='Batch Bank', code='BBNK')
shared_statement = f.make_bank_statement(self.env, journal=shared_journal)
pairs = []
for amount in [100.00, 200.00, 300.00]:
invoice = f.make_invoice(self.env, partner=partner, amount=amount)
recv_lines = invoice.line_ids.filtered(
lambda l: l.account_id.account_type == 'asset_receivable')
bank_line = f.make_bank_line(
self.env, statement=shared_statement, amount=amount,
partner=partner)
pairs.append((bank_line, recv_lines))
orphan_line = f.make_bank_line(
self.env, statement=shared_statement, amount=999.99, partner=partner)
all_lines = self.env['account.bank.statement.line'].browse(
[p[0].id for p in pairs] + [orphan_line.id])
result = self.env['fusion.reconcile.engine'].reconcile_batch(
all_lines, strategy='auto')
self.assertEqual(result['reconciled_count'], 3)
self.assertGreaterEqual(result['skipped'], 1)
self.assertEqual(len(result['errors']), 0)
def test_batch_handles_empty_recordset(self):
empty = self.env['account.bank.statement.line']
result = self.env['fusion.reconcile.engine'].reconcile_batch(empty)
self.assertEqual(result['reconciled_count'], 0)
@tagged('post_install', '-at_install', 'integration')
class TestSuggestThenAccept(TransactionCase):
"""Full flow: suggest_matches creates suggestions; accept_suggestion reconciles."""
def test_suggest_then_accept(self):
partner = self.env['res.partner'].create({'name': 'Suggest Then Accept'})
invoice = f.make_invoice(self.env, partner=partner, amount=750.00)
bank_line = f.make_bank_line(self.env, amount=750.00, partner=partner,
memo='Test suggest accept')
suggestions = self.env['fusion.reconcile.engine'].suggest_matches(
bank_line, limit_per_line=3)
self.assertIn(bank_line.id, suggestions)
self.assertGreater(len(suggestions[bank_line.id]), 0,
"Engine should suggest at least one candidate for matching invoice")
top_suggestion_id = suggestions[bank_line.id][0]['id']
sug = self.env['fusion.reconcile.suggestion'].browse(top_suggestion_id)
result = self.env['fusion.reconcile.engine'].accept_suggestion(sug)
self.assertGreater(len(result['partial_ids']), 0)
sug.invalidate_recordset(['state', 'accepted_at', 'accepted_by'])
self.assertEqual(sug.state, 'accepted')
self.assertTrue(sug.accepted_at)
bank_line.invalidate_recordset(['is_reconciled'])
self.assertTrue(bank_line.is_reconciled)
def test_suggest_supersedes_prior_pending(self):
partner = self.env['res.partner'].create({'name': 'Supersede Test'})
bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner)
invoice = f.make_invoice(self.env, partner=partner, amount=100.00)
self.env['fusion.reconcile.engine'].suggest_matches(bank_line)
first_pending = self.env['fusion.reconcile.suggestion'].search([
('statement_line_id', '=', bank_line.id),
('state', '=', 'pending'),
])
self.assertGreater(len(first_pending), 0)
self.env['fusion.reconcile.engine'].suggest_matches(bank_line)
first_pending.invalidate_recordset(['state'])
for s in first_pending:
self.assertEqual(s.state, 'superseded')
@tagged('post_install', '-at_install', 'integration')
class TestUnreconcile(TransactionCase):
"""Reverse a reconciliation."""
def test_unreconcile_removes_partial(self):
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00)
result = self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
partials = self.env['account.partial.reconcile'].browse(result['partial_ids'])
self.assertGreater(len(partials), 0)
unrec_result = self.env['fusion.reconcile.engine'].unreconcile(partials)
self.assertGreater(len(unrec_result['unreconciled_line_ids']), 0)
self.assertFalse(partials.exists())
bank_line.invalidate_recordset(['is_reconciled'])
self.assertFalse(bank_line.is_reconciled)
@tagged('post_install', '-at_install', 'integration')
class TestEngineEdgeCases(TransactionCase):
"""Edge cases that came up during engine implementation."""
def test_reconcile_validates_line_exists(self):
from odoo.exceptions import ValidationError
with self.assertRaises(ValidationError):
self.env['fusion.reconcile.engine'].reconcile_one(
self.env['account.bank.statement.line'],
against_lines=self.env['account.move.line'])
def test_already_reconciled_line_skipped_in_batch(self):
partner = self.env['res.partner'].create({'name': 'Already Reconciled'})
bank_line, recv_lines = f.make_reconcileable_pair(
self.env, amount=50.00, partner=partner)
self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
bank_line.invalidate_recordset(['is_reconciled'])
self.assertTrue(bank_line.is_reconciled)
result = self.env['fusion.reconcile.engine'].reconcile_batch(bank_line)
self.assertGreater(result['skipped'], 0)

View File

@@ -0,0 +1,216 @@
"""Property-based tests for reconcile engine invariants.
Hypothesis generates random input combinations to catch edge cases that
example-based TDD missed. Each test runs N times (default 50 -- bumpable
via @settings)."""
from datetime import date
from hypothesis import HealthCheck, given, settings, strategies as st
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import (
AmountExactStrategy,
Candidate,
FIFOStrategy,
MultiInvoiceStrategy,
)
@tagged('post_install', '-at_install', 'property_based')
class TestMatchingStrategyInvariants(TransactionCase):
"""Pure-Python invariants on the matching strategies (no ORM needed).
Faster + more iterations than DB-backed property tests."""
@given(
bank_amount=st.floats(min_value=0.01, max_value=100000.00,
allow_nan=False, allow_infinity=False),
invoice_amounts=st.lists(
st.floats(min_value=0.01, max_value=100000.00,
allow_nan=False, allow_infinity=False),
min_size=1, max_size=10,
),
)
@settings(max_examples=100, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_amount_exact_picks_only_when_amount_matches(
self, bank_amount, invoice_amounts):
"""AmountExactStrategy returns picks IFF some candidate amount matches
bank_amount within tolerance."""
candidates = [
Candidate(id=i, amount=round(amt, 2), partner_id=1, age_days=10)
for i, amt in enumerate(invoice_amounts)
]
bank_amount = round(bank_amount, 2)
result = AmountExactStrategy().match(
bank_amount=bank_amount, candidates=candidates)
has_match = any(
abs(c.amount - bank_amount) < 0.005 for c in candidates)
if has_match:
self.assertEqual(
len(result.picked_ids), 1,
f"bank=${bank_amount} candidates={[c.amount for c in candidates]} "
f"has_match={has_match} -> expected 1 pick, got {result.picked_ids}",
)
self.assertEqual(result.confidence, 1.0)
else:
self.assertEqual(result.picked_ids, [])
@given(
bank_amount=st.floats(min_value=10.00, max_value=10000.00,
allow_nan=False, allow_infinity=False),
invoice_amounts=st.lists(
st.floats(min_value=1.00, max_value=10000.00,
allow_nan=False, allow_infinity=False),
min_size=1, max_size=8,
),
)
@settings(max_examples=100, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_fifo_picks_oldest_first(self, bank_amount, invoice_amounts):
"""FIFOStrategy picks candidates in order of decreasing age_days
(oldest first), stopping when remaining <= 0."""
candidates = [
Candidate(id=i, amount=round(amt, 2), partner_id=1,
age_days=100 - i)
for i, amt in enumerate(invoice_amounts)
]
bank_amount = round(bank_amount, 2)
result = FIFOStrategy().match(
bank_amount=bank_amount, candidates=candidates)
if not candidates:
return
oldest_first_ids = [
c.id for c in sorted(candidates, key=lambda c: -c.age_days)]
self.assertEqual(
result.picked_ids,
oldest_first_ids[:len(result.picked_ids)],
)
picked_sum = sum(
c.amount for c in candidates if c.id in result.picked_ids)
self.assertAlmostEqual(
result.residual, bank_amount - picked_sum, places=2)
@given(
amounts=st.lists(
st.floats(min_value=1.00, max_value=1000.00,
allow_nan=False, allow_infinity=False),
min_size=2, max_size=6,
),
)
@settings(max_examples=50, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_multi_invoice_finds_combination_when_one_exists(self, amounts):
"""If amounts can sum to a target via <=3 elements, MultiInvoiceStrategy
finds SOME valid combination."""
rounded = [round(a, 2) for a in amounts]
candidates = [
Candidate(id=i, amount=amt, partner_id=1, age_days=10)
for i, amt in enumerate(rounded)
]
target = round(rounded[0] + rounded[1], 2)
result = MultiInvoiceStrategy(max_combinations=3).match(
bank_amount=target, candidates=candidates)
if result.picked_ids:
picked_sum = sum(
c.amount for c in candidates if c.id in result.picked_ids)
self.assertAlmostEqual(
picked_sum, target, places=2,
msg=(f"target={target} picks={result.picked_ids} "
f"sum={picked_sum} candidates={rounded}"),
)
@tagged('post_install', '-at_install', 'property_based', 'engine_invariants')
class TestReconcileEngineInvariants(TransactionCase):
"""ORM-backed property tests against the engine.
Slower because each test creates real bank_lines + invoices."""
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create(
{'name': 'Engine Property Partner'})
self.journal = self.env['account.journal'].create({
'name': 'Engine Property Bank',
'type': 'bank',
'code': 'EPB',
})
self.receivable_account = self.env['account.account'].search([
('account_type', '=', 'asset_receivable'),
('company_ids', 'in', self.env.company.id),
], limit=1)
if not self.receivable_account:
self.skipTest("No receivable account in chart of accounts")
def _make_bank_line(self, amount):
statement = self.env['account.bank.statement'].create({
'name': f'Test stmt {amount}',
'journal_id': self.journal.id,
})
return self.env['account.bank.statement.line'].create({
'statement_id': statement.id,
'journal_id': self.journal.id,
'date': date.today(),
'payment_ref': f'Test {amount}',
'amount': amount,
'partner_id': self.partner.id,
})
def _make_invoice(self, amount):
product = self.env['product.product'].search(
[('type', '=', 'service')], limit=1)
if not product:
product = self.env['product.product'].create({
'name': 'Property Test Service',
'type': 'service',
})
move = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner.id,
'invoice_date': date.today(),
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'name': 'Property Test',
'quantity': 1,
'price_unit': amount,
})],
})
move.action_post()
return move
@given(amount=st.floats(min_value=10.00, max_value=10000.00,
allow_nan=False, allow_infinity=False))
@settings(max_examples=10, deadline=10000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_invariant_simple_reconcile_balances(self, amount):
"""For any bank_amount = invoice_amount, reconcile_one produces:
- exactly 1 partial reconcile
- amount equal to the bank line amount
- bank line is_reconciled = True"""
amount = round(amount, 2)
bank_line = self._make_bank_line(amount)
invoice = self._make_invoice(amount)
invoice_recv_lines = invoice.line_ids.filtered(
lambda line: line.account_id.account_type == 'asset_receivable')
result = self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=invoice_recv_lines)
self.assertGreater(
len(result['partial_ids']), 0,
f"Expected partial_ids non-empty for amount={amount}, got {result}",
)
partials = self.env['account.partial.reconcile'].browse(
result['partial_ids'])
self.assertAlmostEqual(
sum(partials.mapped('amount')), amount, places=2)
bank_line.invalidate_recordset(['is_reconciled'])
self.assertTrue(
bank_line.is_reconciled,
f"is_reconciled expected True after reconcile for amount={amount}",
)

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

@@ -1 +1,6 @@
from . import models
def post_init_hook(env):
"""Initialize coexistence group membership based on current Enterprise install state."""
env['res.users']._fusion_recompute_coexistence_group()

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Core',
'version': '19.0.1.0.0',
'version': '19.0.1.0.2',
'category': 'Accounting/Accounting',
'sequence': 24,
'summary': 'Shared base for the Fusion Accounting sub-module suite (security, shared schema, runtime helpers).',
@@ -30,4 +30,5 @@ Built by Nexa Systems Inc.
'installable': True,
'application': False,
'license': 'OPL-1',
'post_init_hook': 'post_init_hook',
}

View File

@@ -1,3 +1,5 @@
from . import ir_module_module
from . import res_users
from . import account_move
from . import account_reconcile_model
from . import account_bank_statement_line

View File

@@ -0,0 +1,15 @@
"""Shared-field-ownership for account.bank.statement.line.
Enterprise's account_accountant adds cron_last_check (timestamp of the last
auto-reconcile cron run for the line). By declaring it here with the same
schema, fusion_accounting_core becomes a co-owner so the column persists
when account_accountant uninstalls.
"""
from odoo import fields, models
class AccountBankStatementLine(models.Model):
_inherit = "account.bank.statement.line"
cron_last_check = fields.Datetime(copy=False)

View File

@@ -30,3 +30,26 @@ class IrModuleModule(models.Model):
('name', '=', module_name),
('state', '=', 'installed'),
]))
def button_immediate_install(self):
"""Recompute the coexistence group after install state changes."""
result = super().button_immediate_install()
self.env['res.users']._fusion_recompute_coexistence_group()
return result
def button_immediate_uninstall(self):
"""Recompute the coexistence group after uninstall state changes.
The MRO chains into fusion_accounting_migration's override (which runs
the safety guard before calling super); we recompute only after the
whole chain completes.
"""
result = super().button_immediate_uninstall()
self.env['res.users']._fusion_recompute_coexistence_group()
return result
def module_uninstall(self):
"""Recompute the coexistence group after the lower-level uninstall."""
result = super().module_uninstall()
self.env['res.users']._fusion_recompute_coexistence_group()
return result

View File

@@ -0,0 +1,27 @@
"""Coexistence group membership recomputation."""
from odoo import api, models
class ResUsers(models.Model):
_inherit = "res.users"
@api.model
def _fusion_recompute_coexistence_group(self):
"""Set group membership = all internal users iff Enterprise absent.
Called from ir.module.module.button_immediate_install / uninstall
overrides. Idempotent; safe to call multiple times.
"""
group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
raise_if_not_found=False,
)
if not group:
return
enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed()
if enterprise_installed:
group.sudo().write({'user_ids': [(5, 0, 0)]})
else:
all_internal = self.sudo().search([('share', '=', False)])
group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]})

View File

@@ -43,4 +43,10 @@
<record id="account.group_account_manager" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_admin'))]"/>
</record>
<!-- Phase 1: dynamic coexistence group -->
<record id="group_fusion_show_when_enterprise_absent" model="res.groups">
<field name="name">Fusion: Show menus when Enterprise absent</field>
<field name="comment">Computed group. Membership: all internal users when no Enterprise accounting module is installed. Used to hide fusion sub-module menus that would conflict with Enterprise UIs.</field>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -1,2 +1,4 @@
from . import test_enterprise_detection
from . import test_shared_field_ownership
from . import test_shared_field_bank_statement
from . import test_coexistence_group

View File

@@ -0,0 +1,46 @@
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestCoexistenceGroup(TransactionCase):
"""The 'show when Enterprise absent' group must exist and have computed membership."""
def test_group_exists(self):
group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
raise_if_not_found=False,
)
self.assertTrue(group, "Coexistence group must exist")
def test_membership_matches_enterprise_state(self):
"""A user is in the group iff Enterprise accounting is NOT installed.
We can't toggle Enterprise mid-test, so just assert the current state
matches: if Enterprise is installed, group should have 0 members; if
not, the group should include all internal users.
"""
group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_absent'
)
enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed()
all_internal = self.env['res.users'].sudo().search([('share', '=', False)])
if enterprise_installed:
self.assertEqual(
len(group.user_ids), 0,
"Enterprise installed -> coexistence group should be empty",
)
else:
self.assertEqual(
set(group.user_ids.ids), set(all_internal.ids),
"Enterprise absent -> coexistence group should contain all internal users",
)
def test_recompute_method_exists(self):
"""The recompute helper must be callable on res.users."""
self.assertTrue(
callable(getattr(
self.env['res.users'],
'_fusion_recompute_coexistence_group',
None,
))
)

View File

@@ -0,0 +1,14 @@
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSharedFieldBankStatementLine(TransactionCase):
"""Verify fusion_accounting_core declares the Enterprise extension fields
on account.bank.statement.line so they survive Enterprise uninstall."""
def test_cron_last_check_field_exists(self):
Line = self.env['account.bank.statement.line']
self.assertIn('cron_last_check', Line._fields,
"cron_last_check must be declared on account.bank.statement.line "
"(shared-field-ownership with account_accountant)")
self.assertEqual(Line._fields['cron_last_check'].type, 'datetime')

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -36,6 +36,7 @@
<menuitem id="menu_fusion_migration_root"
name="Fusion Accounting"
sequence="95"
web_icon="fusion_accounting_migration,static/description/icon.png"
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
<menuitem id="menu_fusion_migration_wizard"
name="Migrate from Enterprise"

View File

@@ -1030,7 +1030,7 @@ class AssessmentPortal(CustomerPortal):
sales_reps = []
if SalesGroup:
sales_reps = request.env['res.users'].sudo().search([
('groups_id', 'in', [SalesGroup.id]),
('all_group_ids', 'in', [SalesGroup.id]),
('active', '=', True),
])

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.3.0.0',
'version': '19.0.5.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -102,6 +102,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'web.assets_backend': [
'fusion_plating/static/src/scss/fusion_plating.scss',
'fusion_plating/static/src/scss/recipe_tree_editor.scss',
'fusion_plating/static/src/scss/fp_chatter_dark.scss',
'fusion_plating/static/src/xml/recipe_tree_editor.xml',
'fusion_plating/static/src/js/recipe_tree_editor.js',
],

View File

@@ -29,6 +29,20 @@ class ResCompany(models.Model):
'Settings > Fusion Plating.',
)
# ----- Worker auto-promotion default -----------------------------------
# Default number of successful WO completions a worker needs on a role
# before it's auto-added to their Shop Roles. Each role can override
# via fp.work.role.mastery_required.
x_fc_default_mastery_threshold = fields.Integer(
string='Default Mastery Threshold',
default=3,
help='How many successful WO completions an operator needs on a '
"task before it's added to their Shop Roles automatically. "
'New roles inherit this number; managers can override per '
'role on the role form. 1 = promote on first success; 3 = '
'solid baseline; 5+ for tasks that need real practice.',
)
# ----- Facility footprint for this legal entity ----------------------
x_fc_facility_ids = fields.One2many(
'fusion.plating.facility',

View File

@@ -20,3 +20,8 @@ class ResConfigSettings(models.TransientModel):
readonly=False,
string='Fusion Plating Timezone',
)
x_fc_default_mastery_threshold = fields.Integer(
related='company_id.x_fc_default_mastery_threshold',
readonly=False,
string='Default Mastery Threshold',
)

View File

@@ -0,0 +1,48 @@
// =====================================================================
// Fusion Plating — Chatter dark-mode patch
//
// In dark mode the floating message-action toolbar (reaction / reply /
// star / link icons) renders white-on-white because Odoo sets the
// hover icon color to `white` but doesn't give the toolbar itself a
// dark background. Result: icons invisible, users can't see what
// they're hovering.
//
// Branch at compile time (Odoo 19 compiles every SCSS file into the
// `web.assets_backend` bundle with $o-webclient-color-scheme: bright,
// AND into `web.assets_web_dark` with $o-webclient-color-scheme: dark).
// Light bundle gets nothing (zero output); dark bundle gets the patch.
// =====================================================================
$o-webclient-color-scheme: bright !default;
@if $o-webclient-color-scheme == dark {
.o-mail-Message-actions {
// Solid dark background so light/white icons stand out
background-color: var(--o-component-bgcolor, #2b2f33) !important;
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 6px;
padding: 2px 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
// Make sure every icon (reaction, reply, star, link, more) has
// enough contrast against the dark popup. Defaults sit at 35%
// opacity which barely shows.
button, .btn, .o-mail-ActionList-button {
color: rgba(255, 255, 255, 0.78) !important;
> i, > .oi, > .fa {
color: rgba(255, 255, 255, 0.82) !important;
opacity: 1 !important;
}
&:hover, &:focus, &:focus-visible, &.show {
background-color: rgba(255, 255, 255, 0.10) !important;
color: #fff !important;
> i, > .oi, > .fa {
color: #fff !important;
}
}
}
}
}

View File

@@ -104,7 +104,20 @@
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Plating Certifications" name="fp_certs">
<!--
"Operator Training" — formerly "Plating Certifications".
Renamed to disambiguate from the customer-facing
Certificate of Conformance (fp.certificate). This tab
is the operator's process-level training record (EN,
chrome, anodize, etc.) that gates WO start.
-->
<page string="Operator Training" name="fp_certs">
<p class="text-muted small mb-2">
Process-level training certificates required to start
work orders. The Tablet Station blocks an operator
from hitting Start unless they hold an active
certificate for the WO's process type.
</p>
<field name="x_fc_certification_ids"
context="{'default_employee_id': id}">
<list editable="bottom">

View File

@@ -27,6 +27,16 @@
<field name="x_fc_default_tz"/>
</setting>
</block>
<block title="Workforce Settings"
name="fp_workforce_settings"
help="Defaults that govern how the shop tracks worker skills and promotions across recipes.">
<setting id="fp_default_mastery"
string="Default Mastery Threshold"
help="How many successful WO completions an operator needs on a new task before it's added to their Shop Roles automatically. Each role can override this on its own form (e.g. masking 1, electroless nickel 5).">
<field name="x_fc_default_mastery_threshold"/>
</setting>
</block>
</app>
</xpath>
</field>

View File

@@ -4,8 +4,8 @@
# Part of the Fusion Plating product family.
{
'name': 'Fusion Plating — MRP Bridge',
'version': '19.0.3.0.0',
"name": "Fusion Plating — MRP Bridge",
'version': '19.0.6.7.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """
@@ -42,6 +42,13 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor',
'fusion_plating_configurator',
'hr',
# hr_attendance gives us the standard hr.attendance model
# (check_in / check_out). fusion_clock builds on the same model
# so this works whether the shop runs vanilla attendance or the
# full Fusion Clock T&A. Bringing the dep into the bridge keeps
# the Manager Desk's "show only clocked-in workers" filter
# working out of the box.
'hr_attendance',
'mrp',
'mrp_workorder',
'mrp_account',

View File

@@ -17,4 +17,5 @@ from . import account_move
from . import sale_order
from . import fp_work_role
from . import hr_employee
from . import fp_proficiency
from . import fp_process_node

View File

@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Operator proficiency tracker — counts successful WO completions per
(employee, role) pair and auto-promotes the employee once the role's
mastery threshold is crossed.
The promotion mechanic lets managers casually train workers on the job:
they assign someone a task they've never done, the worker finishes it
successfully, and after N successes the role is added to the employee's
Shop Roles automatically. The operator never has to fill in a form;
their growing skill set just unlocks itself.
"""
from markupsafe import Markup
from odoo import _, api, fields, models
class FpOperatorProficiency(models.Model):
_name = 'fp.operator.proficiency'
_description = 'Fusion Plating — Operator Task Proficiency'
_rec_name = 'display_name'
_order = 'employee_id, role_id'
employee_id = fields.Many2one(
'hr.employee', string='Operator',
required=True, ondelete='cascade', index=True,
)
role_id = fields.Many2one(
'fp.work.role', string='Role',
required=True, ondelete='cascade', index=True,
)
completed_count = fields.Integer(
string='Completions',
default=0,
help='Number of times this operator has successfully finished a '
'WO that required this role.',
)
first_completed_at = fields.Datetime(
string='First Success',
help='When the operator finished their first WO for this role.',
)
last_completed_at = fields.Datetime(
string='Last Success',
help='Most recent WO completion against this role.',
)
promoted = fields.Boolean(
string='Promoted',
default=False,
index=True,
help='True once the role has been added to the operator\'s Shop '
'Roles automatically. Stays True even if a manager removes '
'the role afterwards — the count and promotion history are '
'preserved as a training record.',
)
promoted_at = fields.Datetime(
string='Promoted On',
help='When the auto-promotion fired (count crossed the role\'s '
'mastery threshold).',
)
display_name = fields.Char(
compute='_compute_display_name', store=True,
)
progress_label = fields.Char(
compute='_compute_progress_label',
help='"3 / 5" style indicator of how close this operator is to '
'mastery.',
)
_sql_constraints = [
('fp_proficiency_uniq',
'unique(employee_id, role_id)',
'There is already a proficiency record for this operator and role.'),
]
@api.depends('employee_id.name', 'role_id.name')
def _compute_display_name(self):
for rec in self:
rec.display_name = (
f'{rec.employee_id.name or "?"}{rec.role_id.name or "?"}'
)
@api.depends('completed_count', 'role_id.mastery_required')
def _compute_progress_label(self):
for rec in self:
target = rec.role_id.mastery_required or 0
rec.progress_label = (
f'{rec.completed_count} / {target}' if target
else str(rec.completed_count)
)
# ------------------------------------------------------------------
# API used by mrp.workorder.button_finish (via _fp_record_proficiency).
# ------------------------------------------------------------------
@api.model
def _record_completion(self, employee, role):
"""Increment the (employee, role) tally and promote if at threshold.
Idempotent for the (employee, role) pair — if no record exists,
we create one. Always uses sudo() because the worker may not
have write access to their own profile.
"""
if not employee or not role:
return self.browse()
rec = self.sudo().search([
('employee_id', '=', employee.id),
('role_id', '=', role.id),
], limit=1)
now = fields.Datetime.now()
if rec:
new_count = rec.completed_count + 1
rec.write({
'completed_count': new_count,
'last_completed_at': now,
})
else:
rec = self.sudo().create({
'employee_id': employee.id,
'role_id': role.id,
'completed_count': 1,
'first_completed_at': now,
'last_completed_at': now,
})
rec._maybe_promote()
return rec
def _maybe_promote(self):
"""Promote the employee if they've crossed the role's threshold.
- Already promoted: no-op (history is preserved but no duplicate
chatter spam).
- Already in Shop Roles (e.g. manager added it manually): mark
promoted but don't post chatter.
- Below threshold: nothing to do.
- At/above threshold AND not on Shop Roles yet: add the role and
post a celebratory chatter line on the employee.
"""
for rec in self:
if rec.promoted:
continue
target = rec.role_id.mastery_required or 0
if target <= 0:
continue # Auto-promotion disabled for this role
if rec.completed_count < target:
continue
employee = rec.employee_id
role = rec.role_id
already_assigned = role in employee.x_fc_work_role_ids
rec.sudo().write({
'promoted': True,
'promoted_at': fields.Datetime.now(),
})
if already_assigned:
# Manager pre-added the role; don't double-announce.
continue
# Add to Shop Roles + announce on the employee chatter.
employee.sudo().write({
'x_fc_work_role_ids': [(4, role.id)],
})
employee.message_post(
body=Markup(_(
'🎉 <b>%(name)s promoted</b> — qualified for '
'<b>%(role)s</b> after %(count)s successful '
'completions.'
)) % {
'name': employee.name,
'role': role.name,
'count': rec.completed_count,
},
subtype_xmlid='mail.mt_note',
)

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
from odoo import api, fields, models
class FpWorkRole(models.Model):
@@ -43,7 +43,25 @@ class FpWorkRole(models.Model):
)
active = fields.Boolean(default=True)
_sql_constraints = [
('fp_work_role_code_uniq', 'unique(code)',
'Role code must be unique.'),
]
# ------------------------------------------------------------------
# Mastery threshold — how many successful WO completions a worker
# needs on this role before they're auto-promoted (added to their
# x_fc_work_role_ids). Default reads from the company-level Fusion
# Plating settings so a new role inherits the shop default; the
# manager can override per role for tasks that need more practice
# (e.g. masking = 1, electroless nickel plating = 5).
# ------------------------------------------------------------------
mastery_required = fields.Integer(
string='Mastery Threshold',
default=lambda self: self._default_mastery_required(),
help='Number of successful WO completions a worker needs on this '
"role before they're added to its qualified-operators list "
'automatically. 1 = promote on first success; 3 = solid '
"default for everyday roles; 5+ for tasks that need real "
'practice. Defaults from Settings > Fusion Plating > '
'Default Mastery Threshold.',
)
@api.model
def _default_mastery_required(self):
return self.env.company.x_fc_default_mastery_threshold or 3

Some files were not shown because too many files have changed in this diff Show More