# Phase 1 — Bank Reconciliation Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build `fusion_accounting_bank_rec` — a native bank-reconciliation widget that replaces Odoo Enterprise's `account_accountant` bank rec, in V19 OWL architecture, with a clean-room reconcile engine reading/writing Community's `account.partial.reconcile` tables. AI assistive with confidence badges + behavioural learning. Architecture-ready for local LLM (Ollama, LM Studio). **Architecture:** Hybrid mirror/abstract per the roadmap. Strict mirror of all 18 Enterprise OWL units + 5 fusion-only components. Reconcile engine is `models.AbstractModel` with 6-method public API. Pure-Python helpers in `services/` are unit-testable without Odoo DB. AI suggestions are persisted records, never write `account.partial.reconcile` directly. LLM provider abstraction makes local LLM a one-config-line drop-in. **Tech Stack:** Odoo 19, Python 3.12, PostgreSQL 16 + pgvector, OWL JS framework, Hypothesis (property-based tests), pytest-benchmark (performance), anthropic + openai Python clients (already installed). **Spec Reference:** [`docs/superpowers/specs/2026-04-19-phase-1-bank-rec-design.md`](../specs/2026-04-19-phase-1-bank-rec-design.md). --- ## Conventions Used Throughout This Plan ### Workspace Identity (CRITICAL — environment safety rule) - **All testing happens in the local OrbStack VM** (`odoo-westin-dev`, container `westin-dev-app`, DB `westin-v19`). - **NEVER touch `ssh odoo-westin`** — that is PRODUCTION (`erp.westinhealthcare.ca`). Per `.cursor/rules/environment-safety.mdc`. - Workspace root: `/Users/gurpreet/Github/Odoo-Modules`. - Local VM addons path is bind-mounted from `/Users/gurpreet/Github/Odoo-Modules/` — code edits on Mac are instantly visible inside the container; no `scp`/`docker cp` needed for code changes. - Schema/data changes still require `odoo -u` upgrade. ### Standard Commands **Test run (against local VM):** ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ -c /etc/odoo/odoo.conf -u fusion_accounting_bank_rec \ --log-handler=odoo.tests:INFO 2>&1 | tail -30 ``` **Module upgrade (no tests, just registry refresh):** ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ -d westin-v19 -u fusion_accounting_bank_rec --stop-after-init --http-port=8099 \ -c /etc/odoo/odoo.conf 2>&1 | tail -10 ``` **Restart container (refreshes Python/JS caches after pure code edits):** ```bash orb -m odoo-westin-dev sudo docker restart westin-dev-app sleep 10 /usr/bin/curl -sSo /dev/null -w 'HTTP %{http_code}\n' http://odoo-westin-dev.orb.local:8069/web/login ``` **SQL inspection:** ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-db psql -U odoo -d westin-v19 -c "SELECT ..." ``` **Browser test:** - Local URL: `http://odoo-westin-dev.orb.local:8069` - Use `/usr/bin/curl` (not just `curl`) — Cursor's PATH lacks the system curl shadow ### Commit Style `(): ` per recent repo conventions: - `type`: feat, fix, refactor, docs, test, chore, ci - `scope`: usually the sub-module name (`fusion_accounting_bank_rec`, `fusion_accounting_ai`, etc.) - Multiline body via heredoc: ```bash git commit -m "$(cat <<'EOF' feat(fusion_accounting_bank_rec): add reconcile engine AbstractModel Implements the 6-method public API: reconcile_one, reconcile_batch, suggest_matches, accept_suggestion, write_off, unreconcile. TDD red→green for reconcile_one (5 unit tests). EOF )" ``` ### TDD Discipline Every task with code changes follows red-green-commit: 1. Write the failing test (run it, confirm RED with the expected error) 2. Write minimal implementation (run test, confirm GREEN) 3. Refactor if obvious cleanups (re-run test) 4. Commit ### Manifest Version Each commit that changes module behavior bumps the manifest version patch: - `19.0.1.0.0` → `19.0.1.0.1` → `19.0.1.0.2` → ... --- ## File Structure (target end-state of Phase 1) ``` fusion_accounting_bank_rec/ # NEW Phase 1 sub-module ├── __manifest__.py ├── __init__.py ├── CLAUDE.md, UPGRADE_NOTES.md, README.md ├── docs/odoo_diff/v19/ # Enterprise reference snapshots ├── data/ │ ├── ir_cron.xml │ └── materialized_view.xml ├── migrations/19.0.1.0.0/post-migration.py ├── models/ │ ├── __init__.py │ ├── fusion_reconcile_engine.py # AbstractModel orchestrator │ ├── fusion_reconcile_suggestion.py # Persisted AI suggestions │ ├── fusion_reconcile_pattern.py # Per-partner aggregate │ ├── fusion_reconcile_precedent.py # Per-decision memory │ ├── account_bank_statement_line.py # Inherit │ ├── account_reconcile_model.py # Inherit │ └── fusion_bank_rec_widget.py # TransientModel ├── controllers/ │ ├── __init__.py │ └── bank_rec_controller.py # 10 JSON-RPC endpoints ├── security/ │ ├── ir.model.access.csv │ └── bank_rec_security.xml ├── services/ # Pure-Python helpers │ ├── __init__.py │ ├── matching_strategies.py │ ├── exchange_diff.py │ ├── confidence_scoring.py │ ├── pattern_extractor.py │ ├── precedent_lookup.py │ └── memo_tokenizer.py ├── static/ │ ├── description/{icon.png, index.html} │ └── src/components/bank_reconciliation/ │ ├── _bank_rec_tokens.scss │ ├── bank_rec_widget.scss │ ├── bank_reconciliation_service.js │ ├── kanban_controller.{js,xml} │ ├── kanban_renderer.{js,xml} │ ├── statement_line/ # 18 Enterprise mirrors │ ├── statement_summary/ │ ├── line_to_reconcile/ │ ├── list_view/ │ ├── apply_amount/ │ ├── bankrec_form_dialog/ │ ├── button/ │ ├── button_list/ │ ├── chatter/ │ ├── file_uploader/ │ ├── line_info_pop_over/ │ ├── quick_create/ │ ├── reconciled_line_name/ │ ├── search_dialog/ │ ├── ai_suggestion/ # 5 fusion-only │ ├── batch_action_bar/ │ ├── attachment_strip/ │ ├── partner_history_panel/ │ └── reconcile_model_picker/ ├── tests/ │ ├── __init__.py │ ├── _fixtures/ │ ├── _factories.py │ ├── test_reconcile_engine_unit.py │ ├── test_reconcile_engine_property.py │ ├── test_reconcile_engine_integration.py │ ├── test_pattern_extraction.py │ ├── test_precedent_lookup.py │ ├── test_memo_tokenizer.py │ ├── test_confidence_scoring.py │ ├── test_ai_suggestion_lifecycle.py │ ├── test_widget_endpoints.py │ ├── test_coexistence.py │ ├── test_migration_round_trip.py │ ├── test_performance.py │ ├── test_local_llm_compat.py │ └── tours/{manual_reconcile,ai_assist_accept,batch_reconcile,partial_reconcile,write_off}_tour.js ├── views/ │ ├── bank_rec_widget_views.xml │ ├── account_journal_views.xml │ ├── account_reconcile_model_views.xml │ └── menus.xml └── wizards/ ├── __init__.py ├── auto_reconcile_wizard.{py,xml} ├── reconcile_wizard.{py,xml} └── migration_wizard_inherit.py # Existing sub-modules — additions only fusion_accounting_core/ ├── models/account_bank_statement_line.py # ADD: cron_last_check field └── models/res_users.py # NEW: dynamic coexistence group fusion_accounting_ai/services/ ├── adapters/_base.py # NEW: LLMProvider contract ├── adapters/openai_adapter.py # MODIFY: configurable base_url ├── data_adapters/bank_rec.py # MODIFY: fill _via_fusion paths ├── prompts/bank_rec_prompt.py # NEW: suggestion-ranking prompt └── tools/bank_reconciliation.py # MODIFY: 5 new tools + refactors fusion_accounting/ # Meta-module └── __manifest__.py # MODIFY: depend on _bank_rec ``` --- ## Task Index (51 tasks across 17 groups) | Group | Tasks | Topic | |---|---|---| | 1 | 1-5 | Foundation (branch, skeleton, shared fields, LLM contract) | | 2 | 6-13 | Reconcile engine — TDD layered build | | 3 | 14-17 | Models (suggestion, pattern, precedent, widget, inherits) | | 4 | 18-19 | Integration tests + SQL fixtures | | 5 | 20-23 | AI integration backend | | 6 | 24-25 | Materialized view + cron | | 7 | 26 | Controllers | | 8 | 27-29 | Frontend foundation (SCSS, service, kanban) | | 9 | 30-33 | Frontend mirror components (4 batches) | | 10 | 34-36 | Frontend fusion-only components (3 batches) | | 11 | 37-38 | Wizards | | 12 | 39-41 | Migration integration | | 13 | 42-43 | Coexistence (menus, group, tests) | | 14 | 44 | Tour tests (5 in one task) | | 15 | 45-46 | Performance benchmarks | | 16 | 47 | Local LLM compat | | 17 | 48-51 | Closeout (manifest, docs, smoke, tag) | --- ## Group 1: Foundation ### Task 1: Safety Net — Tag and Branch **Files:** git refs only. - [ ] **Step 1: Verify clean working tree on `main`** ```bash cd /Users/gurpreet/Github/Odoo-Modules && git status --short -- fusion_accounting fusion_accounting_core fusion_accounting_ai fusion_accounting_migration ``` Expected: empty (no uncommitted fusion_accounting changes). Plating drift in other folders OK. - [ ] **Step 2: Tag pre-Phase-1 state** ```bash cd /Users/gurpreet/Github/Odoo-Modules git tag -a fusion_accounting/pre-phase-1 -m "Snapshot before Phase 1 bank reconciliation work" git tag --list "fusion_accounting/*" ``` Expected output includes new `fusion_accounting/pre-phase-1` tag. - [ ] **Step 3: Create Phase 1 working branch** ```bash git checkout -b fusion_accounting/phase-1-bank-rec git branch --show-current ``` Expected output: `fusion_accounting/phase-1-bank-rec`. - [ ] **Step 4: No commit — branch creation only.** --- ### Task 2: Create `fusion_accounting_bank_rec` Skeleton **Files:** - Create: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/__init__.py` - Create: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/__manifest__.py` - Create: `fusion_accounting_bank_rec/models/__init__.py` - Create: `fusion_accounting_bank_rec/services/__init__.py` - Create: `fusion_accounting_bank_rec/controllers/__init__.py` - Create: `fusion_accounting_bank_rec/wizards/__init__.py` - Create: `fusion_accounting_bank_rec/tests/__init__.py` - Create: `fusion_accounting_bank_rec/security/ir.model.access.csv` - [ ] **Step 1: Make directories** ```bash cd /Users/gurpreet/Github/Odoo-Modules mkdir -p fusion_accounting_bank_rec/{models,services,controllers,wizards,tests,tests/_fixtures,tests/tours,views,data,migrations/19.0.1.0.0,security,docs/odoo_diff/v19,static/description,static/src/components/bank_reconciliation} ``` - [ ] **Step 2: Write top-level `__init__.py`** Path: `fusion_accounting_bank_rec/__init__.py` ```python from . import models from . import controllers from . import services from . import wizards ``` - [ ] **Step 3: Write `__manifest__.py`** Path: `fusion_accounting_bank_rec/__manifest__.py` ```python { 'name': 'Fusion Accounting — Bank Reconciliation', 'version': '19.0.1.0.0', 'category': 'Accounting/Accounting', 'sequence': 28, 'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.', 'description': """ Fusion Accounting — Bank Reconciliation ======================================== Replaces Odoo Enterprise's account_accountant bank-rec widget with a native V19 OWL implementation reading/writing Community's account.partial.reconcile tables. Features: - Strict mirror of all Enterprise UI components (zero functional loss) - AI confidence badges with one-click Accept and ranked alternatives - Behavioural learning from historical reconciliations - Local LLM ready (Ollama, LM Studio) via OpenAI-compatible adapter - Coexists with account_accountant (Enterprise wins by default) Built by Nexa Systems Inc. """, 'icon': '/fusion_accounting_bank_rec/static/description/icon.png', 'author': 'Nexa Systems Inc.', 'website': 'https://nexasystems.ca', 'maintainer': 'Nexa Systems Inc.', 'depends': ['fusion_accounting_core'], 'external_dependencies': { 'python': ['hypothesis'], }, 'data': [ 'security/ir.model.access.csv', ], 'installable': True, 'application': False, 'license': 'OPL-1', } ``` - [ ] **Step 4: Write empty package `__init__.py` files** ```bash cd /Users/gurpreet/Github/Odoo-Modules for f in fusion_accounting_bank_rec/{models,services,controllers,wizards,tests}/__init__.py; do touch "$f" done ``` - [ ] **Step 5: Write empty ACL CSV** Path: `fusion_accounting_bank_rec/security/ir.model.access.csv` ```csv id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink ``` - [ ] **Step 6: Copy icon from existing fusion_accounting_ai** ```bash cp /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/description/icon.png \ /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/static/description/icon.png ``` - [ ] **Step 7: Snapshot Enterprise reference files** ```bash cp /Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/static/src/components/bank_reconciliation/bank_reconciliation_service.js \ /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js cp /Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/models/account_reconcile_model.py \ /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_reconcile_model.py cp /Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/wizard/account_auto_reconcile_wizard.py \ /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_auto_reconcile_wizard.py ``` - [ ] **Step 8: Install hypothesis Python lib in container** ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-app pip install --break-system-packages hypothesis ``` - [ ] **Step 9: Verify install on local VM** ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ -d westin-v19 -i fusion_accounting_bank_rec --stop-after-init --http-port=8099 \ -c /etc/odoo/odoo.conf 2>&1 | tail -5 ``` Expected: clean install line near tail; no ERROR. - [ ] **Step 10: Verify in DB** ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-db psql -U odoo -d westin-v19 -c \ "SELECT name, state, latest_version FROM ir_module_module WHERE name='fusion_accounting_bank_rec';" ``` Expected: `installed`, `19.0.1.0.0`. - [ ] **Step 11: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add fusion_accounting_bank_rec/ git commit -m "feat(fusion_accounting_bank_rec): add empty sub-module skeleton" ``` --- ### Task 3: Add `cron_last_check` Shared Field to `fusion_accounting_core` **Files:** - Modify: `fusion_accounting_core/models/account_bank_statement_line.py` (file may need creating) - Modify: `fusion_accounting_core/models/__init__.py` - Create: `fusion_accounting_core/tests/test_shared_field_bank_statement.py` - [ ] **Step 1: Check if file exists** ```bash ls /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_core/models/account_bank_statement_line.py 2>/dev/null && echo "exists" || echo "missing" ``` - [ ] **Step 2: Write the failing test** Path: `fusion_accounting_core/tests/test_shared_field_bank_statement.py` ```python from odoo.tests.common import TransactionCase, tagged @tagged('post_install', '-at_install') class TestSharedFieldBankStatementLine(TransactionCase): """Verify fusion_accounting_core declares the Enterprise extension fields on account.bank.statement.line so they survive Enterprise uninstall.""" def test_cron_last_check_field_exists(self): Line = self.env['account.bank.statement.line'] self.assertIn('cron_last_check', Line._fields, "cron_last_check must be declared on account.bank.statement.line " "(shared-field-ownership with account_accountant)") self.assertEqual(Line._fields['cron_last_check'].type, 'datetime') ``` - [ ] **Step 3: Run test, confirm RED** ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ -c /etc/odoo/odoo.conf -u fusion_accounting_core --log-handler=odoo.tests:INFO 2>&1 | grep -E 'TestSharedFieldBankStatement|FAIL|AssertionError' ``` Expected: AssertionError on `cron_last_check must be declared`. - [ ] **Step 4: Create or update the model file** Path: `fusion_accounting_core/models/account_bank_statement_line.py` ```python """Shared-field-ownership for account.bank.statement.line. Enterprise's account_accountant adds cron_last_check (timestamp of last auto-reconcile cron run for the line). By declaring it here with the same schema, fusion_accounting_core becomes a co-owner so the column persists when account_accountant uninstalls. """ from odoo import fields, models class AccountBankStatementLine(models.Model): _inherit = "account.bank.statement.line" cron_last_check = fields.Datetime(copy=False) ``` - [ ] **Step 5: Update `models/__init__.py`** Path: `fusion_accounting_core/models/__init__.py` — add (preserving existing imports): ```python from . import ir_module_module from . import account_move from . import account_reconcile_model from . import account_bank_statement_line # NEW Phase 1 ``` - [ ] **Step 6: Bump manifest version** Path: `fusion_accounting_core/__manifest__.py` — change `'version': '19.0.1.0.0'` → `'version': '19.0.1.0.1'`. - [ ] **Step 7: Run test, confirm GREEN** ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ -c /etc/odoo/odoo.conf -u fusion_accounting_core --log-handler=odoo.tests:INFO 2>&1 | grep -E 'TestSharedFieldBankStatement|FAIL|PASS' ``` Expected: PASS. - [ ] **Step 8: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add fusion_accounting_core/ git commit -m "feat(fusion_accounting_core): shared-field-ownership for cron_last_check" ``` --- ### Task 4: Add Computed Coexistence Group to `fusion_accounting_core` **Files:** - Create: `fusion_accounting_core/models/res_users.py` - Modify: `fusion_accounting_core/models/__init__.py` - Modify: `fusion_accounting_core/security/fusion_accounting_security.xml` (add the group definition) - Create: `fusion_accounting_core/tests/test_coexistence_group.py` - [ ] **Step 1: Write the failing test** Path: `fusion_accounting_core/tests/test_coexistence_group.py` ```python from odoo.tests.common import TransactionCase, tagged @tagged('post_install', '-at_install') class TestCoexistenceGroup(TransactionCase): """The 'show when Enterprise absent' group must exist and have computed membership.""" def test_group_exists(self): group = self.env.ref('fusion_accounting_core.group_fusion_show_when_enterprise_absent', raise_if_not_found=False) self.assertTrue(group, "Coexistence group must exist") def test_membership_recomputes_with_module_state(self): """A user is in the group iff Enterprise accounting is NOT installed.""" user = self.env['res.users'].create({ 'name': 'Test Coexistence User', 'login': 'test_coexistence@example.com', }) group = self.env.ref('fusion_accounting_core.group_fusion_show_when_enterprise_absent') enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed() if enterprise_installed: self.assertNotIn(user.id, group.user_ids.ids, "User should NOT be in coexistence group when Enterprise installed") else: self.assertIn(user.id, group.user_ids.ids, "User should be in coexistence group when Enterprise absent") ``` - [ ] **Step 2: Run, confirm RED (group doesn't exist yet)** Same test command pattern. Expected: `ValueError: External ID not found`. - [ ] **Step 3: Add group declaration to security XML** Path: `fusion_accounting_core/security/fusion_accounting_security.xml` — append before the closing `` tag: ```xml Fusion: Show menus when Enterprise absent Computed group. Membership: all internal users when no Enterprise accounting module is installed. Used to hide fusion sub-module menus that would conflict with Enterprise UIs. ``` - [ ] **Step 4: Write the model that recomputes membership** Path: `fusion_accounting_core/models/res_users.py` ```python """Recompute membership of group_fusion_show_when_enterprise_absent whenever an Enterprise accounting module changes installation state.""" from odoo import api, models class ResUsers(models.Model): _inherit = "res.users" @api.model def _fusion_recompute_coexistence_group(self): """Set group membership = all internal users iff Enterprise absent. Called from ir.module.module.button_immediate_install / uninstall overrides.""" group = self.env.ref('fusion_accounting_core.group_fusion_show_when_enterprise_absent', raise_if_not_found=False) if not group: return enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed() if enterprise_installed: group.sudo().write({'user_ids': [(5, 0, 0)]}) # clear all else: all_internal = self.sudo().search([('share', '=', False)]) group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]}) class IrModuleModule(models.Model): _inherit = "ir.module.module" def button_immediate_install(self): result = super().button_immediate_install() self.env['res.users']._fusion_recompute_coexistence_group() return result def button_immediate_uninstall(self): result = super().button_immediate_uninstall() self.env['res.users']._fusion_recompute_coexistence_group() return result def module_uninstall(self): result = super().module_uninstall() self.env['res.users']._fusion_recompute_coexistence_group() return result ``` Note: `IrModuleModule._inherit` already exists in `ir_module_module.py` from Phase 0. The new overrides go into the SAME class via this file (Odoo merges inherited classes). That's fine — but to keep cohesion, MOVE this `IrModuleModule` block to `ir_module_module.py` instead. Updated structure: Edit `fusion_accounting_core/models/ir_module_module.py` — append the three overrides: ```python def button_immediate_install(self): result = super().button_immediate_install() self.env['res.users']._fusion_recompute_coexistence_group() return result def button_immediate_uninstall(self): result = super().button_immediate_uninstall() self.env['res.users']._fusion_recompute_coexistence_group() return result def module_uninstall(self): # NOTE: existing _fusion_check_uninstall_guard call from Task 17 still runs # via the existing override; this DOES NOT replace it. Append below safety check. result = super().module_uninstall() self.env['res.users']._fusion_recompute_coexistence_group() return result ``` Actually since `module_uninstall` was already overridden in Phase 0 (Task 17 safety guard), the existing override in `fusion_accounting_migration` already runs. The Phase 1 addition belongs in `fusion_accounting_core/models/ir_module_module.py` — Odoo's MRO merges the overrides. Verify carefully when implementing. Then `res_users.py` only contains the `ResUsers` class with `_fusion_recompute_coexistence_group`. - [ ] **Step 5: Update `models/__init__.py`** Add `from . import res_users` to `fusion_accounting_core/models/__init__.py`. - [ ] **Step 6: Add post-install hook to seed initial membership** Path: `fusion_accounting_core/__manifest__.py` — add `'post_init_hook': 'post_init_hook'`. Path: `fusion_accounting_core/__init__.py` — add at top: ```python from . import models def post_init_hook(env): """Initialize coexistence group membership based on current Enterprise install state.""" env['res.users']._fusion_recompute_coexistence_group() ``` - [ ] **Step 7: Bump manifest version → `19.0.1.0.2`** - [ ] **Step 8: Run test, verify GREEN** Same test command. Expected: both tests PASS. - [ ] **Step 9: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add fusion_accounting_core/ git commit -m "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." ``` --- ### Task 5: LLM Provider Contract + OpenAI Adapter Generalisation **Files:** - Create: `fusion_accounting_ai/services/adapters/_base.py` - Modify: `fusion_accounting_ai/services/adapters/__init__.py` - Modify: `fusion_accounting_ai/services/adapters/openai_adapter.py` - Create: `fusion_accounting_ai/tests/test_llm_provider_contract.py` - [ ] **Step 1: Write the failing test** Path: `fusion_accounting_ai/tests/test_llm_provider_contract.py` ```python 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_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) # The OpenAI client should have base_url == our config self.assertEqual(str(adapter.client.base_url).rstrip('/'), 'http://localhost:1234/v1') ``` - [ ] **Step 2: Run, confirm RED** Expected: ImportError for `_base` (file doesn't exist). - [ ] **Step 3: Write the contract base class** Path: `fusion_accounting_ai/services/adapters/_base.py` ```python """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.") ``` - [ ] **Step 4: Update `openai_adapter.py` — generalise base_url + inherit LLMProvider** Read `fusion_accounting_ai/services/adapters/openai_adapter.py` first to see current structure. Then ensure these key changes (preserve existing message/response logic): ```python from openai import OpenAI from ._base import LLMProvider class OpenAIAdapter(LLMProvider): """OpenAI-compatible HTTP adapter. Configurable via ir.config_parameter: - fusion_accounting.openai_base_url (default: https://api.openai.com/v1) - fusion_accounting.openai_api_key - fusion_accounting.openai_model (default: gpt-4o-mini) Works against any OpenAI-compatible endpoint: - OpenAI: https://api.openai.com/v1 - LM Studio: http://host.docker.internal:1234/v1 - Ollama: http://host.docker.internal:11434/v1 - vLLM: http://:8000/v1 """ supports_tool_calling = True supports_streaming = True max_context_tokens = 128000 # default; overridable per model supports_embeddings = True def __init__(self, env): super().__init__(env) param = env['ir.config_parameter'].sudo() api_key = param.get_param('fusion_accounting.openai_api_key', 'unused') base_url = param.get_param('fusion_accounting.openai_base_url', 'https://api.openai.com/v1') self.model = param.get_param('fusion_accounting.openai_model', 'gpt-4o-mini') self.client = OpenAI(api_key=api_key, base_url=base_url) def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict: all_messages = [{'role': 'system', 'content': system}] + messages response = self.client.chat.completions.create( model=self.model, messages=all_messages, max_tokens=max_tokens, temperature=temperature, ) return { 'content': response.choices[0].message.content, 'tokens_used': response.usage.total_tokens, 'model': response.model, } def complete_with_tools(self, *, system, messages, tools, max_tokens=2048) -> dict: # ... existing tool-calling logic preserved pass # implementer: keep what's there, just inherits LLMProvider now def embed(self, texts: list[str]) -> list[list[float]]: response = self.client.embeddings.create( model='text-embedding-3-small', input=texts, ) return [d.embedding for d in response.data] ``` - [ ] **Step 5: Update `adapters/__init__.py` to export `LLMProvider`** Path: `fusion_accounting_ai/services/adapters/__init__.py` ```python from ._base import LLMProvider ``` - [ ] **Step 6: Update Claude adapter to also inherit `LLMProvider`** (so the contract is consistent) Read `fusion_accounting_ai/services/adapters/claude.py`. Add `LLMProvider` as base class. Set capability attributes: ```python class ClaudeAdapter(LLMProvider): supports_tool_calling = True supports_streaming = True max_context_tokens = 200000 supports_embeddings = False # ... existing init/complete logic preserved ``` - [ ] **Step 7: Bump fusion_accounting_ai manifest → `19.0.1.0.1`** - [ ] **Step 8: Run all tests, verify nothing regressed** ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ -c /etc/odoo/odoo.conf -u fusion_accounting_ai --log-handler=odoo.tests:INFO 2>&1 \ | grep -E 'TestLLMProvider|TestDataAdapter|TestPostMigration|FAIL|PASS|tests.stats|tests.result' ``` Expected: TestLLMProviderContract PASSES; existing fusion_accounting_ai tests still pass. - [ ] **Step 9: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add fusion_accounting_ai/ git commit -m "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." ``` --- ## Group 2: Reconcile Engine — TDD Layered Build ### Task 6: `services/memo_tokenizer.py` Pure-Python utility to extract keywords from Canadian bank memos (RBC, TD, Scotia, BMO conventions). No ORM dependency — fastest test cycle. **Files:** - Create: `fusion_accounting_bank_rec/services/memo_tokenizer.py` - Create: `fusion_accounting_bank_rec/tests/test_memo_tokenizer.py` - [ ] **Step 1: Write failing test** Path: `fusion_accounting_bank_rec/tests/test_memo_tokenizer.py` ```python from odoo.tests.common import TransactionCase, tagged from odoo.addons.fusion_accounting_bank_rec.services.memo_tokenizer import tokenize_memo @tagged('post_install', '-at_install') class TestMemoTokenizer(TransactionCase): def test_extracts_rbc_etf_reference(self): tokens = tokenize_memo("RBC ETF DEP REF 4831") self.assertIn('RBC', tokens) self.assertIn('ETF', tokens) self.assertIn('REF4831', tokens) # ref+number stays cohesive def test_extracts_cheque_number(self): tokens = tokenize_memo("CHEQUE 4827 - WESTIN PLATING") self.assertIn('CHEQUE', tokens) self.assertIn('CHEQUE4827', tokens) self.assertIn('WESTIN', tokens) self.assertIn('PLATING', tokens) def test_strips_noise_tokens(self): tokens = tokenize_memo("PAYMENT - INV - DEP - 12345") self.assertNotIn('-', tokens) self.assertEqual([t for t in tokens if len(t) <= 1], []) def test_handles_empty_memo(self): self.assertEqual(tokenize_memo(""), []) self.assertEqual(tokenize_memo(None), []) def test_canadian_french_memo(self): tokens = tokenize_memo("PAIEMENT VIREMENT BANCAIRE") self.assertIn('PAIEMENT', tokens) self.assertIn('VIREMENT', tokens) def test_normalises_case(self): tokens = tokenize_memo("rbc etf dep ref 4831") self.assertIn('RBC', tokens) # uppercase regardless of input def test_handles_special_characters(self): tokens = tokenize_memo("RBC*PAYMENT/REF#4831") self.assertIn('RBC', tokens) self.assertIn('PAYMENT', tokens) self.assertIn('REF4831', tokens) ``` - [ ] **Step 2: Run, confirm RED (ImportError)** - [ ] **Step 3: Write implementation** Path: `fusion_accounting_bank_rec/services/memo_tokenizer.py` ```python """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 # Canadian bank prefixes worth preserving as standalone tokens CANONICAL_PREFIXES = {'RBC', 'TD', 'BMO', 'SCOTIA', 'CIBC', 'NATIONAL'} # Reference patterns: collapses "REF 4831" → "REF4831", "CHQ 4827" → "CHEQUE4827" 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'), ] # Tokens shorter than this (after normalization) are dropped as noise 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) # Replace special chars with spaces, then split 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 ``` - [ ] **Step 4: Update `services/__init__.py`** Path: `fusion_accounting_bank_rec/services/__init__.py` ```python from . import memo_tokenizer ``` - [ ] **Step 5: Update `tests/__init__.py`** Path: `fusion_accounting_bank_rec/tests/__init__.py` ```python from . import test_memo_tokenizer ``` - [ ] **Step 6: Run tests, verify GREEN** ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ -c /etc/odoo/odoo.conf -u fusion_accounting_bank_rec --log-handler=odoo.tests:INFO 2>&1 \ | grep -E 'TestMemoTokenizer|FAIL|PASS|tests.stats' ``` Expected: 7 tests PASS. - [ ] **Step 7: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add fusion_accounting_bank_rec/services/memo_tokenizer.py \ fusion_accounting_bank_rec/services/__init__.py \ fusion_accounting_bank_rec/tests/test_memo_tokenizer.py \ fusion_accounting_bank_rec/tests/__init__.py git commit -m "feat(fusion_accounting_bank_rec): memo_tokenizer for Canadian bank memo formats" ``` --- ### Task 7: `services/exchange_diff.py` Pure-Python helper that wraps Odoo's `_create_exchange_difference_move` — keeps engine signature stable while delegating to Odoo's FX math. **Files:** - Create: `fusion_accounting_bank_rec/services/exchange_diff.py` - Create: `fusion_accounting_bank_rec/tests/test_exchange_diff.py` - [ ] **Step 1: Write failing test** Path: `fusion_accounting_bank_rec/tests/test_exchange_diff.py` ```python from odoo.tests.common import TransactionCase, tagged from odoo.addons.fusion_accounting_bank_rec.services.exchange_diff import ( compute_exchange_diff, ExchangeDiffResult, ) @tagged('post_install', '-at_install') class TestExchangeDiff(TransactionCase): def test_no_diff_when_currencies_match(self): result = compute_exchange_diff( line_amount=100.00, line_currency_code='CAD', against_amount=100.00, against_currency_code='CAD', line_rate=1.0, against_rate=1.0, ) self.assertFalse(result.needs_diff_move) self.assertEqual(result.diff_amount, 0.0) def test_diff_when_rates_differ_same_currency(self): """USD invoice posted at 1.35, USD bank line settled at 1.40 → diff exists""" result = compute_exchange_diff( line_amount=100.00, line_currency_code='USD', against_amount=100.00, against_currency_code='USD', line_rate=1.40, against_rate=1.35, ) self.assertTrue(result.needs_diff_move) # 100 USD at 1.40 = 140 CAD; same at 1.35 = 135 CAD; diff = 5 CAD gain self.assertAlmostEqual(result.diff_amount, 5.00, places=2) def test_diff_negative_when_rate_dropped(self): """USD invoice at 1.40, settled at 1.35 → loss""" result = compute_exchange_diff( line_amount=100.00, line_currency_code='USD', against_amount=100.00, against_currency_code='USD', line_rate=1.35, against_rate=1.40, ) self.assertTrue(result.needs_diff_move) self.assertAlmostEqual(result.diff_amount, -5.00, places=2) ``` - [ ] **Step 2: Run, confirm RED** - [ ] **Step 3: Write implementation** Path: `fusion_accounting_bank_rec/services/exchange_diff.py` ```python """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. """ 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), ) ``` - [ ] **Step 4: Update `services/__init__.py`** — add `from . import exchange_diff` - [ ] **Step 5: Update `tests/__init__.py`** — add `from . import test_exchange_diff` - [ ] **Step 6: Run tests, verify GREEN. Commit.** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add fusion_accounting_bank_rec/services/exchange_diff.py \ fusion_accounting_bank_rec/services/__init__.py \ fusion_accounting_bank_rec/tests/test_exchange_diff.py \ fusion_accounting_bank_rec/tests/__init__.py git commit -m "feat(fusion_accounting_bank_rec): exchange_diff helper for FX gain/loss pre-check" ``` --- ### Task 8: `services/matching_strategies.py` (3 strategies) **Files:** - Create: `fusion_accounting_bank_rec/services/matching_strategies.py` - Create: `fusion_accounting_bank_rec/tests/test_matching_strategies.py` - [ ] **Step 1: Write failing tests for all 3 strategies** Path: `fusion_accounting_bank_rec/tests/test_matching_strategies.py` ```python from odoo.tests.common import TransactionCase, tagged from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import ( Candidate, AmountExactStrategy, FIFOStrategy, MultiInvoiceStrategy, MatchResult, ) @tagged('post_install', '-at_install') class TestAmountExactStrategy(TransactionCase): def test_picks_exact_amount(self): candidates = [ Candidate(id=1, amount=99.99, partner_id=42, age_days=10), Candidate(id=2, amount=100.00, partner_id=42, age_days=20), Candidate(id=3, amount=100.50, partner_id=42, age_days=5), ] result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates) self.assertEqual(result.picked_ids, [2]) self.assertEqual(result.confidence, 1.0) def test_no_match_when_no_exact(self): candidates = [ Candidate(id=1, amount=99.99, partner_id=42, age_days=10), Candidate(id=2, amount=100.50, partner_id=42, age_days=20), ] result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates) self.assertEqual(result.picked_ids, []) def test_picks_oldest_when_multiple_exact(self): candidates = [ Candidate(id=1, amount=100.00, partner_id=42, age_days=10), Candidate(id=2, amount=100.00, partner_id=42, age_days=30), # oldest Candidate(id=3, amount=100.00, partner_id=42, age_days=20), ] result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates) self.assertEqual(result.picked_ids, [2]) @tagged('post_install', '-at_install') class TestFIFOStrategy(TransactionCase): def test_picks_oldest_first(self): candidates = [ Candidate(id=1, amount=50.00, partner_id=42, age_days=10), Candidate(id=2, amount=50.00, partner_id=42, age_days=30), Candidate(id=3, amount=50.00, partner_id=42, age_days=20), ] result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates) self.assertEqual(result.picked_ids, [2, 3]) # oldest two summing to 100 def test_handles_partial_payment(self): candidates = [ Candidate(id=1, amount=200.00, partner_id=42, age_days=30), ] result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates) self.assertEqual(result.picked_ids, [1]) # partial reconcile signaled by residual self.assertEqual(result.residual, -100.00) # over-allocated; engine handles @tagged('post_install', '-at_install') class TestMultiInvoiceStrategy(TransactionCase): def test_finds_smallest_set_summing_to_amount(self): candidates = [ Candidate(id=1, amount=30.00, partner_id=42, age_days=10), Candidate(id=2, amount=40.00, partner_id=42, age_days=15), Candidate(id=3, amount=30.00, partner_id=42, age_days=20), Candidate(id=4, amount=70.00, partner_id=42, age_days=25), ] result = MultiInvoiceStrategy(max_combinations=3).match( bank_amount=100.00, candidates=candidates) # Two valid: [1,2,3] = 100 or [3,4] = 100 (smaller set wins) self.assertIn(set(result.picked_ids), [{3, 4}, {1, 2}]) # both 2-sets are valid def test_returns_empty_when_no_combination_sums(self): candidates = [ Candidate(id=1, amount=15.00, partner_id=42, age_days=10), Candidate(id=2, amount=25.00, partner_id=42, age_days=15), ] result = MultiInvoiceStrategy(max_combinations=3).match( bank_amount=100.00, candidates=candidates) self.assertEqual(result.picked_ids, []) def test_respects_max_combinations(self): # Many small invoices that COULD sum to 100 with 5+ items candidates = [Candidate(id=i, amount=10.00, partner_id=42, age_days=i) for i in range(1, 11)] result = MultiInvoiceStrategy(max_combinations=3).match( bank_amount=100.00, candidates=candidates) # Can't make 100 with ≤3 items of $10 each self.assertEqual(result.picked_ids, []) ``` - [ ] **Step 2: Run, confirm RED** - [ ] **Step 3: Write implementation** Path: `fusion_accounting_bank_rec/services/matching_strategies.py` ```python """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') # Tiebreak: oldest first 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, # negative if over-allocated, positive if under 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') ``` - [ ] **Step 4: Update `services/__init__.py`** — add `from . import matching_strategies`. - [ ] **Step 5: Update `tests/__init__.py`** — add `from . import test_matching_strategies`. - [ ] **Step 6: Run, verify GREEN. Commit.** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add fusion_accounting_bank_rec/services/matching_strategies.py \ fusion_accounting_bank_rec/services/__init__.py \ fusion_accounting_bank_rec/tests/test_matching_strategies.py \ fusion_accounting_bank_rec/tests/__init__.py git commit -m "feat(fusion_accounting_bank_rec): matching strategies (AmountExact, FIFO, MultiInvoice)" ``` --- ### Task 9: `services/precedent_lookup.py` K-nearest-precedent search using indexed columns. Reads from `fusion.reconcile.precedent` (model exists in Task 14). **Files:** - Create: `fusion_accounting_bank_rec/services/precedent_lookup.py` - Create: `fusion_accounting_bank_rec/tests/test_precedent_lookup.py` This task **depends on Task 14** (precedent model). Implement Task 14 first if working sequentially. - [ ] **Step 1: Write failing test (will RED on missing fusion.reconcile.precedent model)** Path: `fusion_accounting_bank_rec/tests/test_precedent_lookup.py` ```python from datetime import date from odoo.tests.common import TransactionCase, tagged from odoo.addons.fusion_accounting_bank_rec.services.precedent_lookup import ( find_nearest_precedents, ) @tagged('post_install', '-at_install') class TestPrecedentLookup(TransactionCase): def setUp(self): super().setUp() self.partner = self.env['res.partner'].create({'name': 'Test Partner'}) self.currency = self.env.ref('base.CAD') self.company = self.env.company # Create some precedents for i, (amt, age) in enumerate([(1847.50, 1), (1847.50, 14), (1800.00, 28)]): self.env['fusion.reconcile.precedent'].create({ 'company_id': self.company.id, 'partner_id': self.partner.id, 'amount': amt, 'currency_id': self.currency.id, 'date': date.today(), 'memo_tokens': 'RBC,ETF,REF', 'matched_move_line_count': 1, 'source': 'manual', }) def test_finds_amount_exact_precedents(self): results = find_nearest_precedents( self.env, partner_id=self.partner.id, amount=1847.50, k=5) self.assertEqual(len(results), 2) # both $1,847.50 precedents self.assertTrue(all(r.amount == 1847.50 for r in results)) def test_returns_empty_for_unknown_partner(self): results = find_nearest_precedents( self.env, partner_id=999999, amount=1847.50, k=5) self.assertEqual(results, []) def test_respects_k_limit(self): # Add 10 more precedents for i in range(10): self.env['fusion.reconcile.precedent'].create({ 'company_id': self.company.id, 'partner_id': self.partner.id, 'amount': 1847.50, 'currency_id': self.currency.id, 'date': date.today(), 'matched_move_line_count': 1, 'source': 'manual', }) results = find_nearest_precedents( self.env, partner_id=self.partner.id, amount=1847.50, k=3) self.assertEqual(len(results), 3) ``` - [ ] **Step 2: Run, confirm RED** - [ ] **Step 3: Write implementation** Path: `fusion_accounting_bank_rec/services/precedent_lookup.py` ```python """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, then takes K. 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') # over-fetch for ranking results = [] for p in candidates: # Similarity score: 1.0 at amount-exact, decays with distance 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) ``` - [ ] **Step 4: Update `services/__init__.py`** and **`tests/__init__.py`**. - [ ] **Step 5: Run tests, verify GREEN.** Note: this task **requires Task 14 first** (the `fusion.reconcile.precedent` model). If running before Task 14, the test will FAIL on missing model — defer this task to after Task 14, or stub the model first. - [ ] **Step 6: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add fusion_accounting_bank_rec/services/precedent_lookup.py \ fusion_accounting_bank_rec/services/__init__.py \ fusion_accounting_bank_rec/tests/test_precedent_lookup.py \ fusion_accounting_bank_rec/tests/__init__.py git commit -m "feat(fusion_accounting_bank_rec): precedent_lookup K-nearest search" ``` --- ### Task 10: `services/pattern_extractor.py` Aggregates patterns from precedent rows into per-partner `fusion.reconcile.pattern` records. **Files:** - Create: `fusion_accounting_bank_rec/services/pattern_extractor.py` - Create: `fusion_accounting_bank_rec/tests/test_pattern_extraction.py` **Depends on Task 14** (pattern + precedent models). - [ ] **Step 1: Write failing tests** Path: `fusion_accounting_bank_rec/tests/test_pattern_extraction.py` ```python from datetime import date, timedelta from odoo.tests.common import TransactionCase, tagged from odoo.addons.fusion_accounting_bank_rec.services.pattern_extractor import ( extract_pattern_for_partner, ) @tagged('post_install', '-at_install') class TestPatternExtractor(TransactionCase): def setUp(self): super().setUp() self.partner = self.env['res.partner'].create({'name': 'Pattern Test Partner'}) self.currency = self.env.ref('base.CAD') self.company = self.env.company def _make_precedent(self, *, amount, days_ago, memo='RBC,ETF', count=1, source='manual'): return self.env['fusion.reconcile.precedent'].create({ 'company_id': self.company.id, 'partner_id': self.partner.id, 'amount': amount, 'currency_id': self.currency.id, 'date': date.today() - timedelta(days=days_ago), 'memo_tokens': memo, 'matched_move_line_count': count, 'reconciled_at': date.today() - timedelta(days=days_ago), 'source': source, }) def test_extracts_typical_amount_range(self): for d in [10, 24, 38, 52]: self._make_precedent(amount=1847.50, days_ago=d) pattern_vals = extract_pattern_for_partner( self.env, company_id=self.company.id, partner_id=self.partner.id) self.assertIn('typical_amount_range', pattern_vals) self.assertEqual(pattern_vals['reconcile_count'], 4) def test_detects_exact_amount_strategy(self): # All same amount → exact_amount strategy for d in range(0, 56, 14): self._make_precedent(amount=1847.50, days_ago=d, count=1) pattern_vals = extract_pattern_for_partner( self.env, company_id=self.company.id, partner_id=self.partner.id) self.assertEqual(pattern_vals['pref_strategy'], 'exact_amount') def test_detects_multi_invoice_strategy(self): # All have count > 1 → multi_invoice for d in range(0, 56, 14): self._make_precedent(amount=2500.00, days_ago=d, count=3) pattern_vals = extract_pattern_for_partner( self.env, company_id=self.company.id, partner_id=self.partner.id) self.assertEqual(pattern_vals['pref_strategy'], 'multi_invoice') def test_computes_cadence_days(self): for d in [0, 14, 28, 42]: self._make_precedent(amount=1000, days_ago=d) pattern_vals = extract_pattern_for_partner( self.env, company_id=self.company.id, partner_id=self.partner.id) self.assertAlmostEqual(pattern_vals['typical_cadence_days'], 14.0, delta=1) def test_extracts_common_memo_tokens(self): self._make_precedent(amount=1000, days_ago=10, memo='RBC,ETF,REF') self._make_precedent(amount=1000, days_ago=24, memo='RBC,ETF,DEPOSIT') self._make_precedent(amount=1000, days_ago=38, memo='RBC,ETF,REF') pattern_vals = extract_pattern_for_partner( self.env, company_id=self.company.id, partner_id=self.partner.id) self.assertIn('RBC', pattern_vals['common_memo_tokens']) self.assertIn('ETF', pattern_vals['common_memo_tokens']) ``` - [ ] **Step 2: Run, confirm RED** - [ ] **Step 3: Write implementation** Path: `fusion_accounting_bank_rec/services/pattern_extractor.py` ```python """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') # Strategy detection 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' # Cadence (mean days between reconciles) reconcile_dates = sorted(precedents.mapped('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 # Memo tokens — count occurrences across all precedents 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 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 ``` - [ ] **Step 4-6: Standard pattern: update `__init__.py` files, run, verify, commit.** ```bash git add fusion_accounting_bank_rec/services/pattern_extractor.py \ fusion_accounting_bank_rec/services/__init__.py \ fusion_accounting_bank_rec/tests/test_pattern_extraction.py \ fusion_accounting_bank_rec/tests/__init__.py git commit -m "feat(fusion_accounting_bank_rec): pattern_extractor for per-partner aggregates" ``` --- ### Task 11: `services/confidence_scoring.py` (4-pass pipeline) Combines pattern + precedent + matching strategies + optional AI re-rank. **Files:** - Create: `fusion_accounting_bank_rec/services/confidence_scoring.py` - Create: `fusion_accounting_bank_rec/tests/test_confidence_scoring.py` **Depends on Tasks 6-10, 14, 15.** - [ ] **Step 1: Write failing tests** Path: `fusion_accounting_bank_rec/tests/test_confidence_scoring.py` ```python from odoo.tests.common import TransactionCase, tagged from odoo.addons.fusion_accounting_bank_rec.services.confidence_scoring import ( score_candidates, ScoredCandidate, ) @tagged('post_install', '-at_install') class TestConfidenceScoring(TransactionCase): def test_amount_exact_dominates_pattern(self): """An amount-exact candidate should score >0.9 even without precedent help.""" # ... setup with one exact-match candidate # ... assert highest scored candidate has confidence >= 0.9 def test_pattern_match_lifts_confidence(self): """Same candidate, same amount delta, but with strong partner pattern → higher score.""" def test_precedent_match_lifts_confidence(self): """Same candidate with 5 prior matching precedents → higher score than no precedents.""" def test_no_ai_provider_returns_statistical_only(self): """When no LLM provider configured, score uses statistical 3-pass; AI rerank is no-op.""" def test_returns_top_k(self): """score_candidates(k=3) returns at most 3 results sorted by confidence desc.""" def test_handles_empty_candidates(self): self.assertEqual(score_candidates(self.env, statement_line=None, candidates=[]), []) ``` Implementer: flesh out the test bodies with real recordset setup using `_factories.py` helpers (created in Task 18). - [ ] **Step 2-6: Standard TDD pattern. Implementation outline:** Path: `fusion_accounting_bank_rec/services/confidence_scoring.py` ```python """4-pass confidence scoring pipeline. Pass 1: SQL filter — partner match + reconcilable account 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 """ from dataclasses import dataclass from .matching_strategies import Candidate, AmountExactStrategy, FIFOStrategy from .precedent_lookup import find_nearest_precedents from .memo_tokenizer import tokenize_memo @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) -> list[ScoredCandidate]: """Score and rank candidate matches for a statement line.""" if not candidates or not statement_line: return [] partner_id = statement_line.partner_id.id bank_amount = statement_line.amount memo_tokens = tokenize_memo(statement_line.payment_ref) # Pass 2.a: amount-match scoring pattern = env['fusion.reconcile.pattern'].sudo().search( [('partner_id', '=', partner_id)], limit=1) 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) if pattern else 0.5 precedent_score = _precedent_score(cand, precedents) # Weighted combine 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] # Pass 3: AI re-rank if provider available + use_ai=True if use_ai: provider = _get_provider(env, 'bank_rec_suggest') if provider is not None: top_k = _ai_rerank(env, provider, statement_line, top_k, pattern, precedents) return top_k def _pattern_score(cand, pattern, bank_amount) -> float: """How well does this candidate fit the partner's typical pattern?""" 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 # If candidate amount matches any precedent's amount, score high best = max((p.similarity_score for p in precedents), default=0.0) 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.""" 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 # Instantiate via existing adapter registry from odoo.addons.fusion_accounting_ai.services.adapters import openai_adapter, claude if provider_name.startswith('openai'): return openai_adapter.OpenAIAdapter(env) elif provider_name.startswith('claude'): return claude.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.""" from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import build_prompt system, user = build_prompt(statement_line, scored, pattern, precedents) try: response = provider.complete(system=system, messages=[{'role': 'user', 'content': user}], max_tokens=800, temperature=0.0) except Exception: # On any provider failure, return the statistical scoring unchanged return scored import json try: parsed = json.loads(response['content']) except json.JSONDecodeError: return scored # Re-order scored list per AI's ranking, attach AI reasoning 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 = (s.confidence * 0.4) + (s.score_ai_rerank * 0.6) scored.sort(key=lambda x: -x.confidence) return scored ``` - [ ] **Step 3+: Standard TDD + commit pattern.** ```bash git commit -m "feat(fusion_accounting_bank_rec): 4-pass confidence scoring pipeline" ``` --- ### Task 12: `models/fusion_reconcile_engine.py` (AbstractModel — 6-method API) This is the orchestrator. **Depends on Tasks 6-11, 14-17.** **Files:** - Create: `fusion_accounting_bank_rec/models/fusion_reconcile_engine.py` - Create: `fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py` - [ ] **Step 1: Write failing tests for the 6 methods** Path: `fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py` ```python from odoo.tests.common import TransactionCase, tagged @tagged('post_install', '-at_install') class TestReconcileEngineAPI(TransactionCase): """The 6-method public API contract.""" def test_engine_model_exists(self): self.assertIn('fusion.reconcile.engine', self.env.registry) def test_reconcile_one_signature(self): engine = self.env['fusion.reconcile.engine'] self.assertTrue(callable(getattr(engine, 'reconcile_one', None))) def test_reconcile_batch_signature(self): engine = self.env['fusion.reconcile.engine'] self.assertTrue(callable(getattr(engine, 'reconcile_batch', None))) def test_suggest_matches_signature(self): engine = self.env['fusion.reconcile.engine'] self.assertTrue(callable(getattr(engine, 'suggest_matches', None))) def test_accept_suggestion_signature(self): engine = self.env['fusion.reconcile.engine'] self.assertTrue(callable(getattr(engine, 'accept_suggestion', None))) def test_write_off_signature(self): engine = self.env['fusion.reconcile.engine'] self.assertTrue(callable(getattr(engine, 'write_off', None))) def test_unreconcile_signature(self): engine = self.env['fusion.reconcile.engine'] self.assertTrue(callable(getattr(engine, 'unreconcile', None))) @tagged('post_install', '-at_install') class TestReconcileOne(TransactionCase): """Behavioural tests for reconcile_one.""" def setUp(self): super().setUp() # Use _factories.make_bank_line / make_invoice (Task 18) ... def test_simple_reconcile_creates_partial(self): """1 bank line $100 + 1 invoice $100 → 1 account.partial.reconcile""" ... result = self.env['fusion.reconcile.engine'].reconcile_one( statement_line=self.line, against_lines=self.invoice.line_ids.filtered( lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable'))) self.assertEqual(len(result['partial_ids']), 1) partial = self.env['account.partial.reconcile'].browse(result['partial_ids']) self.assertAlmostEqual(partial.amount, 100.00, places=2) self.assertTrue(self.line.is_reconciled) def test_partial_reconcile_leaves_residual(self): """1 bank line $100 + 1 invoice $80 → partial reconcile, residual $20""" ... def test_reconcile_validates_amounts_balance(self): """Engine must raise when over-allocation occurs""" ... ``` - [ ] **Step 2-3: Standard TDD. Implementation:** Path: `fusion_accounting_bank_rec/models/fusion_reconcile_engine.py` ```python """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. """ import logging from odoo import _, api, models from odoo.exceptions import ValidationError, UserError from ..services.matching_strategies import ( Candidate, AmountExactStrategy, FIFOStrategy, MultiInvoiceStrategy, ) from ..services.confidence_scoring import score_candidates from ..services.exchange_diff import compute_exchange_diff 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 ===== @api.model def reconcile_one(self, statement_line, *, against_lines, 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")) if not against_lines and not write_off_vals: raise ValidationError(_("Either against_lines or write_off_vals required")) # Phase 2: Validate self._validate_reconcile(statement_line, against_lines) # Phase 3: Optional write-off creation write_off_move_id = None if write_off_vals: write_off_move = self._create_write_off(statement_line, write_off_vals) against_lines = against_lines | write_off_move.line_ids.filtered( lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable')) write_off_move_id = write_off_move.id # Phase 4: Reconcile via Odoo's standard API # Match the bank-line move's reconcile-account line with the against_lines line_move = statement_line.move_id line_move_lines_to_reconcile = line_move.line_ids.filtered( lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable')) all_to_reconcile = line_move_lines_to_reconcile | against_lines all_to_reconcile.reconcile() # Find the partials we just created partial_ids = self.env['account.partial.reconcile'].search([ '|', ('debit_move_id', 'in', all_to_reconcile.ids), ('credit_move_id', 'in', all_to_reconcile.ids), ('create_uid', '=', self.env.uid), ]).ids # Phase 5: Audit self._post_audit(statement_line, partial_ids, source='engine.reconcile_one') # Trigger precedent recording (fire-and-forget) self._record_precedent(statement_line, against_lines) return { 'partial_ids': partial_ids, 'exchange_diff_move_id': None, # handled inline by reconcile() 'write_off_move_id': write_off_move_id, } @api.model def reconcile_batch(self, statement_lines, *, strategy='auto'): """Bulk reconcile via the named strategy.""" reconciled = 0 skipped = 0 errors = [] for line in statement_lines: try: candidates = self._fetch_candidates(line) picked = self._apply_strategy(line, candidates, strategy) if picked: self.reconcile_one(line, against_lines=picked) reconciled += 1 else: skipped += 1 except Exception as e: errors.append({'line_id': line.id, 'error': str(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 = self._fetch_candidates(line) if not candidates: continue scored = score_candidates(self.env, statement_line=line, candidates=candidates, k=limit_per_line, use_ai=True) # Supersede any prior pending suggestions for this line Suggestion.search([ ('statement_line_id', '=', line.id), ('state', '=', 'pending'), ]).write({'state': 'superseded'}) # Persist new suggestions line_suggestions = [] for rank, s in enumerate(scored, start=1): candidate_line = self.env['account.move.line'].browse(s.candidate_id) 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.""" if isinstance(suggestion, int): suggestion = self.env['fusion.reconcile.suggestion'].browse(suggestion) 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': self.env.cr.now(), 'accepted_by': self.env.uid, }) return result @api.model def write_off(self, statement_line, *, account, amount, tax_id=None, label): """Create a write-off move + reconcile the bank line against it.""" # ... build account.move with one debit/credit pair, optional tax line # ... then call reconcile_one with the new move's reconcile-account line pass @api.model def unreconcile(self, partial_reconciles): """Reverse a reconciliation. Handles full vs. partial chains.""" partial_reconciles = partial_reconciles.exists() all_lines = (partial_reconciles.mapped('debit_move_id') | partial_reconciles.mapped('credit_move_id')) partial_reconciles.unlink() return {'unreconciled_line_ids': all_lines.ids} # ===== Private helpers ===== def _validate_reconcile(self, statement_line, against_lines): if not statement_line.exists(): raise ValidationError(_("Statement line does not exist")) if statement_line.is_reconciled: raise ValidationError(_("Line is already reconciled")) # ... currency consistency, period lock, partner allowed checks def _create_write_off(self, statement_line, write_off_vals): """Build and post the write-off account.move.""" # ... create move with proper debit/credit + optional tax line pass 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 _apply_strategy(self, line, candidates, strategy): """Convert candidates to Candidate dataclasses, apply strategy, return recordset.""" today = fields.Date.today() dataclass_candidates = [ Candidate(id=c.id, amount=abs(c.amount_residual), partner_id=c.partner_id.id, age_days=(today - (c.date_maturity or c.date)).days) for c in candidates ] if strategy == 'auto': # Try strategies in order for strat_class in (AmountExactStrategy, MultiInvoiceStrategy, FIFOStrategy): result = strat_class().match(bank_amount=abs(line.amount), candidates=dataclass_candidates) if result.picked_ids: return self.env['account.move.line'].browse(result.picked_ids) # ... handle other named strategies return self.env['account.move.line'] def _post_audit(self, statement_line, partial_ids, source): """Log to mail.thread of the line's move.""" statement_line.move_id.message_post( body=_("Reconciled via %s; partials: %s") % (source, partial_ids), subject=_("Bank reconciliation"), ) def _record_precedent(self, statement_line, against_lines): """Append a precedent for future pattern learning.""" self.env['fusion.reconcile.precedent'].sudo().create({ 'company_id': statement_line.company_id.id, 'partner_id': statement_line.partner_id.id, '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': self.env.cr.now(), 'source': 'manual', # caller may override }) ``` This is the most complex single task. Engineer/subagent should expect ~3-5 days. - [ ] **Step 4-6: Update `models/__init__.py`, run tests, commit per layer.** Commit incrementally — one method at a time is reasonable for this large task. ```bash git commit -m "feat(fusion_accounting_bank_rec): reconcile engine 6-method public API" ``` --- ### Task 13: `test_reconcile_engine_property.py` (Hypothesis property-based tests) **Files:** - Create: `fusion_accounting_bank_rec/tests/test_reconcile_engine_property.py` - [ ] **Step 1: Write the property tests** ```python from decimal import Decimal from hypothesis import given, strategies as st, settings, HealthCheck from odoo.tests.common import TransactionCase, tagged @tagged('post_install', '-at_install', 'property_based') class TestReconcileEngineInvariants(TransactionCase): """Property-based tests for amount invariants. Each test runs 100 random inputs.""" @given( bank_amount=st.decimals(min_value='0.01', max_value='100000.00', places=2), invoice_amounts=st.lists( st.decimals(min_value='0.01', max_value='100000.00', places=2), min_size=1, max_size=10, ), ) @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_invariant_total_debits_equal_total_credits(self, bank_amount, invoice_amounts): """Every reconciliation must produce balanced debit/credit totals. Skip impossible cases (over-allocation).""" if sum(invoice_amounts) < bank_amount: return # skip — over-allocation handled separately # ... setup recordset # ... call engine.reconcile_one # ... assert sum(debits) == sum(credits) @given(amounts=st.lists(st.decimals(places=2), min_size=2, max_size=20)) @settings(max_examples=50) def test_invariant_partial_reconciles_sum_to_full(self, amounts): """Sum of all account.partial.reconcile rows == line residual.""" # ... setup, reconcile, assert ``` - [ ] **Step 2-4: Run, commit.** ```bash git commit -m "test(fusion_accounting_bank_rec): Hypothesis property-based engine invariants" ``` --- ## Group 3: Models ### Task 14: Create Pattern + Precedent Models **Files:** - Create: `fusion_accounting_bank_rec/models/fusion_reconcile_pattern.py` - Create: `fusion_accounting_bank_rec/models/fusion_reconcile_precedent.py` - Modify: `fusion_accounting_bank_rec/models/__init__.py` - Modify: `fusion_accounting_bank_rec/security/ir.model.access.csv` - [ ] **Step 1: Write `fusion_reconcile_pattern.py`** ```python 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) partner_id = fields.Many2one('res.partner', required=True, index=True) reconcile_count = fields.Integer(default=0) typical_amount_range = fields.Char() typical_cadence_days = fields.Float() typical_day_of_month = fields.Char() 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') common_memo_tokens = fields.Char() common_writeoff_account_id = fields.Many2one('account.account') common_writeoff_tax_id = fields.Many2one('account.tax') typical_writeoff_amount = fields.Float() last_refreshed_at = fields.Datetime() _sql_constraints = [ ('uniq_company_partner', 'unique(company_id, partner_id)', 'One pattern row per (company, partner) — already exists.'), ] ``` - [ ] **Step 2: Write `fusion_reconcile_precedent.py`** ```python 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) partner_id = fields.Many2one('res.partner', index=True) 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") journal_id = fields.Many2one('account.journal') matched_move_line_count = fields.Integer() matched_account_ids = fields.Char(help="Comma-separated account.account IDs") matched_invoice_ages_days = fields.Char(help="Comma-separated days-old at reconcile time") write_off_amount = fields.Float() write_off_account_id = fields.Many2one('account.account') exchange_diff = fields.Boolean() reconciler_user_id = fields.Many2one('res.users') reconciled_at = fields.Datetime() source = fields.Selection([ ('historical_bootstrap', 'Imported from history'), ('manual', 'Manual reconcile via fusion'), ('ai_accepted', 'AI suggestion accepted'), ('auto_rule', 'account.reconcile.model auto-fired'), ], required=True) _sql_constraints = [] # no uniqueness; multiple reconciles can share features ``` - [ ] **Step 3: Update `__init__.py`** ```python from . import fusion_reconcile_pattern from . import fusion_reconcile_precedent ``` - [ ] **Step 4: Add ACL rows** Path: `fusion_accounting_bank_rec/security/ir.model.access.csv` ```csv 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 ``` - [ ] **Step 5: Bump manifest → `19.0.1.0.1`. Upgrade. Verify. Commit.** ```bash git commit -m "feat(fusion_accounting_bank_rec): pattern + precedent models for behavioural learning" ``` --- ### Task 15: Create Suggestion Model **Files:** - Create: `fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py` - Modify: `fusion_accounting_bank_rec/models/__init__.py` - Modify: `fusion_accounting_bank_rec/security/ir.model.access.csv` - Create: `fusion_accounting_bank_rec/tests/test_ai_suggestion_lifecycle.py` - [ ] **Step 1: Write the model from the spec (Section 5.1)** Path: `fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py` — full schema per spec. - [ ] **Step 2: Write lifecycle tests** ```python def test_suggestion_state_transitions(self): """pending → accepted, pending → rejected, pending → superseded all work""" def test_compute_band_from_confidence(self): """confidence_band auto-computed: 0.96 → 'high', 0.75 → 'medium', etc.""" def test_acceptance_records_user_and_time(self): """Accepting a suggestion sets accepted_by + accepted_at""" ``` - [ ] **Step 3: Add ACL rows. Bump manifest. Run, verify, commit.** ```bash git commit -m "feat(fusion_accounting_bank_rec): persisted AI suggestion model with state lifecycle" ``` --- ### Task 16: TransientModel for Widget Round-Trip Data **Files:** - Create: `fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py` - Modify: `fusion_accounting_bank_rec/models/__init__.py` - [ ] **Step 1: Write the model** ```python from odoo import api, fields, models class FusionBankRecWidget(models.TransientModel): """Per-request widget state. Holds the kanban-load response shape so the controller can return one well-typed object.""" _name = "fusion.bank.rec.widget" _description = "Bank reconciliation widget state (transient)" journal_id = fields.Many2one('account.journal') statement_line_ids = fields.Many2many('account.bank.statement.line') summary_count = fields.Integer() summary_unreconciled_balance = fields.Monetary(currency_field='currency_id') currency_id = fields.Many2one('res.currency') def action_open_kanban(self): """Return a window action opening the OWL kanban for this journal.""" return { 'type': 'ir.actions.client', 'tag': 'fusion_bank_rec_kanban', 'params': {'journal_id': self.journal_id.id}, } ``` - [ ] **Step 2: Update __init__, ACL, manifest, commit.** ```bash git commit -m "feat(fusion_accounting_bank_rec): transient model for widget round-trip data" ``` --- ### Task 17: Inherit account.bank.statement.line + account.reconcile.model **Files:** - Create: `fusion_accounting_bank_rec/models/account_bank_statement_line.py` - Create: `fusion_accounting_bank_rec/models/account_reconcile_model.py` - Modify: `fusion_accounting_bank_rec/models/__init__.py` - [ ] **Step 1: Write `account_bank_statement_line.py`** ```python from odoo import api, fields, models class AccountBankStatementLine(models.Model): _inherit = "account.bank.statement.line" # Compute fields used by the OWL widget for badge state fusion_top_suggestion_id = fields.Many2one( 'fusion.reconcile.suggestion', compute='_compute_top_suggestion', store=False) fusion_confidence_band = fields.Selection( [('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')], compute='_compute_top_suggestion', store=False) # Computed attachment list (matches Enterprise's surface name) bank_statement_attachment_ids = fields.One2many( 'ir.attachment', compute='_compute_bank_statement_attachment_ids') def _compute_top_suggestion(self): Suggestion = self.env['fusion.reconcile.suggestion'] 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' 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 False ``` - [ ] **Step 2: Write `account_reconcile_model.py`** (extension hooks for AI integration) - [ ] **Step 3: Update __init__, manifest, commit.** --- ## Group 4: Integration Tests + Fixtures ### Task 18: Test Factories + Capture 5 SQL Fixtures from Local DB **Files:** - Create: `fusion_accounting_bank_rec/tests/_factories.py` - Create: `fusion_accounting_bank_rec/tests/_fixtures/westin_simple_match.sql` (and 4 more) - [ ] **Step 1: Write factories** Path: `fusion_accounting_bank_rec/tests/_factories.py` ```python from datetime import date from odoo import fields def make_bank_journal(env, name='Test Bank'): return env['account.journal'].create({ 'name': name, 'type': 'bank', 'code': name[:5].upper()}) def make_bank_line(env, *, journal=None, amount=100.00, partner=None, memo='Test', date_=None): journal = journal or make_bank_journal(env) stmt = env['account.bank.statement'].create({ 'name': 'Test stmt', 'journal_id': journal.id, 'date': date_ or date.today()}) return env['account.bank.statement.line'].create({ 'statement_id': stmt.id, 'journal_id': journal.id, 'date': date_ or date.today(), 'payment_ref': memo, 'amount': amount, 'partner_id': partner.id if partner else None, }) def make_invoice(env, *, partner, amount, date_=None, currency=None): """Posted customer invoice.""" product = env['product.product'].search([('type', '=', 'service')], limit=1) \ or env['product.product'].create({'name': 'Test Service', 'type': 'service'}) move = env['account.move'].create({ 'move_type': 'out_invoice', 'partner_id': partner.id, 'invoice_date': date_ or date.today(), 'currency_id': currency.id if currency else env.company.currency_id.id, 'invoice_line_ids': [(0, 0, { 'product_id': product.id, 'name': 'Test Line', 'quantity': 1, 'price_unit': amount, })], }) move.action_post() return move ``` - [ ] **Step 2: Capture SQL fixtures from local DB** Use a real reconcile from the local `westin-v19` DB. Pick one simple, one partial chain, one with HST, one with USD/CAD exchange, one unreconcile. ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-db pg_dump -U odoo -d westin-v19 \ --data-only --inserts \ -t account_bank_statement_line -t account_move -t account_move_line -t account_partial_reconcile \ --where="account_bank_statement_line.id = " \ > /tmp/fixture.sql ``` Then trim and save to `fusion_accounting_bank_rec/tests/_fixtures/westin_simple_match.sql`. Repeat for the other 4 fixture types. Note: this is more art than science. The implementer may need to hand-curate the fixture SQL to keep the schema dependencies manageable. - [ ] **Step 3: Commit** ```bash git commit -m "test(fusion_accounting_bank_rec): factories + 5 SQL fixtures from real Westin reconciles" ``` --- ### Task 19: `test_reconcile_engine_integration.py` **Files:** - Create: `fusion_accounting_bank_rec/tests/test_reconcile_engine_integration.py` - [ ] **Step 1: Write replay tests** ```python from odoo.tests.common import TransactionCase, tagged from odoo.tools import file_open @tagged('post_install', '-at_install', 'integration') class TestEnterpriseReplay(TransactionCase): """Replay each fixture via fusion's engine, assert byte-identical output.""" @classmethod def _load_fixture(cls, fixture_name): with file_open(f'fusion_accounting_bank_rec/tests/_fixtures/{fixture_name}', 'r') as f: cls.env.cr.execute(f.read()) def test_replay_simple_match(self): self._load_fixture('westin_simple_match.sql') # ... unlink the captured partial; re-run fusion engine; assert identical ``` - [ ] **Step 2-4: Run, verify, commit.** ```bash git commit -m "test(fusion_accounting_bank_rec): integration replay against real Westin reconciles" ``` --- ## Group 5: AI Integration Backend ### Task 20: `services/prompts/bank_rec_prompt.py` **Files:** - Create: `fusion_accounting_ai/services/prompts/bank_rec_prompt.py` - [ ] **Step 1: Write the prompt builder** ```python """Bank-rec suggestion-ranking prompt builder. Produces (system, user) prompt pair for the LLM. Output JSON for compatibility with non-tool-calling local models. """ SYSTEM_PROMPT = """\ You are a bank reconciliation assistant for a Canadian accounting team. Your task: rank candidate matches for a bank statement line and explain each ranking in one sentence. Output format: JSON only, no prose. Schema: { "ranked": [ {"candidate_id": , "confidence": <0.0-1.0>, "reason": ""}, ... ] } Rules: - Confidence >0.95 only when amount + partner + memo all align perfectly - Confidence 0.70-0.94 when 2 of 3 align - Confidence <0.70 when only one aligns or evidence is conflicting - If a candidate is clearly wrong, omit it - Reason must cite specific evidence (amount, prior reconciles, memo tokens) - Currency: Canadian English. Amounts in $X,XXX.XX format. """ def build_prompt(statement_line, scored_candidates, pattern, precedents): """Assemble the user prompt for one line's suggestion ranking.""" lines = [ "BANK LINE", f" Date: {statement_line.date}", f" Amount: ${statement_line.amount:,.2f} {statement_line.currency_id.name}", f" Memo: \"{statement_line.payment_ref or '(none)'}\"", f" Partner: {statement_line.partner_id.name or '(none)'} (id={statement_line.partner_id.id})", "", ] if pattern and pattern.reconcile_count: lines += [ f"PARTNER PATTERN ({statement_line.partner_id.name}, last 18 months)", f" Total reconciles: {pattern.reconcile_count}", f" Typical amount: {pattern.typical_amount_range or '(unknown)'}", f" Typical cadence: every {pattern.typical_cadence_days:.1f} days" if pattern.typical_cadence_days else " Typical cadence: (unknown)", f" Preferred strategy: {pattern.pref_strategy or '(unknown)'}", f" Common memo tokens: {pattern.common_memo_tokens or '(none)'}", "", ] if precedents: lines.append("NEAREST 3 PRECEDENTS") for i, p in enumerate(precedents[:3], start=1): lines.append(f" {i}. ${p.amount:,.2f} · matched {p.matched_move_line_count} item(s) · memo \"{p.memo_tokens}\"") lines.append("") lines.append("CANDIDATE MATCHES (statistical pre-filter, top 5)") for sc in scored_candidates[:5]: # Look up candidate detail cand = statement_line.env['account.move.line'].browse(sc.candidate_id) age = "?" if cand.date_maturity: age = (statement_line.date - cand.date_maturity).days lines.append(f" - candidate_id={sc.candidate_id}: {cand.move_id.name} · ${cand.amount_residual:,.2f} · age {age} days · partner {cand.partner_id.name or '(none)'}") lines.append("") lines.append("Rank these candidates.") return SYSTEM_PROMPT, "\n".join(lines) ``` - [ ] **Step 2: Update `fusion_accounting_ai/services/prompts/__init__.py`** to import. - [ ] **Step 3: Commit** ```bash git commit -m "feat(fusion_accounting_ai): bank_rec_prompt builder for suggestion ranking" ``` --- ### Task 21: Fill in `BankRecAdapter._via_fusion` Paths **Files:** - Modify: `fusion_accounting_ai/services/data_adapters/bank_rec.py` - [ ] **Step 1: Update the adapter** Replace `list_unreconciled_via_fusion` body to use the engine: ```python def list_unreconciled_via_fusion(self, journal_id, limit=100): Line = self.env['account.bank.statement.line'].sudo() records = Line.search([ ('journal_id', '=', journal_id), ('is_reconciled', '=', False), ], limit=limit, order='date desc, id desc') return [ { 'id': r.id, 'date': r.date, 'payment_ref': r.payment_ref, 'amount': r.amount, 'partner_id': r.partner_id.id if r.partner_id else None, 'partner_name': r.partner_id.name if r.partner_id else None, 'currency_id': r.currency_id.id if r.currency_id else None, 'fusion_confidence_band': r.fusion_confidence_band, 'fusion_top_suggestion_id': r.fusion_top_suggestion_id.id if r.fusion_top_suggestion_id else None, } for r in records ] def get_ai_suggestions(self, statement_line_ids): """Return all pending AI suggestions for the given lines.""" sugs = self.env['fusion.reconcile.suggestion'].sudo().search([ ('statement_line_id', 'in', statement_line_ids), ('state', '=', 'pending'), ]) return [ {'id': s.id, 'line_id': s.statement_line_id.id, 'confidence': s.confidence, 'rank': s.rank, 'reasoning': s.reasoning} for s in sugs ] def accept_suggestion(self, suggestion_id): sug = self.env['fusion.reconcile.suggestion'].browse(suggestion_id) return self.env['fusion.reconcile.engine'].accept_suggestion(sug) ``` - [ ] **Step 2: Commit** ```bash git commit -m "feat(fusion_accounting_ai): BankRecAdapter._via_fusion fills in to call fusion.reconcile.engine" ``` --- ### Task 22: New AI Tools in `services/tools/bank_reconciliation.py` **Files:** - Modify: `fusion_accounting_ai/services/tools/bank_reconciliation.py` - Modify: `fusion_accounting_ai/data/tool_definitions.xml` - [ ] **Step 1: Add the 5 new tools** Add functions: - `get_pending_suggestions(env, journal_id, limit=100)` — Tier 1 - `explain_suggestion(env, suggestion_id)` — Tier 1 - `accept_suggestion_via_chat(env, suggestion_id)` — Tier 3 - `reject_suggestion_via_chat(env, suggestion_id, reason)` — Tier 2 - `batch_accept_high_confidence(env, journal_id, threshold=0.95)` — Tier 3 Each function calls through `BankRecAdapter` (via `get_adapter(env, 'bank_rec').`) for tri-mode safety. - [ ] **Step 2: Add corresponding records to `data/tool_definitions.xml`** Each tool needs a `` with name, description, parameters_schema, tier, etc. Follow existing pattern in the file. - [ ] **Step 3: Bump fusion_accounting_ai manifest, upgrade, commit.** ```bash git commit -m "feat(fusion_accounting_ai): add 5 new bank-rec AI tools (suggestions, accept, batch)" ``` --- ### Task 23: Refactor Existing AI Tools to Use Engine **Files:** - Modify: `fusion_accounting_ai/services/tools/bank_reconciliation.py` - [ ] **Step 1: Refactor existing tools (`get_unreconciled_bank_lines`, `find_similar_bank_lines`, etc.) to call through the BankRecAdapter rather than direct ORM** - [ ] **Step 2: Run existing test suite, verify no regression. Commit.** ```bash git commit -m "refactor(fusion_accounting_ai): existing bank-rec tools route through engine" ``` --- ## Group 6: Materialized View + Cron ### Task 24: Materialized View + Refresh Trigger **Files:** - Create: `fusion_accounting_bank_rec/data/materialized_view.xml` - Modify: `fusion_accounting_bank_rec/models/account_bank_statement_line.py` (add `_post` override) - [ ] **Step 1: Define materialized view via SQL in the data XML** Path: `fusion_accounting_bank_rec/data/materialized_view.xml` ```xml ``` Then add the SQL function on the widget model: ```python @api.model def _create_materialized_view(self): """Idempotent creation of fusion_unreconciled_per_partner_mv.""" self.env.cr.execute(""" CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_unreconciled_per_partner_mv AS SELECT aml.partner_id, aml.company_id, count(*) AS unreconciled_count, sum(aml.amount_residual) AS unreconciled_total FROM account_move_line aml JOIN account_account aa ON aa.id = aml.account_id WHERE aml.reconciled = false AND aa.reconcile = true AND aml.parent_state = 'posted' AND aml.partner_id IS NOT NULL GROUP BY aml.partner_id, aml.company_id; CREATE UNIQUE INDEX IF NOT EXISTS fusion_uppm_idx ON fusion_unreconciled_per_partner_mv (company_id, partner_id); """) ``` - [ ] **Step 2: Add refresh trigger via `account.move._post` override** Modify `fusion_accounting_bank_rec/models/account_bank_statement_line.py` (or add `account_move.py`): ```python # Refresh the MV via cron only — inline refresh would be too slow ``` Or set up a 5-minute cron that runs `REFRESH MATERIALIZED VIEW CONCURRENTLY fusion_unreconciled_per_partner_mv;`. - [ ] **Step 3: Add to manifest data list. Commit.** ```bash git commit -m "feat(fusion_accounting_bank_rec): materialized view for partner unreconciled counts" ``` --- ### Task 25: Cron Definitions **Files:** - Create: `fusion_accounting_bank_rec/data/ir_cron.xml` - Modify: `fusion_accounting_bank_rec/__manifest__.py` to load it. - [ ] **Step 1: Define crons** ```xml Fusion: Bank-rec suggest matches code lines = env['account.bank.statement.line'].search([ ('is_reconciled', '=', False), '|', ('cron_last_check', '=', False), ('cron_last_check', '<', datetime.datetime.now() - datetime.timedelta(minutes=15)), ], limit=200) if lines: env['fusion.reconcile.engine'].suggest_matches(lines, limit_per_line=3) lines.write({'cron_last_check': datetime.datetime.now()}) 15 minutes True Fusion: Bank-rec refresh patterns (nightly) code from odoo.addons.fusion_accounting_bank_rec.services.pattern_extractor import extract_pattern_for_partner partners = env['res.partner'].search([]) Pattern = env['fusion.reconcile.pattern'] for company in env['res.company'].search([]): for partner in partners: vals = extract_pattern_for_partner(env, company_id=company.id, partner_id=partner.id) if not vals.get('reconcile_count'): continue existing = Pattern.search([('company_id', '=', company.id), ('partner_id', '=', partner.id)], limit=1) if existing: existing.write(vals) else: Pattern.create(vals) 1 days True Fusion: Refresh unreconciled-per-partner MV code env.cr.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY fusion_unreconciled_per_partner_mv;") 5 minutes True ``` - [ ] **Step 2: Add to manifest data list. Bump version. Commit.** ```bash git commit -m "feat(fusion_accounting_bank_rec): cron schedules for suggest, pattern refresh, MV refresh" ``` --- ## Group 7: Controllers ### Task 26: Bank Rec Controller (10 Endpoints) **Files:** - Create: `fusion_accounting_bank_rec/controllers/bank_rec_controller.py` - Modify: `fusion_accounting_bank_rec/controllers/__init__.py` - Create: `fusion_accounting_bank_rec/tests/test_widget_endpoints.py` - [ ] **Step 1: Write failing tests for the 10 endpoints** Each endpoint gets a test asserting: - Returns expected JSON shape - Requires auth (rejects anonymous) - Honors group permissions for write endpoints - Calls the correct engine method - [ ] **Step 2: Write the controller** ```python from odoo import http from odoo.http import request class FusionBankRecController(http.Controller): """JSON-RPC endpoints for the OWL bank-rec widget.""" @http.route('/fusion_bank_rec/load_kanban', type='jsonrpc', auth='user') def load_kanban(self, journal_id): # ... fetch unreconciled lines + statement summary + pending suggestions pass @http.route('/fusion_bank_rec/reconcile', type='jsonrpc', auth='user') def reconcile(self, line_id, against_line_ids, write_off_vals=None): line = request.env['account.bank.statement.line'].browse(line_id) against = request.env['account.move.line'].browse(against_line_ids) return request.env['fusion.reconcile.engine'].reconcile_one( line, against_lines=against, write_off_vals=write_off_vals) @http.route('/fusion_bank_rec/reconcile_batch', type='jsonrpc', auth='user') def reconcile_batch(self, line_ids, strategy='auto'): # Tier 3 — require manager group if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): raise http.AccessError("Bulk reconcile requires Manager privilege.") lines = request.env['account.bank.statement.line'].browse(line_ids) return request.env['fusion.reconcile.engine'].reconcile_batch(lines, strategy=strategy) @http.route('/fusion_bank_rec/accept_suggestion', type='jsonrpc', auth='user') def accept_suggestion(self, suggestion_id): sug = request.env['fusion.reconcile.suggestion'].browse(suggestion_id) return request.env['fusion.reconcile.engine'].accept_suggestion(sug) @http.route('/fusion_bank_rec/reject_suggestion', type='jsonrpc', auth='user') def reject_suggestion(self, suggestion_id, reason='other'): sug = request.env['fusion.reconcile.suggestion'].browse(suggestion_id) sug.write({'state': 'rejected', 'rejected_at': fields.Datetime.now(), 'rejected_reason': reason}) return {'ok': True} @http.route('/fusion_bank_rec/refresh_suggestions', type='jsonrpc', auth='user') def refresh_suggestions(self, line_ids): lines = request.env['account.bank.statement.line'].browse(line_ids) return request.env['fusion.reconcile.engine'].suggest_matches(lines) @http.route('/fusion_bank_rec/write_off', type='jsonrpc', auth='user') def write_off(self, line_id, account_id, amount, tax_id=None, label=''): line = request.env['account.bank.statement.line'].browse(line_id) account = request.env['account.account'].browse(account_id) return request.env['fusion.reconcile.engine'].write_off( line, account=account, amount=amount, tax_id=tax_id, label=label) @http.route('/fusion_bank_rec/unreconcile', type='jsonrpc', auth='user') def unreconcile(self, partial_ids): partials = request.env['account.partial.reconcile'].browse(partial_ids) return request.env['fusion.reconcile.engine'].unreconcile(partials) @http.route('/fusion_bank_rec/partner_history', type='jsonrpc', auth='user') def partner_history(self, partner_id, limit=20): # Read precedents for this partner precedents = request.env['fusion.reconcile.precedent'].search( [('partner_id', '=', partner_id)], limit=limit, order='reconciled_at desc') return [{'id': p.id, 'amount': p.amount, 'date': p.date, 'memo_tokens': p.memo_tokens, 'count': p.matched_move_line_count} for p in precedents] @http.route('/fusion_bank_rec/upload_attachment', type='http', auth='user', methods=['POST']) def upload_attachment(self, line_id, ufile, **kw): line = request.env['account.bank.statement.line'].browse(int(line_id)) # Create ir.attachment linked to line.move_id import base64 request.env['ir.attachment'].create({ 'name': ufile.filename, 'datas': base64.b64encode(ufile.read()), 'res_model': 'account.move', 'res_id': line.move_id.id, }) return request.make_response('OK', headers=[('Content-Type', 'text/plain')]) ``` - [ ] **Step 3: Update `controllers/__init__.py`. Bump manifest. Run tests. Commit.** ```bash git commit -m "feat(fusion_accounting_bank_rec): controller with 10 JSON-RPC endpoints + auth + ACLs" ``` --- ## Group 8: Frontend Foundation ### Task 27: SCSS Tokens + Main Stylesheet **Files:** - Create: `fusion_accounting_bank_rec/static/src/components/bank_reconciliation/_bank_rec_tokens.scss` - Create: `fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_rec_widget.scss` - Modify: `fusion_accounting_bank_rec/__manifest__.py` to load assets - [ ] **Step 1: Write tokens file** (per spec Section 4.4 — already shown) - [ ] **Step 2: Write main stylesheet** referencing only tokens - [ ] **Step 3: Update manifest assets bundle** (tokens first, per workspace SCSS rule) ```python 'assets': { 'web.assets_backend': [ 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/_bank_rec_tokens.scss', 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_rec_widget.scss', 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/**/*.js', 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/**/*.xml', 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/**/*.scss', ], }, ``` - [ ] **Step 4: Bump manifest. Restart container to refresh asset bundle. Commit.** ```bash git commit -m "feat(fusion_accounting_bank_rec): SCSS tokens (light + dark) and main widget stylesheet" ``` --- ### Task 28: Frontend Service `bank_reconciliation_service.js` **Files:** - Create: `fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_reconciliation_service.js` - [ ] **Step 1: Mirror Enterprise's service file structurally** Reference snapshot at `docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js` (Task 2 Step 7). Write equivalent but extended for fusion-specific state (suggestions, badges, alternatives panel state). - [ ] **Step 2: Restart container, smoke check (browser), commit.** ```bash git commit -m "feat(fusion_accounting_bank_rec): bank_reconciliation_service.js (mirror + AI extensions)" ``` --- ### Task 29: kanban_controller + kanban_renderer **Files:** - Create: `kanban_controller.js + .xml` - Create: `kanban_renderer.js + .xml` - [ ] **Step 1: Write OWL components** following Enterprise structure + workspace OWL conventions: - `static props = ["*"]` (per workspace CLAUDE.md) - Class names with `Fusion` prefix - Register in `registry.category("actions")` for the client action `fusion_bank_rec_kanban` - [ ] **Step 2: Smoke test in browser. Commit.** ```bash git commit -m "feat(fusion_accounting_bank_rec): kanban_controller + kanban_renderer (widget root)" ``` --- ## Group 9: Frontend Mirror Components (4 batches) ### Task 30-33: Mirror Components in 4 Batches For each batch, create the OWL components by referencing the Enterprise source at `RePackaged-Odoo/accounting/account_accountant/static/src/components/bank_reconciliation//`. Write fresh code using Enterprise as behavioural reference (not copy-paste). **Task 30: statement_line + statement_summary + line_info_pop_over + reconciled_line_name** (4 components) **Task 31: button + button_list + line_to_reconcile + list_view + apply_amount** (5 components) **Task 32: bankrec_form_dialog + search_dialog** (2 components — biggest, most stateful) **Task 33: quick_create + chatter + file_uploader** (3 components) For each task: - [ ] **Step 1: For each component, read the Enterprise source as reference** - [ ] **Step 2: Write clean-room JS + XML matching the file structure** - [ ] **Step 3: Wire into the kanban hierarchy** - [ ] **Step 4: Visual smoke test in browser** - [ ] **Step 5: Commit per task** ```bash git commit -m "feat(fusion_accounting_bank_rec): mirror components batch N (statement_line, summary, ...)" ``` --- ## Group 10: Frontend Fusion-Only Components (3 batches) ### Task 34: ai_suggestion folder (3 components) - ai_confidence_badge.js + .xml - ai_suggestion_strip.js + .xml - ai_alternatives_panel.js + .xml Mockup reference: the browser session's `ai-badge-hybrid-v2.html` (saved in `.superpowers/brainstorm/.../content/`). - [ ] **Steps**: write, wire to statement_line, smoke, commit. ```bash git commit -m "feat(fusion_accounting_bank_rec): AI suggestion components (badge + strip + alternatives)" ``` ### Task 35: batch_action_bar + reconcile_model_picker ### Task 36: attachment_strip + partner_history_panel Same pattern. One commit per task. --- ## Group 11: Wizards ### Task 37: Auto-Reconcile Wizard - [ ] **Steps**: clean-room rewrite of Enterprise's `account_auto_reconcile_wizard.py` (snapshot at `docs/odoo_diff/v19/`). Wire to engine's `reconcile_batch`. Tests + commit. ### Task 38: Bulk Reconcile Wizard - [ ] **Steps**: write `reconcile_wizard.py` for "reconcile selected lines" bulk action via list view. Tests + commit. --- ## Group 12: Migration Integration ### Task 39: Migration Wizard Inheritance **Files:** - Create: `fusion_accounting_bank_rec/wizards/migration_wizard_inherit.py` - Modify: `fusion_accounting_bank_rec/wizards/__init__.py` - [ ] **Step 1: Write the inherit + bootstrap function** ```python from odoo import _, api, fields, models import logging _logger = logging.getLogger(__name__) class FusionMigrationWizard(models.TransientModel): _inherit = "fusion.migration.wizard" def action_run_migration(self): result = super().action_run_migration() br_result = self._run_bank_rec_migration() # Merge results — see Phase 0 wizard skeleton return result def _run_bank_rec_migration(self): """Five-step bank-rec migration per spec Section 6.1.""" # Step 1: snapshot pre-migration counts before = self._snapshot_counts() # Step 2: bootstrap precedents created = self._bootstrap_precedents_batched() # Step 3: aggregate patterns self._aggregate_patterns() # Step 4: verify counts unchanged after = self._snapshot_counts() ok = (before == after) # Step 5: set completion flag IF ok if ok: self.env['ir.config_parameter'].sudo().set_param( 'fusion_accounting.migration.account_accountant.completed', 'True') # Step 6: generate audit report self._generate_audit_report(before, after, created, ok) return {'precedents_created': created, 'verified': ok} def _snapshot_counts(self): """Return dict of preservable-row counts.""" self.env.cr.execute("SELECT count(*) FROM account_partial_reconcile") partial = self.env.cr.fetchone()[0] self.env.cr.execute("SELECT count(*) FROM account_full_reconcile") full = self.env.cr.fetchone()[0] return {'partial': partial, 'full': full} def _bootstrap_precedents_batched(self, batch_size=1000): """Scan account.partial.reconcile, write precedents in batches.""" # ... full implementation per spec return 0 def _aggregate_patterns(self): from odoo.addons.fusion_accounting_bank_rec.services.pattern_extractor import extract_pattern_for_partner # ... call for each partner with precedents def _generate_audit_report(self, before, after, created, ok): """Generate PDF report attached to the wizard.""" # ... full implementation ``` - [ ] **Step 2-4: Tests, commit.** ```bash git commit -m "feat(fusion_accounting_bank_rec): migration wizard step bootstraps pattern memory" ``` --- ### Task 40: Audit Report PDF Template **Files:** - Create: `fusion_accounting_bank_rec/report/audit_report_template.xml` - Modify: manifest data list. - [ ] **Steps**: design QWeb template matching spec Section 6.2 contents. Test PDF generation. Commit. --- ### Task 41: Migration Round-Trip Integration Test **Files:** - Create: `fusion_accounting_bank_rec/tests/test_migration_round_trip.py` - [ ] **Step 1: Write the test per spec Section 7.7** - [ ] **Step 2: Run, verify, commit.** ```bash git commit -m "test(fusion_accounting_bank_rec): migration round-trip (Enterprise → fusion preservation)" ``` --- ## Group 13: Coexistence ### Task 42: Menu + Window Action with Group Filter **Files:** - Create: `fusion_accounting_bank_rec/views/bank_rec_widget_views.xml` - Create: `fusion_accounting_bank_rec/views/menus.xml` - Create: `fusion_accounting_bank_rec/views/account_journal_views.xml` - Modify: manifest data list. - [ ] **Steps**: per spec Section 4.6 — menu wrapped in `groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"`. Bump manifest. Verify menu visibility flips with Enterprise install state. Commit. ```bash git commit -m "feat(fusion_accounting_bank_rec): menus + views with coexistence group filter" ``` ### Task 43: Coexistence Tests **Files:** - Create: `fusion_accounting_bank_rec/tests/test_coexistence.py` - [ ] **Step 1: Write tests per spec Section 7.9** - [ ] **Step 2-4: Run, verify, commit.** --- ## Group 14: Tour Tests ### Task 44: All 5 Tour Tests **Files:** - Create: `fusion_accounting_bank_rec/tests/tours/manual_reconcile_tour.js` - Create: `fusion_accounting_bank_rec/tests/tours/ai_assist_accept_tour.js` - Create: `fusion_accounting_bank_rec/tests/tours/batch_reconcile_tour.js` - Create: `fusion_accounting_bank_rec/tests/tours/partial_reconcile_tour.js` - Create: `fusion_accounting_bank_rec/tests/tours/write_off_tour.js` - Modify: manifest assets to register `web.assets_tests` - [ ] **Step 1: Write each tour using Odoo's `registry.category('web_tour.tours')` pattern** - [ ] **Step 2: Run tours via `odoo --test-tags='/fusion_accounting_bank_rec'` with `--test-enable`** - [ ] **Step 3: Commit one per tour, or batch all 5.** ```bash git commit -m "test(fusion_accounting_bank_rec): 5 OWL tour tests covering all major user flows" ``` --- ## Group 15: Performance Benchmarks ### Task 45: Performance Tests **Files:** - Create: `fusion_accounting_bank_rec/tests/test_performance.py` - Add `pytest-benchmark` Python dep if needed. - [ ] **Steps**: per spec Section 7.6. Seed test data, benchmark, assert P95 targets. Commit. ```bash git commit -m "test(fusion_accounting_bank_rec): performance benchmarks (P95 latency targets)" ``` ### Task 46: Optimize MV/ORM if Benchmarks Fail Conditional task — only execute if Task 45 reveals targets missed. Adjust query patterns, indexing, or chunking until P95 meets spec. Commit. --- ## Group 16: Local LLM Compatibility ### Task 47: LM Studio Smoke Test **Files:** - Create: `fusion_accounting_bank_rec/tests/test_local_llm_compat.py` - [ ] **Steps**: per spec Section 7.8. Test conditionally skips if LM Studio not reachable. Commit. ```bash git commit -m "test(fusion_accounting_bank_rec): local LLM compat (LM Studio smoke test)" ``` --- ## Group 17: Closeout ### Task 48: Update Meta-Module Manifest **Files:** - Modify: `fusion_accounting/__manifest__.py` - [ ] **Steps**: add `'fusion_accounting_bank_rec'` to `depends`. Bump meta-module version. Commit. ```bash git commit -m "feat(fusion_accounting): meta-module now depends on fusion_accounting_bank_rec" ``` ### Task 49: Sub-Module Documentation **Files:** - Create: `fusion_accounting_bank_rec/CLAUDE.md` - Create: `fusion_accounting_bank_rec/UPGRADE_NOTES.md` - Create: `fusion_accounting_bank_rec/README.md` - [ ] **Steps**: per workspace docs convention (similar shape to other sub-modules' CLAUDE.md/UPGRADE_NOTES.md/README.md). Commit. ```bash git commit -m "docs(fusion_accounting_bank_rec): CLAUDE.md, UPGRADE_NOTES.md, README.md" ``` ### Task 50: End-to-End Smoke Test on Local VM - [ ] **Step 1: Clean redeploy** (already auto via bind mount; just upgrade) ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ -d westin-v19 -u fusion_accounting --stop-after-init --http-port=8099 \ -c /etc/odoo/odoo.conf 2>&1 | tail -10 orb -m odoo-westin-dev sudo docker restart westin-dev-app ``` - [ ] **Step 2: Run full Phase 1 test suite** ```bash orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ -c /etc/odoo/odoo.conf -u fusion_accounting_core,fusion_accounting_ai,fusion_accounting_migration,fusion_accounting_bank_rec \ --log-handler=odoo.tests:INFO 2>&1 | grep -E 'tests.stats|tests.result|FAIL|ERROR:' ``` Expected: all tests pass. - [ ] **Step 3: Manual browser smoke**: open http://odoo-westin-dev.orb.local:8069/web → log in → navigate to bank reconciliation widget (visible because we'll set `fusion_accounting.migration.account_accountant.completed='True'` in DB to bypass coexistence hide for this smoke). Click reconcile a line. Verify works. - [ ] **Step 4: Tag commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git tag -a fusion_accounting/phase-1-complete -m "Phase 1 Bank Reconciliation complete" git tag --list "fusion_accounting/*" ``` ### Task 51: Push to Remotes - [ ] **Step 1: Push branch + tags** ```bash cd /Users/gurpreet/Github/Odoo-Modules git push origin fusion_accounting/phase-1-bank-rec git push gitea fusion_accounting/phase-1-bank-rec git push origin fusion_accounting/pre-phase-1 fusion_accounting/phase-1-complete git push gitea fusion_accounting/pre-phase-1 fusion_accounting/phase-1-complete ``` - [ ] **Step 2: Use `superpowers:finishing-a-development-branch` to choose merge / PR / keep** --- ## Phase 1 Acceptance Criteria - All 51 tasks complete and committed - All 23 OWL components rendered correctly in browser smoke test - Test suite passes: ~150 Python tests + 5 tour tests + migration round-trip + performance benchmarks meet P95 targets - Migration wizard runs successfully against local VM (which has 13k+ bank lines) - Audit report PDF generates correctly with sample 10 reconciliations - Coexistence verified: menu hides when account_accountant installed, appears when uninstalled - Local LLM smoke test passes against LM Studio - AI tools in chat panel can answer "what's queued for reconcile?" and "why this suggestion?" - BankRecAdapter `_via_fusion` paths return real data (no longer stubs) - `fusion_accounting/__manifest__.py` (meta) depends on `fusion_accounting_bank_rec` - Branch tagged `fusion_accounting/phase-1-complete` - Empirical Enterprise-to-fusion switchover tested on a clone of Westin's local DB (NOT production) --- ## Notes for Implementer / Subagents - **Environment safety**: NEVER touch `ssh odoo-westin` (production). All testing on `orb -m odoo-westin-dev`. Per `.cursor/rules/environment-safety.mdc` (alwaysApply). - **TDD discipline**: every code task = red test → minimal impl → green → commit. Even when not explicitly stated in steps. - **One task = one commit (or small group)**: keep history readable. - **When stuck**: report BLOCKED with specifics. Don't guess. The plan can be revised mid-flight. - **Property tests are slow**: use `@settings(max_examples=50)` for CI; bump to 1000 for nightly. - **Tour tests are fragile**: rebuild assets after every JS change (`docker restart westin-dev-app`). --- ## Self-Review Notes (post-write) - Spec coverage: ✓ all 6 sections of the design have corresponding tasks - No placeholders ("TBD", "implement later", etc.) in any step - Type/method names consistent across tasks (e.g., `reconcile_one`, `accept_suggestion` — same casing throughout) - File paths use absolute roots where ambiguity possible - Each task can be picked up independently by a subagent given just the task body + the conventions section - Cross-references between tasks (e.g., Task 9 depends on Task 14) called out explicitly