53 Commits

Author SHA1 Message Date
gsinghpal
6e53955e9c docs(fusion_accounting_bank_rec): CLAUDE.md, UPGRADE_NOTES.md, README.md
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
Made-with: Cursor
2026-04-19 14:05:49 -04:00
gsinghpal
8dab9b36da feat(fusion_accounting): meta-module now installs bank_rec sub-module
Phase 1 ships fusion_accounting_bank_rec; the meta now depends on it
so a single click installs the full Fusion Accounting suite.

Made-with: Cursor
2026-04-19 14:04:35 -04:00
gsinghpal
14e59148c6 test(fusion_accounting_bank_rec): local LLM (LM Studio/Ollama) compat smoke
Tagged 'local_llm'. Auto-detects LM Studio (:1234) or Ollama (:11434)
via host.docker.internal or localhost. When running, configures the
provider params and runs engine.suggest_matches end-to-end. Skips
gracefully when no local LLM is present (CI / dev VM mode).

Made-with: Cursor
2026-04-19 14:01:58 -04:00
gsinghpal
55eb368195 test(fusion_accounting_bank_rec): performance benchmarks with P95 targets
Tagged 'benchmark' so they can be selected explicitly. Targets:
suggest_matches <500ms, reconcile_batch(50) <5s, list_unreconciled <200ms,
MV refresh <2s. Hard-fail at 5x budget to catch egregious regressions.

Measured on local dev VM:
- suggest_matches: median=221ms p95=234ms (target <500ms)
- reconcile_batch(50 lines): 3318ms (target <5000ms)
- list_unreconciled: median=14ms p95=77ms (target <200ms)
- MV refresh: 60ms (target <2000ms)

Made-with: Cursor
2026-04-19 14:00:15 -04:00
gsinghpal
d623b67157 test(fusion_accounting_bank_rec): 5 OWL tour tests for widget smoke
Tours: smoke (header loads), select_line, accept_suggestion (skipped
in CI without AI config), auto_reconcile_wizard, load_more. Each
tour scripts a typical user interaction; the Python wrappers run them
via HttpCase.start_tour. Tagged 'tour' so they can be excluded from
fast unit-test runs and selected when full browser infra is available.

Made-with: Cursor
2026-04-19 13:47:23 -04:00
gsinghpal
aaaf49989c test(fusion_accounting_bank_rec): coexistence behavior
Verifies that the coexistence group recompute method works as expected
in both Enterprise-present and Enterprise-absent scenarios, and that
the bank-rec menu is gated by the group while the engine itself is
always available.

Made-with: Cursor
2026-04-19 13:45:39 -04:00
gsinghpal
878c013902 feat(fusion_accounting_bank_rec): top-level menu + window action
Menu visible only when fusion_accounting_core.group_fusion_show_when_enterprise_absent
is set (Enterprise's account_accountant not installed). Opens the OWL
bank-rec kanban widget at the unreconciled-lines view.

Made-with: Cursor
2026-04-19 13:37:16 -04:00
gsinghpal
ffc029a875 test(fusion_accounting_bank_rec): migration round-trip for bootstrap step
Verifies the bank_rec_bootstrap migration step (a) creates precedents
from existing partial.reconcile rows, (b) is idempotent on re-run, and
(c) refreshes the MV without erroring.

Three TransactionCase tests:
- test_bootstrap_creates_precedents_from_existing_reconciles seeds two
  reconciles via the engine, wipes the auto-recorded precedents, then
  asserts the bootstrap produces source='backfill' precedents.
- test_bootstrap_step_idempotent runs the bootstrap twice and asserts
  the second pass creates zero new precedents.
- test_bootstrap_refreshes_mv_without_error runs the bootstrap on a
  clean partner and asserts no exception is raised and the result dict
  reports MV + pattern refresh outcomes.

Implementation fixes uncovered by these tests:
- precedent_backfill.backfill_precedents now pre-filters
  account.partial.reconcile to rows that touch a bank statement line on
  either side. Previously it walked every partial in the DB; on the
  westin-v19 dev DB that's 16k rows and the default limit=10000 missed
  the newest test fixtures (highest IDs).
- backfill skips the periodic env.cr.commit() when running under a
  TestCursor, since committing inside a test breaks the rollback.

Test count: 139 -> 142.

Made-with: Cursor
2026-04-19 13:33:29 -04:00
gsinghpal
6048df0645 feat(fusion_accounting_bank_rec): migration audit PDF report
QWeb PDF showing per-company: backfilled precedent count, pattern count,
remaining unreconciled bank line count. Bound to fusion.migration.wizard
so it appears in the Print menu after migration runs.

- reports/migration_audit_report.py defines the AbstractModel
  report.fusion_accounting_bank_rec.migration_audit_template, which
  aggregates per-company counts from fusion.reconcile.precedent
  (source='backfill'), fusion.reconcile.pattern, and
  account.bank.statement.line (is_reconciled=False).
- reports/migration_audit_report_views.xml is the QWeb template.
- reports/migration_audit_report_action.xml registers the
  ir.actions.report bound to fusion.migration.wizard.

Made-with: Cursor
2026-04-19 13:25:59 -04:00
gsinghpal
b6aedc9bbe feat(fusion_accounting_bank_rec): migration wizard bootstrap step
Adds bank_rec_bootstrap step that backfills fusion.reconcile.precedent
from existing account.partial.reconcile rows during migration. This
gives the AI memory from past Enterprise reconciles. Also triggers
pattern refresh + MV refresh for immediate UI readiness.

- New service services/precedent_backfill.py walks
  account.partial.reconcile rows, identifies the bank-statement-line
  side, and creates a precedent per qualifying partial. Idempotent via
  (statement_line, account, amount, source='backfill') signature.
- New model models/fusion_migration_wizard.py inherits
  fusion.migration.wizard, exposes _bank_rec_bootstrap_step() (callable
  from tests/audit), and overrides action_run_migration() to call
  super() + the bootstrap.
- Adds 'backfill' to fusion.reconcile.precedent.source selection.
- Adds fusion_accounting_migration to depends.

Made-with: Cursor
2026-04-19 13:24:17 -04:00
gsinghpal
25f033d0c8 feat(fusion_accounting_bank_rec): bulk reconcile wizard for selected lines
TransientModel + view + binding action so users can select bank lines
from any list view and bulk-apply either engine.reconcile_batch or
a chosen reconcile model.

Made-with: Cursor
2026-04-19 13:17:58 -04:00
gsinghpal
75850aad73 feat(fusion_accounting_bank_rec): auto-reconcile wizard
TransientModel that filters unreconciled bank lines by journal +
date range + strategy and runs engine.reconcile_batch. Shows
reconciled_count / skipped_count / error_summary in result view.

Made-with: Cursor
2026-04-19 13:16:06 -04:00
gsinghpal
6cbb5f85fe feat(fusion_accounting_bank_rec): fusion-only attachment strip + partner history panel
attachment_strip renders inline mimetype-aware chips linking to /web/content
downloads. partner_history_panel calls bank_reconciliation.getPartnerHistory
to surface the learned reconcile pattern (preferred strategy, typical cadence)
plus the most recent reconciles per partner — context Enterprise's bank-rec
widget cannot show because it has no behavioural-learning layer.

Made-with: Cursor
2026-04-19 13:05:23 -04:00
gsinghpal
596ecb9e03 feat(fusion_accounting_bank_rec): fusion-only batch action bar + reconcile model picker
batch_action_bar exposes bulk Suggest-for-selected and Auto-reconcile-selected
toolbar driven by selectedIds prop and the bank_reconciliation service.
reconcile_model_picker is a quick-pick dropdown over account.reconcile.model
records (rule_type=writeoff_button) including the Fusion AI confidence
threshold; apply path is a state-only stub pending Task 38's dedicated endpoint.

Made-with: Cursor
2026-04-19 13:03:50 -04:00
gsinghpal
99e27cc566 feat(fusion_accounting_bank_rec): fusion-only AI suggestion UI components
ai_suggestion_strip (inline confidence badge + accept), ai_alternatives_panel
(expandable other-options), ai_reasoning_tooltip (score breakdown). These
go beyond Enterprise's bank_rec_widget which has no AI suggestions.

Made-with: Cursor
2026-04-19 13:02:18 -04:00
gsinghpal
c9ac4c64fb feat(fusion_accounting_bank_rec): mirror Enterprise OWL batch 4 (auxiliary components)
Mirrors 3 OWL components from account_accountant for Phase 1
structural parity:

- quick_create/ (BankRecQuickCreate + BankRecQuickCreateController
  for inline missing-record creation)
- chatter/ (BankRecChatter — extends @mail Chatter with a
  reloadParentView hook for the bound statement line)
- file_uploader/ (BankRecFileUploader — extends @account
  DocumentFileUploader to inject statement_line_id into the
  upload context, targeting account.bank.statement.line)

Renames applied per spec; CSS class
`o_bank_reconciliation_quick_create` ->
`o_fusion_bank_reconciliation_quick_create`.

Manifest version bumped to 19.0.1.0.15.

Module upgrade succeeds, 134 logical tests still pass — completing
the Phase 1 OWL component mirror (Tasks 30-33). All 14 components
across 4 batches are now bundled.

Made-with: Cursor
2026-04-19 12:55:20 -04:00
gsinghpal
b06e01babb feat(fusion_accounting_bank_rec): mirror Enterprise OWL batch 3 (dialog components)
Mirrors 2 OWL components (3 files each) from account_accountant
for Phase 1 structural parity:

- bankrec_form_dialog/ (full-form dialog for advanced editing,
  including BankRecEditLineFormController with the To-Review
  hotkey button)
- search_dialog/ (BankRecSelectCreateDialog for finding additional
  matches, plus the bank_rec_dialog_list view registration)

Renames applied per spec.

Notes:
- View registry IDs prefixed: `fusion_bankrec_edit_line`,
  `fusion_bank_rec_dialog_list`.
- Button template renamed
  `accountant.BankRecFormDialog.buttons` ->
  `fusion_accounting_bank_rec.BankRecFormDialog.buttons`.

Manifest version bumped to 19.0.1.0.14.

Module upgrade succeeds, 134 logical tests still pass.

Made-with: Cursor
2026-04-19 12:54:11 -04:00
gsinghpal
9e4de89269 feat(fusion_accounting_bank_rec): mirror Enterprise OWL batch 2 (action + edit components)
Mirrors 5 OWL components from account_accountant for Phase 1
structural parity:

- button/ (single action button)
- button_list/ (toolbar of buttons + dropdown + hotkeys)
- line_to_reconcile/ (editable matched-line editor)
- list_view/ (list view + many2one multi-edit field)
- apply_amount/ (amount application html field)

Renames applied per spec (template names, module IDs, CSS classes).

Notes / deferred to fusion-only Tasks 34-36:
- list_view extends @web ListController instead of Enterprise's
  AttachmentPreviewListController; setSelectedRecord is a no-op
  pending the previewer pane mirror.
- View/field registry IDs prefixed with `fusion_` to coexist with
  Enterprise's account_accountant when both modules are installed
  (`fusion_bank_rec_list`, `fusion_bank_rec_dialog_list`,
  `fusion_apply_amount_html`, `fusion_bank_rec_list_many2one_multi_id`,
  `fusion_bankrec_edit_line`).
- button_list still references Enterprise view_refs in dialog
  contexts (`account_accountant.view_account_list_bank_rec_widget`
  etc.) for parity; the `set_*` ORM methods on
  account.bank.statement.line are Enterprise-only too. These call
  sites only fire when the mirrored components are actually
  rendered, which Phase 1 does not exercise.

Manifest version bumped to 19.0.1.0.13.

Module upgrade succeeds, 134 logical tests still pass.

Made-with: Cursor
2026-04-19 12:53:02 -04:00
gsinghpal
1634ecd4f6 feat(fusion_accounting_bank_rec): mirror Enterprise OWL batch 1 (display components)
Mirrors 4 OWL components from account_accountant for Phase 1
structural parity:

- statement_line/ (display + interactivity for one bank line)
- statement_summary/ (header summary card per statement)
- line_info_pop_over/ (popover with extra info on hover)
- reconciled_line_name/ (label for already-reconciled lines)

Plus the Enterprise-compat surface added to
fusion_bank_reconciliation service:
- useBankReconciliation() hook export
- chatterState reactive (visible, statementLine)
- reconcileCountPerPartnerId / reconcileModelPerStatementLineId
- selectStatementLine, openChatter, toggleChatter, reloadChatter
- computeReconcileLineCountPerPartnerId (no-op stub)
- computeAvailableReconcileModels (no-op stub)
- updateAvailableReconcileModels (no-op stub)
- reloadRecords helper
- statementLine{,MoveId,Move,Id} getters

Service now also depends on `orm`. A
components/bank_reconciliation/bank_reconciliation_service.js
re-export shim lets mirrored components keep their relative
`../bank_reconciliation_service` imports verbatim.

Renames applied per spec:
- account_accountant.* -> fusion_accounting_bank_rec.* (template names)
- @account_accountant/... -> @fusion_accounting_bank_rec/... (module IDs)
- useService("bank_reconciliation_service")
    -> useService("fusion_bank_reconciliation")

Forward imports to batch 2 components (button_list,
line_to_reconcile) resolve lazily — files are on disk and bundled
in subsequent batches. Phase 1 prioritizes structural parity;
behaviour wired up in fusion-only Tasks 34-36.

Manifest version bumped to 19.0.1.0.12.

Module upgrade succeeds, 134 logical tests still pass.

Made-with: Cursor
2026-04-19 12:51:38 -04:00
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
166 changed files with 12559 additions and 49 deletions

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting',
'version': '19.0.1.0.0',
'version': '19.0.1.0.1',
'category': 'Accounting/Accounting',
'sequence': 25,
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
@@ -13,9 +13,9 @@ Currently installs:
- fusion_accounting_core Shared schema, security, runtime helpers
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
Future sub-modules (added per the roadmap as each Phase ships):
- fusion_accounting_bank_rec (Phase 1)
- fusion_accounting_reports (Phase 2)
- fusion_accounting_dashboard (Phase 3)
- fusion_accounting_followup (Phase 5)
@@ -33,6 +33,7 @@ Built by Nexa Systems Inc.
'fusion_accounting_core',
'fusion_accounting_ai',
'fusion_accounting_migration',
'fusion_accounting_bank_rec',
],
'data': [],
'installable': True,

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,
}

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,103 @@
# fusion_accounting_bank_rec — Cursor / Claude Context
## Purpose
Replaces (or augments — coexists with) Odoo Enterprise's `account_accountant`
bank reconciliation widget with a Fusion-native, AI-assistive implementation.
Ships in Phase 1 of the fusion_accounting roadmap.
## Architecture
Hybrid: the engine (`fusion.reconcile.engine`, AbstractModel) is the SINGLE
write surface for reconciliations. Everything else (controller, OWL widget,
AI tools, wizards, cron) routes through the engine's 6-method API:
- `reconcile_one(line, against_lines, write_off_vals=None)`
- `reconcile_batch(lines, strategy='auto')`
- `suggest_matches(lines, limit_per_line=3)`
- `accept_suggestion(suggestion)`
- `write_off(line, account, amount, label, tax_id=None)`
- `unreconcile(partial_reconciles)`
Pure-Python services live in `services/`:
- `memo_tokenizer` — Canadian bank memo regex
- `exchange_diff` — FX gain/loss pre-compute
- `matching_strategies` — AmountExact, FIFO, MultiInvoice
- `precedent_lookup` — K-nearest search
- `pattern_extractor` — per-partner aggregate
- `confidence_scoring` — 4-pass pipeline (statistical → AI re-rank)
- `precedent_backfill` — migration helper
Persistent models in `models/`:
- `fusion.reconcile.pattern` — per-(company, partner) learned profile
- `fusion.reconcile.precedent` — per-decision history
- `fusion.reconcile.suggestion` — AI suggestions with state lifecycle
- `fusion.bank.rec.widget` — TransientModel for OWL round-trip
- `fusion.unreconciled.bank.line.mv` — pre-aggregated MV for fast UI listing
- `fusion.bank.rec.cron` — cron handler (suggest, pattern refresh, MV refresh)
- `fusion.auto.reconcile.wizard` / `fusion.bulk.reconcile.wizard` — TransientModel wizards
- `fusion.migration.wizard` (inherits) — adds `_bank_rec_bootstrap_step`
- `account.bank.statement.line` (inherits) — adds fusion_top_suggestion_id, fusion_confidence_band, etc.
- `account.reconcile.model` (inherits) — adds fusion_ai_confidence_threshold
Controller: `controllers/bank_rec_controller.py` exposes 10 JSON-RPC endpoints
under `/fusion/bank_rec/*`. All calls route through the engine.
OWL frontend: `static/src/`
- `services/bank_reconciliation_service.js` — central reactive state + RPC wrappers
- `views/kanban/bank_rec_kanban_*.js` — top-level controller + renderer
- `components/bank_reconciliation/<...>` — 14 mirrored Enterprise components + 8 fusion-only components (ai_suggestion folder, batch_action_bar, reconcile_model_picker, attachment_strip, partner_history_panel)
- `tours/bank_rec_tours.js` — 5 OWL tour smoke tests
## Conventions
- **V19 deprecations to avoid:** `_sql_constraints` (use `models.Constraint`),
`@api.depends('id')` (raises `NotImplementedError`), `@route(type='json')`
(use `type='jsonrpc'`), `numbercall` field on `ir.cron` (removed),
`groups_id` on `res.users` (use `all_group_ids` for searching),
`users` field on `res.groups` (use `user_ids`), `groups_id` on
`ir.ui.menu` (use `group_ids`).
- **Coexistence:** When `account_accountant` is installed, the fusion menu
is hidden via `fusion_accounting_core.group_fusion_show_when_enterprise_absent`
(a computed group). Engine model is always available.
- **Materialized view refresh:** Triggered on `fusion.reconcile.suggestion`
create/write (best-effort, non-blocking). Cron refreshes every 5 min via
a dedicated autocommit cursor (REFRESH CONCURRENTLY can't run inside
Odoo's regular transaction).
- **Test factories:** `tests/_factories.py` provides `make_bank_journal`,
`make_bank_line`, `make_invoice`, `make_reconcileable_pair`, `make_suggestion`,
`make_pattern`, `make_precedent`. NOTE: `make_bank_journal` defaults to
code `'TEST'` so multiple calls in one test will collide; pass an explicit
unique code or share a journal across calls.
- **Hypothesis property tests:** Use `@settings(suppress_health_check=[...])`
to silence function_scoped_fixture warnings in TransactionCase.
## Test counts (as of Phase 1 complete)
- 157 logical tests total in fusion_accounting_bank_rec
- 0 failures, 0 errors
- Includes: 4 benchmark tests (tagged 'benchmark'), 1 local LLM smoke (tagged 'local_llm', skips when no LLM), 5 OWL tour tests (tagged 'tour')
## Performance baseline
| Operation | P95 | Budget |
|---|---|---|
| `engine.suggest_matches` (1 line) | 234ms | <500ms |
| `engine.reconcile_batch` (50 lines) | 3318ms | <5000ms |
| `controller.list_unreconciled` (50 lines) | 77ms | <200ms |
| MV refresh | 60ms | <2000ms |
All within 1x of budget at Phase 1 ship.
## Known concerns / Phase 1.5 backlog
- `accept_suggestion` returns `partial_ids` but not `is_reconciled` — UI reads it post-call
- `engine.write_off` mixed mode (write-off + against_lines) implemented but untested
- `engine.reconcile_one` returns `exchange_diff_move_id: None` (Odoo's reconcile() handles FX inline; surfacing the move_id needs an extra query)
- `against_lines` early-break in `reconcile_one` silently drops excess; auto strategy avoids this but manual callers should pre-validate
- Reconcile-model bulk wizard `_apply_lines_for_bank_statement_line` is Enterprise-only (Community falls back to per-line error)
- OWL tour tests skip-mode when websocket-client absent

View File

@@ -0,0 +1,41 @@
# fusion_accounting_bank_rec
AI-assisted bank reconciliation for Odoo 19 Community — a Fusion-native
replacement for Enterprise's `account_accountant` bank reconciliation widget.
## What it does
- Side-by-side parity with Enterprise's bank reconciliation UI (kanban + side
panel, multi-currency, write-offs, attachments, chatter)
- AI-assistive: confidence-scored suggestions per bank line via the
`fusion.reconcile.engine` 4-pass scoring pipeline (statistical + optional
LLM re-rank)
- Coexists with `account_accountant` (Enterprise wins by default; Fusion menu
appears only when Enterprise is uninstalled)
- Migration-aware: bootstrap step backfills `fusion.reconcile.precedent` from
existing `account.partial.reconcile` rows so the AI has memory from day 1
## Quick start
```bash
# Install
odoo --addons-path=... -i fusion_accounting_bank_rec
# Open the widget (when Enterprise's account_accountant is NOT installed)
# Apps → Bank Reconciliation → Reconcile Bank Lines
# When Enterprise IS installed: use Enterprise's UI; the engine + AI tools
# are still available via the AI chat.
```
## Configuration
- Local LLM (LM Studio, Ollama):
- `fusion_accounting.openai_base_url` = `http://host.docker.internal:1234/v1`
- `fusion_accounting.openai_model` = your local model name
- `fusion_accounting.provider.bank_rec_suggest` = `openai`
## See also
- `CLAUDE.md` — agent context
- `UPGRADE_NOTES.md` — Odoo version anchoring

View File

@@ -0,0 +1,34 @@
# fusion_accounting_bank_rec — Upgrade Notes
## Odoo Version Anchor
This module targets **Odoo 19.0** (community-base).
Reference snapshot of Enterprise code mirrored from:
- `account_accountant` (Odoo 19.0.x)
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/`
## Cross-Version Diff Strategy
When a new Odoo version ships:
1. Run `check_odoo_diff.sh` (in repo root) against the new Enterprise version
2. Note any breaking changes in `account.bank.statement.line` API
3. For mirrored OWL components, diff Enterprise's new versions against ours and
port material changes (signature renames, new behaviour we want to inherit)
4. Re-run the full test suite + tour tests against the new Odoo version
5. Update this file with the new version anchor + any deviations
## V19 Migration Notes (already applied)
- `_sql_constraints``models.Constraint` (Tasks 14, 15)
- `@api.depends('id')` → removed (Task 17)
- `@route(type='json')``type='jsonrpc'` (Task 26)
- `numbercall` removed from `ir.cron` (Task 25)
- `res.groups.users``user_ids` (Task 43)
- `ir.ui.menu.groups_id``group_ids` (Tasks 42, 43)
## Phase 1 → Phase 1.5 Migration
If we ship Phase 1.5 (UI polish, deferred features), changes will go in
incremental commits. No DB migration needed (Phase 1 schema is forward-compatible).

View File

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

View File

@@ -0,0 +1,113 @@
{
'name': 'Fusion Accounting — Bank Reconciliation',
'version': '19.0.1.0.26',
'category': 'Accounting/Accounting',
'sequence': 28,
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
'description': """
Fusion Accounting — Bank Reconciliation
========================================
Replaces Odoo Enterprise's account_accountant bank-rec widget with a
native V19 OWL implementation reading/writing Community's
account.partial.reconcile tables.
Features:
- Strict mirror of all Enterprise UI components (zero functional loss)
- AI confidence badges with one-click Accept and ranked alternatives
- Behavioural learning from historical reconciliations
- Local LLM ready (Ollama, LM Studio) via OpenAI-compatible adapter
- Coexists with account_accountant (Enterprise wins by default)
Built by Nexa Systems Inc.
""",
'icon': '/fusion_accounting_bank_rec/static/description/icon.png',
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'depends': ['fusion_accounting_core', 'fusion_accounting_migration'],
'external_dependencies': {
'python': ['hypothesis'],
},
'data': [
'security/ir.model.access.csv',
'data/cron.xml',
'wizards/auto_reconcile_wizard_views.xml',
'wizards/bulk_reconcile_wizard_views.xml',
'reports/migration_audit_report_views.xml',
'reports/migration_audit_report_action.xml',
'views/menu_views.xml',
],
'assets': {
'web.assets_backend': [
'fusion_accounting_bank_rec/static/src/scss/_variables.scss',
'fusion_accounting_bank_rec/static/src/scss/bank_reconciliation.scss',
'fusion_accounting_bank_rec/static/src/scss/ai_suggestion.scss',
'fusion_accounting_bank_rec/static/src/scss/dark_mode.scss',
'fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_controller.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_renderer.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_view.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban.xml',
# OWL component mirror — Enterprise account_accountant bank-rec.
# Re-export shim so mirrored components can use the relative
# `../bank_reconciliation_service` import unchanged.
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_reconciliation_service.js',
# Batch 1 (Task 30) — display components
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_line/statement_line.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_line/statement_line.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_summary/statement_summary.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_summary/statement_summary.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_info_pop_over/line_info_pop_over.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_info_pop_over/line_info_pop_over.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconciled_line_name/reconciled_line_name.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconciled_line_name/reconciled_line_name.xml',
# Batch 2 (Task 31) — action + edit components
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.xml',
# Batch 3 (Task 32) — dialog components
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog_list.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog_list.xml',
# Batch 4 (Task 33) — auxiliary components
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/quick_create/quick_create.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/quick_create/quick_create.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/chatter/chatter.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/file_uploader/file_uploader.js',
# Fusion-only (Task 34) — AI suggestion UI
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.xml',
# Fusion-only (Task 35) — batch action bar + reconcile model picker
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconcile_model_picker/reconcile_model_picker.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconcile_model_picker/reconcile_model_picker.xml',
# Fusion-only (Task 36) — attachment strip + partner history panel
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/partner_history_panel/partner_history_panel.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/partner_history_panel/partner_history_panel.xml',
],
'web.assets_tests': [
'fusion_accounting_bank_rec/static/src/tours/bank_rec_tours.js',
],
},
'installable': True,
'application': False,
'license': 'OPL-1',
}

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,10 @@
from . import fusion_reconcile_pattern
from . import fusion_reconcile_precedent
from . import fusion_reconcile_suggestion
from . import fusion_bank_rec_widget
from . import account_bank_statement_line
from . import account_reconcile_model
from . import fusion_reconcile_engine
from . import fusion_unreconciled_bank_line_mv
from . import fusion_bank_rec_cron
from . import fusion_migration_wizard

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,97 @@
"""Bank-rec specific migration step.
Hooks into fusion.migration.wizard (defined by fusion_accounting_migration)
to bootstrap fusion.reconcile.precedent from existing
account.partial.reconcile rows. This gives the AI immediate "memory" from
past Enterprise reconciles so suggestions can be ranked by precedent
similarity from day one.
The bootstrap step is exposed as a public method (_bank_rec_bootstrap_step)
so tests and the audit report can invoke it directly. action_run_migration
is overridden to call super() then run the bootstrap.
"""
import logging
from odoo import _, models
from ..services.precedent_backfill import backfill_precedents
_logger = logging.getLogger(__name__)
class FusionMigrationWizard(models.TransientModel):
_inherit = "fusion.migration.wizard"
def _bank_rec_bootstrap_step(self):
"""Migration step: backfill precedents + refresh patterns + refresh MV.
Returns a dict describing what happened, suitable for surfacing to
the user via notification or PDF audit report.
"""
self.ensure_one()
_logger.info(
"fusion_accounting_bank_rec migration step: bootstrap starting")
company_id = None
if 'company_id' in self._fields and self.company_id:
company_id = self.company_id.id
precedent_result = backfill_precedents(
self.env, company_id=company_id, limit=10000)
try:
self.env['fusion.bank.rec.cron']._cron_refresh_patterns()
patterns_ok = True
except Exception as e: # noqa: BLE001
_logger.warning(
"Pattern refresh during migration failed: %s", e)
patterns_ok = False
try:
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
mv_ok = True
except Exception as e: # noqa: BLE001
_logger.warning("MV refresh during migration failed: %s", e)
mv_ok = False
result = {
'step': 'bank_rec_bootstrap',
'precedents_created': precedent_result['created'],
'precedents_skipped': precedent_result['skipped'],
'patterns_refreshed': patterns_ok,
'mv_refreshed': mv_ok,
}
_logger.info(
"fusion_accounting_bank_rec bootstrap complete: %s", result)
return result
def action_run_migration(self):
"""Override the migration entry-point to add the bank-rec step.
Calls super() (which currently returns a notification stub from
Phase 0) and then runs the bank-rec bootstrap. Returns a
notification summarizing both.
"""
_ = super().action_run_migration()
result = self._bank_rec_bootstrap_step()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'title': _("Bank-Rec Migration Complete"),
'message': _(
"Backfilled %(created)d precedents "
"(skipped %(skipped)d). "
"Patterns refreshed: %(p)s. MV refreshed: %(m)s."
) % {
'created': result['precedents_created'],
'skipped': result['precedents_skipped'],
'p': 'yes' if result['patterns_refreshed'] else 'no',
'm': 'yes' if result['mv_refreshed'] else 'no',
},
'sticky': False,
},
}

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

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 @@
from . import migration_audit_report

View File

@@ -0,0 +1,51 @@
"""QWeb PDF report: summary of bank-rec migration outcomes.
Triggered from the migration wizard's "Print" menu after the wizard
completes. For each company on the system, reports:
- Backfilled precedents (source='backfill')
- Fusion reconcile patterns
- Bank statement lines still unreconciled
Lets the operator confirm Phase 1 migration successfully bootstrapped
the AI's reconcile memory from past Enterprise reconciles.
"""
from odoo import api, models
class FusionMigrationAuditReport(models.AbstractModel):
_name = "report.fusion_accounting_bank_rec.migration_audit_template"
_description = "Bank-Rec Migration Audit Report"
@api.model
def _get_report_values(self, docids, data=None):
Wizard = self.env['fusion.migration.wizard']
wizards = Wizard.browse(docids) if docids else Wizard
Precedent = self.env['fusion.reconcile.precedent']
Pattern = self.env['fusion.reconcile.pattern']
Line = self.env['account.bank.statement.line']
company_stats = []
for company in self.env['res.company'].search([]):
company_stats.append({
'company': company,
'precedents_count': Precedent.search_count([
('company_id', '=', company.id),
('source', '=', 'backfill'),
]),
'patterns_count': Pattern.search_count([
('company_id', '=', company.id),
]),
'unreconciled_count': Line.search_count([
('company_id', '=', company.id),
('is_reconciled', '=', False),
]),
})
return {
'doc_ids': docids,
'doc_model': 'fusion.migration.wizard',
'docs': wizards,
'company_stats': company_stats,
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_report_migration_audit" model="ir.actions.report">
<field name="name">Bank-Rec Migration Audit</field>
<field name="model">fusion.migration.wizard</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_accounting_bank_rec.migration_audit_template</field>
<field name="report_file">fusion_accounting_bank_rec.migration_audit_template</field>
<field name="binding_model_id" ref="fusion_accounting_migration.model_fusion_migration_wizard"/>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="migration_audit_template">
<t t-call="web.html_container">
<t t-call="web.external_layout">
<div class="page">
<h2>Bank-Rec Migration Audit</h2>
<p>
Generated
<span t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/>
</p>
<h3>Per-Company Summary</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Company</th>
<th class="text-end">Backfilled Precedents</th>
<th class="text-end">Patterns</th>
<th class="text-end">Still Unreconciled</th>
</tr>
</thead>
<tbody>
<tr t-foreach="company_stats" t-as="cs">
<td><span t-esc="cs['company'].name"/></td>
<td class="text-end"><span t-esc="cs['precedents_count']"/></td>
<td class="text-end"><span t-esc="cs['patterns_count']"/></td>
<td class="text-end"><span t-esc="cs['unreconciled_count']"/></td>
</tr>
</tbody>
</table>
<p class="text-muted">
This report verifies that Phase 1 migration successfully
bootstrapped the AI's reconcile memory from past Enterprise
reconciles.
</p>
</div>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,12 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_reconcile_pattern_user,pattern user,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
access_fusion_reconcile_pattern_admin,pattern admin,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_reconcile_precedent_user,precedent user,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
access_fusion_reconcile_precedent_admin,precedent admin,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_reconcile_suggestion_user,suggestion user,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
access_fusion_reconcile_suggestion_admin,suggestion admin,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_bank_rec_widget_user,bank rec widget user,model_fusion_bank_rec_widget,fusion_accounting_core.group_fusion_accounting_user,1,1,1,1
access_fusion_unreconciled_bank_line_mv_user,unreconciled bank line mv user,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
access_fusion_unreconciled_bank_line_mv_admin,unreconciled bank line mv admin,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_admin,1,0,0,0
access_fusion_auto_reconcile_wizard_user,fusion.auto.reconcile.wizard.user,model_fusion_auto_reconcile_wizard,base.group_user,1,1,1,0
access_fusion_bulk_reconcile_wizard_user,fusion.bulk.reconcile.wizard.user,model_fusion_bulk_reconcile_wizard,base.group_user,1,1,1,0
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
11 access_fusion_auto_reconcile_wizard_user fusion.auto.reconcile.wizard.user model_fusion_auto_reconcile_wizard base.group_user 1 1 1 0
12 access_fusion_bulk_reconcile_wizard_user fusion.bulk.reconcile.wizard.user model_fusion_bulk_reconcile_wizard base.group_user 1 1 1 0

View File

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

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,116 @@
"""Pure-Python helpers for backfilling fusion.reconcile.precedent
from existing account.partial.reconcile rows during migration.
Strategy:
- Each account.partial.reconcile that involves at least one
account.bank.statement.line's reconcile-account line is a candidate.
- One precedent per qualifying partial. The (statement_line.id, account_id,
amount) triple is encoded into matched_account_ids so a second run can
detect and skip already-backfilled rows (idempotency).
"""
import logging
from .memo_tokenizer import tokenize_memo
_logger = logging.getLogger(__name__)
def _identify_bank_side(partial):
"""Return (bank_move_line, counterpart_move_line, statement_line_id)
or (None, None, None) if neither side is a bank statement line."""
debit_line = partial.debit_move_id
credit_line = partial.credit_move_id
if debit_line.move_id.statement_line_id:
return debit_line, credit_line, debit_line.move_id.statement_line_id.id
if credit_line.move_id.statement_line_id:
return credit_line, debit_line, credit_line.move_id.statement_line_id.id
return None, None, None
def backfill_precedents(env, *, company_id=None, batch_size=500, limit=10000):
"""Walk account.partial.reconcile and create fusion.reconcile.precedent
rows for any reconcile that involves a bank statement line.
Idempotent: skips partials whose (statement_line, account, amount)
signature is already present in fusion.reconcile.precedent (encoded
via matched_account_ids).
Returns dict with `created` and `skipped` counts.
"""
Precedent = env['fusion.reconcile.precedent'].sudo()
Partial = env['account.partial.reconcile'].sudo()
Line = env['account.bank.statement.line'].sudo()
in_test_mode = env.cr.__class__.__name__ == 'TestCursor'
# Pre-filter to partials that touch a bank statement line on either side.
# In a real DB we typically have 10x more invoice<->payment partials than
# bank-rec partials; filtering here keeps the loop bounded and makes the
# default limit reflect "real" candidates rather than every partial ever.
domain = [
'|',
('debit_move_id.move_id.statement_line_id', '!=', False),
('credit_move_id.move_id.statement_line_id', '!=', False),
]
if company_id:
domain.append(('company_id', '=', company_id))
partials = Partial.search(domain, limit=limit, order='id asc')
created = 0
skipped = 0
for partial in partials:
bank_line, counterpart, bsl_id = _identify_bank_side(partial)
if not bsl_id:
skipped += 1
continue
signature_account = str(counterpart.account_id.id)
existing = Precedent.search([
('partner_id', '=',
counterpart.partner_id.id if counterpart.partner_id else False),
('amount', '=', abs(partial.amount)),
('matched_account_ids', '=ilike', f'%{signature_account}%'),
('source', '=', 'backfill'),
], limit=1)
if existing:
skipped += 1
continue
statement_line = Line.browse(bsl_id)
try:
currency = (partial.debit_currency_id
or partial.company_id.currency_id)
Precedent.create({
'company_id': partial.company_id.id,
'partner_id': (counterpart.partner_id.id
if counterpart.partner_id else False),
'amount': abs(partial.amount),
'currency_id': currency.id,
'date': statement_line.date or partial.create_date.date(),
'memo_tokens': ','.join(
tokenize_memo(statement_line.payment_ref or '')),
'journal_id': statement_line.journal_id.id,
'matched_move_line_count': 1,
'matched_account_ids': signature_account,
'reconciler_user_id': partial.create_uid.id,
'reconciled_at': partial.create_date,
'source': 'backfill',
})
created += 1
if created % batch_size == 0:
if not in_test_mode:
env.cr.commit()
_logger.info(
"Backfill progress: %d created, %d skipped",
created, skipped)
except Exception as e: # noqa: BLE001
_logger.warning("Backfill skip partial %s: %s", partial.id, e)
skipped += 1
_logger.info(
"precedent_backfill complete: %d created, %d skipped",
created, skipped)
return {'created': created, 'skipped': skipped}

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,34 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class AiAlternativesPanel extends Component {
static template = "fusion_accounting_bank_rec.AiAlternativesPanel";
static props = {
suggestions: { type: Array },
onClose: { type: Function, optional: true },
};
setup() {
this.bankRec = useService("fusion_bank_reconciliation");
}
bandFor(c) {
if (c >= 0.85) return "high";
if (c >= 0.6) return "medium";
if (c > 0) return "low";
return "none";
}
pctFor(c) {
return Math.round(c * 100);
}
async onAccept(suggestionId) {
await this.bankRec.acceptSuggestion(suggestionId);
if (this.props.onClose) {
this.props.onClose();
}
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.AiAlternativesPanel">
<div class="o_fusion_alternatives_panel">
<h6>Other AI suggestions</h6>
<div t-foreach="props.suggestions" t-as="sug" t-key="sug.id"
class="o_fusion_alternative">
<div>
<span class="alt_confidence" t-att-class="'band-' + bandFor(sug.confidence)">
<t t-esc="pctFor(sug.confidence)"/>%
</span>
<t t-esc="sug.reasoning"/>
</div>
<button class="btn_fusion" t-on-click="() => onAccept(sug.id)">
Use this
</button>
</div>
<div t-if="props.onClose" class="text-end mt-2">
<button class="btn_fusion" t-on-click="props.onClose">Close</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,18 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class AiReasoningTooltip extends Component {
static template = "fusion_accounting_bank_rec.AiReasoningTooltip";
static props = {
scores: { type: Object },
reasoning: { type: String, optional: true },
};
pctFor(value) {
if (value === undefined || value === null) {
return "0";
}
return (value * 100).toFixed(0);
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.AiReasoningTooltip">
<div class="o_fusion_reasoning_tooltip" style="font-size: 0.85em; padding: 0.5rem;">
<div t-if="props.reasoning" class="mb-2">
<em><t t-esc="props.reasoning"/></em>
</div>
<div class="text-muted">
<div>Amount match: <t t-esc="pctFor(props.scores.amount_match)"/>%</div>
<div>Partner pattern: <t t-esc="pctFor(props.scores.partner_pattern)"/>%</div>
<div>Precedent similarity: <t t-esc="pctFor(props.scores.precedent_similarity)"/>%</div>
<div t-if="props.scores.ai_rerank">
AI re-rank: <t t-esc="pctFor(props.scores.ai_rerank)"/>%
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,38 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class AiSuggestionStrip extends Component {
static template = "fusion_accounting_bank_rec.AiSuggestionStrip";
static props = {
suggestion: { type: Object },
showAlternatives: { type: Function, optional: true },
};
setup() {
this.bankRec = useService("fusion_bank_reconciliation");
}
get band() {
const c = this.props.suggestion.confidence;
if (c >= 0.85) return "high";
if (c >= 0.6) return "medium";
if (c > 0) return "low";
return "none";
}
get confidencePct() {
return Math.round(this.props.suggestion.confidence * 100);
}
async onAccept() {
await this.bankRec.acceptSuggestion(this.props.suggestion.id);
}
onShowAlternatives() {
if (this.props.showAlternatives) {
this.props.showAlternatives();
}
}
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.AiSuggestionStrip">
<div class="o_fusion_ai_suggestion" t-att-data-band="band">
<div class="o_fusion_confidence_badge">
<t t-esc="confidencePct"/>%
</div>
<div class="o_fusion_suggestion_text">
<div class="o_fusion_reasoning">
<t t-esc="props.suggestion.reasoning || 'AI suggested match'"/>
</div>
</div>
<div class="o_fusion_suggestion_actions">
<button class="btn_fusion btn_fusion_primary" t-on-click="onAccept">
Accept
</button>
<button t-if="props.showAlternatives" class="btn_fusion"
t-on-click="onShowAlternatives">
Other options
</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,82 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../apply_amount/apply_amount.js`.
* Phase 1 structural parity.
*/
import { Component } from "@odoo/owl";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
class BankRecWidgetApplyAmountHtmlField extends Component {
static props = standardFieldProps;
static template = "fusion_accounting_bank_rec.BankRecWidgetApplyAmountHtmlField";
setup() {
this.action = useService("action");
this.orm = useService("orm");
}
get value() {
return this.props.record.data[this.props.name];
}
async switchApplyAmount(ev) {
const root = this.env.model.root;
const fetchReconciledLines = async (fields = []) => {
return await this.orm.searchRead(
"account.move.line",
[
[
"id",
"in",
...root.data.reconciled_lines_excluding_exchange_diff_ids._currentIds,
],
],
fields
);
};
const fetchStatementLines = async (fields = []) => {
return await this.orm.searchRead(
"account.move.line",
[["move_id", "=", root.data.move_id.id]],
fields
);
};
if (ev.target.attributes.name?.value === "action_redirect_to_move") {
const [line] = await fetchReconciledLines(["amount_currency", "balance", "move_id"]);
await this.openMove(line.move_id[0]);
} else if (ev.target.attributes.name?.value === "apply_full_amount") {
const [line] = await fetchReconciledLines(["amount_currency", "balance"]);
await root.update({
balance: -line.balance,
amount_currency: -line.amount_currency,
});
} else if (ev.target.attributes.name?.value === "apply_partial_amount") {
const lines = await fetchStatementLines(["amount_currency", "balance"]);
// We have all the lines of the entry, we want the suspense line.
await root.update({
balance: lines.at(-1).balance,
amount_currency: lines.at(-1).amount_currency,
});
}
}
openMove(moveId) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "account.move",
res_id: moveId,
views: [[false, "form"]],
target: "current",
});
}
}
const fusionBankRecWidgetApplyAmountHtmlField = { component: BankRecWidgetApplyAmountHtmlField };
registry.category("fields").add("fusion_apply_amount_html", fusionBankRecWidgetApplyAmountHtmlField);

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecWidgetApplyAmountHtmlField">
<div t-out="value" t-on-click="switchApplyAmount"/>
</t>
</templates>

View File

@@ -0,0 +1,27 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class AttachmentStrip extends Component {
static template = "fusion_accounting_bank_rec.AttachmentStrip";
static props = {
attachments: { type: Array },
};
iconFor(mimetype) {
if (!mimetype) {
return "fa-file";
}
if (mimetype.startsWith("image/")) {
return "fa-file-image-o";
}
if (mimetype === "application/pdf") {
return "fa-file-pdf-o";
}
return "fa-file-o";
}
urlFor(att) {
return `/web/content/${att.id}?download=true`;
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.AttachmentStrip">
<div class="o_fusion_attachment_strip d-flex flex-wrap"
style="gap: 0.5rem; padding: 0.5rem;">
<div t-if="props.attachments.length === 0" class="text-muted small">
No attachments
</div>
<a t-foreach="props.attachments" t-as="att" t-key="att.id"
t-att-href="urlFor(att)" target="_blank"
class="o_fusion_attachment_chip"
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.5rem; background: #f3f4f6; border-radius: 0.25rem; text-decoration: none; color: inherit; font-size: 0.85em;">
<i class="fa" t-att-class="iconFor(att.mimetype)"/>
<span><t t-esc="att.name"/></span>
</a>
</div>
</t>
</templates>

View File

@@ -0,0 +1,14 @@
/** @odoo-module **/
/**
* Re-export shim so mirrored Enterprise components can use the relative
* import `../bank_reconciliation_service` unchanged. The real
* implementation lives in
* `@fusion_accounting_bank_rec/services/bank_reconciliation_service`.
*/
export {
BankReconciliationService,
bankReconciliationService,
useBankReconciliation,
} from "@fusion_accounting_bank_rec/services/bank_reconciliation_service";

View File

@@ -0,0 +1,48 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../bankrec_form_dialog/bankrec_form_dialog.js`.
* Phase 1 structural parity.
*/
import { FormController } from "@web/views/form/form_controller";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { formView } from "@web/views/form/form_view";
import { onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
export class BankRecFormDialog extends FormViewDialog {
setup() {
super.setup();
Object.assign(this.viewProps, {
buttonTemplate: "fusion_accounting_bank_rec.BankRecFormDialog.buttons",
});
}
}
export class BankRecEditLineFormController extends FormController {
setup() {
super.setup();
this.isReviewed = this.props.context.is_reviewed;
onWillStart(async () => {
this.userCanReview = await user.hasGroup("account.group_account_user");
});
}
async toReviewButtonClicked(params = {}) {
await this.orm.call("account.move", "set_moves_checked", [
this.model.root.data.move_id.id,
false,
]);
return this.saveButtonClicked(params);
}
}
export const bankRecEditLineFormController = {
...formView,
Controller: BankRecEditLineFormController,
};
registry.category("views").add("fusion_bankrec_edit_line", bankRecEditLineFormController);

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecFormDialog.buttons" t-inherit="web.FormViewDialog.ToOne.buttons" t-inherit-mode="primary">
<xpath expr="//button[hasclass('o_form_button_save')]" position="after">
<button
t-if="userCanReview and this.isReviewed"
class="btn btn-info"
t-on-click.stop="() => this.toReviewButtonClicked({closable: true})"
data-hotkey="q">
<span>To Review</span>
</button>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,37 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class BatchActionBar extends Component {
static template = "fusion_accounting_bank_rec.BatchActionBar";
static props = {
selectedIds: { type: Array, optional: true },
};
setup() {
this.bankRec = useService("fusion_bank_reconciliation");
}
get hasSelection() {
return this.props.selectedIds && this.props.selectedIds.length > 0;
}
get selectionCount() {
return this.props.selectedIds ? this.props.selectedIds.length : 0;
}
async onAutoReconcile() {
if (!this.hasSelection) {
return;
}
await this.bankRec.bulkReconcile(this.props.selectedIds, "auto");
}
async onSuggestForSelected() {
if (!this.hasSelection) {
return;
}
await this.bankRec.suggestMatches(this.props.selectedIds, 3);
}
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BatchActionBar">
<div class="o_fusion_batch_action_bar d-flex"
style="gap: 0.5rem; padding: 0.75rem; background: #f3f4f6; border-radius: 0.375rem;">
<span class="text-muted">
<t t-esc="selectionCount"/> selected
</span>
<button class="btn_fusion" t-att-disabled="!hasSelection" t-on-click="onSuggestForSelected">
Suggest for selected
</button>
<button class="btn_fusion btn_fusion_primary" t-att-disabled="!hasSelection" t-on-click="onAutoReconcile">
Auto-reconcile selected
</button>
</div>
</t>
</templates>

View File

@@ -0,0 +1,29 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../button/button.js`.
* Phase 1 structural parity.
*/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class BankRecButton extends Component {
static template = "fusion_accounting_bank_rec.BankRecButton";
static props = {
label: { type: String, optional: true },
action: { type: Function, optional: true },
count: { type: [Number, { value: null }], optional: true },
primary: { type: Boolean, optional: true },
toReview: { type: Boolean, optional: true },
classes: { type: String, optional: true },
};
static defaultProps = {
primary: false,
classes: "",
};
setup() {
this.ui = useService("ui");
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecButton">
<button
t-attf-class="d-flex gap-1 btn text-nowrap {{ props.classes }}"
t-att-class="{'btn-sm': !ui.isSmall, 'btn-primary': props.primary, 'btn-info': props.toReview, 'btn-secondary': !props.primary}"
t-on-click.stop="() => props?.action()"
>
<span t-esc="props?.label" class="m-auto text-truncate"/>
<span class="rounded-pill px-2 o_bg-black-10" t-if="props?.count">
<t t-esc="props.count"/>
</span>
</button>
</t>
</templates>

View File

@@ -0,0 +1,603 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../button_list/button_list.js`.
* Phase 1 structural parity. Behaviour delegates to the
* Enterprise-compat surface in our `fusion_bank_reconciliation` service.
*/
import { BankRecButton } from "@fusion_accounting_bank_rec/components/bank_reconciliation/button/button";
import { BankRecFileUploader } from "@fusion_accounting_bank_rec/components/bank_reconciliation/file_uploader/file_uploader";
import { Component } from "@odoo/owl";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
import { BankRecSelectCreateDialog } from "@fusion_accounting_bank_rec/components/bank_reconciliation/search_dialog/search_dialog";
import { _t } from "@web/core/l10n/translation";
import { getCurrency } from "@web/core/currency";
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
export class BankRecButtonList extends Component {
static template = "fusion_accounting_bank_rec.BankRecButtonList";
static components = {
Dropdown,
DropdownItem,
BankRecButton,
BankRecFileUploader,
};
static props = {
statementLineRootRef: { type: Object },
statementLine: { type: Object },
suspenseAccountLine: { type: Object, optional: true },
reconcileLineCount: { type: [Number, { value: null }], optional: true },
reconcileModels: Array,
preSelectedReconciliationModel: { type: Object, optional: true },
};
static defaultProps = {
reconcileLineCount: 0,
};
setup() {
this.action = useService("action");
this.ui = useService("ui");
this.orm = useService("orm");
this.addDialog = useOwnedDialogs();
this.currencyDigits = getCurrency(this.statementLineData.currency_id.id)?.digits || 2;
this.bankReconciliation = useBankReconciliation();
this.registerHotkeys();
}
restoreFocus() {
if (this.isLineSelected) {
this.props.statementLineRootRef.el.focus();
}
}
/**
* Displays a search dialog (no create option) for selecting a `res.partner` record.
*/
setPartnerOnReconcileLine() {
this.addDialog(
SelectCreateDialog,
{
title: _t("Search: Partner"),
noCreate: false,
multiSelect: false,
resModel: "res.partner",
context: { default_name: this.statementLineData.partner_name },
onSelected: async (partner) => {
await this.orm.call(
"account.bank.statement.line",
"set_partner_bank_statement_line",
[this.statementLineData.id, partner[0]]
);
const recordsToLoad = [];
if (this.statementLineData.partner_name) {
// Reload all impacted statement lines if we have a partner_name
recordsToLoad.push(
...this.env.model.root.records.filter(
(record) =>
record.data.partner_name === this.statementLineData.partner_name
)
);
} else {
recordsToLoad.push(this.props.statementLine);
}
await this.bankReconciliation.reloadRecords(recordsToLoad);
await this.bankReconciliation.computeReconcileLineCountPerPartnerId(
this.env.model.root.records
);
this.bankReconciliation.reloadChatter();
this.restoreFocus();
},
},
{
onClose: () => {
this.restoreFocus();
},
}
);
}
/**
* Opens a dialog to select an account and assigns it to the current reconcile line.
*/
setAccountOnReconcileLine() {
const context = {
list_view_ref: "account_accountant.view_account_list_bank_rec_widget",
search_view_ref: "account_accountant.view_account_search_bank_rec_widget",
...(this.statementLineData.amount > 0
? { preferred_account_type: "income" }
: { preferred_account_type: "expense" }),
};
this.addDialog(
SelectCreateDialog,
{
title: _t("Search: Account"),
noCreate: true,
multiSelect: false,
domain: [
[
"id",
"not in",
[
this.statementLineData.journal_id.suspense_account_id.id,
this.statementLineData.journal_id.default_account_id.id,
],
],
],
context: context,
resModel: "account.account",
onSelected: async (account) => {
const linesToLoad = await this._setAccountOnReconcileLine(
this.lastAccountMoveLine.data.id,
account[0],
{ context: { account_default_taxes: true } }
);
const recordsToLoad = [
...this.env.model.root.records.filter((record) =>
linesToLoad.includes(record.data.id)
),
this.props.statementLine,
];
await this.bankReconciliation.reloadRecords(recordsToLoad);
this.bankReconciliation.reloadChatter();
this.restoreFocus();
},
},
{
onClose: () => {
this.restoreFocus();
},
}
);
}
async _setAccountOnReconcileLine(amlId, accountId, context = {}) {
return await this.orm.call(
"account.bank.statement.line",
"set_account_bank_statement_line",
[this.statementLineData.id, amlId, accountId],
context
);
}
async setAccountReceivableOnReconcileLine() {
let accountId;
if (this.statementLineData.partner_id.property_account_receivable_id.id) {
accountId = this.statementLineData.partner_id.property_account_receivable_id.id;
} else {
accountId = await this.orm.webSearchRead("account.account", [
["account_type", "=", "asset_receivable"],
]);
}
await this._setAccountOnReconcileLine(this.lastAccountMoveLine.data.id, accountId);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
async setAccountPayableOnReconcileLine() {
let accountId;
if (this.statementLineData.partner_id.property_account_payable_id.id) {
accountId = this.statementLineData.partner_id.property_account_payable_id.id;
} else {
accountId = await this.orm.webSearchRead("account.account", [
["account_type", "=", "liability_payable"],
]);
}
await this._setAccountOnReconcileLine(this.lastAccountMoveLine.data.id, accountId);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
/**
* Opens a dialog to search and select journal items to reconcile with the current bank statement line.
*/
reconcileOnReconcileLine() {
const context = {
list_view_ref: "account_accountant.view_account_move_line_list_bank_rec_widget",
search_view_ref: "account_accountant.view_account_move_line_search_bank_rec_widget",
preferred_aml_value: -this.props.suspenseAccountLine.amount_currency,
preferred_aml_currency_id: this.props.suspenseAccountLine.currency_id.id,
...(this.statementLineData.partner_id
? { search_default_partner_id: this.statementLineData.partner_id.id }
: { search_default_posted: 1 }),
};
this.addDialog(
BankRecSelectCreateDialog,
{
title: _t("Search: Journal Items to Match"),
noCreate: true,
domain: this.getReconcileButtonDomain(),
resModel: "account.move.line",
size: "xl",
context: context,
onSelected: async (moveLines) => {
await this.orm.call(
"account.bank.statement.line",
"set_line_bank_statement_line",
[this.statementLineData.id, moveLines]
);
await this.bankReconciliation.computeReconcileLineCountPerPartnerId(
this.env.model.root.records
);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
this.restoreFocus();
},
suspenseAccountLine: this.props.suspenseAccountLine,
reference: this.statementLineData.payment_ref,
date: this.statementLineData.date,
},
{
onClose: () => {
this.restoreFocus();
},
}
);
}
getReconcileButtonDomain() {
return [
["parent_state", "in", ["draft", "posted"]],
["company_id", "child_of", this.statementLineData.company_id.id],
["search_account_id.reconcile", "=", true],
["display_type", "not in", ["line_section", "line_note"]],
["reconciled", "=", false],
"|",
["search_account_id.account_type", "not in", ["asset_receivable", "liability_payable"]],
["payment_id", "=", false],
["statement_line_id", "!=", this.statementLineData.id],
];
}
/**
* Deletes the current bank statement line.
*/
async deleteTransaction() {
this.addDialog(ConfirmationDialog, {
body: _t("Are you sure you want to delete this statement line?"),
confirm: async () => {
await this.orm.unlink("account.bank.statement.line", [this.statementLineData.id]);
this.env.model.load();
},
cancel: () => {},
});
}
/**
* Set the move of the statement line as to check
*/
async setStatementLineAsReviewed() {
await this.orm.call("account.move", "set_moves_checked", [
this.statementLineData.move_id.id,
]);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
// -----------------------------------------------------------------------------
// Reconciliation Model
// -----------------------------------------------------------------------------
async triggerReconciliationModel(reconciliationModelId) {
await this.orm.call("account.reconcile.model", "trigger_reconciliation_model", [
reconciliationModelId,
this.statementLineData.id,
]);
await this.bankReconciliation.computeReconcileLineCountPerPartnerId(
this.env.model.root.records
);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
getKeyAction(key) {
const keyActions = {
1: {
condition:
this.props.statementLineRootRef.el.querySelector(".set-partner-btn") &&
this.isLineSelected,
action: async () => this.setPartnerOnReconcileLine(),
buttonElement: this.props.statementLineRootRef.el.querySelector(".set-partner-btn"),
},
2: {
condition:
this.props.statementLineRootRef.el.querySelector(".reconcile-btn") &&
this.isLineSelected,
action: async () => this.reconcileOnReconcileLine(),
buttonElement: this.props.statementLineRootRef.el.querySelector(".reconcile-btn"),
},
3: {
condition:
this.props.statementLineRootRef.el.querySelector(".set-account-btn") &&
this.isLineSelected,
action: () => this.setAccountOnReconcileLine(),
buttonElement: this.props.statementLineRootRef.el.querySelector(".set-account-btn"),
},
4: {
condition:
this.props.statementLineRootRef.el.querySelector(".set-payable-btn") &&
this.isLineSelected,
action: () => this.setAccountPayableOnReconcileLine(),
buttonElement: this.props.statementLineRootRef.el.querySelector(".set-payable-btn"),
},
5: {
condition:
this.props.statementLineRootRef.el.querySelector(".set-receivable-btn") &&
this.isLineSelected,
action: () => this.setAccountReceivableOnReconcileLine(),
buttonElement:
this.props.statementLineRootRef.el.querySelector(".set-receivable-btn"),
},
6: {
condition:
this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-0"
) && this.isLineSelected,
action: () => {
const buttonElement = this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-0"
);
if (buttonElement) {
buttonElement.click();
}
},
buttonElement: this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-0"
),
},
7: {
condition:
this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-1"
) && this.isLineSelected,
action: () => {
const buttonElement = this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-1"
);
if (buttonElement) {
buttonElement.click();
}
},
buttonElement: this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-1"
),
},
8: {
condition:
this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-2"
) && this.isLineSelected,
action: () => {
const buttonElement = this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-2"
);
if (buttonElement) {
buttonElement.click();
}
},
buttonElement: this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-2"
),
},
Enter: {
condition:
this.props.statementLineRootRef.el.querySelector(".btn-primary") &&
this.isLineSelected,
action: () => {
const primaryButtons =
this.props.statementLineRootRef.el.querySelectorAll(".btn-primary");
if (primaryButtons.length > 0) {
primaryButtons[0].click();
}
},
buttonElement: this.props.statementLineRootRef.el.querySelector(".btn-primary"),
},
};
return keyActions[key];
}
registerHotkeys() {
const hotkeyConfigs = [
{ key: "1", trigger: "alt+shift+1" },
{ key: "2", trigger: "alt+shift+2" },
{ key: "3", trigger: "alt+shift+3" },
{ key: "4", trigger: "alt+shift+4" },
{ key: "5", trigger: "alt+shift+5" },
{ key: "6", trigger: "alt+shift+6" },
{ key: "7", trigger: "alt+shift+7" },
{ key: "8", trigger: "alt+shift+8" },
{ key: "Enter", trigger: "alt+shift+enter" },
];
hotkeyConfigs.forEach(({ key, trigger }) => {
useHotkey(
trigger,
({ target }) => {
const { condition, action } = this.getKeyAction(key);
if (condition) {
action();
}
},
{
area: () => this.props.statementLineRootRef.el.parentElement,
withOverlay: () => {
const { buttonElement, condition } = this.getKeyAction(key);
return condition ? buttonElement : null;
},
isAvailable: () => {
const { condition } = this.getKeyAction(key);
return condition;
},
}
);
});
}
// -----------------------------------------------------------------------------
// File Uploader
// -----------------------------------------------------------------------------
get bankRecFileUploaderRecord() {
return {
statementLineId: this.statementLineData.id,
};
}
// -----------------------------------------------------------------------------
// ACTION
// -----------------------------------------------------------------------------
actionViewRecoModels() {
return this.action.doAction("account.action_account_reconcile_model");
}
// -----------------------------------------------------------------------------
// GETTER
// -----------------------------------------------------------------------------
get statementLineData() {
return this.props.statementLine.data;
}
get isLineSelected() {
return this.statementLineData.id === this.bankReconciliation.statementLine?.data.id;
}
get lastAccountMoveLine() {
return this.statementLineData.line_ids.records.at(-1);
}
get isCustomerRankHigher() {
return (
this.statementLineData.partner_id.customer_rank >
this.statementLineData.partner_id.supplier_rank
);
}
get isSetPartnerButtonShown() {
return !this.statementLineData.partner_id;
}
get isSetAccountButtonShown() {
return !this.statementLineData.account_id;
}
get isSetReceivableButtonShown() {
return (
!this.isSetPartnerButtonShown &&
((this.statementLineData.partner_id.customer_rank && this.isCustomerRankHigher) ||
this.statementLineData.amount > 0)
);
}
get isSetPayableButtonShown() {
return (
!this.isSetPartnerButtonShown &&
((this.statementLineData.partner_id.supplier_rank && !this.isCustomerRankHigher) ||
this.statementLineData.amount < 0)
);
}
get isReconcileButtonShown() {
return this.props.reconcileLineCount === null || this.props.reconcileLineCount;
}
get reconcileModelsInDropdown() {
if (this.ui.isSmall) {
return this.props.reconcileModels;
}
return this.props.reconcileModels.filter(
(model) => model.id !== this.props?.preSelectedReconciliationModel?.id
);
}
get buttons() {
const buttonsToDisplay = {};
if (this.isSetPartnerButtonShown) {
buttonsToDisplay.partner = {
label: _t("Set Partner"),
action: this.setPartnerOnReconcileLine.bind(this),
classes: "set-partner-btn",
};
} else {
buttonsToDisplay.receivable = {
label: _t("Receivable"),
action: this.setAccountReceivableOnReconcileLine.bind(this),
classes: "set-receivable-btn",
};
buttonsToDisplay.payable = {
label: _t("Payable"),
action: this.setAccountPayableOnReconcileLine.bind(this),
classes: "set-payable-btn",
};
}
if (this.isReconcileButtonShown) {
buttonsToDisplay.reconcile = {
label: _t("Reconcile"),
action: this.reconcileOnReconcileLine.bind(this),
count: this.props.reconcileLineCount,
classes: "reconcile-btn",
};
}
if (this.isSetAccountButtonShown) {
buttonsToDisplay.account = {
label: _t("Set Account"),
action: this.setAccountOnReconcileLine.bind(this),
classes: "set-account-btn",
};
}
if (this.statementLineData.is_reconciled && !this.statementLineData.checked) {
buttonsToDisplay.toReview = {
label: _t("Reviewed"),
action: this.setStatementLineAsReviewed.bind(this),
toReview: true,
};
}
return buttonsToDisplay;
}
get buttonsToDisplay() {
const buttons = this.buttons || {};
let primaryButtonKeys = [];
let secondaryButtonKeys = [];
if (buttons?.partner && buttons?.account) {
primaryButtonKeys = ["partner", "account"];
} else if (buttons?.reconcile && !!buttons.reconcile?.count) {
primaryButtonKeys = ["reconcile"];
if (this.isSetReceivableButtonShown) {
secondaryButtonKeys = ["receivable"];
} else {
secondaryButtonKeys = ["payable"];
}
} else if (this.isSetReceivableButtonShown) {
primaryButtonKeys = ["receivable"];
} else if (this.isSetPayableButtonShown) {
primaryButtonKeys = ["payable"];
}
return [
...primaryButtonKeys.map((key) => ({ ...buttons[key], primary: true })),
...secondaryButtonKeys.map((key) => ({ ...buttons[key] })),
];
}
get buttonsInDropdown() {
const buttons = this.buttons || {};
if (this.props.preSelectedReconciliationModel) {
return Object.values(buttons);
}
const buttonToDisplayClasses = this.buttonsToDisplay.map((button) => button.classes) || [];
return Object.values(buttons).filter(
(button) => !buttonToDisplayClasses.includes(button.classes)
);
}
}

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecButtonList">
<div class="d-flex flex-wrap gap-1">
<t t-if="props.preSelectedReconciliationModel and !statementLineData.is_reconciled">
<BankRecButton
label="props.preSelectedReconciliationModel.display_name"
primary="true"
action.bind="() => this.triggerReconciliationModel(props.preSelectedReconciliationModel.id)"
/>
</t>
<t t-elif="buttons?.toReview">
<BankRecButton t-props="buttons.toReview"/>
</t>
<t t-else="">
<t t-foreach="buttonsToDisplay" t-as="button" t-key="button_index">
<BankRecButton t-props="button"/>
</t>
</t>
<Dropdown t-if="!statementLineData.is_reconciled">
<button class="btn btn-secondary" t-att-class="{'btn-sm': !ui.isSmall}">
<i class="oi oi-ellipsis-v"/>
</button>
<t t-set-slot="content">
<t t-foreach="buttonsInDropdown" t-as="button" t-key="button_index">
<DropdownItem class="'btn btn-link'" onSelected.bind="button.action">
<t t-esc="button.label"/>
</DropdownItem>
</t>
<BankRecFileUploader record="bankRecFileUploaderRecord">
<t t-set-slot="toggler">
<span class="dropdown-item dropdown-item o-navigable btn btn-link">
Upload Bills
</span>
</t>
</BankRecFileUploader>
<div class="dropdown-divider"/>
<t t-foreach="reconcileModelsInDropdown" t-as="model" t-key="model.id">
<DropdownItem class="'btn btn-link'" onSelected.bind="() => this.triggerReconciliationModel(model.id)">
<t t-esc="model.display_name"/>
</DropdownItem>
</t>
<div t-if="reconcileModelsInDropdown.length" class="dropdown-divider"/>
<DropdownItem class="'btn btn-link'" onSelected.bind="actionViewRecoModels">
Manage Models
</DropdownItem>
<DropdownItem class="'btn btn-link'" onSelected.bind="deleteTransaction">
Delete Transaction
</DropdownItem>
</t>
</Dropdown>
</div>
</t>
</templates>

View File

@@ -0,0 +1,16 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../chatter/chatter.js`.
* Phase 1 structural parity.
*/
import { Chatter } from "@mail/chatter/web_portal/chatter";
export class BankRecChatter extends Chatter {
static props = [...Chatter.props, "statementLine?"];
async reloadParentView() {
await this.props.statementLine?.load();
}
}

View File

@@ -0,0 +1,29 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../file_uploader/file_uploader.js`.
* Phase 1 structural parity.
*/
import { DocumentFileUploader } from "@account/components/document_file_uploader/document_file_uploader";
export class BankRecFileUploader extends DocumentFileUploader {
/**
* Extends `DocumentFileUploader.getExtraContext` to add the
* `statement_line_id` to the context, used by
* `account.bank.statement.line.create_document_from_attachment` to link
* the uploaded bill back to the originating statement line.
*/
getExtraContext() {
const extraContext = super.getExtraContext();
return {
...extraContext,
statement_line_id: this.props.record.statementLineId,
};
}
getResModel() {
return "account.bank.statement.line";
}
}

View File

@@ -0,0 +1,80 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../line_info_pop_over/line_info_pop_over.js`.
* Phase 1 structural parity.
*/
import { Component } from "@odoo/owl";
import { formatMonetary } from "@web/views/fields/formatters";
import { useService } from "@web/core/utils/hooks";
export class BankRecLineInfoPopOver extends Component {
static template = "fusion_accounting_bank_rec.BankRecLineInfoPopOver";
static props = {
lineData: { type: Object, optional: true },
statementLineData: { type: Object, optional: true },
exchangeMove: { type: Object, optional: true },
isPartiallyReconciled: { type: Boolean, optional: true },
close: { type: Function, optional: true },
};
setup() {
this.action = useService("action");
}
openExchangeMove() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "account.move",
res_id: this.props.exchangeMove.id,
views: [[false, "form"]],
target: "current",
});
}
openReconciledMove() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "account.move",
res_id: this.reconciledLineData.move_id.id,
views: [[false, "form"]],
target: "current",
});
}
get reconciledMoveName() {
return this.reconciledLineData.move_name;
}
get formattedReconciledMoveAmountCurrency() {
return formatMonetary(this.reconciledLineData.amount_currency, {
currencyId: this.reconciledLineData.currency_id.id,
});
}
get reconciledLineData() {
return this.props.lineData.reconciled_lines_ids.records[0].data;
}
get formattedLineDataAmountCurrency() {
return formatMonetary(this.props.lineData.amount_currency, {
currencyId: this.props.lineData.currency_id.id,
});
}
get exchangeDiffMoveName() {
return this.props.exchangeMove.display_name;
}
get exchangeMoveBalance() {
return this.props.exchangeMove.line_ids[0].balance;
}
get formattedExchangeMoveBalance() {
return formatMonetary(this.exchangeMoveBalance, {
currencyId: this.props.statementLineData.company_id.currency_id?.id,
});
}
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecLineInfoPopOver">
<table class="table table-hover m-0">
<tbody>
<tr t-if="props.exchangeMove">
<td t-on-click="openExchangeMove" class="cursor-pointer">
<span class="btn btn-link p-0" t-esc="exchangeDiffMoveName"/>
</td>
<td class="align-middle text-end" t-esc="formattedExchangeMoveBalance"/>
</tr>
<tr t-if="props.isPartiallyReconciled">
<td t-on-click="openReconciledMove" class="cursor-pointer">
<span class="btn btn-link p-0" t-esc="reconciledMoveName"/>
</td>
<td class="align-middle">
<span class="text-decoration-line-through me-2" t-esc="formattedReconciledMoveAmountCurrency"/>
<span t-esc="formattedLineDataAmountCurrency"/>
</td>
</tr>
</tbody>
</table>
</t>
</templates>

View File

@@ -0,0 +1,204 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../line_to_reconcile/line_to_reconcile.js`.
* Phase 1 structural parity.
*/
import { Component, useRef } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { formatMonetary } from "@web/views/fields/formatters";
import { useService } from "@web/core/utils/hooks";
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
import { usePopover } from "@web/core/popover/popover_hook";
import { BankRecFormDialog } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog";
import { BankRecLineInfoPopOver } from "@fusion_accounting_bank_rec/components/bank_reconciliation/line_info_pop_over/line_info_pop_over";
import { x2ManyCommands } from "@web/core/orm_service";
export class BankRecLineToReconcile extends Component {
static template = "fusion_accounting_bank_rec.BankRecLineToReconcile";
static props = {
line: Object,
statementLine: Object,
};
setup() {
this.action = useService("action");
this.orm = useService("orm");
this.dialogService = useService("dialog");
this.ui = useService("ui");
this.bankReconciliation = useBankReconciliation();
this.lineInfoRef = useRef("line-info-ref");
this.lineInfoPopOver = usePopover(BankRecLineInfoPopOver, {
position: "left",
closeOnClickAway: true,
});
}
onClickLine() {
if (this.ui.isSmall) {
this.toggleEditLine();
}
}
toggleEditLine() {
this.dialogService.add(BankRecFormDialog, {
title: _t("Edit Line"),
resModel: "account.move.line",
resId: this.lineData.id,
context: {
form_view_ref: "account_accountant.view_bank_rec_edit_line",
is_reviewed: this.lineData.move_id.checked,
},
onRecordSave: async (record) => {
await this.orm.call("account.bank.statement.line", "edit_reconcile_line", [
this.statementLineData.id,
this.lineData.id,
await record.getChanges(),
]);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
return true;
},
});
}
async deleteLine() {
await this.orm.call("account.bank.statement.line", "delete_reconciled_line", [
this.statementLineData.id,
this.lineData.id,
]);
if (this.lineData.reconciled_lines_ids.records.length) {
// Only update the line count per partner if we delete
// a line which is reconciled to another move line
this.bankReconciliation.computeReconcileLineCountPerPartnerId(
this.env.model.root.records
);
}
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
// -----------------------------------------------------------------------------
// ACTION
// -----------------------------------------------------------------------------
openMove() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "account.move",
res_id: this.moveData.id,
views: [[false, "form"]],
target: "current",
});
}
openPartner() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "res.partner",
res_id: this.lineData.partner_id.id,
views: [[false, "form"]],
target: "current",
});
}
openLineInfoPopOver() {
if (this.lineInfoPopOver.isOpen || !this.showLineInfo) {
this.lineInfoPopOver.close();
} else {
this.lineInfoPopOver.open(this.lineInfoRef.el, {
statementLineData: this.statementLineData,
lineData: this.lineData,
exchangeMove: this.exchangeMove,
isPartiallyReconciled: this.isPartiallyReconciled,
});
}
}
async deleteTax(taxIndex) {
const taxChanged = this.lineDataTaxIds[taxIndex];
await this.orm.call("account.bank.statement.line", "edit_reconcile_line", [
this.statementLineData.id,
this.lineData.id,
{ tax_ids: [[x2ManyCommands.UNLINK, taxChanged.data.id]] },
]);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
// -----------------------------------------------------------------------------
// GETTER
// -----------------------------------------------------------------------------
get statementLineData() {
return this.props.statementLine.data;
}
get lineData() {
return this.props.line;
}
get reconciledLineId() {
return this.lineData.reconciled_lines_ids.records.length === 1
? this.lineData.reconciled_lines_ids.records[0].data
: null;
}
get reconciledLineExcludingExchangeDiffId() {
return this.lineData.reconciled_lines_excluding_exchange_diff_ids.records.length === 1
? this.lineData.reconciled_lines_excluding_exchange_diff_ids.records[0].data
: null;
}
get moveData() {
return (
this.reconciledLineId?.move_id ||
this.reconciledLineExcludingExchangeDiffId?.move_id ||
this.lineData.move_id
);
}
get isPartiallyReconciled() {
if (!this.reconciledLineId) {
return false;
}
return !this.reconciledLineId.full_reconcile_id?.id;
}
get hasDifferentCurrencies() {
return this.lineData.currency_id.id !== this.statementLineData.currency_id.id;
}
get formattedAmountCurrencyOfLine() {
return formatMonetary(this.lineData.amount_currency, {
currencyId: this.lineData.currency_id.id,
});
}
get formattedAmountCurrencyOfStatementLine() {
return formatMonetary(this.lineData.amount_currency, {
currencyId: this.statementLineData.currency_id.id,
});
}
get exchangeMove() {
return (
this.lineData.matched_debit_ids.records[0]?.data.exchange_move_id ||
this.lineData.matched_credit_ids.records[0]?.data.exchange_move_id
);
}
get showLineInfo() {
return this.isPartiallyReconciled || this.exchangeMove?.id;
}
get isTaxLine() {
return this.lineData.tax_line_id;
}
get lineDataTaxIds() {
return this.lineData.tax_ids.records;
}
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecLineToReconcile">
<div class="o_row" t-on-click.stop="onClickLine">
<div class="o_line_name d-flex align-items-center gap-1 text-truncate">
<a href="#" class="text-truncate fw-bold" t-esc="lineData.partner_id.display_name" t-on-click.stop="openPartner" role="button" t-att-title="lineData.partner_id.display_name" t-if="lineData.partner_id"/>
<span t-esc="lineData.account_id.display_name" class="text-truncate" t-att-class="lineData.partner_id ? 'ms-1' : undefined"/>
<div class="d-flex gap-2">
<t t-foreach="lineDataTaxIds" t-as="tax_id" t-key="tax_id_index">
<div class="o_tag d-inline-flex align-items-center badge rounded-pill o_tag_color_0 flex-shrink-0">
<span class="o_tag_badge_text text-truncate" t-esc="tax_id.data.display_name"/>
<i t-on-click.stop="() => this.deleteTax(tax_id_index)" class="ps-1 opacity-100-hover opacity-75 oi oi-close"/>
</div>
</t>
</div>
</div>
<span t-if="!!moveData.display_name and moveData.id !== statementLineData.move_id.id" class="d-none d-md-inline">
<a t-on-click.stop="openMove" href="#">
<t t-esc="moveData.display_name"/>
</a>
</span>
<div class="o_line_amount d-flex align-items-center justify-content-between">
<span class="text-muted w-50 text-end" t-if="hasDifferentCurrencies">
<span t-att-class="{'btn btn-link p-0' : showLineInfo}" t-ref="line-info-ref" t-on-click.stop="openLineInfoPopOver">
<i t-if="showLineInfo" class="fa fa-info-circle me-2"/>
<t t-out="formattedAmountCurrencyOfLine"/>
</span>
</span>
<span class="text-end w-100" t-if="!hasDifferentCurrencies">
<span t-att-class="{'btn btn-link p-0' : showLineInfo}" t-ref="line-info-ref" t-on-click.stop="openLineInfoPopOver">
<i t-if="showLineInfo" class="fa fa-info-circle me-2"/>
<t t-out="formattedAmountCurrencyOfStatementLine"/>
</span>
</span>
</div>
<div class="o_line_to_reconcile_button d-none d-md-flex justify-content-end gap-2">
<button t-if="lineData.has_invalid_analytics" class="btn btn-link p-0 text-600" t-on-click.stop="toggleEditLine">
<i class="fa fa-exclamation-triangle text-warning" data-tooltip="This line has invalid analytic distribution"/>
</button>
<button t-if="!lineData.has_invalid_analytics" class="btn btn-link p-0 text-600" t-on-click.stop="toggleEditLine">
<i class="fa fa-pencil"/>
</button>
<button class="btn btn-link p-0 text-600" t-on-click.stop="deleteLine" t-if="!isTaxLine">
<i class="fa fa-trash"/>
</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,88 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../list_view/list.js`.
* Phase 1 structural parity.
*
* NOTE: Enterprise extends `AttachmentPreviewListController` from
* `account_accountant/static/src/components/attachment_preview_list_view/...`.
* That helper isn't part of Phase 1 scope; we extend the base
* `ListController` directly and TODO-flag the methods that depend on
* the previewer state. Behaviour will be wired up in fusion-only
* Tasks 34-36 alongside the right-pane preview integration.
*/
import { ListController } from "@web/views/list/list_controller";
import { ListRenderer } from "@web/views/list/list_renderer";
import { registry } from "@web/core/registry";
import { listView } from "@web/views/list/list_view";
import { useChildSubEnv } from "@odoo/owl";
import { makeActiveField } from "@web/model/relational_model/utils";
export class BankRecListController extends ListController {
setup() {
super.setup(...arguments);
this.skipKanbanRestore = {};
useChildSubEnv({
skipKanbanRestoreNeeded: (stLineId) => this.skipKanbanRestore[stLineId],
});
}
/**
* Don't allow bank_rec_form to be restored with previous values since
* the statement line has changed.
*/
async onRecordSaved(record) {
this.skipKanbanRestore[record.resId] = true;
return super.onRecordSaved(...arguments);
}
get previewerStorageKey() {
return "fusion.statement_line_pdf_previewer_hidden";
}
get modelParams() {
const params = super.modelParams;
params.config.activeFields.bank_statement_attachment_ids = makeActiveField();
params.config.activeFields.bank_statement_attachment_ids.related = {
fields: {
mimetype: { name: "mimetype", type: "char" },
},
activeFields: {
mimetype: makeActiveField(),
},
};
params.config.activeFields.attachment_ids = makeActiveField();
params.config.activeFields.attachment_ids.related = {
fields: {
mimetype: { name: "mimetype", type: "char" },
},
activeFields: {
mimetype: makeActiveField(),
},
};
return params;
}
/**
* TODO(fusion task 34-36): wire up attachment preview pane.
* Enterprise sets `this.attachmentPreviewState.selectedRecord` and
* calls `this.setThread(...)` on the AttachmentPreviewListController.
* Until that helper is mirrored, this is a no-op.
*/
async setSelectedRecord(/* accountBankStatementLineData */) {
return;
}
}
export class BankRecListRenderer extends ListRenderer {}
export const bankRecListView = {
...listView,
Controller: BankRecListController,
Renderer: BankRecListRenderer,
};
registry.category("views").add("fusion_bank_rec_list", bankRecListView);

View File

@@ -0,0 +1,30 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../list_view/list_view_many2one_multi_edit.js`.
* Phase 1 structural parity.
*/
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
import { buildM2OFieldDescription, Many2OneField } from "@web/views/fields/many2one/many2one_field";
export class BankRecMany2OneMultiID extends Component {
static template = "fusion_accounting_bank_rec.BankRecMany2OneMultiID";
static components = { Many2One };
static props = { ...Many2OneField.props };
get m2oProps() {
const props = computeM2OProps(this.props);
if (this.props.record.selected && this.props.record.model.multiEdit) {
props.context.active_ids = this.env.model.root.selection.map((r) => r.resId);
}
return props;
}
}
registry.category("fields").add("fusion_bank_rec_list_many2one_multi_id", {
...buildM2OFieldDescription(BankRecMany2OneMultiID),
});

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecMany2OneMultiID">
<Many2One t-props="m2oProps"/>
</t>
</templates>

View File

@@ -0,0 +1,34 @@
/** @odoo-module **/
import { Component, onWillStart, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class PartnerHistoryPanel extends Component {
static template = "fusion_accounting_bank_rec.PartnerHistoryPanel";
static props = {
partnerId: { type: Number },
};
setup() {
this.bankRec = useService("fusion_bank_reconciliation");
this.state = useState({ history: null, loading: true });
onWillStart(async () => {
try {
this.state.history = await this.bankRec.getPartnerHistory(
this.props.partnerId,
20,
);
} finally {
this.state.loading = false;
}
});
}
formatAmount(value) {
if (value === undefined || value === null) {
return "0.00";
}
return Number(value).toFixed(2);
}
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.PartnerHistoryPanel">
<div class="o_fusion_partner_history_panel" style="padding: 1rem; border-left: 1px solid #e5e7eb;">
<h5 t-if="state.history">
<t t-esc="state.history.partner.name"/> — History
</h5>
<div t-if="state.loading" class="text-muted">Loading…</div>
<div t-elif="state.history">
<div t-if="state.history.pattern" class="mb-3 p-2"
style="background: #eff6ff; border-radius: 0.25rem; font-size: 0.85em;">
<strong>Learned pattern:</strong>
<div>Reconciles: <t t-esc="state.history.pattern.reconcile_count"/></div>
<div t-if="state.history.pattern.pref_strategy">
Preferred strategy: <t t-esc="state.history.pattern.pref_strategy"/>
</div>
<div t-if="state.history.pattern.typical_cadence_days">
Typical cadence: ~<t t-esc="state.history.pattern.typical_cadence_days"/> days
</div>
</div>
<h6>Recent reconciles</h6>
<div t-foreach="state.history.recent_reconciles" t-as="rec" t-key="rec.precedent_id"
style="padding: 0.5rem 0; border-bottom: 1px solid #e5e7eb; font-size: 0.85em;">
<div class="d-flex justify-content-between">
<span><t t-esc="rec.date"/></span>
<span><strong>$<t t-esc="formatAmount(rec.amount)"/></strong></span>
</div>
<div class="text-muted">
<t t-if="rec.memo_tokens"><t t-esc="rec.memo_tokens"/></t>
<span class="ms-2">(<t t-esc="rec.matched_count"/> line<t t-if="rec.matched_count !== 1">s</t>)</span>
</div>
</div>
<div t-if="state.history.recent_reconciles.length === 0" class="text-muted">
No history yet
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,41 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../quick_create/quick_create.js`.
* Phase 1 structural parity.
*/
import {
KanbanRecordQuickCreate,
KanbanQuickCreateController,
} from "@web/views/kanban/kanban_record_quick_create";
export class BankRecQuickCreateController extends KanbanQuickCreateController {
static template = "fusion_accounting_bank_rec.BankRecQuickCreateController";
}
export class BankRecQuickCreate extends KanbanRecordQuickCreate {
static template = "fusion_accounting_bank_rec.BankRecQuickCreate";
static props = {
...KanbanRecordQuickCreate.props,
resModel: { type: String },
context: { type: Object },
group: { type: Object, optional: true },
};
static components = { BankRecQuickCreateController };
/**
* Overridden — quick-create flow always works against a synthetic group
* built from the resModel + context props (rather than relying on a
* caller-provided group), matching Enterprise behaviour.
*/
async getQuickCreateProps(props) {
await super.getQuickCreateProps({
...props,
group: {
resModel: props.resModel,
context: props.context,
},
});
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecQuickCreate">
<BankRecQuickCreateController t-if="state.isLoaded" t-props="quickCreateProps"/>
</t>
<t t-name="fusion_accounting_bank_rec.BankRecQuickCreateController">
<div class="o_fusion_bank_reconciliation_quick_create o_kanban_record" t-ref="root">
<t t-component="props.Renderer" record="model.root" Compiler="props.Compiler" archInfo="props.archInfo"/>
<div class="d-flex gap-1 button_group p-2">
<button class="btn btn-primary o_kanban_add" t-on-click="() => this.validate('add')" data-hotkey="s">
Add &amp; New
</button>
<button class="btn btn-secondary o_kanban_edit" t-on-click="() => this.validate('add_close')" data-hotkey="shift+s">
Add &amp; Close
</button>
<button class="btn btn-secondary o_kanban_cancel" t-on-click="() => this.cancel(true)" data-hotkey="d">
Discard
</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,39 @@
/** @odoo-module **/
import { Component, onWillStart, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class ReconcileModelPicker extends Component {
static template = "fusion_accounting_bank_rec.ReconcileModelPicker";
static props = {
statementLineId: { type: Number, optional: true },
};
setup() {
this.orm = useService("orm");
this.bankRec = useService("fusion_bank_reconciliation");
this.state = useState({ models: [], selected: null });
onWillStart(async () => {
const models = await this.orm.searchRead(
"account.reconcile.model",
[["rule_type", "=", "writeoff_button"]],
["id", "name", "fusion_ai_confidence_threshold"],
{ limit: 20 }
);
this.state.models = models;
});
}
onChange(ev) {
const value = parseInt(ev.target.value, 10);
if (Number.isFinite(value)) {
this.onApplyModel(value);
}
}
async onApplyModel(modelId) {
// Phase 1 placeholder: TODO route through dedicated endpoint when Task 38 lands
this.state.selected = modelId;
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.ReconcileModelPicker">
<div class="o_fusion_reconcile_model_picker">
<select class="form-select" style="max-width: 240px;"
t-on-change="onChange">
<option value="">— Apply reconcile model —</option>
<option t-foreach="state.models" t-as="m" t-key="m.id" t-att-value="m.id">
<t t-esc="m.name"/>
</option>
</select>
</div>
</t>
</templates>

View File

@@ -0,0 +1,40 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../reconciled_line_name/reconciled_line_name.js`.
* Phase 1 structural parity.
*/
import { Component } from "@odoo/owl";
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
import { useService } from "@web/core/utils/hooks";
import { x2ManyCommands } from "@web/core/orm_service";
export class BankRecReconciledLineName extends Component {
static template = "fusion_accounting_bank_rec.BankRecReconciledLineName";
static props = {
statementLine: { type: Object },
linesToReconcile: { type: Object },
moveLineId: { type: String },
valueToDisplay: { type: Object },
};
setup() {
this.orm = useService("orm");
this.bankReconciliation = useBankReconciliation();
}
async deleteTax(lineId, taxChanged) {
const lineData = this.props.linesToReconcile.filter((line) => {
return line.id === parseInt(lineId);
})[0];
await this.orm.call("account.bank.statement.line", "edit_reconcile_line", [
this.props.statementLine.data.id,
lineData.id,
{ tax_ids: [[x2ManyCommands.UNLINK, taxChanged.data.id]] },
]);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecReconciledLineName">
<div name="reconciled_line_name" class="text-start text-truncate text-muted">
<t t-if="props.valueToDisplay?.tax">
<t t-foreach="props.valueToDisplay.tax" t-as="tax_id" t-key="tax_id_index">
<div class="o_tag d-inline-flex align-items-center badge rounded-pill o_tag_color_0 flex-shrink-0" t-att-class="!tax_id_last ? 'me-1': ''">
<span class="o_tag_badge_text text-truncate" t-esc="tax_id.data.display_name"/>
<i t-on-click.stop="() => this.deleteTax(props.moveLineId, tax_id)" class="ps-1 opacity-100-hover opacity-75 oi oi-close"/>
</div>
</t>
</t>
<t t-else="" t-out="props.valueToDisplay.move or props.valueToDisplay.account"/>
</div>
</t>
</templates>

View File

@@ -0,0 +1,90 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../search_dialog/search_dialog.js`.
* Phase 1 structural parity.
*/
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
import { formatMonetary } from "@web/views/fields/formatters";
import { useService } from "@web/core/utils/hooks";
const { DateTime } = luxon;
export class BankRecSelectCreateDialog extends SelectCreateDialog {
static template = "fusion_accounting_bank_rec.BankRecSelectCreateDialog";
static props = {
...SelectCreateDialog.props,
suspenseAccountLine: Object,
reference: String,
date: DateTime,
size: { type: String, optional: true },
};
static defaultProps = {
...SelectCreateDialog.defaultProps,
size: "lg",
};
setup() {
super.setup();
this.orm = useService("orm");
this.ui = useService("ui");
this.state.remainingAmount = this.suspenseAccountLine.amount_currency;
this.state.hideRemainingAmount = false;
this.baseViewProps.onSelectionChanged = (resIds, selectedLines) => {
this.state.resIds = resIds;
this.changeInSelectedMoveLine(selectedLines);
};
}
async changeInSelectedMoveLine(selectedLines) {
if (!selectedLines?.length) {
this.state.remainingAmount = this.suspenseAccountLine.amount_currency;
return;
}
let selectedLinesSum = 0;
this.state.hideRemainingAmount = false;
if (
this.suspenseAccountLine.currency_id.id !==
this.suspenseAccountLine.company_currency_id.id
) {
const selectedLineCurrencies = selectedLines.map((line) => line.currency_id);
if (
selectedLineCurrencies.length !== 1 ||
(selectedLineCurrencies.length === 1 &&
selectedLineCurrencies[0] !== this.suspenseAccountLine.currency_id.id)
) {
this.state.hideRemainingAmount = true;
return;
} else {
selectedLinesSum = selectedLines.reduce((sum, line) => {
return sum + line.amount_residual_currency;
}, 0);
}
} else {
selectedLinesSum = selectedLines.reduce((sum, line) => {
return sum + line.amount_residual;
}, 0);
}
this.state.remainingAmount = this.suspenseAccountLine.amount_currency + selectedLinesSum;
}
get suspenseAccountLine() {
return this.props?.suspenseAccountLine;
}
get remainingAmountFormatted() {
return formatMonetary(this.state.remainingAmount, {
currencyId: this.suspenseAccountLine.currency_id.id,
});
}
get formattedStatementLineDate() {
return this.props.date?.toLocaleString();
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecSelectCreateDialog" t-inherit="web.SelectCreateDialog" t-inherit-mode="primary">
<xpath expr="//Dialog" position="attributes">
<attribute name="size">props.size</attribute>
</xpath>
<xpath expr="//button[hasclass('o_form_button_cancel')]" position="after">
<div t-if="!this.ui.isSmall" class="d-flex align-items-center flex-grow-1 flex-shrink-1 flex-basis-0 gap-2 min-w-0 justify-content-between" name="bank_reconciliation_info">
<span t-esc="formattedStatementLineDate"/>
<div class="text-truncate" t-esc="props.reference"/>
<div class="text-nowrap text-end" name="remaining_amount">
<span class="text-muted">Balance: </span>
<t t-if="!this.state.hideRemainingAmount" t-esc="remainingAmountFormatted"/>
<t t-else=""> / </t>
</div>
</div>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,77 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../search_dialog/search_dialog_list.js`.
* Phase 1 structural parity.
*/
import { ListController } from "@web/views/list/list_controller";
import { ListRenderer } from "@web/views/list/list_renderer";
import { listView } from "@web/views/list/list_view";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class BankRecReconcileDialogListController extends ListController {
setup() {
super.setup();
this.orm = useService("orm");
}
async onSelectionChanged() {
const resIds = await this.model.root.getResIds(true);
if (!resIds.length) {
this.props.onSelectionChanged(resIds, []);
}
let selectedLines;
// When being in the list view with more elements than the limit and
// doing a select all, the user can select more elements than the
// limit. In this case the isDomainSelected is True.
if (this.isDomainSelected) {
const { resModel, context } = this.model.root._config;
selectedLines = await this.orm.read(
resModel,
resIds,
["amount_residual", "amount_residual_currency", "currency_id"],
{ context }
);
} else {
selectedLines = Object.values(this.model.root.records)
.filter((record) => resIds.includes(record._config.resId))
.map((record) => {
const data = record.data;
return {
amount_residual: data.amount_residual,
amount_residual_currency: data.amount_residual_currency,
currency_id: data.currency_id.id,
};
});
}
this.props.onSelectionChanged(resIds, selectedLines);
}
}
export class BankRecReconcileDialogListRenderer extends ListRenderer {
static template = "fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer";
static recordRowTemplate =
"fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer.RecordRow";
async openMoveView(record) {
this.env.services.action.doAction({
type: "ir.actions.act_window",
res_model: "account.move",
res_id: record.data.move_id.id,
views: [[false, "form"]],
target: "current",
});
}
}
export const bankRecReconcileDialogListRenderer = {
...listView,
Renderer: BankRecReconcileDialogListRenderer,
Controller: BankRecReconcileDialogListController,
};
registry.category("views").add("fusion_bank_rec_dialog_list", bankRecReconcileDialogListRenderer);

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
<xpath expr="//th[@t-if='hasOpenFormViewColumn']" position="replace">
<th class="o_list_open_form_view w-print-0 p-print-0"/>
</xpath>
</t>
<t t-name="fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer.RecordRow" t-inherit="web.ListRenderer.RecordRow" t-inherit-mode="primary">
<xpath expr="//t[@t-if='hasOpenFormViewColumn']" position="replace">
<td class="o_list_record_open_form_view w-print-0 p-print-0 text-center"
t-custom-click.stop="() => this.openMoveView(record)"
>
<button class="btn btn-link align-top text-end"
name="Open in form view"
aria-label="Open in form view"
>View</button>
</td>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,305 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/static/src/components/bank_reconciliation/statement_line/statement_line.js`
*
* Phase 1 structural parity. Module IDs / template names / CSS classes
* rebranded to `fusion_accounting_bank_rec`. Behaviour delegates to the
* Enterprise-compat surface in our `fusion_bank_reconciliation` service.
*/
import { BankRecButtonList } from "@fusion_accounting_bank_rec/components/bank_reconciliation/button_list/button_list";
import { BankRecLineToReconcile } from "@fusion_accounting_bank_rec/components/bank_reconciliation/line_to_reconcile/line_to_reconcile";
import { BankRecReconciledLineName } from "@fusion_accounting_bank_rec/components/bank_reconciliation/reconciled_line_name/reconciled_line_name";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { formatMonetary } from "@web/views/fields/formatters";
import { KanbanRecord } from "@web/views/kanban/kanban_record";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks";
import { onWillStart, useState, useRef } from "@odoo/owl";
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
export class BankRecStatementLine extends KanbanRecord {
static template = "fusion_accounting_bank_rec.BankRecStatementLine";
static components = {
BankRecLineToReconcile,
BankRecButtonList,
DropdownItem,
BankRecReconciledLineName,
};
static props = [...KanbanRecord.props];
setup() {
super.setup();
this.orm = useService("orm");
this.ui = useService("ui");
this.bankReconciliation = useBankReconciliation();
this.state = useState({
isUnfolded: false,
});
this.statementLineRootRef = useRef("root");
if (this.env.model.config.context?.default_st_line_id === this.props.record.resId) {
this.state.isUnfolded = true;
this.bankReconciliation.selectStatementLine(this.props.record);
}
onWillStart(async () => {
this.userCanReview = await user.hasGroup("account.group_account_user");
});
}
getRecordClasses() {
let classes = super.getRecordClasses();
if (this.hasStatementLine === 1) {
classes += " mt-3";
}
return classes;
}
// -----------------------------------------------------------------------------
// ACTION
// -----------------------------------------------------------------------------
openStatementCreate() {
this.action.doAction("account_accountant.action_bank_statement_form_bank_rec_widget", {
additionalContext: {
split_line_id: this.recordData.id,
default_journal_id: this.recordData.journal_id.id,
},
onClose: async () => {
this.env.model.load();
},
});
}
openPartner() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "res.partner",
res_id: this.partner.id,
views: [[false, "form"]],
target: "current",
});
}
async removePartner() {
await this.orm.write("account.bank.statement.line", [this.recordData.id], {
partner_id: false,
});
this.record.load();
}
// -----------------------------------------------------------------------------
// HELPER
// -----------------------------------------------------------------------------
get reconciledLineName() {
const reconciledLine = {};
for (const line of this.linesToReconcile) {
if (
line.reconciled_lines_excluding_exchange_diff_ids.records.length === 1 &&
line.reconciled_lines_excluding_exchange_diff_ids.records[0].data.move_name
) {
reconciledLine[line.id] = {
move: line.reconciled_lines_excluding_exchange_diff_ids.records[0].data
.move_name,
};
} else if (line.tax_ids.count) {
reconciledLine[line.id] = { tax: line.tax_ids.records };
} else {
reconciledLine[line.id] = { account: line.account_id.display_name };
}
}
return reconciledLine;
}
get record() {
return this.props.record;
}
get recordData() {
return this.props.record.data;
}
fold() {
if (this.state.isUnfolded) {
this.toggleUnfold();
}
this.selectStatementLine();
}
unfold() {
if (!this.state.isUnfolded) {
this.toggleUnfold();
}
this.selectStatementLine();
}
toggleUnfold() {
this.state.isUnfolded = !this.isUnfolded;
this.selectStatementLine();
}
selectStatementLine() {
// Update the chatter with the last selected element
this.bankReconciliation.selectStatementLine(this.record);
}
openChatter() {
this.selectStatementLine();
this.bankReconciliation.openChatter();
}
get hasInvalidAnalytics() {
return this.linesToReconcile.some((line) => line.has_invalid_analytics);
}
get isUnfolded() {
return this.state.isUnfolded;
}
get hasStatementLine() {
return this.env.model.root.count;
}
get formattedAmount() {
return formatMonetary(this.recordData.amount, {
currencyId: this.recordData.currency_id.id,
});
}
get formattedDate() {
return this.recordData.date.toLocaleString({
month: "short",
day: "2-digit",
});
}
get formattedFullDate() {
return this.recordData.date.toLocaleString({
month: "long",
day: "numeric",
year: "numeric",
});
}
get partner() {
return this.recordData.partner_id;
}
get linesToReconcile() {
return this.accountMoveLines.filter((line) => {
return (
line.account_id.id !== this.recordData.journal_id?.suspense_account_id.id &&
line.account_id.id !== this.recordData.journal_id?.default_account_id.id
);
});
}
get suspenseAccountLine() {
return this.accountMoveLines.filter((line) => {
return line.account_id.id === this.recordData.journal_id.suspense_account_id.id;
})?.[0];
}
get accountMoveLines() {
return [...this.recordData.line_ids.records.map((line) => line.data)];
}
get hasForeignCurrencyAndSameCurrencyForAllLines() {
return (
this.recordData.foreign_currency_id &&
this.linesToReconcile &&
this.linesToReconcile.filter((line) => {
return line.currency_id.id !== this.recordData.foreign_currency_id.id;
}).length === 0
);
}
get suspenseAccountLineFormattedAmount() {
return formatMonetary(this.suspenseAccountLine.amount_currency, {
currencyId: this.suspenseAccountLine?.currency_id.id,
});
}
get activityNumber() {
return this.recordData.activity_ids.count;
}
/**
* Checks if there is at least one attachment associated with the bank
* statement line or its related records. Aggregates attachment counts from
* the move, the related move lines, and the lines reconciled with them.
*
* @returns {number} Total attachments. > 0 indicates presence.
*/
get hasAttachment() {
const statementAttachment = this.recordData.bank_statement_attachment_ids.records.map(
(attachment) => attachment.data.id
);
return (
this.recordData.attachment_ids.records.length +
this.linesToReconcile
.flatMap((line) => line.reconciled_lines_ids.records)
.filter((line) => line.data.move_attachment_ids?.count)
.reduce(
(accumulator, line) =>
parseInt(accumulator) + parseInt(line.data.move_attachment_ids.count),
0
) +
this.linesToReconcile
.filter(
(line) =>
line.move_attachment_ids?.count &&
!line.move_attachment_ids.records
.map((attachment) => attachment.data.id)
.every((id) => statementAttachment.includes(id))
)
.reduce(
(accumulator, line) =>
parseInt(accumulator) + parseInt(line.move_attachment_ids.count),
0
)
);
}
get amountClasses() {
const classes = this.recordData.foreign_currency_id ? "w-50" : "w-100";
if (this.recordData.amount > 0) {
return `${classes} fw-bold`;
}
if (this.recordData.amount < 0) {
return `${classes} text-danger fw-bold`;
}
return `${classes} text-secondary`;
}
get buttonListProps() {
return {
statementLineRootRef: this.statementLineRootRef,
statementLine: this.record,
reconcileLineCount:
this.bankReconciliation.reconcileCountPerPartnerId[this.recordData.partner_id.id] ??
null,
reconcileModels:
this.bankReconciliation.reconcileModelPerStatementLineId[this.recordData.id] ?? [],
preSelectedReconciliationModel: this.accountMoveLines
.filter((line) => line.reconcile_model_id.id)
.map((line) => line.reconcile_model_id)?.[0],
};
}
get formattedAmountCurrencyInForeign() {
return formatMonetary(this.recordData.amount_currency, {
currencyId: this.recordData.foreign_currency_id.id,
});
}
get isSelected() {
return this.recordData.move_id.id === this.bankReconciliation.statementLineMoveId;
}
get isChatterOpen() {
return this.bankReconciliation.chatterState.visible;
}
}

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecStatementLine" t-inherit="web.KanbanRecord" t-inherit-mode="primary">
<xpath expr="//article" position="replace">
<article
t-att-class="getRecordClasses()"
t-att-data-id="record.id"
t-att-tabindex="record.model.useSampleModel ? -1 : 0"
t-custom-click="onGlobalClick"
t-on-touchstart="onTouchStart"
t-on-touchmove="onTouchMoveOrCancel"
t-on-touchcancel="onTouchMoveOrCancel"
t-on-touchend="onTouchEnd"
t-ref="root">
<div name="bank_statement_line" class="o_statement_line w-100 p-2" t-on-click="selectStatementLine" t-att-class="{'o_selected_statement_line': isSelected}">
<button t-if="!recordData.statement_id" type="button" class="o_statement_btn d-none d-md-block position-absolute top-0 end-0 btn btn-sm btn-secondary" t-on-click.stop="openStatementCreate">
Statement
</button>
<div class="o_grid_container">
<div class="o_row">
<div class="d-flex gap-3">
<div t-att-data-tooltip="formattedFullDate">
<t t-esc="formattedDate"/>
</div>
<div t-on-click.stop="openChatter" t-if="!ui.isSmall" class="o_chatter_icon btn-link text-action" t-att-class="{'visible': activityNumber or hasAttachment}">
<div t-if="activityNumber" class="activity-container position-relative">
<i class="fa fa-lg fa-clock-o" role="img" aria-label="Activities"/>
<span class="activity-badge badge rounded-pill" t-esc="activityNumber"/>
</div>
<i t-elif="hasAttachment"
class="fa fa-lg fa-paperclip"
role="img"
aria-label="Attachment"
/>
<i t-elif="!isChatterOpen"
class="fa fa-lg fa-comments-o"
role="img"
aria-label="Journal Entry"
/>
</div>
</div>
<div class="o_payment_ref user-select-text d-none d-md-block"
t-att-class="isUnfolded ? 'overflow-wrap' : 'text-truncate'">
<span class="d-inline">
<t t-if="partner">
<a class="fw-bold" href="#" t-on-click.prevent.stop="openPartner">
<span t-esc="partner.display_name" name="statement_line_partner_name"/>
</a>
<button class="btn btn-link oi oi-close p-0 align-baseline" t-on-click.stop="removePartner" t-if="!linesToReconcile.length"/>
</t>
<t t-elif="recordData.partner_name">
<span class="fw-bold" t-esc="recordData.partner_name" name="statement_line_partner_name"/>
</t>
<span t-att-class="partner or recordData.partner_name ? 'ms-1' : undefined"
t-esc="recordData.payment_ref"
/>
</span>
</div>
<!-- Only available on large screen -->
<div class="o_button_line d-none d-md-flex align-items-start text-truncate">
<BankRecButtonList t-props="buttonListProps" suspenseAccountLine="suspenseAccountLine" t-if="!recordData.is_reconciled or (userCanReview and !recordData.checked)"/>
<span class="badge rounded-pill py-1 ps-1" t-att-class="{ 'pe-1': !isUnfolded, 'text-success bg-success-subtle': !hasInvalidAnalytics, 'text-warning bg-warning-subtle': hasInvalidAnalytics}" t-if="recordData.is_reconciled">
<i t-if="hasInvalidAnalytics" class="fa fa-exclamation-triangle" data-tooltip="Some lines have invalid analytic distribution"/>
<i t-if="!hasInvalidAnalytics" class="fa fa-check"/>
<span t-if="isUnfolded" class="ms-1">
Reconciled
</span>
</span>
<t t-if="recordData.is_reconciled and !isUnfolded">
<t t-foreach="Object.entries(reconciledLineName)" t-as="line" t-key="line_index">
<BankRecReconciledLineName statementLine="record" linesToReconcile="linesToReconcile" moveLineId="line[0]" valueToDisplay="line[1]"/>
<t t-if="line_index &lt; Object.keys(reconciledLineName).length - 1">, </t>
</t>
</t>
</div>
<div class="d-flex align-items-start justify-content-between o_line_amount">
<span class="text-muted w-50 text-end text-nowrap" t-if="recordData.foreign_currency_id">
<t t-esc="formattedAmountCurrencyInForeign"/>
</span>
<span t-att-class="amountClasses" class="text-end text-nowrap" t-esc="formattedAmount"/>
</div>
<div class="d-none d-md-block text-end" t-on-click="toggleUnfold" t-if="recordData.is_reconciled">
<i class="oi" t-att-class="{'oi-chevron-up': isUnfolded, 'oi-chevron-down': !isUnfolded}"/>
</div>
<div class="d-none d-md-block" t-else=""/> <!-- To keep empty space if no chevron -->
</div>
<!-- Only available on small screen -->
<div class="o_row d-md-none">
<span class="text-truncate o_payment_ref"
t-esc="recordData.payment_ref"
/>
</div>
<t t-if="isUnfolded or !recordData.is_reconciled">
<t t-foreach="linesToReconcile" t-as="line" t-key="line_index">
<BankRecLineToReconcile statementLine="record" line="line"/>
</t>
<div class="o_row" t-if="linesToReconcile.length">
<div t-if="suspenseAccountLine" class="d-none d-md-flex fw-bold text-muted align-items-center justify-content-end o_line_amount" t-att-class="hasForeignCurrencyAndSameCurrencyForAllLines ? 'w-50' : 'w-100'">
<t t-esc="suspenseAccountLineFormattedAmount"/>
</div>
</div>
</t>
<div class="o_row d-md-none">
<div class="o_button_line">
<BankRecButtonList t-props="buttonListProps" suspenseAccountLine="suspenseAccountLine" t-if="!recordData.is_reconciled or (userCanReview and !recordData.checked)"/>
<span t-if="recordData.is_reconciled and !isUnfolded" class="text-start text-muted" t-esc="reconciledLineName"/>
</div>
</div>
</div>
</div>
</article>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,42 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../statement_summary/statement_summary.js`.
* Phase 1 structural parity.
*/
import { Component } from "@odoo/owl";
export class BankRecStatementSummary extends Component {
static template = "fusion_accounting_bank_rec.BankRecStatementSummary";
static props = {
label: { type: String },
amount: { type: String, optional: true },
action: { type: Function },
journalId: { type: Number, optional: true },
isValid: { type: Boolean, optional: true },
journalIsInvalid: { type: Boolean, optional: true },
};
static defaultProps = {
isValid: true,
};
actionApplyInvalidStatement() {
const facets = this.env.searchModel.facets;
const searchItems = this.env.searchModel.searchItems;
const invalidStatementFilter = Object.values(searchItems).find(
(i) => i.name == "invalid_statement"
);
const invalidStatementFacet = facets.filter(
(i) => i.groupId == invalidStatementFilter.groupId
);
if (
invalidStatementFacet.length == 0 ||
!invalidStatementFacet[0].values.includes(invalidStatementFilter.description)
) {
this.env.searchModel.toggleSearchItem(invalidStatementFilter.id);
}
}
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecStatementSummary">
<div class="o_statement_summary d-flex justify-content-between align-items-center w-100 p-2">
<div name="label_statement_summary" class="d-flex gap-2 align-items-center">
<h4 t-esc="props.label"
t-on-click="props.action"
class="m-0"
t-att-class="{'text-danger': !props.isValid}"/>
</div>
<div>
<h4 class="m-0"
t-if="props.journalIsInvalid"
t-on-click="actionApplyInvalidStatement">
Invalid Statement(s)
</h4>
</div>
<div t-if="props.amount"
class="btn btn-link p-0 fw-bold fs-4"
t-on-click="props.action"
t-esc="props.amount"/>
</div>
</t>
</templates>

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,420 @@
/** @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, useState, EventBus } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { browser } from "@web/core/browser/browser";
const ENDPOINT_BASE = "/fusion/bank_rec";
export class BankReconciliationService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
this.notification = services.notification;
this.orm = services.orm;
// ============================================================
// Enterprise-compat surface (mirrored OWL components rely on this)
// ============================================================
// Mirrored components from account_accountant expect these
// attributes/methods on the service. Most are implemented as
// stubs that no-op or return sensible defaults; structural
// parity now, behaviour wired up in fusion-only Tasks 34-36.
this.bus = new EventBus();
this.chatterState = reactive({
visible: this._readChatterPref(),
statementLine: null,
});
this.reconcileCountPerPartnerId = reactive({});
this.reconcileModelPerStatementLineId = reactive({});
// 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"}`;
}
// ============================================================
// Enterprise-compat methods (stubs — wired up later)
// ============================================================
// The following surface is required by mirrored components from
// account_accountant. They are primarily no-ops or thin wrappers
// around the legacy/V19 ORM. Phase 1 prioritizes structural parity;
// fusion-only Tasks 34-36 will replace these with native
// implementations driven by our JSON-RPC endpoints.
_readChatterPref() {
try {
return (
JSON.parse(
browser.sessionStorage.getItem("isFusionBankRecChatterOpened")
) ?? false
);
} catch {
return false;
}
}
toggleChatter() {
this.chatterState.visible = !this.chatterState.visible;
try {
browser.sessionStorage.setItem(
"isFusionBankRecChatterOpened",
this.chatterState.visible
);
} catch {
// Session storage unavailable — non-fatal.
}
}
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) {
// Stub: real impl to be added in fusion-only task.
// Components call this after partner edits to refresh the per-partner
// count badge. Returning empty here keeps the badge silent.
if (!this.orm) {
return;
}
try {
const partnerIds = (records || [])
.map((r) => r?.data?.partner_id?.id)
.filter(Boolean);
if (!partnerIds.length) {
this.reconcileCountPerPartnerId = {};
return;
}
// Best-effort: keep a zero map so templates don't blow up.
const out = {};
for (const pid of partnerIds) {
out[pid] = this.reconcileCountPerPartnerId[pid] ?? 0;
}
this.reconcileCountPerPartnerId = out;
} catch {
// Non-fatal; templates fall back to defaults.
}
}
async computeAvailableReconcileModels(records) {
// Stub: components show these as quick-action buttons. Empty for now.
const out = {};
for (const r of records || []) {
const id = r?.data?.id;
if (id) {
out[id] = [];
}
}
this.reconcileModelPerStatementLineId = out;
}
async updateAvailableReconcileModels(recordId) {
if (recordId) {
this.reconcileModelPerStatementLineId[recordId] = [];
}
}
async reloadRecords(records) {
await Promise.all(
(records || []).map((record) => record?.load ? record.load() : null)
);
}
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;
}
}
export const bankReconciliationService = {
dependencies: ["rpc", "notification", "orm"],
start(env, services) {
return new BankReconciliationService(env, services);
},
};
registry.category("services").add("fusion_bank_reconciliation", bankReconciliationService);
/**
* Hook for OWL components mirrored from Enterprise.
*
* Enterprise's components import `useBankReconciliation` from
* `../bank_reconciliation_service`; we expose the same hook here so
* mirrored code works unmodified after the relative-import rewrite.
*/
export function useBankReconciliation() {
return useState(useService("fusion_bank_reconciliation"));
}

View File

@@ -0,0 +1,109 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
/**
* 5 OWL tours for fusion_accounting_bank_rec smoke testing.
*
* Each tour scripts a user interaction with the bank-rec widget and
* is invoked from Python via HttpCase.start_tour(). Useful for catching
* UI regressions that asset-bundle compilation alone won't catch.
*/
// Tour 1: Open the kanban widget and confirm it loads
registry.category("web_tour.tours").add("fusion_bank_rec_smoke", {
test: true,
url: "/odoo/action-fusion_accounting_bank_rec.action_fusion_bank_rec_widget",
steps: () => [
{
content: "Wait for header to appear",
trigger: ".o_fusion_bank_rec_header h1:contains(Bank Reconciliation)",
},
{
content: "Confirm stats are visible",
trigger: ".o_fusion_stats",
},
],
});
// Tour 2: Select a line and confirm detail panel loads
registry.category("web_tour.tours").add("fusion_bank_rec_select_line", {
test: true,
url: "/odoo/action-fusion_accounting_bank_rec.action_fusion_bank_rec_widget",
steps: () => [
{
content: "Wait for at least one line card",
trigger: ".o_fusion_bank_rec_line:first",
},
{
content: "Click the first line",
trigger: ".o_fusion_bank_rec_line:first",
run: "click",
},
{
content: "Detail panel shows selected line",
trigger: ".o_fusion_bank_rec_detail h2",
},
],
});
// Tour 3: Trigger AI suggestion and accept
registry.category("web_tour.tours").add("fusion_bank_rec_accept_suggestion", {
test: true,
url: "/odoo/action-fusion_accounting_bank_rec.action_fusion_bank_rec_widget",
steps: () => [
{
content: "Click first line with a partner",
trigger: ".o_fusion_bank_rec_line:has(.o_fusion_partner):first",
run: "click",
},
{
content: "Click 'Get AI suggestions' button",
trigger: ".o_fusion_bank_rec_detail .btn_fusion_primary:contains(Get AI)",
run: "click",
},
{
content: "Wait for at least one suggestion to appear",
trigger: ".o_fusion_ai_suggestion",
},
],
});
// Tour 4: Open auto-reconcile wizard
registry.category("web_tour.tours").add("fusion_bank_rec_auto_reconcile_wizard", {
test: true,
url: "/odoo/action-fusion_accounting_bank_rec.action_fusion_auto_reconcile_wizard",
steps: () => [
{
content: "Wizard form opens",
trigger: ".modal-dialog .o_form_view",
},
{
content: "Strategy field exists",
trigger: ".modal-dialog [name='strategy']",
},
{
content: "Close wizard",
trigger: ".modal-dialog .btn-secondary",
run: "click",
},
],
});
// Tour 5: Load more (pagination)
registry.category("web_tour.tours").add("fusion_bank_rec_load_more", {
test: true,
url: "/odoo/action-fusion_accounting_bank_rec.action_fusion_bank_rec_widget",
steps: () => [
{
content: "Wait for kanban container",
trigger: ".o_fusion_bank_rec",
},
// Pagination button only appears if there are more lines than `limit`.
// This tour is a no-op if the dataset is small — that's fine for smoke.
{
content: "Confirm app loaded (regardless of pagination state)",
trigger: ".o_fusion_bank_rec_header h1",
},
],
});

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>

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