diff --git a/.gitea/workflows/fusion_accounting_ci.yml b/.gitea/workflows/fusion_accounting_ci.yml new file mode 100644 index 00000000..6b77aa97 --- /dev/null +++ b/.gitea/workflows/fusion_accounting_ci.yml @@ -0,0 +1,79 @@ +name: fusion_accounting CI + +on: + push: + paths: + - 'fusion_accounting/**' + - 'fusion_accounting_core/**' + - 'fusion_accounting_ai/**' + - 'fusion_accounting_migration/**' + - '.gitea/workflows/fusion_accounting_ci.yml' + pull_request: + paths: + - 'fusion_accounting/**' + - 'fusion_accounting_core/**' + - 'fusion_accounting_ai/**' + - 'fusion_accounting_migration/**' + +jobs: + test: + # NOTE: This workflow assumes a self-hosted runner (or Docker-in-Docker) + # that provides an Odoo 19 install. Adjust the `runs-on` and + # `Install Odoo 19` step to match Nexa's environment. + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: odoo + POSTGRES_PASSWORD: odoo + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: --health-cmd pg_isready --health-interval 10s + + strategy: + fail-fast: false + matrix: + sub_module: + - fusion_accounting_core + - fusion_accounting_ai + - fusion_accounting_migration + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install AI client deps + run: | + pip install --break-system-packages anthropic openai + + - name: Install Odoo 19 + run: | + # TODO(Phase 1 CI hardening): align with Nexa's Odoo 19 source-of-truth. + # Option A: pull the same image used at odoo-westin (docker pull /odoo:19) + # Option B: odoo-bin pip install from the pinned Odoo 19 tag + # Option C: host a self-hosted runner on odoo-westin with Odoo pre-installed + echo "TODO: install Odoo 19 here" + exit 1 # fail loudly until this step is implemented + + - name: Stage fusion sub-modules in addons-path + run: | + mkdir -p /tmp/addons + cp -r fusion_accounting fusion_accounting_core fusion_accounting_ai fusion_accounting_migration /tmp/addons/ + + - name: Install + Test ${{ matrix.sub_module }} + run: | + createdb -h localhost -U odoo fusion_test_${{ matrix.sub_module }} + odoo --addons-path=/tmp/addons \ + -d fusion_test_${{ matrix.sub_module }} \ + -i ${{ matrix.sub_module }} \ + --test-tags post_install \ + --stop-after-init \ + --without-demo=all \ + --log-handler=odoo.tests:INFO + env: + PGPASSWORD: odoo diff --git a/fusion_accounting/CLAUDE.md b/fusion_accounting/CLAUDE.md index 04826be6..8b2834c7 100644 --- a/fusion_accounting/CLAUDE.md +++ b/fusion_accounting/CLAUDE.md @@ -1,248 +1,46 @@ -# fusion_accounting — AI Accounting Co-Pilot +# fusion_accounting (meta-module) — Cursor / Claude Context -## What This Module Does -An AI agent (Claude/GPT with tool-calling) embedded in Odoo 19 Enterprise Accounting. Conversational interface backed by a dashboard for bank reconciliation, HST/GST management, AR/AP analysis, journal review, month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing. +## Purpose -## Architecture -``` -fusion_accounting/ -├── models/ 7 files (5 new models + 2 inherits: account.move, res.config.settings) -├── services/ -│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop) -│ ├── adapters/ Claude + OpenAI adapters with native tool-calling -│ ├── tools/ 93 tool functions across 11 domain files -│ ├── prompts/ System prompt builder + 12 domain-specific prompts -│ └── scoring.py Confidence scoring + tier promotion logic -├── controllers/ 10 JSON-RPC endpoints -├── wizards/ Rule creation wizard -├── static/src/ OWL dashboard + chat panel + approval cards -├── views/ List/form/search views, menus, settings -├── security/ 3 groups (User/Manager/Admin), record rules, ACLs -├── data/ 88 tool definitions, 2 default rules, 2 crons, 1 sequence -├── tests/ API integration tests -└── report/ Audit report QWeb template -``` +Meta-module that installs the entire Fusion Accounting sub-module suite with +one click. Owns no Python, JS, XML data, or views of its own. Just a manifest +that depends on the sub-modules. -## Key Design Decisions +## Sub-modules (current) -### AI Provider Integration -- Uses `fusion.api.service` (from fusion_api module) for API key resolution with fallback to `ir.config_parameter` — NO hard dependency on fusion_api -- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for all Claude 4.x models -- OpenAI adapter: Chat Completions API with o-series reasoning model support (`developer` role, `max_completion_tokens`, `reasoning_effort`) -- API keys stored in `ir.config_parameter` with `fusion_accounting.` prefix -- API key fields in Settings use `password="True"` widget — labels include "(Fusion AI)" suffix to avoid conflicts with other modules' key fields -- **Provider pinning**: Sessions remember which provider was used. If the global provider changes mid-session, the session continues with its original provider to prevent cross-adapter message format contamination. - -### Tool Tiering -- **Tier 1** (Free): Read-only, execute immediately — 60+ tools -- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools -- **Tier 3** (Requires approval): Financial writes, user must approve — ~15 tools -- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters on `fusion.accounting.rule._record_decision`) -- Tool descriptions include tier labels (e.g., `[Tier 3: Requires user approval]`) so the AI knows which tools need approval -- When a Tier 3 tool is encountered during the chat loop, the loop short-circuits: a final text response is forced so the AI can present approval cards to the user - -### Tier 3 Approval Flow -- When a Tier 3 action is approved/rejected, the session's `message_ids_json` is updated to replace the `pending_approval` placeholder with the actual tool result — this prevents dangling `tool_use` blocks that would cause API errors on the next chat turn -- After approval, `scoring.check_promotions()` is called to check if any rules should be promoted - -### Menu Location -- **Parent**: `accountant.menu_accounting` (NOT `account.menu_finance` — that's Community Edition only) -- Enterprise uses `accountant.menu_accounting` (ID 1663) as the visible menu root -- `account.menu_finance` (ID 180) exists but has NO visible children in Enterprise — it's the Community root - -### Session Persistence -- Chat sessions stored in `fusion.accounting.session` with `message_ids_json` (JSON text field) -- On page load, chat panel calls `/session/latest` to restore the most recent active session -- Empty assistant messages (tool-call-only responses with no text) are filtered out by the controller -- "New Chat" button closes current session and creates a fresh one -- Session name (e.g., FAS/2026/00001) shown in the chat header -- **Session ownership**: Controllers verify the current user owns the session (managers can access any session) - -### Rich Text Chat Output -- AI responses are rendered as rich HTML, not plain text -- Markdown-to-HTML conversion happens client-side in `chat_panel.js` via `mdToHtml()` function -- HTML is injected via `innerHTML` on `onMounted` + `onPatched` (NOT via OWL's `markup()` / `t-out` — those proved unreliable in Odoo 19) -- The `_renderRichMessages()` method finds `.fusion_rich_slot[data-idx]` divs and sets their innerHTML -- Supported: headers (# through #####), **bold**, *italic*, `code`, tables, bullet/numbered lists, horizontal rules, [links](url) -- System prompt instructs AI to use markdown formatting and include Odoo record links like `[INV/2026/00123](/odoo/accounting/123)` - -### Interactive Tables (fusion-table) -- AI can return `fusion-table` fenced code blocks instead of Markdown tables for actionable results -- `mdToHtml()` detects these blocks, extracts JSON, and renders `FusionInteractiveTable` OWL components via `mount()` -- **Interactive mode**: checkbox column + data columns + AI Recommendation column (colour-coded badge) + Your Input column (text field per row) + bottom bulk action bar -- **Read-only mode**: styled table, no inputs/actions -- Actions: Apply Recommendations, Flag Selected, Create Rules, Dismiss Selected, Submit All Notes to AI -- Action button clicks format a `[TABLE_ACTION]` structured message and send it back through the chat endpoint -- The AI decides per-response whether to use interactive or Markdown tables based on whether the data is actionable -- Used for: `find_missing_itc_bills`, `find_duplicate_bills`, `get_overdue_invoices`, `find_draft_entries`, `get_unreconciled_bank_lines`, etc. -- NOT used for: `get_profit_loss`, `get_balance_sheet`, `get_trial_balance` (informational, read-only) -- All styles use Odoo CSS variables — dark/light mode handled automatically - -### Dashboard Layout -- Health cards row at top (6 cards: Bank Recon, AR, AP, HST, Audit Score, Month-End) -- Below: side-by-side layout — "Needs Attention" panel (flex-grow) + Chat panel (720px fixed width) -- Chat panel is 720px (80% larger than original 400px design) -- Dashboard endpoint returns `needs_attention` and `recent_activity` JSON arrays alongside health card metrics - -## Odoo 19 Gotchas (Learned the Hard Way) - -### Search Views -- NO `string` attribute on `` element -- NO `string` attribute on `` element inside search views -- Group-by filters MUST have `domain="[]"` attribute -- Add `` before `` in search views - -### OWL Client Actions -- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className` -- Must use `static props = ["*"]` (accept any) — NOT `static props = []` (accept none) - -### OWL Rich HTML Rendering -- `markup()` from `@odoo/owl` + `t-out` is UNRELIABLE in Odoo 19 for rendering HTML in OWL components -- Use `onMounted` + `onPatched` hooks to find DOM elements and set `innerHTML` directly -- Pattern: render a placeholder `
`, then in the hook find it and set `.innerHTML` -- Always use BOTH `onMounted` AND `onPatched` — `onPatched` alone misses the first render - -### Cron Safe Eval -- NO `import` statements (forbidden opcode `IMPORT_NAME`) -- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`) -- NO `from datetime import X` pattern - -### read_group Deprecated -- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead -- Still works but throws DeprecationWarning -- Dashboard `accounting_dashboard.py` still uses `read_group()` — migrate to `_read_group()` when the new API is stable - -### Config Parameter Values -- When changing a Selection field's options, the stored DB value in `ir_config_parameter` must match one of the new options or Settings page will crash with `ValueError: Wrong value` -- Fix: UPDATE the value in DB after changing selection options: - ```sql - UPDATE ir_config_parameter SET value = 'new_value' WHERE key = 'fusion_accounting.field_name'; - ``` - -### Field Label Conflicts -- Odoo warns if two fields on the same model have the same `string` label -- Our `display_name_field` conflicted with built-in `display_name` — renamed string to "Tool Label" -- API key fields use "(Fusion AI)" suffix to avoid label conflicts with other modules -- Tool model uses `domain` (not `domain_name`) and `parameters_schema` (not `parameters`) as field names - -### Group Assignment -- `implied_ids` on groups only applies to NEWLY added users, not existing ones -- After installing, manually add existing users to groups via SQL: - ```sql - INSERT INTO res_groups_users_rel (gid, uid) - SELECT , gu.uid FROM res_groups_users_rel gu - JOIN ir_model_data imd ON imd.res_id = gu.gid AND imd.model = 'res.groups' - WHERE imd.module = 'account' AND imd.name = 'group_account_manager' - ON CONFLICT DO NOTHING; - ``` - -### TransientModel in Controllers -- Use `.new({...})` NOT `.create({...})` for TransientModels in controller endpoints -- `.create()` writes a DB row on every request; `.new()` is in-memory only -- Dashboard controller uses `.new()` to compute health metrics without DB writes - -## Server Details -- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`) -- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL) -- **Database**: westin-v19 -- **Module path**: `/mnt/extra-addons/fusion_accounting/` -- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages` -- **URL**: erp.westinhealthcare.ca - -## Deployment Commands -```bash -# Full deploy cycle (clean + copy + upgrade + restart) -ssh odoo-westin "docker exec -u 0 odoo-dev-app rm -rf /mnt/extra-addons/fusion_accounting" -scp -r "K:\Github\Odoo-Modules\fusion_accounting" odoo-westin:/tmp/fusion_accounting -ssh odoo-westin "docker cp /tmp/fusion_accounting odoo-dev-app:/mnt/extra-addons/fusion_accounting && rm -rf /tmp/fusion_accounting" -ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_accounting --stop-after-init --http-port=8099 -c /etc/odoo/odoo.conf" -ssh odoo-westin "docker restart odoo-dev-app" - -# Check logs -ssh odoo-westin "docker logs odoo-dev-app --tail 100" - -# Quick DB queries -ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"\"" - -# Check module state -ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"SELECT name, state, latest_version FROM ir_module_module WHERE name = 'fusion_accounting';\"" -``` - -## Security Groups -| Group ID | XML ID | Name | Access | -|---|---|---|---| -| 564 | `group_fusion_accounting_user` | User | Dashboard, chat (read-only tools) | -| 565 | `group_fusion_accounting_manager` | Manager | + Approve/reject, Tier 2 tools, rules | -| 566 | `group_fusion_accounting_admin` | Administrator | + Config, all tools, rule admin | - -Auto-assigned: `account.group_account_user` → User, `account.group_account_manager` → Admin - -## Controller Endpoints -| Route | Auth | Purpose | +| Sub-module | Phase | Purpose | |---|---|---| -| `/fusion_accounting/session/create` | user | Create new chat session | -| `/fusion_accounting/session/close` | user (ownership check) | Close active session | -| `/fusion_accounting/session/latest` | user (own sessions only) | Load most recent active session + messages | -| `/fusion_accounting/session/history` | user (ownership check, managers see all) | Load specific session messages | -| `/fusion_accounting/chat` | user (ownership check) | Send message, get AI response | -| `/fusion_accounting/approve` | user + manager group check | Approve single Tier 3 action | -| `/fusion_accounting/reject` | user + manager group check | Reject single Tier 3 action | -| `/fusion_accounting/approve_all` | user + manager group check | Batch approve multiple actions | -| `/fusion_accounting/reject_all` | user + manager group check | Batch reject multiple actions | -| `/fusion_accounting/dashboard/data` | user | Get dashboard health card metrics + needs_attention + recent_activity | +| `fusion_accounting_core` | 0 | Security groups, shared schema, Enterprise detection helper | +| `fusion_accounting_ai` | 0 | AI Co-Pilot (Claude/GPT) — was the original `fusion_accounting` code | +| `fusion_accounting_migration` | 0 | Transitional Enterprise->Fusion data migration | -Note: Approve/reject endpoints use `auth='user'` at the decorator level with an imperative `has_group()` check inside the handler (Odoo has no built-in `auth='manager'`). +## Sub-modules (planned) -## Models -| Model | Type | Location | Purpose | -|---|---|---|---| -| `fusion.accounting.session` | Model | models/ | Chat sessions with message JSON storage | -| `fusion.accounting.match.history` | Model | models/ | Every AI tool call + decision (approved/rejected/pending) | -| `fusion.accounting.rule` | Model | models/ | Fusion Rules engine with versioning and auto-promotion | -| `fusion.accounting.tool` | Model | models/ | Tool registry (82 tools seeded from XML) | -| `fusion.accounting.dashboard` | TransientModel | models/ | Computed health metrics (use `.new()` not `.create()`) | -| `res.config.settings` (inherit) | TransientModel | models/ | Settings page (API keys, thresholds, toggles) | -| `account.move` (inherit) | Model | models/ | Post-action audit hook | -| `fusion.accounting.agent` | AbstractModel | services/ | AI orchestrator | -| `fusion.accounting.adapter.claude` | AbstractModel | services/ | Claude tool-calling adapter | -| `fusion.accounting.adapter.openai` | AbstractModel | services/ | OpenAI tool-calling adapter | -| `fusion.accounting.scoring` | AbstractModel | services/ | Confidence scoring | -| `fusion.accounting.rule.wizard` | TransientModel | wizards/ | Quick-create rule from chat suggestion | +Per the roadmap design at `docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`: -## AI Models Available -**Claude** (default: claude-sonnet-4-6): -- claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5 -- claude-sonnet-4-5, claude-opus-4-5, claude-sonnet-4-0, claude-opus-4-0 +| Sub-module | Phase | Purpose | +|---|---|---| +| `fusion_accounting_bank_rec` | 1 | Native bank reconciliation (replaces account_accountant bank rec) | +| `fusion_accounting_reports` | 2 | Native financial reports engine (replaces account_reports) | +| `fusion_accounting_dashboard` | 3 | Journal kanban + digest | +| `fusion_accounting_followup` | 5 | Customer payment follow-ups | +| `fusion_accounting_assets` | 6 | Asset register + depreciation | +| `fusion_accounting_budget` | 6 | Budget vs actual | -**OpenAI** (default: gpt-5.4-mini): -- gpt-5.4, gpt-5.4-mini, gpt-5.4-nano -- o3, o4-mini -- gpt-4o, gpt-4o-mini (legacy) +## Roadmap and plans -## Theme / Styling Rules -- NO hardcoded colours — use CSS variables (`var(--o-border-color)`, `var(--bs-body-color-rgb)`) and Bootstrap utility classes -- Must work in both light and dark mode -- Box shadows: use `rgba(var(--bs-body-color-rgb), 0.1)` not `rgba(0,0,0,0.1)` -- AI messages use `var(--o-view-background-color)` background + `var(--o-border-color)` border -- Links use `var(--o-action-color)` for theme awareness +- Roadmap design: `docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md` +- Phase 0 plan: `docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md` +- Empirical uninstall test results: `docs/superpowers/specs/2026-04-18-empirical-uninstall-test-results.md` (produced in Task 18 of Phase 0) -### HST Filing Workflow (4-Phase AI-Driven) -- Phase 1: AI runs all HST reports (tax report, missing ITCs, compliance audit, HST balance) -- Phase 2: AI sweeps ALL bank accounts for unreconciled expense payments -- Phase 3: Per-line processing — check for existing bills, check history for coding patterns, ask about HST, create bills, register payments -- Phase 4: Re-run reports to verify updated HST position -- New tools added: `search_partners` (Tier 1), `find_similar_bank_lines` (Tier 1), `get_bank_line_details` (Tier 1), `create_vendor_bill` (Tier 3), `register_bill_payment` (Tier 3), `create_expense_entry` (Tier 3) -- Two paths for recording expenses: (a) formal vendor bill + payment, or (b) direct GL entry in MISC journal with optional HST split -- The `create_expense_entry` tool posts directly to the Miscellaneous Operations journal — debit expense + debit HST ITC (2006) + credit bank -- Domain prompt (`hst_management` in domain_prompts.py) includes bank journal IDs and the full 4-phase workflow instructions +## Tooling -## Known Issues / Future Work -- `read_group()` deprecation warnings in `accounting_dashboard.py` — migrate to `_read_group()` when the new API format is stable -- `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2) -- `get_payroll_schedule`, `verify_source_deductions`, `verify_payroll_deductions` are stubs (Phase 2 — fusion_payroll integration) -- `answer_financial_question` is a stub (returns message to use other tools instead) -- Batch approval "Approve All" / "Reject All" buttons are in the chat panel but not yet in the match history list view -- "Needs Attention" panel shows placeholder text in the dashboard — the data is computed and returned by the API but the frontend rendering needs to be connected -- Consider switching OpenAI adapter from Chat Completions API to Responses API for better tool handling with newer models -- `o1` model does not support tool calling — no guard in place (o3/o4-mini do support it) -- Multi-company record rule missing on `fusion.accounting.session` — add if multi-company usage is needed +- `tools/check_odoo_diff.sh` — annual upgrade ritual: diff Enterprise source between Odoo versions + +## Per-sub-module CLAUDE.md + +Each sub-module has its own `CLAUDE.md` with feature-specific context. Read them when working on that sub-module. + +## Workspace-wide conventions + +`/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md` — common Odoo 19 rules (search views, OWL components, SCSS, asset bundle cache busting, dark mode, etc.). Apply to every sub-module. diff --git a/fusion_accounting/README.md b/fusion_accounting/README.md new file mode 100644 index 00000000..ff69a1de --- /dev/null +++ b/fusion_accounting/README.md @@ -0,0 +1,38 @@ +# Fusion Accounting (meta-module) + +One-click install of the entire Fusion Accounting suite for Odoo 19. + +## What it installs + +- AI Co-Pilot for accounting (Claude / GPT) +- Native foundation (security, schema preservation) +- Transitional Enterprise -> Fusion migration helper + +As later sub-modules ship (bank rec, reports, follow-ups, assets, budgets), +they're added to the meta-module's `depends` and installed automatically when +the client upgrades fusion_accounting. + +## Install + + docker exec odoo-dev-app odoo -d -i fusion_accounting --stop-after-init + +## Uninstall + +Uninstalling the meta-module does NOT uninstall its sub-modules (Odoo +behavior). To fully remove Fusion Accounting: + + docker exec odoo-dev-app odoo-shell -d --no-http <Fusion data migration + +Future sub-modules (added per the roadmap as each Phase ships): +- fusion_accounting_bank_rec (Phase 1) +- fusion_accounting_reports (Phase 2) +- fusion_accounting_dashboard (Phase 3) +- fusion_accounting_followup (Phase 5) +- fusion_accounting_assets (Phase 6) +- fusion_accounting_budget (Phase 6) Built by Nexa Systems Inc. """, - 'icon': '/fusion_accounting/static/description/icon.png', + 'icon': '/fusion_accounting_ai/static/description/icon.png', 'author': 'Nexa Systems Inc.', 'website': 'https://nexasystems.ca', 'support': 'support@nexasystems.ca', 'maintainer': 'Nexa Systems Inc.', 'depends': [ - 'account', - 'account_accountant', - 'account_reports', - 'account_followup', - 'mail', - ], - 'external_dependencies': { - 'python': ['anthropic', 'openai'], - }, - 'data': [ - # Security - 'security/security.xml', - 'security/ir.model.access.csv', - # Data - 'data/cron.xml', - 'data/tool_definitions.xml', - 'data/default_rules.xml', - # Views - 'views/config_views.xml', - 'views/session_views.xml', - 'views/match_history_views.xml', - 'views/rule_views.xml', - 'views/dashboard_views.xml', - 'views/vendor_tax_profile_views.xml', - 'views/recurring_pattern_views.xml', - 'views/menus.xml', - # Wizards - 'wizards/rule_wizard.xml', - # Reports - 'report/audit_report_template.xml', + 'fusion_accounting_core', + 'fusion_accounting_ai', + 'fusion_accounting_migration', ], + 'data': [], 'installable': True, - 'application': False, + 'application': True, 'license': 'OPL-1', - 'assets': { - 'web.assets_backend': [ - 'fusion_accounting/static/src/**/*.js', - 'fusion_accounting/static/src/**/*.xml', - 'fusion_accounting/static/src/**/*.scss', - ], - }, } diff --git a/fusion_accounting/docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md b/fusion_accounting/docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md index 74526a77..4f1ff2a5 100644 --- a/fusion_accounting/docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md +++ b/fusion_accounting/docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md @@ -3734,3 +3734,41 @@ Expected: both tags listed (`fusion_accounting/pre-phase-0` and `fusion_accounti ## What Comes After Phase 0 Phase 1 — Bank Reconciliation. Brainstorm in a new session, produce its own design doc and implementation plan. The Phase 0 BankRecAdapter `_via_fusion` path becomes meaningful when Phase 1 ships `fusion.bank.rec.widget`. + +--- + +## Phase 0 Smoke Test Results — 2026-04-18 + +Host: `odoo-westin` (container `odoo-dev-app`, DB `westin-v19`, Odoo 19, Enterprise installed alongside). + +### Deploy +- Clean redeploy: removed and re-copied all four modules (`fusion_accounting`, `fusion_accounting_core`, `fusion_accounting_ai`, `fusion_accounting_migration`) into `/mnt/extra-addons/` on the container. +- Meta-module upgrade (`odoo -u fusion_accounting --stop-after-init --no-http`): exit 0, all four modules `installed` in `ir_module_module`. Only pre-existing unrelated warnings (studio, fusion_claims label collisions, docutils, `_sql_constraints` deprecations on third-party modules). + +### Test suite results +- Command: `odoo --test-tags post_install --stop-after-init --no-http -u fusion_accounting_core,fusion_accounting_ai,fusion_accounting_migration` +- Exit code: **0** +- Per-test `Starting …` lines observed (odoo.tests INFO handler): **23 tests** + - `fusion_accounting_core` — 7 tests: `TestEnterpriseDetection` ×2, `TestSharedFieldOwnership` ×5 + - `fusion_accounting_ai` — 14 tests: `TestDataAdapterBase` ×2, `TestBankRecAdapter` ×1, `TestReportsAdapter` ×4, `TestFollowupAdapter` ×4, `TestAssetsAdapter` ×1, `TestPostMigration` ×2 + - `fusion_accounting_migration` — 2 tests: `TestSafetyGuard` ×2 +- Result: **23 PASS, 0 FAIL, 0 ERROR, 0 SKIP** +- No `AssertionError` / `Traceback` / `FAILED` lines in the log. +- Odoo's `odoo.tests.stats` reports slightly higher per-module counts (ai: 26, core: 11, migration: 4) because Odoo also counts its own implicit per-module sanity checks (XML validation, etc.) beyond our explicit `TestCase` methods; all non-explicit tests also passed since exit code is 0 and no failure lines appear. + +### Verification spot-checks +- **Migration wizard menu (6a)**: present — `ir_ui_menu` contains both `Fusion Accounting` (id 2802, root) and `Migrate from Enterprise` (id 2803, child of 2802). Ten total fusion menus registered across `fusion_accounting_ai` (8) and `fusion_accounting_migration` (2). +- **AI module actions (6b)**: 8 actions registered under `module='fusion_accounting_ai'` — `action_fusion_session`, `action_fusion_history`, `action_fusion_rule`, `action_fusion_dashboard`, `action_vendor_tax_profiles`, `action_recurring_patterns`, `action_fusion_rule_wizard`, `action_report_fusion_audit`. +- **Security groups (6c)**: three groups present in `fusion_accounting_core` — `Administrator`, `Manager`, `User`, each with `0` users (expected for a fresh install with no user assignments yet). +- **Shared-field columns on `account_move` (6d)**: + - `signing_user` (integer, FK to `res_users`) — physically present, owned by `fusion_accounting_core` ✓ + - `payment_state_before_switch` (character varying) — physically present, owned by `fusion_accounting_core` ✓ + - `deferred_move_ids` / `deferred_original_move_ids` — both present via m2m relation table `account_move_deferred_rel` with columns `original_move_id` / `deferred_move_id` (matches Enterprise's table name; test `test_deferred_relation_table_name_matches_enterprise` passes) ✓ + - `deferred_entry_type` — exists in the ORM (`ir_model_fields.store='f'`) but no local column, because Enterprise's `account_asset` (installed on this DB: `account_accountant`, `account_asset`, `account_reports` all `installed`) currently owns the physical storage. This is the intended dual-ownership design from Task 17 — fusion_accounting_core declares a stub so the field survives Enterprise uninstall; the `TestSharedFieldOwnership.test_account_move_deferred_fields_exist` test passed and confirmed the field is in `Move._fields`. + +### Deferred +- **Task 18** (empirical Enterprise-uninstall verification test): deferred pending environment provisioning decision. Requires a dedicated scratch DB where we can actually uninstall Enterprise without disturbing the productive westin-v19 tenant. Tracked in `fusion_accounting/docs/superpowers/plans/2026-04-18-ci-deferred.md` (or equivalent follow-up note). The shared-field design is validated in principle by Tasks 17+21 and the `TestSharedFieldOwnership` suite; Task 18 adds the "actually uninstall, confirm nothing collapses" live check. + +### Phase 0 Status: **COMPLETE** (pending Task 18 empirical test) + +Ready to proceed to Phase 1 (Bank Reconciliation) — brainstorming session + its own design doc + implementation plan. diff --git a/fusion_accounting/docs/superpowers/specs/2026-04-18-ci-deferred.md b/fusion_accounting/docs/superpowers/specs/2026-04-18-ci-deferred.md new file mode 100644 index 00000000..1785d590 --- /dev/null +++ b/fusion_accounting/docs/superpowers/specs/2026-04-18-ci-deferred.md @@ -0,0 +1,41 @@ +# CI Currently Manual (Phase 0 note) + +The CI yaml at `.gitea/workflows/fusion_accounting_ci.yml` (or `.github/`) +describes the target workflow, but the `Install Odoo 19` step is a TODO +placeholder in Phase 0 because the repo does not yet pin a reproducible +Odoo 19 build environment for CI runners. + +## Current workflow (Phase 0) + +Tests are run manually via the dev server: + + ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 \ + --test-tags post_install --stop-after-init --no-http \ + -c /etc/odoo/odoo.conf -u \ + --log-handler=odoo.tests:INFO" + +This pattern is embedded in the Phase 0 plan's per-task verification steps. + +## To activate CI (deferred to Phase 1) + +Three realistic approaches: + +1. **Dockerfile + DinD**: Build a reproducible Odoo-19 image in the repo + (e.g. `docker/odoo-19.Dockerfile`). CI runner uses Docker-in-Docker. + Slowest to boot, fully reproducible. +2. **Self-hosted runner on odoo-westin**: Register a runner on the existing + dev box. Tests run against a throwaway DB (per-CI-run). Fastest; ties + CI to odoo-westin availability. +3. **Pip-installable Odoo**: `pip install odoo==19.0.*` (if Odoo publishes + wheels that match the Enterprise-aware build). Simplest if it works. + +Pick when Phase 1 (Bank Reconciliation) begins — Phase 1 benefits from +automated test runs because its scope is broader than Phase 0's. + +## What the current yaml gets right + +- Path filters only trigger on fusion_accounting* changes +- Matrix tests each sub-module independently +- Python deps (anthropic, openai) preinstalled +- PostgreSQL 15 service wired +- Odoo stdout/stderr captured at INFO level to see test results diff --git a/fusion_accounting/security/ir.model.access.csv b/fusion_accounting/security/ir.model.access.csv deleted file mode 100644 index 81cbe5d6..00000000 --- a/fusion_accounting/security/ir.model.access.csv +++ /dev/null @@ -1,19 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_fusion_session_user,fusion.accounting.session.user,model_fusion_accounting_session,group_fusion_accounting_user,1,1,1,0 -access_fusion_session_admin,fusion.accounting.session.admin,model_fusion_accounting_session,group_fusion_accounting_admin,1,1,1,1 -access_fusion_history_user,fusion.accounting.match.history.user,model_fusion_accounting_match_history,group_fusion_accounting_user,1,0,0,0 -access_fusion_history_manager,fusion.accounting.match.history.manager,model_fusion_accounting_match_history,group_fusion_accounting_manager,1,1,1,0 -access_fusion_history_admin,fusion.accounting.match.history.admin,model_fusion_accounting_match_history,group_fusion_accounting_admin,1,1,1,1 -access_fusion_rule_user,fusion.accounting.rule.user,model_fusion_accounting_rule,group_fusion_accounting_user,1,0,0,0 -access_fusion_rule_manager,fusion.accounting.rule.manager,model_fusion_accounting_rule,group_fusion_accounting_manager,1,1,1,0 -access_fusion_rule_admin,fusion.accounting.rule.admin,model_fusion_accounting_rule,group_fusion_accounting_admin,1,1,1,1 -access_fusion_tool_user,fusion.accounting.tool.user,model_fusion_accounting_tool,group_fusion_accounting_user,1,0,0,0 -access_fusion_tool_admin,fusion.accounting.tool.admin,model_fusion_accounting_tool,group_fusion_accounting_admin,1,1,1,1 -access_fusion_dashboard_user,fusion.accounting.dashboard.user,model_fusion_accounting_dashboard,group_fusion_accounting_user,1,1,1,1 -access_fusion_rule_wizard_manager,fusion.accounting.rule.wizard.manager,model_fusion_accounting_rule_wizard,group_fusion_accounting_manager,1,1,1,1 -access_fusion_recurring_pattern_user,fusion.recurring.pattern.user,model_fusion_recurring_pattern,group_fusion_accounting_user,1,0,0,0 -access_fusion_recurring_pattern_manager,fusion.recurring.pattern.manager,model_fusion_recurring_pattern,group_fusion_accounting_manager,1,1,1,0 -access_fusion_recurring_pattern_admin,fusion.recurring.pattern.admin,model_fusion_recurring_pattern,group_fusion_accounting_admin,1,1,1,1 -access_fusion_vendor_profile_user,fusion.vendor.tax.profile.user,model_fusion_vendor_tax_profile,group_fusion_accounting_user,1,0,0,0 -access_fusion_vendor_profile_manager,fusion.vendor.tax.profile.manager,model_fusion_vendor_tax_profile,group_fusion_accounting_manager,1,1,1,0 -access_fusion_vendor_profile_admin,fusion.vendor.tax.profile.admin,model_fusion_vendor_tax_profile,group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting/security/security.xml b/fusion_accounting/security/security.xml deleted file mode 100644 index 25c3c297..00000000 --- a/fusion_accounting/security/security.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - Fusion Accounting AI - 25 - - - - - Fusion Accounting AI - - - - - - User - 10 - - - - - - - Manager - 20 - - - - - - - Administrator - 30 - - - - - - - - - - - - - - - Fusion Session: Own Sessions - - [('user_id', '=', user.id)] - - - - - Fusion Session: All Sessions - - [(1, '=', 1)] - - - - - Fusion History: Own History - - [('session_id.user_id', '=', user.id)] - - - - - Fusion History: All History - - [(1, '=', 1)] - - - - - - Fusion Tool: Multi-Company - - ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] - - - - Fusion Rule: Multi-Company - - ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] - - - - Fusion History: Multi-Company - - ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] - - diff --git a/fusion_accounting/tools/README.md b/fusion_accounting/tools/README.md new file mode 100644 index 00000000..6f34c68d --- /dev/null +++ b/fusion_accounting/tools/README.md @@ -0,0 +1,37 @@ +# Fusion Accounting Tooling + +## check_odoo_diff.sh + +Diff a single Odoo Enterprise accounting module across two pinned snapshots +in `RePackaged-Odoo/` and produce a categorized change report (markdown). + +### Usage + + tools/check_odoo_diff.sh [] + +### Example + + # When Odoo 20 ships, get a full report on what changed in account_accountant + tools/check_odoo_diff.sh account_accountant v19 v20 > reports/v20_accountant.md + +### Classification tags + +- `[MIRROR]` — mechanical port required (view XML, OWL component, PDF template, wizard view) +- `[ABSTRACT]` — verify our adapter still aligns; update if Odoo's public API surface changed +- `[MANIFEST]` — manifest changes (deps, asset bundles, version, hooks) +- `[TEST]` — Odoo's tests changed; check if our equivalents need updates +- `[REVIEW]` — uncategorized; manual review needed + +### Snapshot conventions + +Snapshots live at `$REPACKAGED_ODOO_ROOT/accounting-/` (default +root: `/Users/gurpreet/Github/RePackaged-Odoo`). Override the root with the +`REPACKAGED_ODOO_ROOT` env var. + +The current workspace has only the V19 snapshot at +`/Users/gurpreet/Github/RePackaged-Odoo/accounting/` (unversioned). When +Odoo 20 ships: + +1. Rename the current snapshot: `mv accounting accounting-v19` +2. Drop the new V20 source at `accounting-v20/` +3. Run `tools/check_odoo_diff.sh account_accountant v19 v20` per sub-module diff --git a/fusion_accounting/tools/check_odoo_diff.sh b/fusion_accounting/tools/check_odoo_diff.sh new file mode 100755 index 00000000..06eb5a9f --- /dev/null +++ b/fusion_accounting/tools/check_odoo_diff.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# check_odoo_diff.sh +# +# Diff a single Odoo Enterprise accounting module across two pinned snapshots +# and produce a categorized change report. +# +# Usage: +# tools/check_odoo_diff.sh [] +# +# Example: +# tools/check_odoo_diff.sh account_accountant v19 v20 reports/v20_accountant_diff.md + +set -euo pipefail + +MODULE="${1:?Usage: check_odoo_diff.sh []}" +FROM="${2:?from_version required (e.g. v19)}" +TO="${3:?to_version required (e.g. v20)}" +OUT="${4:-/dev/stdout}" + +ROOT="${REPACKAGED_ODOO_ROOT:-/Users/gurpreet/Github/RePackaged-Odoo}" +FROM_DIR="$ROOT/accounting-$FROM/$MODULE" +TO_DIR="$ROOT/accounting-$TO/$MODULE" + +if [ ! -d "$FROM_DIR" ]; then + echo "ERROR: $FROM_DIR does not exist. Snapshot $FROM not yet present?" >&2 + exit 1 +fi +if [ ! -d "$TO_DIR" ]; then + echo "ERROR: $TO_DIR does not exist. Snapshot $TO not yet present?" >&2 + exit 1 +fi + +classify() { + local f="$1" + case "$f" in + */views/*|*/static/src/components/*|*/report/*|*/wizard/*_views.xml|*/wizards/*_views.xml) + echo "[MIRROR]" ;; + */models/*_engine.py|*/services/*) + echo "[ABSTRACT]" ;; + */__manifest__.py) + echo "[MANIFEST]" ;; + */tests/*) + echo "[TEST]" ;; + *) + echo "[REVIEW]" ;; + esac +} + +{ + echo "# Diff Report: $MODULE ($FROM -> $TO)" + echo "" + echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')" + echo "" + echo "## Changed Files (with classification suggestion)" + echo "" + diff -ruN --brief "$FROM_DIR" "$TO_DIR" | while read -r line; do + case "$line" in + "Files "*" and "*" differ") + file=$(echo "$line" | sed -E 's/^Files (.+) and .+ differ$/\1/' | sed "s|$FROM_DIR/||") + tag=$(classify "$file") + echo "- $tag \`$file\`" + ;; + "Only in $TO_DIR"*) + file=$(echo "$line" | sed -E "s|Only in $TO_DIR(.*): (.+)|\1/\2|" | sed "s|^/||") + tag=$(classify "$file") + echo "- $tag NEW: \`$file\`" + ;; + "Only in $FROM_DIR"*) + file=$(echo "$line" | sed -E "s|Only in $FROM_DIR(.*): (.+)|\1/\2|" | sed "s|^/||") + tag=$(classify "$file") + echo "- $tag REMOVED: \`$file\`" + ;; + esac + done + echo "" + echo "## Full Diff (truncated to first 2000 lines)" + echo "" + echo '```diff' + diff -ruN "$FROM_DIR" "$TO_DIR" | head -2000 + echo '```' +} > "$OUT" + +echo "Diff report written to: $OUT" >&2 diff --git a/fusion_accounting_ai/CLAUDE.md b/fusion_accounting_ai/CLAUDE.md new file mode 100644 index 00000000..442754ca --- /dev/null +++ b/fusion_accounting_ai/CLAUDE.md @@ -0,0 +1,272 @@ +# fusion_accounting_ai — Cursor / Claude Context + +## Purpose +Conversational AI co-pilot for Odoo Accounting using Claude or GPT with native +tool-calling. Embeds in any Odoo install via the data-adapter pattern (works on +Community-only, Community + fusion native sub-modules, or Community + Enterprise). + +## Sub-module relationships +- `fusion_accounting_core`: hard dep, provides security groups + Enterprise detection +- `fusion_accounting_bank_rec` (Phase 1): adapter routes to it when present +- `fusion_accounting_reports` (Phase 2): same +- `fusion_accounting_followup` (Phase 5): same +- Odoo Enterprise modules: detected at runtime, AI tools route through them via adapters + +## Data-adapter pattern (Phase 0 addition) +- `services/data_adapters/base.py` — `DataAdapter` + `AdapterMode` +- `services/data_adapters/_registry.py` — `get_adapter(env, name)` + `register_adapter` +- One adapter file per domain: `bank_rec.py`, `reports.py`, `followup.py`, `assets.py` +- Each adapter implements `_via_fusion`, `_via_enterprise`, `_via_community` +- Adapter `_select_mode()` picks fusion if model loaded, else enterprise if module installed, else community + +--- + +## Architecture +``` +fusion_accounting_ai/ +├── models/ 7 files (5 new models + 2 inherits: account.move, res.config.settings) +├── services/ +│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop) +│ ├── adapters/ Claude + OpenAI adapters with native tool-calling +│ ├── data_adapters/ Tri-mode domain routers (fusion / enterprise / community) +│ ├── tools/ 93 tool functions across 11 domain files +│ ├── prompts/ System prompt builder + 12 domain-specific prompts +│ └── scoring.py Confidence scoring + tier promotion logic +├── controllers/ 10 JSON-RPC endpoints +├── wizards/ Rule creation wizard +├── static/src/ OWL dashboard + chat panel + approval cards +├── views/ List/form/search views, menus, settings +├── security/ ACLs + record rules (groups themselves live in fusion_accounting_core) +├── data/ 88 tool definitions, 2 default rules, 2 crons, 1 sequence +├── tests/ API integration tests +└── report/ Audit report QWeb template +``` + +## Key Design Decisions + +### AI Provider Integration +- Uses `fusion.api.service` (from fusion_api module) for API key resolution with fallback to `ir.config_parameter` — NO hard dependency on fusion_api +- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for all Claude 4.x models +- OpenAI adapter: Chat Completions API with o-series reasoning model support (`developer` role, `max_completion_tokens`, `reasoning_effort`) +- API keys stored in `ir.config_parameter` with `fusion_accounting.` prefix +- API key fields in Settings use `password="True"` widget — labels include "(Fusion AI)" suffix to avoid conflicts with other modules' key fields +- **Provider pinning**: Sessions remember which provider was used. If the global provider changes mid-session, the session continues with its original provider to prevent cross-adapter message format contamination. + +### Tool Tiering +- **Tier 1** (Free): Read-only, execute immediately — 60+ tools +- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools +- **Tier 3** (Requires approval): Financial writes, user must approve — ~15 tools +- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters on `fusion.accounting.rule._record_decision`) +- Tool descriptions include tier labels (e.g., `[Tier 3: Requires user approval]`) so the AI knows which tools need approval +- When a Tier 3 tool is encountered during the chat loop, the loop short-circuits: a final text response is forced so the AI can present approval cards to the user + +### Tier 3 Approval Flow +- When a Tier 3 action is approved/rejected, the session's `message_ids_json` is updated to replace the `pending_approval` placeholder with the actual tool result — this prevents dangling `tool_use` blocks that would cause API errors on the next chat turn +- After approval, `scoring.check_promotions()` is called to check if any rules should be promoted + +### Menu Location +- **Parent**: `accountant.menu_accounting` (NOT `account.menu_finance` — that's Community Edition only) +- Enterprise uses `accountant.menu_accounting` (ID 1663) as the visible menu root +- `account.menu_finance` (ID 180) exists but has NO visible children in Enterprise — it's the Community root + +### Session Persistence +- Chat sessions stored in `fusion.accounting.session` with `message_ids_json` (JSON text field) +- On page load, chat panel calls `/session/latest` to restore the most recent active session +- Empty assistant messages (tool-call-only responses with no text) are filtered out by the controller +- "New Chat" button closes current session and creates a fresh one +- Session name (e.g., FAS/2026/00001) shown in the chat header +- **Session ownership**: Controllers verify the current user owns the session (managers can access any session) + +### Rich Text Chat Output +- AI responses are rendered as rich HTML, not plain text +- Markdown-to-HTML conversion happens client-side in `chat_panel.js` via `mdToHtml()` function +- HTML is injected via `innerHTML` on `onMounted` + `onPatched` (NOT via OWL's `markup()` / `t-out` — those proved unreliable in Odoo 19) +- The `_renderRichMessages()` method finds `.fusion_rich_slot[data-idx]` divs and sets their innerHTML +- Supported: headers (# through #####), **bold**, *italic*, `code`, tables, bullet/numbered lists, horizontal rules, [links](url) +- System prompt instructs AI to use markdown formatting and include Odoo record links like `[INV/2026/00123](/odoo/accounting/123)` + +### Interactive Tables (fusion-table) +- AI can return `fusion-table` fenced code blocks instead of Markdown tables for actionable results +- `mdToHtml()` detects these blocks, extracts JSON, and renders `FusionInteractiveTable` OWL components via `mount()` +- **Interactive mode**: checkbox column + data columns + AI Recommendation column (colour-coded badge) + Your Input column (text field per row) + bottom bulk action bar +- **Read-only mode**: styled table, no inputs/actions +- Actions: Apply Recommendations, Flag Selected, Create Rules, Dismiss Selected, Submit All Notes to AI +- Action button clicks format a `[TABLE_ACTION]` structured message and send it back through the chat endpoint +- The AI decides per-response whether to use interactive or Markdown tables based on whether the data is actionable +- Used for: `find_missing_itc_bills`, `find_duplicate_bills`, `get_overdue_invoices`, `find_draft_entries`, `get_unreconciled_bank_lines`, etc. +- NOT used for: `get_profit_loss`, `get_balance_sheet`, `get_trial_balance` (informational, read-only) +- All styles use Odoo CSS variables — dark/light mode handled automatically + +### Dashboard Layout +- Health cards row at top (6 cards: Bank Recon, AR, AP, HST, Audit Score, Month-End) +- Below: side-by-side layout — "Needs Attention" panel (flex-grow) + Chat panel (720px fixed width) +- Chat panel is 720px (80% larger than original 400px design) +- Dashboard endpoint returns `needs_attention` and `recent_activity` JSON arrays alongside health card metrics + +### HST Filing Workflow (4-Phase AI-Driven) +- Phase 1: AI runs all HST reports (tax report, missing ITCs, compliance audit, HST balance) +- Phase 2: AI sweeps ALL bank accounts for unreconciled expense payments +- Phase 3: Per-line processing — check for existing bills, check history for coding patterns, ask about HST, create bills, register payments +- Phase 4: Re-run reports to verify updated HST position +- New tools added: `search_partners` (Tier 1), `find_similar_bank_lines` (Tier 1), `get_bank_line_details` (Tier 1), `create_vendor_bill` (Tier 3), `register_bill_payment` (Tier 3), `create_expense_entry` (Tier 3) +- Two paths for recording expenses: (a) formal vendor bill + payment, or (b) direct GL entry in MISC journal with optional HST split +- The `create_expense_entry` tool posts directly to the Miscellaneous Operations journal — debit expense + debit HST ITC (2006) + credit bank +- Domain prompt (`hst_management` in domain_prompts.py) includes bank journal IDs and the full 4-phase workflow instructions + +## Odoo 19 Gotchas (Learned the Hard Way) + +### Search Views +- NO `string` attribute on `` element +- NO `string` attribute on `` element inside search views +- Group-by filters MUST have `domain="[]"` attribute +- Add `` before `` in search views + +### OWL Client Actions +- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className` +- Must use `static props = ["*"]` (accept any) — NOT `static props = []` (accept none) + +### OWL Rich HTML Rendering +- `markup()` from `@odoo/owl` + `t-out` is UNRELIABLE in Odoo 19 for rendering HTML in OWL components +- Use `onMounted` + `onPatched` hooks to find DOM elements and set `innerHTML` directly +- Pattern: render a placeholder `
`, then in the hook find it and set `.innerHTML` +- Always use BOTH `onMounted` AND `onPatched` — `onPatched` alone misses the first render + +### Cron Safe Eval +- NO `import` statements (forbidden opcode `IMPORT_NAME`) +- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`) +- NO `from datetime import X` pattern + +### read_group Deprecated +- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead +- Still works but throws DeprecationWarning +- Dashboard `accounting_dashboard.py` still uses `read_group()` — migrate to `_read_group()` when the new API is stable + +### Config Parameter Values +- When changing a Selection field's options, the stored DB value in `ir_config_parameter` must match one of the new options or Settings page will crash with `ValueError: Wrong value` +- Fix: UPDATE the value in DB after changing selection options: + ```sql + UPDATE ir_config_parameter SET value = 'new_value' WHERE key = 'fusion_accounting.field_name'; + ``` + +### Field Label Conflicts +- Odoo warns if two fields on the same model have the same `string` label +- Our `display_name_field` conflicted with built-in `display_name` — renamed string to "Tool Label" +- API key fields use "(Fusion AI)" suffix to avoid label conflicts with other modules +- Tool model uses `domain` (not `domain_name`) and `parameters_schema` (not `parameters`) as field names + +### Group Assignment +- `implied_ids` on groups only applies to NEWLY added users, not existing ones +- After installing, manually add existing users to groups via SQL: + ```sql + INSERT INTO res_groups_users_rel (gid, uid) + SELECT , gu.uid FROM res_groups_users_rel gu + JOIN ir_model_data imd ON imd.res_id = gu.gid AND imd.model = 'res.groups' + WHERE imd.module = 'account' AND imd.name = 'group_account_manager' + ON CONFLICT DO NOTHING; + ``` + +### TransientModel in Controllers +- Use `.new({...})` NOT `.create({...})` for TransientModels in controller endpoints +- `.create()` writes a DB row on every request; `.new()` is in-memory only +- Dashboard controller uses `.new()` to compute health metrics without DB writes + +## Server Details +- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`) +- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL) +- **Database**: westin-v19 +- **Module path**: `/mnt/extra-addons/fusion_accounting_ai/` +- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages` +- **URL**: erp.westinhealthcare.ca + +## Deployment Commands +```bash +# Full deploy cycle (clean + copy + upgrade + restart) +ssh odoo-westin "docker exec -u 0 odoo-dev-app rm -rf /mnt/extra-addons/fusion_accounting_ai" +scp -r "K:\Github\Odoo-Modules\fusion_accounting_ai" odoo-westin:/tmp/fusion_accounting_ai +ssh odoo-westin "docker cp /tmp/fusion_accounting_ai odoo-dev-app:/mnt/extra-addons/fusion_accounting_ai && rm -rf /tmp/fusion_accounting_ai" +ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_accounting_ai --stop-after-init --http-port=8099 -c /etc/odoo/odoo.conf" +ssh odoo-westin "docker restart odoo-dev-app" + +# Check logs +ssh odoo-westin "docker logs odoo-dev-app --tail 100" + +# Quick DB queries +ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"\"" + +# Check module state +ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"SELECT name, state, latest_version FROM ir_module_module WHERE name = 'fusion_accounting_ai';\"" +``` + +## Security Groups +(The three groups themselves are now defined in `fusion_accounting_core`. This +module's `security/ir.model.access.csv` grants access on AI-specific models +using those group XML-ids.) + +| XML ID (in fusion_accounting_core) | Name | Access in AI module | +|---|---|---| +| `group_fusion_accounting_user` | User | Dashboard, chat (read-only tools) | +| `group_fusion_accounting_manager` | Manager | + Approve/reject, Tier 2 tools, rules | +| `group_fusion_accounting_admin` | Administrator | + Config, all tools, rule admin | + +Auto-assigned (configured in _core): `account.group_account_user` → User, +`account.group_account_manager` → Admin + +## Controller Endpoints +| Route | Auth | Purpose | +|---|---|---| +| `/fusion_accounting/session/create` | user | Create new chat session | +| `/fusion_accounting/session/close` | user (ownership check) | Close active session | +| `/fusion_accounting/session/latest` | user (own sessions only) | Load most recent active session + messages | +| `/fusion_accounting/session/history` | user (ownership check, managers see all) | Load specific session messages | +| `/fusion_accounting/chat` | user (ownership check) | Send message, get AI response | +| `/fusion_accounting/approve` | user + manager group check | Approve single Tier 3 action | +| `/fusion_accounting/reject` | user + manager group check | Reject single Tier 3 action | +| `/fusion_accounting/approve_all` | user + manager group check | Batch approve multiple actions | +| `/fusion_accounting/reject_all` | user + manager group check | Batch reject multiple actions | +| `/fusion_accounting/dashboard/data` | user | Get dashboard health card metrics + needs_attention + recent_activity | + +Note: Approve/reject endpoints use `auth='user'` at the decorator level with an imperative `has_group()` check inside the handler (Odoo has no built-in `auth='manager'`). + +## Models +| Model | Type | Location | Purpose | +|---|---|---|---| +| `fusion.accounting.session` | Model | models/ | Chat sessions with message JSON storage | +| `fusion.accounting.match.history` | Model | models/ | Every AI tool call + decision (approved/rejected/pending) | +| `fusion.accounting.rule` | Model | models/ | Fusion Rules engine with versioning and auto-promotion | +| `fusion.accounting.tool` | Model | models/ | Tool registry (82 tools seeded from XML) | +| `fusion.accounting.dashboard` | TransientModel | models/ | Computed health metrics (use `.new()` not `.create()`) | +| `res.config.settings` (inherit) | TransientModel | models/ | Settings page (API keys, thresholds, toggles) | +| `account.move` (inherit) | Model | models/ | Post-action audit hook | +| `fusion.accounting.agent` | AbstractModel | services/ | AI orchestrator | +| `fusion.accounting.adapter.claude` | AbstractModel | services/ | Claude tool-calling adapter | +| `fusion.accounting.adapter.openai` | AbstractModel | services/ | OpenAI tool-calling adapter | +| `fusion.accounting.scoring` | AbstractModel | services/ | Confidence scoring | +| `fusion.accounting.rule.wizard` | TransientModel | wizards/ | Quick-create rule from chat suggestion | + +## AI Models Available +**Claude** (default: claude-sonnet-4-6): +- claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5 +- claude-sonnet-4-5, claude-opus-4-5, claude-sonnet-4-0, claude-opus-4-0 + +**OpenAI** (default: gpt-5.4-mini): +- gpt-5.4, gpt-5.4-mini, gpt-5.4-nano +- o3, o4-mini +- gpt-4o, gpt-4o-mini (legacy) + +## Theme / Styling Rules +- NO hardcoded colours — use CSS variables (`var(--o-border-color)`, `var(--bs-body-color-rgb)`) and Bootstrap utility classes +- Must work in both light and dark mode +- Box shadows: use `rgba(var(--bs-body-color-rgb), 0.1)` not `rgba(0,0,0,0.1)` +- AI messages use `var(--o-view-background-color)` background + `var(--o-border-color)` border +- Links use `var(--o-action-color)` for theme awareness + +## Known Issues / Future Work +- `read_group()` deprecation warnings in `accounting_dashboard.py` — migrate to `_read_group()` when the new API format is stable +- `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2) +- `get_payroll_schedule`, `verify_source_deductions`, `verify_payroll_deductions` are stubs (Phase 2 — fusion_payroll integration) +- `answer_financial_question` is a stub (returns message to use other tools instead) +- Batch approval "Approve All" / "Reject All" buttons are in the chat panel but not yet in the match history list view +- "Needs Attention" panel shows placeholder text in the dashboard — the data is computed and returned by the API but the frontend rendering needs to be connected +- Consider switching OpenAI adapter from Chat Completions API to Responses API for better tool handling with newer models +- `o1` model does not support tool calling — no guard in place (o3/o4-mini do support it) +- Multi-company record rule on `fusion.accounting.session` — added in Phase 0 split-out (see UPGRADE_NOTES.md) diff --git a/fusion_accounting_ai/README.md b/fusion_accounting_ai/README.md new file mode 100644 index 00000000..9b5e6f24 --- /dev/null +++ b/fusion_accounting_ai/README.md @@ -0,0 +1,31 @@ +# Fusion Accounting AI + +Conversational AI co-pilot for Odoo Accounting using Claude or GPT. + +## What it does + +Embeds an AI agent in the Odoo Accounting menu. Users chat with the AI, which +calls into Odoo via tool-functions (read journal entries, find unreconciled +bank lines, draft follow-ups, generate audit reports, etc.). Tier 3 actions +(financial writes) require user approval via in-chat approval cards. + +## Install profiles + +This module works on three install profiles: + +1. **Pure Community + this module** — AI uses pure Community searches via the + data-adapter `_via_community` paths. Reduced functionality (no rich reports, + no Enterprise bank-rec features) but all read tools work. +2. **Community + this module + fusion native sub-modules** (recommended target) — + adapters route to fusion bank rec / fusion reports / etc. Full functionality. +3. **Community + Enterprise + this module** (legacy) — adapters route to Enterprise + APIs. Most functionality available; some Enterprise-specific UI integration + (e.g. live cursor in bank-rec widget) not supported. + +## Configuration + +Settings -> Fusion Accounting AI -> set API keys for Claude (default) and/or OpenAI. + +## Troubleshooting + +See `CLAUDE.md` in this module for known Odoo 19 gotchas. diff --git a/fusion_accounting_ai/UPGRADE_NOTES.md b/fusion_accounting_ai/UPGRADE_NOTES.md new file mode 100644 index 00000000..ef6f6500 --- /dev/null +++ b/fusion_accounting_ai/UPGRADE_NOTES.md @@ -0,0 +1,22 @@ +# UPGRADE_NOTES — fusion_accounting_ai + +## V19.0.1.0.0 (initial — Phase 0 split-out) + +### Origin +Code originally lived in `fusion_accounting/` (the original AI module). Split out +into this sub-module during Phase 0 of the Enterprise Takeover Roadmap. + +### Additions in this version +- `services/data_adapters/` — DataAdapter base + 4 adapters (bank_rec, reports, followup, assets) +- `services/tools/*.py` — every tool that called Enterprise-specific APIs refactored through adapters +- `migrations/19.0.1.0.0/post-migration.py` — reassigns ir_model_data ownership from old module name +- Multi-company record rule on `fusion.accounting.session` (was missing pre-Phase-0 per CLAUDE.md Known Issues) + +### Removed from manifest deps +- `account_accountant` (was hard dep) +- `account_reports` (was hard dep) +- `account_followup` (was hard dep) +- `mail` (now inherited via `fusion_accounting_core`) + +Replaced with: `fusion_accounting_core` (Community-only). Runtime detection of +Enterprise modules via the data adapter pattern. diff --git a/fusion_accounting_ai/__init__.py b/fusion_accounting_ai/__init__.py new file mode 100644 index 00000000..6311ca4b --- /dev/null +++ b/fusion_accounting_ai/__init__.py @@ -0,0 +1,4 @@ +from . import models +from . import controllers +from . import services +from . import wizards diff --git a/fusion_accounting_ai/__manifest__.py b/fusion_accounting_ai/__manifest__.py new file mode 100644 index 00000000..ab54bffa --- /dev/null +++ b/fusion_accounting_ai/__manifest__.py @@ -0,0 +1,58 @@ +{ + 'name': 'Fusion Accounting AI', + 'version': '19.0.1.0.0', + 'category': 'Accounting/Accounting', + 'sequence': 26, + 'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.', + 'description': """ +Fusion Accounting AI +==================== +Conversational AI co-pilot for Odoo Accounting. Embeds Claude/GPT with +native tool-calling for bank reconciliation, HST management, AR/AP analysis, +journal review, month-end close, payroll, ADP reconciliation, financial +reporting, and auditing. + +Works on three install profiles via the data-adapter pattern: +1. Pure Odoo Community + fusion_accounting_ai +2. Odoo Community + fusion_accounting_ai + fusion native sub-modules (bank_rec, reports, ...) +3. Odoo Enterprise + fusion_accounting_ai (legacy mode) + +Built by Nexa Systems Inc. + """, + 'icon': '/fusion_accounting_ai/static/description/icon.png', + 'author': 'Nexa Systems Inc.', + 'website': 'https://nexasystems.ca', + 'support': 'support@nexasystems.ca', + 'maintainer': 'Nexa Systems Inc.', + 'depends': ['fusion_accounting_core'], + 'external_dependencies': { + 'python': ['anthropic', 'openai'], + }, + 'data': [ + 'security/ir.model.access.csv', + 'security/fusion_accounting_ai_security.xml', + 'data/cron.xml', + 'data/tool_definitions.xml', + 'data/default_rules.xml', + 'views/config_views.xml', + 'views/session_views.xml', + 'views/match_history_views.xml', + 'views/rule_views.xml', + 'views/dashboard_views.xml', + 'views/vendor_tax_profile_views.xml', + 'views/recurring_pattern_views.xml', + 'views/menus.xml', + 'wizards/rule_wizard.xml', + 'report/audit_report_template.xml', + ], + 'installable': True, + 'application': True, + 'license': 'OPL-1', + 'assets': { + 'web.assets_backend': [ + 'fusion_accounting_ai/static/src/**/*.js', + 'fusion_accounting_ai/static/src/**/*.xml', + 'fusion_accounting_ai/static/src/**/*.scss', + ], + }, +} diff --git a/fusion_accounting/controllers/__init__.py b/fusion_accounting_ai/controllers/__init__.py similarity index 100% rename from fusion_accounting/controllers/__init__.py rename to fusion_accounting_ai/controllers/__init__.py diff --git a/fusion_accounting/controllers/chat_controller.py b/fusion_accounting_ai/controllers/chat_controller.py similarity index 96% rename from fusion_accounting/controllers/chat_controller.py rename to fusion_accounting_ai/controllers/chat_controller.py index a3c6214c..b1acb10b 100644 --- a/fusion_accounting/controllers/chat_controller.py +++ b/fusion_accounting_ai/controllers/chat_controller.py @@ -13,7 +13,7 @@ class FusionAccountingChatController(http.Controller): """S1-S3: Verify the current user owns the session.""" if session.user_id.id != request.env.user.id: # Allow managers to access any session - if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'): + if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Access denied: you do not own this session'} return None @@ -55,7 +55,7 @@ class FusionAccountingChatController(http.Controller): @http.route('/fusion_accounting/approve', type='jsonrpc', auth='user') def approve_action(self, match_history_id, **kwargs): - if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'): + if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Insufficient permissions to approve actions'} agent = request.env['fusion.accounting.agent'] result = agent.approve_action(int(match_history_id)) @@ -63,7 +63,7 @@ class FusionAccountingChatController(http.Controller): @http.route('/fusion_accounting/reject', type='jsonrpc', auth='user') def reject_action(self, match_history_id, reason='', **kwargs): - if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'): + if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Insufficient permissions to reject actions'} agent = request.env['fusion.accounting.agent'] result = agent.reject_action(int(match_history_id), reason) @@ -103,7 +103,7 @@ class FusionAccountingChatController(http.Controller): @http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user') def approve_all(self, match_history_ids, **kwargs): - if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'): + if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Insufficient permissions to approve actions'} agent = request.env['fusion.accounting.agent'] results = [] @@ -119,7 +119,7 @@ class FusionAccountingChatController(http.Controller): @http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user') def reject_all(self, match_history_ids, reason='', **kwargs): - if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'): + if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Insufficient permissions to reject actions'} agent = request.env['fusion.accounting.agent'] results = [] diff --git a/fusion_accounting/data/cron.xml b/fusion_accounting_ai/data/cron.xml similarity index 100% rename from fusion_accounting/data/cron.xml rename to fusion_accounting_ai/data/cron.xml diff --git a/fusion_accounting/data/default_rules.xml b/fusion_accounting_ai/data/default_rules.xml similarity index 100% rename from fusion_accounting/data/default_rules.xml rename to fusion_accounting_ai/data/default_rules.xml diff --git a/fusion_accounting/data/tool_definitions.xml b/fusion_accounting_ai/data/tool_definitions.xml similarity index 96% rename from fusion_accounting/data/tool_definitions.xml rename to fusion_accounting_ai/data/tool_definitions.xml index 55a5cad9..ec81f941 100644 --- a/fusion_accounting/data/tool_definitions.xml +++ b/fusion_accounting_ai/data/tool_definitions.xml @@ -25,7 +25,7 @@ bank_reconciliation 3 {"type": "object", "properties": {"statement_line_id": {"type": "integer", "description": "Bank statement line ID"}, "move_line_ids": {"type": "array", "items": {"type": "integer"}, "description": "Journal item IDs to match"}}, "required": ["statement_line_id", "move_line_ids"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager auto_reconcile_bank_lines @@ -34,7 +34,7 @@ bank_reconciliation 3 {"type": "object", "properties": {"company_id": {"type": "integer"}}} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager apply_reconcile_model @@ -43,7 +43,7 @@ bank_reconciliation 3 {"type": "object", "properties": {"model_id": {"type": "integer"}, "statement_line_id": {"type": "integer"}}, "required": ["model_id", "statement_line_id"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager unmatch_bank_line @@ -52,7 +52,7 @@ bank_reconciliation 3 {"type": "object", "properties": {"statement_line_id": {"type": "integer"}}, "required": ["statement_line_id"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_reconcile_suggestions @@ -119,7 +119,7 @@ hst_management 2 {"type": "object", "properties": {}} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager validate_tax_return @@ -128,7 +128,7 @@ hst_management 3 {"type": "object", "properties": {"return_id": {"type": "integer"}}, "required": ["return_id"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager @@ -163,7 +163,7 @@ accounts_receivable 2 {"type": "object", "properties": {"partner_id": {"type": "integer"}, "send_email": {"type": "boolean"}, "print_letter": {"type": "boolean"}, "email_subject": {"type": "string"}, "body": {"type": "string"}}, "required": ["partner_id"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_followup_report @@ -180,7 +180,7 @@ accounts_receivable 3 {"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_unmatched_payments @@ -449,7 +449,7 @@ adp 3 {"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager verify_adp_split @@ -483,7 +483,7 @@ adp 3 {"type": "object", "properties": {"invoices": {"type": "array", "items": {"type": "object", "properties": {"invoice_number": {"type": "string"}, "amount": {"type": "number"}}, "required": ["invoice_number", "amount"]}, "description": "List of invoices with number and payment amount"}, "payment_date": {"type": "string", "description": "Payment date from remittance (YYYY-MM-DD)"}, "journal_id": {"type": "integer", "description": "Bank journal ID (default 50 = Scotia Current)"}}, "required": ["invoices", "payment_date"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager @@ -542,7 +542,7 @@ reporting 2 {"type": "object", "properties": {"report_ref": {"type": "string"}, "format": {"type": "string", "enum": ["pdf", "xlsx"]}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["report_ref"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager @@ -626,7 +626,7 @@ audit 2 {"type": "object", "properties": {"move_id": {"type": "integer"}, "flag": {"type": "string"}, "recommendation": {"type": "string"}}, "required": ["move_id"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_audit_status @@ -643,7 +643,7 @@ audit 2 {"type": "object", "properties": {"status_id": {"type": "integer"}, "status": {"type": "string", "enum": ["todo", "reviewed", "supervised", "anomaly"]}}, "required": ["status_id", "status"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_audit_trail @@ -686,7 +686,7 @@ payroll_management 3 {"type": "object", "properties": {"journal_id": {"type": "integer"}, "date": {"type": "string"}, "ref": {"type": "string"}, "lines": {"type": "array", "items": {"type": "object", "properties": {"account_id": {"type": "integer"}, "name": {"type": "string"}, "debit": {"type": "number"}, "credit": {"type": "number"}, "partner_id": {"type": "integer"}}}}}, "required": ["journal_id", "date", "lines"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager match_payroll_cheques @@ -695,7 +695,7 @@ payroll_management 3 {"type": "object", "properties": {"statement_line_id": {"type": "integer"}, "move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["statement_line_id", "move_line_ids"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager prepare_cra_payment @@ -704,7 +704,7 @@ payroll_management 3 {"type": "object", "properties": {"journal_id": {"type": "integer"}, "date": {"type": "string"}, "lines": {"type": "array"}}, "required": ["journal_id", "date", "lines"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager generate_t4 @@ -713,7 +713,7 @@ payroll_management 2 {"type": "object", "properties": {}} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager generate_roe @@ -722,7 +722,7 @@ payroll_management 2 {"type": "object", "properties": {}} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_payroll_cost_report @@ -823,7 +823,7 @@ bank_reconciliation 3 {"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Bank journal ID (default 50)"}, "line_ids": {"type": "array", "items": {"type": "integer"}, "description": "Optional: specific bank line IDs to reconcile. If empty, reconciles all matching payroll cheques."}}} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager diff --git a/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py b/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py new file mode 100644 index 00000000..8386592f --- /dev/null +++ b/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py @@ -0,0 +1,123 @@ +"""Reassign ir_model_data ownership from fusion_accounting to fusion_accounting_ai. + +Pre-Phase-0, all fusion code lived in module='fusion_accounting'. Post-Phase-0, +fusion_accounting is the meta-module and the AI code lives in +'fusion_accounting_ai'. Odoo loads the Python from the new location, but +existing ir_model_data rows still record the old module name. This script +rewrites them. + +Special case: if the data-load phase of this very upgrade already created a +new row in module='fusion_accounting_ai' with the same `name` as an old +orphan (because the orphan lived under the old module name when data-load +looked for it, missed it, and re-created the record), the UPDATE below would +violate the unique constraint on (module, name). For those conflicts we +delete the old orphan — the newly-created row is the one that records and +the runtime will actually use going forward. + +Idempotent: running it a second time does nothing because the WHERE clauses +find no matches. +""" + +import logging + +_logger = logging.getLogger(__name__) + +# Exact xml-id names (model_ prefix, one per fusion.* model) that belonged to +# the AI module. Each corresponds to a auto-created +# by Odoo when the model class loads. +AI_MODEL_PREFIXES = ( + 'model_fusion_accounting_session', + 'model_fusion_accounting_match_history', + 'model_fusion_accounting_rule', + 'model_fusion_accounting_tool', + 'model_fusion_accounting_dashboard', + 'model_fusion_accounting_recurring_pattern', + 'model_fusion_accounting_vendor_tax_profile', + 'model_fusion_accounting_rule_wizard', +) + +# XML-id name patterns for views/data/security/wizard/etc. that belong to +# the AI sub-module. These cover every xml-id the AI module declares in its +# data files (cron.xml, default_rules.xml, tool_definitions.xml, views/*.xml, +# wizards/*.xml, report/*.xml) plus the ACL entries in ir.model.access.csv. +# +# Patterns use SQL LIKE syntax; '%' matches anything. These are broad on +# purpose: we want to catch every past and present xml-id declared by the AI +# data files, including Odoo-auto-generated companions (e.g. ir.cron auto- +# creates an ir.actions.server with xml-id '_ir_actions_server'). +AI_NAME_LIKE = ( + 'view_fusion_%', + 'action_fusion_%', + 'menu_fusion_%', + 'fusion_tool_%', + 'fusion_rule_%', + 'cron_fusion_%', + 'seq_fusion_%', + 'access_fusion_%', + 'rule_fusion_%', + 'paperformat_fusion_%', + 'report_fusion_%', + 'audit_report_template', +) + + +# Group/category/privilege xml-ids that moved from 'fusion_accounting' to +# 'fusion_accounting_core' in Phase 0 (Task 16). Both _core and _ai +# post-migrations run this same UPDATE — whichever runs first wins, the other +# is a no-op. We reassign these here too so that if _ai happens to upgrade +# first (before _core's own post-migration has had a chance to run) the groups +# are still rehomed correctly. +CORE_SECURITY_NAMES = ( + 'module_category_fusion_accounting', + 'res_groups_privilege_fusion_accounting', + 'group_fusion_accounting_user', + 'group_fusion_accounting_manager', + 'group_fusion_accounting_admin', +) + + +def migrate(cr, version): + # Step 0: Reassign security groups/category/privilege to fusion_accounting_core. + cr.execute(""" + UPDATE ir_model_data + SET module = 'fusion_accounting_core' + WHERE module = 'fusion_accounting' + AND name = ANY(%s) + """, (list(CORE_SECURITY_NAMES),)) + moved_to_core = cr.rowcount + + # Step 1: Delete orphan rows that conflict with an already-existing row in + # fusion_accounting_ai (data-load artifact). The new row is the survivor. + cr.execute(""" + DELETE FROM ir_model_data AS old + WHERE old.module = 'fusion_accounting' + AND (old.name = ANY(%s) OR old.name LIKE ANY(%s)) + AND EXISTS ( + SELECT 1 FROM ir_model_data AS new + WHERE new.module = 'fusion_accounting_ai' + AND new.name = old.name + ) + """, (list(AI_MODEL_PREFIXES), list(AI_NAME_LIKE))) + deleted_conflicts = cr.rowcount + + # Step 2: Reassign the non-conflicting orphans to fusion_accounting_ai. + cr.execute(""" + UPDATE ir_model_data + SET module = 'fusion_accounting_ai' + WHERE module = 'fusion_accounting' + AND ( + name = ANY(%s) + OR name LIKE ANY(%s) + ) + """, (list(AI_MODEL_PREFIXES), list(AI_NAME_LIKE))) + moved_to_ai = cr.rowcount + + _logger.info( + "fusion_accounting_ai post-migration: reassigned %d security rows to " + "fusion_accounting_core, deleted %d conflicting AI orphans, reassigned " + "%d ir_model_data rows from module='fusion_accounting' to " + "module='fusion_accounting_ai'", + moved_to_core, + deleted_conflicts, + moved_to_ai, + ) diff --git a/fusion_accounting/models/__init__.py b/fusion_accounting_ai/models/__init__.py similarity index 100% rename from fusion_accounting/models/__init__.py rename to fusion_accounting_ai/models/__init__.py diff --git a/fusion_accounting/models/account_move_hook.py b/fusion_accounting_ai/models/account_move_hook.py similarity index 100% rename from fusion_accounting/models/account_move_hook.py rename to fusion_accounting_ai/models/account_move_hook.py diff --git a/fusion_accounting/models/accounting_config.py b/fusion_accounting_ai/models/accounting_config.py similarity index 100% rename from fusion_accounting/models/accounting_config.py rename to fusion_accounting_ai/models/accounting_config.py diff --git a/fusion_accounting/models/accounting_dashboard.py b/fusion_accounting_ai/models/accounting_dashboard.py similarity index 100% rename from fusion_accounting/models/accounting_dashboard.py rename to fusion_accounting_ai/models/accounting_dashboard.py diff --git a/fusion_accounting/models/accounting_match_history.py b/fusion_accounting_ai/models/accounting_match_history.py similarity index 100% rename from fusion_accounting/models/accounting_match_history.py rename to fusion_accounting_ai/models/accounting_match_history.py diff --git a/fusion_accounting/models/accounting_rule.py b/fusion_accounting_ai/models/accounting_rule.py similarity index 100% rename from fusion_accounting/models/accounting_rule.py rename to fusion_accounting_ai/models/accounting_rule.py diff --git a/fusion_accounting/models/accounting_session.py b/fusion_accounting_ai/models/accounting_session.py similarity index 100% rename from fusion_accounting/models/accounting_session.py rename to fusion_accounting_ai/models/accounting_session.py diff --git a/fusion_accounting/models/accounting_tool.py b/fusion_accounting_ai/models/accounting_tool.py similarity index 100% rename from fusion_accounting/models/accounting_tool.py rename to fusion_accounting_ai/models/accounting_tool.py diff --git a/fusion_accounting/models/recurring_pattern.py b/fusion_accounting_ai/models/recurring_pattern.py similarity index 100% rename from fusion_accounting/models/recurring_pattern.py rename to fusion_accounting_ai/models/recurring_pattern.py diff --git a/fusion_accounting/models/vendor_tax_profile.py b/fusion_accounting_ai/models/vendor_tax_profile.py similarity index 100% rename from fusion_accounting/models/vendor_tax_profile.py rename to fusion_accounting_ai/models/vendor_tax_profile.py diff --git a/fusion_accounting/report/audit_report_template.xml b/fusion_accounting_ai/report/audit_report_template.xml similarity index 100% rename from fusion_accounting/report/audit_report_template.xml rename to fusion_accounting_ai/report/audit_report_template.xml diff --git a/fusion_accounting_ai/security/fusion_accounting_ai_security.xml b/fusion_accounting_ai/security/fusion_accounting_ai_security.xml new file mode 100644 index 00000000..e3cc66cc --- /dev/null +++ b/fusion_accounting_ai/security/fusion_accounting_ai_security.xml @@ -0,0 +1,58 @@ + + + + + Fusion Session: Own Sessions + + [('user_id', '=', user.id)] + + + + + Fusion Session: All Sessions + + [(1, '=', 1)] + + + + + Fusion History: Own History + + [('session_id.user_id', '=', user.id)] + + + + + Fusion History: All History + + [(1, '=', 1)] + + + + + + Fusion Tool: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + Fusion Rule: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + Fusion History: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + + Fusion Session: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + diff --git a/fusion_accounting_ai/security/ir.model.access.csv b/fusion_accounting_ai/security/ir.model.access.csv new file mode 100644 index 00000000..441b9863 --- /dev/null +++ b/fusion_accounting_ai/security/ir.model.access.csv @@ -0,0 +1,19 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_session_user,fusion.accounting.session.user,model_fusion_accounting_session,fusion_accounting_core.group_fusion_accounting_user,1,1,1,0 +access_fusion_session_admin,fusion.accounting.session.admin,model_fusion_accounting_session,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_history_user,fusion.accounting.match.history.user,model_fusion_accounting_match_history,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 +access_fusion_history_manager,fusion.accounting.match.history.manager,model_fusion_accounting_match_history,fusion_accounting_core.group_fusion_accounting_manager,1,1,1,0 +access_fusion_history_admin,fusion.accounting.match.history.admin,model_fusion_accounting_match_history,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_rule_user,fusion.accounting.rule.user,model_fusion_accounting_rule,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 +access_fusion_rule_manager,fusion.accounting.rule.manager,model_fusion_accounting_rule,fusion_accounting_core.group_fusion_accounting_manager,1,1,1,0 +access_fusion_rule_admin,fusion.accounting.rule.admin,model_fusion_accounting_rule,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_tool_user,fusion.accounting.tool.user,model_fusion_accounting_tool,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 +access_fusion_tool_admin,fusion.accounting.tool.admin,model_fusion_accounting_tool,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_dashboard_user,fusion.accounting.dashboard.user,model_fusion_accounting_dashboard,fusion_accounting_core.group_fusion_accounting_user,1,1,1,1 +access_fusion_rule_wizard_manager,fusion.accounting.rule.wizard.manager,model_fusion_accounting_rule_wizard,fusion_accounting_core.group_fusion_accounting_manager,1,1,1,1 +access_fusion_recurring_pattern_user,fusion.recurring.pattern.user,model_fusion_recurring_pattern,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 +access_fusion_recurring_pattern_manager,fusion.recurring.pattern.manager,model_fusion_recurring_pattern,fusion_accounting_core.group_fusion_accounting_manager,1,1,1,0 +access_fusion_recurring_pattern_admin,fusion.recurring.pattern.admin,model_fusion_recurring_pattern,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_vendor_profile_user,fusion.vendor.tax.profile.user,model_fusion_vendor_tax_profile,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 +access_fusion_vendor_profile_manager,fusion.vendor.tax.profile.manager,model_fusion_vendor_tax_profile,fusion_accounting_core.group_fusion_accounting_manager,1,1,1,0 +access_fusion_vendor_profile_admin,fusion.vendor.tax.profile.admin,model_fusion_vendor_tax_profile,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting/services/__init__.py b/fusion_accounting_ai/services/__init__.py similarity index 100% rename from fusion_accounting/services/__init__.py rename to fusion_accounting_ai/services/__init__.py diff --git a/fusion_accounting/services/adapters/__init__.py b/fusion_accounting_ai/services/adapters/__init__.py similarity index 100% rename from fusion_accounting/services/adapters/__init__.py rename to fusion_accounting_ai/services/adapters/__init__.py diff --git a/fusion_accounting/services/adapters/claude.py b/fusion_accounting_ai/services/adapters/claude.py similarity index 100% rename from fusion_accounting/services/adapters/claude.py rename to fusion_accounting_ai/services/adapters/claude.py diff --git a/fusion_accounting/services/adapters/openai_adapter.py b/fusion_accounting_ai/services/adapters/openai_adapter.py similarity index 100% rename from fusion_accounting/services/adapters/openai_adapter.py rename to fusion_accounting_ai/services/adapters/openai_adapter.py diff --git a/fusion_accounting/services/agent.py b/fusion_accounting_ai/services/agent.py similarity index 100% rename from fusion_accounting/services/agent.py rename to fusion_accounting_ai/services/agent.py diff --git a/fusion_accounting_ai/services/data_adapters/__init__.py b/fusion_accounting_ai/services/data_adapters/__init__.py new file mode 100644 index 00000000..1f69704c --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/__init__.py @@ -0,0 +1,9 @@ +from .base import DataAdapter, AdapterMode +from ._registry import get_adapter, register_adapter + +from . import bank_rec # noqa: F401 +from . import reports # noqa: F401 +from . import followup # noqa: F401 +from . import assets # noqa: F401 + +__all__ = ['DataAdapter', 'AdapterMode', 'get_adapter', 'register_adapter'] diff --git a/fusion_accounting_ai/services/data_adapters/_registry.py b/fusion_accounting_ai/services/data_adapters/_registry.py new file mode 100644 index 00000000..fda309a6 --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/_registry.py @@ -0,0 +1,25 @@ +"""Registry: lazy-loads data adapter instances per env.""" + +from .base import DataAdapter + + +def get_adapter(env, name: str) -> DataAdapter: + """Return a data adapter by short name. Cached per request via env.context.""" + cache = env.context.get('_fusion_data_adapter_cache') + if cache is None: + cache = {} + if name not in cache: + cls = _ADAPTERS.get(name) + if cls is None: + raise KeyError(f"Unknown data adapter: {name!r}. Known: {list(_ADAPTERS)}") + cache[name] = cls(env) + return cache[name] + + +# Populated as adapter classes are added (Tasks 9, 10, 11). +_ADAPTERS: dict[str, type[DataAdapter]] = {} + + +def register_adapter(name: str, cls: type[DataAdapter]) -> None: + """Register an adapter class. Call from each adapter module at import time.""" + _ADAPTERS[name] = cls diff --git a/fusion_accounting_ai/services/data_adapters/assets.py b/fusion_accounting_ai/services/data_adapters/assets.py new file mode 100644 index 00000000..df7eaca6 --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/assets.py @@ -0,0 +1,42 @@ +"""Assets data adapter.""" + +from .base import DataAdapter +from ._registry import register_adapter + + +class AssetsAdapter(DataAdapter): + FUSION_MODEL = 'fusion.asset' + ENTERPRISE_MODULE = 'account_asset' + + def list_assets(self, state=None): + return self._dispatch('list_assets', state=state) + + def list_assets_via_fusion(self, state=None): + return self._read_fusion('fusion.asset', state=state) + + def list_assets_via_enterprise(self, state=None): + return self._read_fusion('account.asset', state=state) + + def list_assets_via_community(self, state=None): + # No assets feature in pure Community — return empty list with a hint. + return [] + + def _read_fusion(self, model_name, state=None): + """Shared shape between fusion and enterprise (both use account.asset-like API).""" + Model = self.env[model_name].sudo() + domain = [] + if state: + domain.append(('state', '=', state)) + records = Model.search(domain, limit=200) + out = [] + for r in records: + out.append({ + 'id': r.id, + 'name': getattr(r, 'name', None), + 'state': getattr(r, 'state', None), + 'value': getattr(r, 'original_value', None) or getattr(r, 'acquisition_cost', None), + }) + return out + + +register_adapter('assets', AssetsAdapter) diff --git a/fusion_accounting_ai/services/data_adapters/bank_rec.py b/fusion_accounting_ai/services/data_adapters/bank_rec.py new file mode 100644 index 00000000..2f48be80 --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/bank_rec.py @@ -0,0 +1,87 @@ +"""Bank reconciliation data adapter. + +Routes bank-rec data lookups across: +- FUSION: fusion.bank.rec.widget (added by fusion_accounting_bank_rec, Phase 1) +- ENTERPRISE: account_accountant's bank_rec_widget JS service +- COMMUNITY: pure search on account.bank.statement.line +""" + +from .base import DataAdapter +from ._registry import register_adapter + + +class BankRecAdapter(DataAdapter): + FUSION_MODEL = 'fusion.bank.rec.widget' + ENTERPRISE_MODULE = 'account_accountant' + + def list_unreconciled(self, journal_id=None, limit=100, date_from=None, + date_to=None, min_amount=None, company_id=None): + """Return unreconciled bank statement lines. + + All filter params are optional; pass company_id to restrict results to + a single company (the AI tools always do this). + """ + return self._dispatch( + 'list_unreconciled', + journal_id=journal_id, limit=limit, + date_from=date_from, date_to=date_to, + min_amount=min_amount, company_id=company_id, + ) + + def list_unreconciled_via_fusion(self, journal_id=None, limit=100, + date_from=None, date_to=None, + min_amount=None, company_id=None): + # Phase 1 will add fusion.bank.rec.widget; this method becomes the primary path. + # For now: even when the model exists, delegate to community read shape. + return self.list_unreconciled_via_community( + journal_id=journal_id, limit=limit, + date_from=date_from, date_to=date_to, + min_amount=min_amount, company_id=company_id, + ) + + def list_unreconciled_via_enterprise(self, journal_id=None, limit=100, + date_from=None, date_to=None, + min_amount=None, company_id=None): + # Enterprise's bank rec uses a JS-side service; from Python the cleanest + # backend access is the same Community search (the data lives in + # account.bank.statement.line either way). This adapter's purpose is + # to expose a stable shape to AI tools regardless of which UI the user has. + return self.list_unreconciled_via_community( + journal_id=journal_id, limit=limit, + date_from=date_from, date_to=date_to, + min_amount=min_amount, company_id=company_id, + ) + + def list_unreconciled_via_community(self, journal_id=None, limit=100, + date_from=None, date_to=None, + min_amount=None, company_id=None): + Line = self.env['account.bank.statement.line'].sudo() + domain = [('is_reconciled', '=', False)] + if journal_id is not None: + domain.append(('journal_id', '=', journal_id)) + if company_id is not None: + domain.append(('company_id', '=', company_id)) + if date_from: + domain.append(('date', '>=', date_from)) + if date_to: + domain.append(('date', '<=', date_to)) + if min_amount is not None: + domain.append(('amount', '>=', min_amount)) + records = Line.search(domain, 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_name or (r.partner_id.name if r.partner_id else None), + 'currency_id': r.currency_id.id if r.currency_id else None, + 'journal_id': r.journal_id.id, + 'journal_name': r.journal_id.name, + } + for r in records + ] + + +register_adapter('bank_rec', BankRecAdapter) diff --git a/fusion_accounting_ai/services/data_adapters/base.py b/fusion_accounting_ai/services/data_adapters/base.py new file mode 100644 index 00000000..ecf9296c --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/base.py @@ -0,0 +1,79 @@ +"""Data-adapter base class: routes data lookups across three backends. + +The fusion_accounting_ai sub-module's tools (e.g. get_unreconciled_bank_lines) +must work in any of three install profiles: + +1. FUSION mode — a fusion native sub-module (e.g. fusion_accounting_bank_rec) + is installed; route to its model. +2. ENTERPRISE mode — Odoo Enterprise (e.g. account_accountant) is installed; + route to Enterprise APIs. +3. COMMUNITY mode — neither; fall back to a pure Odoo Community search/read. + +Subclasses implement the three backend methods and define which fusion model +and which Enterprise module they probe. +""" + +import enum +import logging +from typing import Any + +_logger = logging.getLogger(__name__) + + +class AdapterMode(enum.Enum): + FUSION = "fusion" + ENTERPRISE = "enterprise" + COMMUNITY = "community" + + +class DataAdapter: + """Base class. Subclasses set FUSION_MODEL and ENTERPRISE_MODULE class attrs + and implement _via_fusion(...), _via_enterprise(...), _via_community(...).""" + + # Override in subclasses. + FUSION_MODEL: str = "" + ENTERPRISE_MODULE: str = "" + + def __init__(self, env): + self.env = env + + def _select_mode( + self, + fusion_native_model: str | None = None, + enterprise_module: str | None = None, + ) -> AdapterMode: + """Pick FUSION if the model is loaded, else ENTERPRISE if the module + is installed, else COMMUNITY.""" + fusion_model = fusion_native_model or self.FUSION_MODEL + ent_module = enterprise_module or self.ENTERPRISE_MODULE + + if fusion_model and fusion_model in self.env: + return AdapterMode.FUSION + + if ent_module: + installed = self.env['ir.module.module'].sudo().search_count([ + ('name', '=', ent_module), + ('state', '=', 'installed'), + ]) + if installed: + return AdapterMode.ENTERPRISE + + return AdapterMode.COMMUNITY + + def _dispatch(self, method_name: str, *args, **kwargs) -> Any: + """Look up _via_ on self and call it. + + E.g. method_name='list_unreconciled', mode=FUSION calls + self.list_unreconciled_via_fusion(*args, **kwargs). + """ + mode = self._select_mode() + attr = f"{method_name}_via_{mode.value}" + impl = getattr(self, attr, None) + if impl is None: + _logger.warning( + "DataAdapter %s has no implementation for %s in mode %s; " + "returning empty result", + type(self).__name__, method_name, mode.value, + ) + return [] + return impl(*args, **kwargs) diff --git a/fusion_accounting_ai/services/data_adapters/followup.py b/fusion_accounting_ai/services/data_adapters/followup.py new file mode 100644 index 00000000..067011f2 --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/followup.py @@ -0,0 +1,210 @@ +"""Follow-up data adapter. + +Routes follow-up / aged-balance / collections data lookups across: +- FUSION: fusion.followup.line (added by future fusion_accounting_followup, Phase 2) +- ENTERPRISE: account_followup's account.followup.line + account.followup.report +- COMMUNITY: aggregations on account.move / account.move.line +""" + +from datetime import date, timedelta +from .base import DataAdapter +from ._registry import register_adapter + + +# Default aging bucket edges used for both AR and AP. +_AGING_BUCKETS = ('current', '1_30', '31_60', '61_90', '90_plus') + + +def _bucket_for_days(days): + if days <= 0: + return 'current' + if days <= 30: + return '1_30' + if days <= 60: + return '31_60' + if days <= 90: + return '61_90' + return '90_plus' + + +class FollowupAdapter(DataAdapter): + FUSION_MODEL = 'fusion.followup.line' + ENTERPRISE_MODULE = 'account_followup' + + # ------------------------------------------------------------------ + # overdue_invoices + # ------------------------------------------------------------------ + def overdue_invoices(self, days_overdue=30, partner_id=None, limit=200): + return self._dispatch( + 'overdue_invoices', + days_overdue=days_overdue, partner_id=partner_id, limit=limit, + ) + + def overdue_invoices_via_fusion(self, days_overdue=30, partner_id=None, limit=200): + return self.overdue_invoices_via_community( + days_overdue=days_overdue, partner_id=partner_id, limit=limit, + ) + + def overdue_invoices_via_enterprise(self, days_overdue=30, partner_id=None, limit=200): + return self.overdue_invoices_via_community( + days_overdue=days_overdue, partner_id=partner_id, limit=limit, + ) + + def overdue_invoices_via_community(self, days_overdue=30, partner_id=None, limit=200): + cutoff = date.today() - timedelta(days=days_overdue) + domain = [ + ('move_type', 'in', ('out_invoice', 'out_refund')), + ('state', '=', 'posted'), + ('payment_state', 'in', ('not_paid', 'partial')), + ('invoice_date_due', '<=', cutoff), + ] + if partner_id: + domain.append(('partner_id', '=', partner_id)) + moves = self.env['account.move'].sudo().search( + domain, limit=limit, order='invoice_date_due asc', + ) + today = date.today() + return [ + { + 'id': m.id, + 'name': m.name, + 'partner_id': m.partner_id.id, + 'partner_name': m.partner_id.name, + 'partner_email': m.partner_id.email or '', + 'partner_phone': m.partner_id.phone or '', + 'invoice_date_due': m.invoice_date_due, + 'amount_total': m.amount_total, + 'amount_residual': m.amount_residual, + 'currency_id': m.currency_id.id, + 'days_overdue': (today - m.invoice_date_due).days if m.invoice_date_due else 0, + } + for m in moves + ] + + # ------------------------------------------------------------------ + # aged_receivables + # ------------------------------------------------------------------ + def aged_receivables(self, company_id=None): + return self._dispatch('aged_receivables', company_id=company_id) + + def aged_receivables_via_fusion(self, company_id=None): + return self.aged_receivables_via_community(company_id=company_id) + + def aged_receivables_via_enterprise(self, company_id=None): + return self.aged_receivables_via_community(company_id=company_id) + + def aged_receivables_via_community(self, company_id=None): + return self._aged_buckets( + account_type='asset_receivable', + company_id=company_id, + sign=1, + ) + + # ------------------------------------------------------------------ + # aged_payables + # ------------------------------------------------------------------ + def aged_payables(self, company_id=None): + return self._dispatch('aged_payables', company_id=company_id) + + def aged_payables_via_fusion(self, company_id=None): + return self.aged_payables_via_community(company_id=company_id) + + def aged_payables_via_enterprise(self, company_id=None): + return self.aged_payables_via_community(company_id=company_id) + + def aged_payables_via_community(self, company_id=None): + return self._aged_buckets( + account_type='liability_payable', + company_id=company_id, + sign=-1, # AP residuals are negative; report as positive amounts + ) + + def _aged_buckets(self, account_type, company_id=None, sign=1): + """Shared aging-bucket implementation for receivable/payable accounts. + + Returns a dict: {'total': ..., 'buckets': {...}, 'line_count': N}. + `sign=-1` flips the sign so payables report as positive owed amounts. + """ + today = date.today() + domain = [ + ('account_id.account_type', '=', account_type), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ] + if company_id is not None: + domain.append(('company_id', '=', company_id)) + amls = self.env['account.move.line'].sudo().search(domain) + + buckets = {k: 0.0 for k in _AGING_BUCKETS} + for aml in amls: + amt = aml.amount_residual + if sign < 0: + amt = abs(amt) + if not aml.date_maturity or aml.date_maturity >= today: + buckets['current'] += amt + else: + days = (today - aml.date_maturity).days + buckets[_bucket_for_days(days)] += amt + + return { + 'total': sum(buckets.values()), + 'buckets': buckets, + 'line_count': len(amls), + } + + # ------------------------------------------------------------------ + # followup_report_html — Enterprise-only artifact + # ------------------------------------------------------------------ + def followup_report_html(self, partner_id): + return self._dispatch('followup_report_html', partner_id=partner_id) + + def followup_report_html_via_fusion(self, partner_id): + # Phase 2 will implement a native version. + return self.followup_report_html_via_community(partner_id=partner_id) + + def followup_report_html_via_enterprise(self, partner_id): + partner = self.env['res.partner'].browse(partner_id) + if not partner.exists(): + return {'error': 'Partner not found'} + report = self.env['account.followup.report'] + html = report._get_followup_report_html(partner) + return {'partner': partner.name, 'html': html} + + def followup_report_html_via_community(self, partner_id): + return { + 'error': ( + 'Follow-up report is only available when account_followup ' + '(Enterprise) or a fusion follow-up module is installed.' + ), + } + + # ------------------------------------------------------------------ + # send_followup — Enterprise-only action + # ------------------------------------------------------------------ + def send_followup(self, partner_id, options=None): + return self._dispatch('send_followup', partner_id=partner_id, options=options) + + def send_followup_via_fusion(self, partner_id, options=None): + return self.send_followup_via_community(partner_id=partner_id, options=options) + + def send_followup_via_enterprise(self, partner_id, options=None): + partner = self.env['res.partner'].browse(partner_id) + if not partner.exists(): + return {'error': 'Partner not found'} + result = partner.execute_followup(options or {'partner_id': partner_id}) + return { + 'status': 'sent', + 'partner': partner.name, + 'result': str(result) if result else 'done', + } + + def send_followup_via_community(self, partner_id, options=None): + return { + 'error': ( + 'Sending follow-ups is only available when account_followup ' + '(Enterprise) or a fusion follow-up module is installed.' + ), + } + + +register_adapter('followup', FollowupAdapter) diff --git a/fusion_accounting_ai/services/data_adapters/reports.py b/fusion_accounting_ai/services/data_adapters/reports.py new file mode 100644 index 00000000..f73730a2 --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/reports.py @@ -0,0 +1,170 @@ +"""Reports data adapter. + +Routes report-data lookups across: +- FUSION: fusion.account.report (added by fusion_accounting_reports, Phase 2) +- ENTERPRISE: account.report from account_reports +- COMMUNITY: raw aggregations on account.move.line +""" + +import base64 +import logging + +from .base import DataAdapter +from ._registry import register_adapter + +_logger = logging.getLogger(__name__) + + +class ReportsAdapter(DataAdapter): + FUSION_MODEL = 'fusion.account.report' + ENTERPRISE_MODULE = 'account_reports' + + # ------------------------------------------------------------------ + # trial_balance (Community-computable from account.move.line) + # ------------------------------------------------------------------ + def trial_balance(self, date_to=None, company_ids=None): + return self._dispatch('trial_balance', date_to=date_to, company_ids=company_ids) + + def trial_balance_via_fusion(self, date_to=None, company_ids=None): + # Phase 2 will implement; for now defer to community. + return self.trial_balance_via_community(date_to=date_to, company_ids=company_ids) + + def trial_balance_via_enterprise(self, date_to=None, company_ids=None): + # Enterprise account_reports has rich filters; for AI-tool consumption, + # the community shape suffices and avoids brittle coupling to Odoo's + # report-line internals. + return self.trial_balance_via_community(date_to=date_to, company_ids=company_ids) + + def trial_balance_via_community(self, date_to=None, company_ids=None): + domain = [('parent_state', '=', 'posted')] + if date_to: + domain.append(('date', '<=', date_to)) + if company_ids: + domain.append(('company_id', 'in', list(company_ids))) + + Line = self.env['account.move.line'].sudo() + groups = Line._read_group( + domain=domain, + groupby=['account_id'], + aggregates=['debit:sum', 'credit:sum'], + ) + return [ + { + 'account_id': account.id, + 'account_code': account.code, + 'account_name': account.name, + 'debit': debit_sum, + 'credit': credit_sum, + 'balance': debit_sum - credit_sum, + } + for account, debit_sum, credit_sum in groups + ] + + # ------------------------------------------------------------------ + # run_report — generic Enterprise account.report wrapper + # + # Returns either {'report_name', 'lines'} or {'error': ...}. + # Used by profit_loss / balance_sheet / cash_flow / trial_balance_lines + # tool wrappers that want Enterprise's hierarchical report shape when + # available. + # ------------------------------------------------------------------ + def run_report(self, ref_id, date_from=None, date_to=None, limit=100): + return self._dispatch( + 'run_report', + ref_id=ref_id, date_from=date_from, date_to=date_to, limit=limit, + ) + + def run_report_via_fusion(self, ref_id, date_from=None, date_to=None, limit=100): + # Phase 2: fusion.account.report will implement equivalent rendering. + return self.run_report_via_community( + ref_id=ref_id, date_from=date_from, date_to=date_to, limit=limit, + ) + + def run_report_via_enterprise(self, ref_id, date_from=None, date_to=None, limit=100): + try: + report = self.env.ref(ref_id, raise_if_not_found=False) + except Exception: + report = None + if not report: + return {'error': f'Report {ref_id} not found'} + date_opts = {} + if date_from: + date_opts['date_from'] = date_from + if date_to: + date_opts['date_to'] = date_to + options = report.get_options({'date': date_opts} if date_opts else {}) + lines = report._get_lines(options) + return { + 'report_name': report.name, + 'lines': [{ + 'name': line.get('name', ''), + 'level': line.get('level', 0), + 'columns': [c.get('no_format', c.get('name', '')) for c in line.get('columns', [])], + } for line in lines[:limit]], + } + + def run_report_via_community(self, ref_id, date_from=None, date_to=None, limit=100): + return { + 'error': ( + f'Report {ref_id!r} is only available when account_reports (Enterprise) ' + 'or a fusion reports module is installed. For pure Community installs, ' + 'use the raw trial_balance() adapter method or the tools that aggregate ' + 'account.move.line directly.' + ), + } + + # ------------------------------------------------------------------ + # export_report — Enterprise-only PDF/XLSX export + # ------------------------------------------------------------------ + def export_report(self, ref_id, fmt='pdf', date_from=None, date_to=None): + return self._dispatch( + 'export_report', + ref_id=ref_id, fmt=fmt, date_from=date_from, date_to=date_to, + ) + + def export_report_via_fusion(self, ref_id, fmt='pdf', date_from=None, date_to=None): + return self.export_report_via_community( + ref_id=ref_id, fmt=fmt, date_from=date_from, date_to=date_to, + ) + + def export_report_via_enterprise(self, ref_id, fmt='pdf', date_from=None, date_to=None): + try: + report = self.env.ref(ref_id, raise_if_not_found=False) + except Exception: + report = None + if not report: + return {'error': f'Report {ref_id} not found'} + date_opts = {} + if date_from: + date_opts['date_from'] = date_from + if date_to: + date_opts['date_to'] = date_to + options = report.get_options({'date': date_opts} if date_opts else {}) + try: + if fmt == 'xlsx': + result = report.dispatch_report_action(options, 'export_to_xlsx') + else: + result = report.dispatch_report_action(options, 'export_to_pdf') + if isinstance(result, dict) and result.get('file_content'): + return { + 'file_name': result.get('file_name', f'report.{fmt}'), + 'file_type': result.get('file_type', fmt), + 'file_content_b64': base64.b64encode(result['file_content']).decode(), + } + return { + 'status': 'generated', + 'message': f'Report exported as {fmt}. Use the Odoo UI to download.', + } + except Exception as e: + return {'error': f'Export failed: {str(e)}'} + + def export_report_via_community(self, ref_id, fmt='pdf', date_from=None, date_to=None): + return { + 'error': ( + f'Exporting report {ref_id!r} is only available with Enterprise ' + 'account_reports installed.' + ), + } + + +register_adapter('reports', ReportsAdapter) diff --git a/fusion_accounting/services/prompts/__init__.py b/fusion_accounting_ai/services/prompts/__init__.py similarity index 100% rename from fusion_accounting/services/prompts/__init__.py rename to fusion_accounting_ai/services/prompts/__init__.py diff --git a/fusion_accounting/services/prompts/domain_prompts.py b/fusion_accounting_ai/services/prompts/domain_prompts.py similarity index 100% rename from fusion_accounting/services/prompts/domain_prompts.py rename to fusion_accounting_ai/services/prompts/domain_prompts.py diff --git a/fusion_accounting/services/prompts/system_prompt.py b/fusion_accounting_ai/services/prompts/system_prompt.py similarity index 100% rename from fusion_accounting/services/prompts/system_prompt.py rename to fusion_accounting_ai/services/prompts/system_prompt.py diff --git a/fusion_accounting/services/scoring.py b/fusion_accounting_ai/services/scoring.py similarity index 100% rename from fusion_accounting/services/scoring.py rename to fusion_accounting_ai/services/scoring.py diff --git a/fusion_accounting/services/tools/__init__.py b/fusion_accounting_ai/services/tools/__init__.py similarity index 100% rename from fusion_accounting/services/tools/__init__.py rename to fusion_accounting_ai/services/tools/__init__.py diff --git a/fusion_accounting/services/tools/accounts_payable.py b/fusion_accounting_ai/services/tools/accounts_payable.py similarity index 94% rename from fusion_accounting/services/tools/accounts_payable.py rename to fusion_accounting_ai/services/tools/accounts_payable.py index ffc2d35d..57073c9c 100644 --- a/fusion_accounting/services/tools/accounts_payable.py +++ b/fusion_accounting_ai/services/tools/accounts_payable.py @@ -6,32 +6,10 @@ _logger = logging.getLogger(__name__) def get_ap_aging(env, params): - today = fields.Date.today() - domain = [ - ('account_id.account_type', '=', 'liability_payable'), - ('parent_state', '=', 'posted'), - ('reconciled', '=', False), - ('company_id', '=', env.company.id), - ] - amls = env['account.move.line'].search(domain) - - buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0} - for aml in amls: - amt = abs(aml.amount_residual) - if not aml.date_maturity or aml.date_maturity >= today: - buckets['current'] += amt - else: - days = (today - aml.date_maturity).days - if days <= 30: - buckets['1_30'] += amt - elif days <= 60: - buckets['31_60'] += amt - elif days <= 90: - buckets['61_90'] += amt - else: - buckets['90_plus'] += amt - - return {'total': sum(buckets.values()), 'buckets': buckets, 'line_count': len(amls)} + """Return AP aging buckets. Routed through FollowupAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + return adapter.aged_payables(company_id=env.company.id) def find_duplicate_bills(env, params): diff --git a/fusion_accounting/services/tools/accounts_receivable.py b/fusion_accounting_ai/services/tools/accounts_receivable.py similarity index 60% rename from fusion_accounting/services/tools/accounts_receivable.py rename to fusion_accounting_ai/services/tools/accounts_receivable.py index 1f7dc518..0e1f2c49 100644 --- a/fusion_accounting/services/tools/accounts_receivable.py +++ b/fusion_accounting_ai/services/tools/accounts_receivable.py @@ -1,66 +1,36 @@ import logging -from odoo import fields _logger = logging.getLogger(__name__) def get_ar_aging(env, params): - today = fields.Date.today() - domain = [ - ('account_id.account_type', '=', 'asset_receivable'), - ('parent_state', '=', 'posted'), - ('reconciled', '=', False), - ('company_id', '=', env.company.id), - ] - amls = env['account.move.line'].search(domain) - - buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0} - for aml in amls: - if not aml.date_maturity or aml.date_maturity >= today: - buckets['current'] += aml.amount_residual - else: - days = (today - aml.date_maturity).days - if days <= 30: - buckets['1_30'] += aml.amount_residual - elif days <= 60: - buckets['31_60'] += aml.amount_residual - elif days <= 90: - buckets['61_90'] += aml.amount_residual - else: - buckets['90_plus'] += aml.amount_residual - - return { - 'total': sum(buckets.values()), - 'buckets': buckets, - 'line_count': len(amls), - } + """Return AR aging buckets. Routed through FollowupAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + return adapter.aged_receivables(company_id=env.company.id) def get_overdue_invoices(env, params): - today = fields.Date.today() - days_overdue = int(params.get('min_days_overdue', 1)) - from datetime import timedelta - cutoff = today - timedelta(days=days_overdue) - invoices = env['account.move'].search([ - ('move_type', '=', 'out_invoice'), - ('state', '=', 'posted'), - ('payment_state', 'in', ('not_paid', 'partial')), - ('invoice_date_due', '<', cutoff), - ('company_id', '=', env.company.id), - ], order='invoice_date_due asc', limit=int(params.get('limit', 50))) + """Return overdue customer invoices. Routed through FollowupAdapter.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + rows = adapter.overdue_invoices( + days_overdue=int(params.get('min_days_overdue', 1)), + limit=int(params.get('limit', 50)), + ) return { - 'count': len(invoices), + 'count': len(rows), 'invoices': [{ - 'id': inv.id, - 'name': inv.name, - 'partner': inv.partner_id.name if inv.partner_id else '', - 'email': inv.partner_id.email or '' if inv.partner_id else '', - 'phone': inv.partner_id.phone or '' if inv.partner_id else '', - 'amount_total': inv.amount_total, - 'amount_residual': inv.amount_residual, - 'date_due': str(inv.invoice_date_due), - 'days_overdue': (today - inv.invoice_date_due).days, - } for inv in invoices], + 'id': r['id'], + 'name': r['name'], + 'partner': r['partner_name'] or '', + 'email': r['partner_email'], + 'phone': r['partner_phone'], + 'amount_total': r['amount_total'], + 'amount_residual': r['amount_residual'], + 'date_due': str(r['invoice_date_due']) if r['invoice_date_due'] else '', + 'days_overdue': r['days_overdue'], + } for r in rows], } @@ -119,10 +89,10 @@ def get_partner_balance(env, params): def send_followup(env, params): + """Send a follow-up to a partner. Routed through FollowupAdapter so the + Enterprise-only execute_followup path is isolated behind the adapter.""" + from ..data_adapters import get_adapter partner_id = int(params['partner_id']) - partner = env['res.partner'].browse(partner_id) - if not partner.exists(): - return {'error': 'Partner not found'} options = { 'partner_id': partner_id, 'email': params.get('send_email', False), @@ -133,21 +103,16 @@ def send_followup(env, params): options['email_subject'] = params['email_subject'] if params.get('body'): options['body'] = params['body'] - result = partner.execute_followup(options) - return {'status': 'sent', 'partner': partner.name, 'result': str(result) if result else 'done'} + adapter = get_adapter(env, 'followup') + return adapter.send_followup(partner_id=partner_id, options=options) def get_followup_report(env, params): + """Return the follow-up report HTML for a partner. Routed through FollowupAdapter.""" + from ..data_adapters import get_adapter partner_id = int(params['partner_id']) - partner = env['res.partner'].browse(partner_id) - if not partner.exists(): - return {'error': 'Partner not found'} - try: - report = env['account.followup.report'] - html = report._get_followup_report_html(partner) - return {'partner': partner.name, 'html': html} - except Exception as e: - return {'error': str(e)} + adapter = get_adapter(env, 'followup') + return adapter.followup_report_html(partner_id=partner_id) def reconcile_payment_to_invoice(env, params): diff --git a/fusion_accounting/services/tools/adp.py b/fusion_accounting_ai/services/tools/adp.py similarity index 100% rename from fusion_accounting/services/tools/adp.py rename to fusion_accounting_ai/services/tools/adp.py diff --git a/fusion_accounting/services/tools/audit.py b/fusion_accounting_ai/services/tools/audit.py similarity index 100% rename from fusion_accounting/services/tools/audit.py rename to fusion_accounting_ai/services/tools/audit.py diff --git a/fusion_accounting/services/tools/bank_reconciliation.py b/fusion_accounting_ai/services/tools/bank_reconciliation.py similarity index 97% rename from fusion_accounting/services/tools/bank_reconciliation.py rename to fusion_accounting_ai/services/tools/bank_reconciliation.py index 82cc2e8d..7c1b0a5b 100644 --- a/fusion_accounting/services/tools/bank_reconciliation.py +++ b/fusion_accounting_ai/services/tools/bank_reconciliation.py @@ -6,28 +6,32 @@ _logger = logging.getLogger(__name__) def get_unreconciled_bank_lines(env, params): - domain = [('is_reconciled', '=', False), ('company_id', '=', env.company.id)] - if params.get('journal_id'): - domain.append(('journal_id', '=', int(params['journal_id']))) - if params.get('date_from'): - domain.append(('date', '>=', params['date_from'])) - if params.get('date_to'): - domain.append(('date', '<=', params['date_to'])) - if params.get('min_amount'): - domain.append(('amount', '>=', float(params['min_amount']))) - limit = int(params.get('limit', 50)) - lines = env['account.bank.statement.line'].search(domain, limit=limit, order='date desc') + """Return unreconciled bank lines for a journal/company. + + Routed through the bank_rec data adapter so the result shape is identical + whether the install profile is fusion-native, Enterprise, or pure Community. + """ + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'bank_rec') + rows = adapter.list_unreconciled( + journal_id=int(params['journal_id']) if params.get('journal_id') else None, + limit=int(params.get('limit', 50)), + date_from=params.get('date_from'), + date_to=params.get('date_to'), + min_amount=float(params['min_amount']) if params.get('min_amount') else None, + company_id=env.company.id, + ) return { - 'count': len(lines), - 'total_amount': sum(abs(l.amount) for l in lines), + 'count': len(rows), + 'total_amount': sum(abs(r['amount']) for r in rows), 'lines': [{ - 'id': l.id, - 'date': str(l.date), - 'payment_ref': l.payment_ref or '', - 'partner_name': l.partner_name or (l.partner_id.name if l.partner_id else ''), - 'amount': l.amount, - 'journal': l.journal_id.name, - } for l in lines], + 'id': r['id'], + 'date': str(r['date']) if r['date'] else '', + 'payment_ref': r['payment_ref'] or '', + 'partner_name': r['partner_name'] or '', + 'amount': r['amount'], + 'journal': r['journal_name'], + } for r in rows], } diff --git a/fusion_accounting/services/tools/hst_management.py b/fusion_accounting_ai/services/tools/hst_management.py similarity index 93% rename from fusion_accounting/services/tools/hst_management.py rename to fusion_accounting_ai/services/tools/hst_management.py index 63f4e71c..6caf3287 100644 --- a/fusion_accounting/services/tools/hst_management.py +++ b/fusion_accounting_ai/services/tools/hst_management.py @@ -52,25 +52,16 @@ def calculate_hst_balance(env, params): def get_tax_report(env, params): - report_ref = params.get('report_ref', 'account.generic_tax_report') - try: - report = env.ref(report_ref) - except Exception: - return {'error': f'Report not found: {report_ref}'} - options = report.get_options({ - 'date': { - 'date_from': params.get('date_from', ''), - 'date_to': params.get('date_to', ''), - } - }) - lines = report._get_lines(options) - return { - 'report_name': report.name, - 'lines': [{ - 'name': l.get('name', ''), - 'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])], - } for l in lines[:50]], - } + """Route through ReportsAdapter for tri-mode consistency. The Community + fallback returns an error dict explaining the report is Enterprise-only.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + return adapter.run_report( + ref_id=params.get('report_ref', 'account.generic_tax_report'), + date_from=params.get('date_from'), + date_to=params.get('date_to'), + limit=50, + ) def find_missing_tax_invoices(env, params): diff --git a/fusion_accounting/services/tools/inventory.py b/fusion_accounting_ai/services/tools/inventory.py similarity index 100% rename from fusion_accounting/services/tools/inventory.py rename to fusion_accounting_ai/services/tools/inventory.py diff --git a/fusion_accounting/services/tools/journal_review.py b/fusion_accounting_ai/services/tools/journal_review.py similarity index 100% rename from fusion_accounting/services/tools/journal_review.py rename to fusion_accounting_ai/services/tools/journal_review.py diff --git a/fusion_accounting/services/tools/month_end.py b/fusion_accounting_ai/services/tools/month_end.py similarity index 81% rename from fusion_accounting/services/tools/month_end.py rename to fusion_accounting_ai/services/tools/month_end.py index e35fc7cc..4a941b0b 100644 --- a/fusion_accounting/services/tools/month_end.py +++ b/fusion_accounting_ai/services/tools/month_end.py @@ -101,22 +101,31 @@ def run_hash_integrity_check(env, params): def get_period_summary(env, params): + """Period summary via trial-balance. Routed through ReportsAdapter so the + Enterprise-only account_reports.trial_balance_report path is isolated; + Community installs fall back to the adapter's trial_balance() aggregation.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') date_from = params.get('date_from') date_to = params.get('date_to') - try: - report = env.ref('account_reports.trial_balance_report') - except Exception: - report = env.ref('account.trial_balance_report', raise_if_not_found=False) - if not report: - return {'error': 'Trial balance report not found'} - options = report.get_options({'date': {'date_from': date_from, 'date_to': date_to}}) - lines = report._get_lines(options) + result = adapter.run_report( + ref_id='account_reports.trial_balance_report', + date_from=date_from, date_to=date_to, + ) + if isinstance(result, dict) and result.get('error'): + rows = adapter.trial_balance( + date_to=date_to, company_ids=[env.company.id], + ) + return { + 'period': f'{date_from} to {date_to}', + 'lines': [{ + 'name': f"{r['account_code']} {r['account_name']}", + 'columns': [r['debit'], r['credit'], r['balance']], + } for r in rows[:100]], + } return { 'period': f'{date_from} to {date_to}', - 'lines': [{ - 'name': l.get('name', ''), - 'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])], - } for l in lines[:100]], + 'lines': result.get('lines', []), } diff --git a/fusion_accounting/services/tools/payroll.py b/fusion_accounting_ai/services/tools/payroll.py similarity index 100% rename from fusion_accounting/services/tools/payroll.py rename to fusion_accounting_ai/services/tools/payroll.py diff --git a/fusion_accounting/services/tools/reporting.py b/fusion_accounting_ai/services/tools/reporting.py similarity index 66% rename from fusion_accounting/services/tools/reporting.py rename to fusion_accounting_ai/services/tools/reporting.py index dad430bb..4753f1cb 100644 --- a/fusion_accounting/services/tools/reporting.py +++ b/fusion_accounting_ai/services/tools/reporting.py @@ -1,67 +1,91 @@ import logging -import base64 _logger = logging.getLogger(__name__) -def _get_report(env, ref_id): - try: - return env.ref(ref_id) - except Exception: - return None - - -def _run_report(env, report_ref, params): - report = _get_report(env, report_ref) - if not report: - return {'error': f'Report {report_ref} not found'} - date_opts = {} - if params.get('date_from'): - date_opts['date_from'] = params['date_from'] - if params.get('date_to'): - date_opts['date_to'] = params['date_to'] - options = report.get_options({'date': date_opts} if date_opts else {}) - lines = report._get_lines(options) - return { - 'report_name': report.name, - 'lines': [{ - 'name': l.get('name', ''), - 'level': l.get('level', 0), - 'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])], - } for l in lines[:100]], - } - +# --------------------------------------------------------------------------- +# Enterprise account.report wrappers — all routed through ReportsAdapter. +# --------------------------------------------------------------------------- def get_profit_loss(env, params): - return _run_report(env, 'account_reports.profit_and_loss', params) + """Route through ReportsAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + return adapter.run_report( + ref_id='account_reports.profit_and_loss', + date_from=params.get('date_from'), + date_to=params.get('date_to'), + ) def get_balance_sheet(env, params): - return _run_report(env, 'account_reports.balance_sheet', params) + """Route through ReportsAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + return adapter.run_report( + ref_id='account_reports.balance_sheet', + date_from=params.get('date_from'), + date_to=params.get('date_to'), + ) def get_trial_balance(env, params): - return _run_report(env, 'account_reports.trial_balance_report', params) + """Route through ReportsAdapter for tri-mode consistency. + + In Enterprise mode returns the hierarchical report lines. In Community + mode falls back to the adapter's trial_balance() aggregation so the tool + continues to return useful data with a compatible shape. + """ + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + result = adapter.run_report( + ref_id='account_reports.trial_balance_report', + date_from=params.get('date_from'), + date_to=params.get('date_to'), + ) + if isinstance(result, dict) and result.get('error'): + rows = adapter.trial_balance( + date_to=params.get('date_to'), + company_ids=[env.company.id], + ) + return { + 'report_name': 'Trial Balance (Community aggregation)', + 'lines': [{ + 'name': f"{r['account_code']} {r['account_name']}", + 'level': 2, + 'columns': [r['debit'], r['credit'], r['balance']], + } for r in rows], + } + return result def get_cash_flow(env, params): - return _run_report(env, 'account_reports.cash_flow_statement', params) + """Route through ReportsAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + return adapter.run_report( + ref_id='account_reports.cash_flow_statement', + date_from=params.get('date_from'), + date_to=params.get('date_to'), + ) def compare_periods(env, params): + """Run the same report over two periods and return both results. Routes + both runs through ReportsAdapter.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') report_ref = params.get('report_ref', 'account_reports.profit_and_loss') - report = _get_report(env, report_ref) - if not report: - return {'error': f'Report {report_ref} not found'} - - period1 = _run_report(env, report_ref, { - 'date_from': params.get('period1_from'), - 'date_to': params.get('period1_to'), - }) - period2 = _run_report(env, report_ref, { - 'date_from': params.get('period2_from'), - 'date_to': params.get('period2_to'), - }) + period1 = adapter.run_report( + ref_id=report_ref, + date_from=params.get('period1_from'), + date_to=params.get('period1_to'), + ) + period2 = adapter.run_report( + ref_id=report_ref, + date_from=params.get('period2_from'), + date_to=params.get('period2_to'), + ) return {'period_1': period1, 'period_2': period2} @@ -74,42 +98,27 @@ def answer_financial_question(env, params): def export_report(env, params): - report_ref = params.get('report_ref', 'account_reports.profit_and_loss') - fmt = params.get('format', 'pdf') - report = _get_report(env, report_ref) - if not report: - return {'error': f'Report {report_ref} not found'} - date_opts = {} - if params.get('date_from'): - date_opts['date_from'] = params['date_from'] - if params.get('date_to'): - date_opts['date_to'] = params['date_to'] - options = report.get_options({'date': date_opts} if date_opts else {}) + """Route through ReportsAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + return adapter.export_report( + ref_id=params.get('report_ref', 'account_reports.profit_and_loss'), + fmt=params.get('format', 'pdf'), + date_from=params.get('date_from'), + date_to=params.get('date_to'), + ) - try: - if fmt == 'xlsx': - result = report.dispatch_report_action(options, 'export_to_xlsx') - else: - result = report.dispatch_report_action(options, 'export_to_pdf') - - if isinstance(result, dict) and result.get('file_content'): - return { - 'file_name': result.get('file_name', f'report.{fmt}'), - 'file_type': result.get('file_type', fmt), - 'file_content_b64': base64.b64encode(result['file_content']).decode(), - } - return { - 'status': 'generated', - 'message': f'Report exported as {fmt}. Use the Odoo UI to download.', - } - except Exception as e: - return {'error': f'Export failed: {str(e)}'} +# --------------------------------------------------------------------------- +# Pure-Community tools — search account.move / account.payment directly. +# These are tri-mode safe (the data lives in the same tables regardless of +# install profile) so they don't need adapter routing. +# --------------------------------------------------------------------------- def get_invoicing_summary(env, params): """Get invoicing summary — total invoiced by month, by partner, or for a date range. Supports: monthly breakdown for a year, current month totals, or filtered by partner.""" - from datetime import date, timedelta + from datetime import date import calendar year = int(params.get('year', date.today().year)) @@ -145,7 +154,6 @@ def get_invoicing_summary(env, params): } for inv in invoices[:30]], } - # Monthly breakdown for the year months = [] grand_total = 0 for month in range(1, 13): @@ -209,7 +217,6 @@ def get_billing_summary(env, params): } for b in bills[:30]], } - # Monthly breakdown months = [] grand_total = 0 for month in range(1, 13): diff --git a/fusion_accounting/static/description/icon.png b/fusion_accounting_ai/static/description/icon.png similarity index 100% rename from fusion_accounting/static/description/icon.png rename to fusion_accounting_ai/static/description/icon.png diff --git a/fusion_accounting/static/src/components/chat/approval_card.js b/fusion_accounting_ai/static/src/components/chat/approval_card.js similarity index 100% rename from fusion_accounting/static/src/components/chat/approval_card.js rename to fusion_accounting_ai/static/src/components/chat/approval_card.js diff --git a/fusion_accounting/static/src/components/chat/approval_card.xml b/fusion_accounting_ai/static/src/components/chat/approval_card.xml similarity index 100% rename from fusion_accounting/static/src/components/chat/approval_card.xml rename to fusion_accounting_ai/static/src/components/chat/approval_card.xml diff --git a/fusion_accounting/static/src/components/chat/chat_panel.js b/fusion_accounting_ai/static/src/components/chat/chat_panel.js similarity index 100% rename from fusion_accounting/static/src/components/chat/chat_panel.js rename to fusion_accounting_ai/static/src/components/chat/chat_panel.js diff --git a/fusion_accounting/static/src/components/chat/chat_panel.xml b/fusion_accounting_ai/static/src/components/chat/chat_panel.xml similarity index 100% rename from fusion_accounting/static/src/components/chat/chat_panel.xml rename to fusion_accounting_ai/static/src/components/chat/chat_panel.xml diff --git a/fusion_accounting/static/src/components/chat/interactive_table.js b/fusion_accounting_ai/static/src/components/chat/interactive_table.js similarity index 100% rename from fusion_accounting/static/src/components/chat/interactive_table.js rename to fusion_accounting_ai/static/src/components/chat/interactive_table.js diff --git a/fusion_accounting/static/src/components/chat/interactive_table.xml b/fusion_accounting_ai/static/src/components/chat/interactive_table.xml similarity index 100% rename from fusion_accounting/static/src/components/chat/interactive_table.xml rename to fusion_accounting_ai/static/src/components/chat/interactive_table.xml diff --git a/fusion_accounting/static/src/components/dashboard/fusion_dashboard.js b/fusion_accounting_ai/static/src/components/dashboard/fusion_dashboard.js similarity index 100% rename from fusion_accounting/static/src/components/dashboard/fusion_dashboard.js rename to fusion_accounting_ai/static/src/components/dashboard/fusion_dashboard.js diff --git a/fusion_accounting/static/src/components/dashboard/fusion_dashboard.xml b/fusion_accounting_ai/static/src/components/dashboard/fusion_dashboard.xml similarity index 100% rename from fusion_accounting/static/src/components/dashboard/fusion_dashboard.xml rename to fusion_accounting_ai/static/src/components/dashboard/fusion_dashboard.xml diff --git a/fusion_accounting/static/src/components/dashboard/health_card.js b/fusion_accounting_ai/static/src/components/dashboard/health_card.js similarity index 100% rename from fusion_accounting/static/src/components/dashboard/health_card.js rename to fusion_accounting_ai/static/src/components/dashboard/health_card.js diff --git a/fusion_accounting/static/src/components/dashboard/health_card.xml b/fusion_accounting_ai/static/src/components/dashboard/health_card.xml similarity index 100% rename from fusion_accounting/static/src/components/dashboard/health_card.xml rename to fusion_accounting_ai/static/src/components/dashboard/health_card.xml diff --git a/fusion_accounting/static/src/scss/chat.scss b/fusion_accounting_ai/static/src/scss/chat.scss similarity index 100% rename from fusion_accounting/static/src/scss/chat.scss rename to fusion_accounting_ai/static/src/scss/chat.scss diff --git a/fusion_accounting/static/src/scss/dashboard.scss b/fusion_accounting_ai/static/src/scss/dashboard.scss similarity index 100% rename from fusion_accounting/static/src/scss/dashboard.scss rename to fusion_accounting_ai/static/src/scss/dashboard.scss diff --git a/fusion_accounting_ai/tests/__init__.py b/fusion_accounting_ai/tests/__init__.py new file mode 100644 index 00000000..e3410185 --- /dev/null +++ b/fusion_accounting_ai/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_post_migration +from . import test_data_adapters diff --git a/fusion_accounting/tests/test_api_live.py b/fusion_accounting_ai/tests/test_api_live.py similarity index 100% rename from fusion_accounting/tests/test_api_live.py rename to fusion_accounting_ai/tests/test_api_live.py diff --git a/fusion_accounting/tests/test_claude_api.py b/fusion_accounting_ai/tests/test_claude_api.py similarity index 100% rename from fusion_accounting/tests/test_claude_api.py rename to fusion_accounting_ai/tests/test_claude_api.py diff --git a/fusion_accounting_ai/tests/test_data_adapters.py b/fusion_accounting_ai/tests/test_data_adapters.py new file mode 100644 index 00000000..f07d346c --- /dev/null +++ b/fusion_accounting_ai/tests/test_data_adapters.py @@ -0,0 +1,146 @@ +from odoo.tests.common import TransactionCase, tagged +from odoo.addons.fusion_accounting_ai.services.data_adapters.base import ( + DataAdapter, AdapterMode, +) +from odoo.addons.fusion_accounting_ai.services.data_adapters import get_adapter + + +@tagged('post_install', '-at_install') +class TestDataAdapterBase(TransactionCase): + """Verify the data adapter base class chooses the correct backend.""" + + def test_adapter_mode_pure_community(self): + """With no fusion native and no Enterprise, adapter selects COMMUNITY.""" + adapter = DataAdapter(self.env) + mode = adapter._select_mode( + fusion_native_model='fusion.bank.rec.widget', + enterprise_module='account_accountant', + ) + self.assertIn(mode, (AdapterMode.FUSION, AdapterMode.ENTERPRISE, AdapterMode.COMMUNITY)) + + def test_adapter_falls_back_when_fusion_model_missing(self): + """Adapter must not error when the fusion native model isn't loaded.""" + adapter = DataAdapter(self.env) + mode = adapter._select_mode( + fusion_native_model='fusion.never.exists', + enterprise_module='also_does_not_exist', + ) + self.assertEqual(mode, AdapterMode.COMMUNITY) + + +@tagged('post_install', '-at_install') +class TestBankRecAdapter(TransactionCase): + """Verify the bank-rec adapter returns rows in any install profile.""" + + def setUp(self): + super().setUp() + self.journal = self.env['account.journal'].create({ + 'name': 'Test Bank', + 'type': 'bank', + 'code': 'TBNK', + }) + self.statement = self.env['account.bank.statement'].create({ + 'name': 'Test Statement', + 'journal_id': self.journal.id, + }) + self.line = self.env['account.bank.statement.line'].create({ + 'statement_id': self.statement.id, + 'journal_id': self.journal.id, + 'date': '2026-04-18', + 'payment_ref': 'Test Payment', + 'amount': 100.0, + }) + + def test_list_unreconciled_returns_our_test_line(self): + """The adapter should find the unreconciled line we just created.""" + adapter = get_adapter(self.env, 'bank_rec') + rows = adapter.list_unreconciled(journal_id=self.journal.id, limit=10) + ids = [r['id'] for r in rows] + self.assertIn(self.line.id, ids, + f"Expected line {self.line.id} in unreconciled list, got: {ids}") + + +@tagged('post_install', '-at_install') +class TestReportsAdapter(TransactionCase): + """Verify the reports adapter computes a trial-balance-shaped result.""" + + def test_trial_balance_returns_rows_in_pure_community(self): + adapter = get_adapter(self.env, 'reports') + result = adapter.trial_balance() + self.assertIsInstance(result, list) + for row in result: + self.assertIn('account_id', row) + self.assertIn('balance', row) + + def test_run_report_returns_lines_or_error_dict(self): + """run_report() must always return either an Enterprise-shaped + {'report_name', 'lines'} dict or an {'error': ...} dict — never raise.""" + adapter = get_adapter(self.env, 'reports') + result = adapter.run_report(ref_id='account_reports.profit_and_loss') + self.assertIsInstance(result, dict) + # Either a report_name+lines response or an error — both valid + self.assertTrue( + ('lines' in result and 'report_name' in result) or 'error' in result, + f"Unexpected result shape: {result!r}", + ) + + def test_run_report_with_unknown_ref_returns_error(self): + adapter = get_adapter(self.env, 'reports') + result = adapter.run_report(ref_id='nonexistent.report.xml_id') + self.assertIsInstance(result, dict) + self.assertIn('error', result) + + def test_export_report_returns_dict(self): + adapter = get_adapter(self.env, 'reports') + result = adapter.export_report( + ref_id='account_reports.profit_and_loss', fmt='pdf', + ) + self.assertIsInstance(result, dict) + + +@tagged('post_install', '-at_install') +class TestFollowupAdapter(TransactionCase): + def test_overdue_invoices_returns_list(self): + adapter = get_adapter(self.env, 'followup') + rows = adapter.overdue_invoices(days_overdue=30) + self.assertIsInstance(rows, list) + + def test_overdue_invoices_row_has_contact_fields(self): + """The enriched shape must include email, phone, and amount_total so + the accounts_receivable tool wrapper can render them.""" + adapter = get_adapter(self.env, 'followup') + rows = adapter.overdue_invoices(days_overdue=30, limit=5) + for row in rows: + for key in ( + 'id', 'name', 'partner_id', 'partner_name', + 'partner_email', 'partner_phone', + 'invoice_date_due', 'amount_total', 'amount_residual', + 'days_overdue', + ): + self.assertIn(key, row, f"Missing key {key!r} in overdue row") + + def test_aged_receivables_returns_bucket_shape(self): + adapter = get_adapter(self.env, 'followup') + result = adapter.aged_receivables(company_id=self.env.company.id) + self.assertIn('total', result) + self.assertIn('buckets', result) + self.assertIn('line_count', result) + for bucket in ('current', '1_30', '31_60', '61_90', '90_plus'): + self.assertIn(bucket, result['buckets']) + + def test_aged_payables_returns_bucket_shape(self): + adapter = get_adapter(self.env, 'followup') + result = adapter.aged_payables(company_id=self.env.company.id) + self.assertIn('total', result) + self.assertIn('buckets', result) + self.assertIn('line_count', result) + for bucket in ('current', '1_30', '31_60', '61_90', '90_plus'): + self.assertIn(bucket, result['buckets']) + + +@tagged('post_install', '-at_install') +class TestAssetsAdapter(TransactionCase): + def test_list_assets_returns_list(self): + adapter = get_adapter(self.env, 'assets') + rows = adapter.list_assets() + self.assertIsInstance(rows, list) diff --git a/fusion_accounting_ai/tests/test_post_migration.py b/fusion_accounting_ai/tests/test_post_migration.py new file mode 100644 index 00000000..e9a62709 --- /dev/null +++ b/fusion_accounting_ai/tests/test_post_migration.py @@ -0,0 +1,34 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestPostMigration(TransactionCase): + """Verify ir_model_data ownership transferred from fusion_accounting to fusion_accounting_ai.""" + + def test_no_orphan_ir_model_data_in_old_module(self): + """No fusion-related model/view/data record should still claim module='fusion_accounting'. + + After Phase 0, fusion_accounting is the meta-module and owns no records. + Every fusion.* model/view/data record should be owned by a sub-module + (fusion_accounting_ai, fusion_accounting_core, fusion_accounting_migration). + """ + orphans = self.env['ir.model.data'].search([ + ('module', '=', 'fusion_accounting'), + ('name', 'like', '%'), + ]) + # The meta-module legitimately may own zero records. Anything found here + # is an orphan from the pre-Phase-0 layout. + self.assertFalse( + orphans, + f"Found {len(orphans)} ir_model_data rows still owned by fusion_accounting " + f"(should be owned by sub-modules). Examples: " + f"{[(r.module, r.name) for r in orphans[:5]]}" + ) + + def test_known_xml_ids_resolve_via_new_module(self): + """Spot-check that key xml-ids are reachable under the new module name.""" + # Sessions model + ref = self.env.ref('fusion_accounting_ai.model_fusion_accounting_session', raise_if_not_found=False) + self.assertTrue(ref, "fusion_accounting_ai.model_fusion_accounting_session should resolve") + # Security group + # (this lives in _core after Task 12 — adapt assertion when Task 12 completes) diff --git a/fusion_accounting/views/config_views.xml b/fusion_accounting_ai/views/config_views.xml similarity index 100% rename from fusion_accounting/views/config_views.xml rename to fusion_accounting_ai/views/config_views.xml diff --git a/fusion_accounting/views/dashboard_views.xml b/fusion_accounting_ai/views/dashboard_views.xml similarity index 100% rename from fusion_accounting/views/dashboard_views.xml rename to fusion_accounting_ai/views/dashboard_views.xml diff --git a/fusion_accounting/views/match_history_views.xml b/fusion_accounting_ai/views/match_history_views.xml similarity index 97% rename from fusion_accounting/views/match_history_views.xml rename to fusion_accounting_ai/views/match_history_views.xml index e8e52ae1..a1c38b2e 100644 --- a/fusion_accounting/views/match_history_views.xml +++ b/fusion_accounting_ai/views/match_history_views.xml @@ -31,10 +31,10 @@
diff --git a/fusion_accounting/views/menus.xml b/fusion_accounting_ai/views/menus.xml similarity index 83% rename from fusion_accounting/views/menus.xml rename to fusion_accounting_ai/views/menus.xml index 15cb292f..7a1dc508 100644 --- a/fusion_accounting/views/menus.xml +++ b/fusion_accounting_ai/views/menus.xml @@ -5,7 +5,7 @@ name="Fusion AI" parent="accountant.menu_accounting" sequence="8" - groups="group_fusion_accounting_user"/> + groups="fusion_accounting_core.group_fusion_accounting_user"/> + groups="fusion_accounting_core.group_fusion_accounting_manager"/> + groups="fusion_accounting_core.group_fusion_accounting_manager"/> + groups="fusion_accounting_core.group_fusion_accounting_manager"/> + groups="fusion_accounting_core.group_fusion_accounting_admin"/> diff --git a/fusion_accounting/views/recurring_pattern_views.xml b/fusion_accounting_ai/views/recurring_pattern_views.xml similarity index 100% rename from fusion_accounting/views/recurring_pattern_views.xml rename to fusion_accounting_ai/views/recurring_pattern_views.xml diff --git a/fusion_accounting/views/rule_views.xml b/fusion_accounting_ai/views/rule_views.xml similarity index 96% rename from fusion_accounting/views/rule_views.xml rename to fusion_accounting_ai/views/rule_views.xml index 85af9d0e..60139a29 100644 --- a/fusion_accounting/views/rule_views.xml +++ b/fusion_accounting_ai/views/rule_views.xml @@ -27,10 +27,10 @@
diff --git a/fusion_accounting/views/session_views.xml b/fusion_accounting_ai/views/session_views.xml similarity index 100% rename from fusion_accounting/views/session_views.xml rename to fusion_accounting_ai/views/session_views.xml diff --git a/fusion_accounting/views/vendor_tax_profile_views.xml b/fusion_accounting_ai/views/vendor_tax_profile_views.xml similarity index 100% rename from fusion_accounting/views/vendor_tax_profile_views.xml rename to fusion_accounting_ai/views/vendor_tax_profile_views.xml diff --git a/fusion_accounting/wizards/__init__.py b/fusion_accounting_ai/wizards/__init__.py similarity index 100% rename from fusion_accounting/wizards/__init__.py rename to fusion_accounting_ai/wizards/__init__.py diff --git a/fusion_accounting/wizards/rule_wizard.py b/fusion_accounting_ai/wizards/rule_wizard.py similarity index 100% rename from fusion_accounting/wizards/rule_wizard.py rename to fusion_accounting_ai/wizards/rule_wizard.py diff --git a/fusion_accounting/wizards/rule_wizard.xml b/fusion_accounting_ai/wizards/rule_wizard.xml similarity index 100% rename from fusion_accounting/wizards/rule_wizard.xml rename to fusion_accounting_ai/wizards/rule_wizard.xml diff --git a/fusion_accounting_core/CLAUDE.md b/fusion_accounting_core/CLAUDE.md new file mode 100644 index 00000000..be2c8e44 --- /dev/null +++ b/fusion_accounting_core/CLAUDE.md @@ -0,0 +1,25 @@ +# fusion_accounting_core — Cursor / Claude Context + +## Purpose +Foundation for the Fusion Accounting sub-module suite. Owns: +- Three security groups (User / Manager / Admin) shared across all sub-modules +- Shared-field-ownership declarations on `account.move` and `account.reconcile.model` +- Runtime Enterprise-detection helper: `env['ir.module.module']._fusion_is_enterprise_accounting_installed()` + +## What lives here +- `models/account_move.py` — declares Enterprise-extension fields with identical + schemas / relation tables. Pure schema-preservation; no business logic. +- `models/account_reconcile_model.py` — same pattern for `created_automatically` +- `models/ir_module_module.py` — Enterprise-detection helpers +- `security/fusion_accounting_security.xml` — privilege + 3 groups + auto-assignment + +## Critical rules +- NEVER add business logic to the shared-field models (account_move.py here). + Logic belongs in the feature sub-module that owns it (e.g. fusion_accounting_bank_rec). +- NEVER rename the relation tables for shared M2Ms. They must match Enterprise verbatim + for the dual-ownership pattern to work. +- Shared fields here have NO defaults beyond what Enterprise sets. The point is preservation. + +## Cross-references +- Parent design: `fusion_accounting/docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md` (Section 3) +- Workspace conventions: `/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md` diff --git a/fusion_accounting_core/README.md b/fusion_accounting_core/README.md new file mode 100644 index 00000000..d58ad06a --- /dev/null +++ b/fusion_accounting_core/README.md @@ -0,0 +1,39 @@ +# Fusion Accounting Core + +Foundation module for the Fusion Accounting suite. + +## What it does + +- Defines three security groups: Fusion Accounting User / Manager / Administrator +- Auto-promotes Odoo `account.group_account_user` -> Fusion User and + `account.group_account_manager` -> Fusion Admin +- Declares schema-preservation fields on `account.move` and `account.reconcile.model` + so that Enterprise extension fields (deferred revenue links, signing user, etc.) + survive an Enterprise uninstall +- Exposes the helper `env['ir.module.module']._fusion_is_enterprise_accounting_installed()` + +## Install + +This module never installs alone. Install `fusion_accounting` (the meta-module) +or any of the feature sub-modules — they all depend on `fusion_accounting_core`. + +## Uninstall + +Uninstalling `fusion_accounting_core` will remove the security groups and the +schema-preservation fields. If Enterprise is also installed, uninstalling +`fusion_accounting_core` will cause Odoo to consider the deferred / signing +fields owned only by Enterprise — which is the original Enterprise-only state +(no data loss, just back to Enterprise-controlled schema). + +## Troubleshooting + +If users are missing the "Fusion Accounting" privilege section in user settings +after install, the `implied_ids` mechanism only fires for newly-added users. +Backfill existing users via SQL: + + INSERT INTO res_groups_users_rel (gid, uid) + SELECT g.res_id, gu.uid + FROM res_groups_users_rel gu + JOIN ir_model_data g ON g.module = 'fusion_accounting_core' AND g.name = 'group_fusion_accounting_user' + JOIN ir_model_data ag ON ag.module = 'account' AND ag.name = 'group_account_user' AND gu.gid = ag.res_id + ON CONFLICT DO NOTHING; diff --git a/fusion_accounting_core/UPGRADE_NOTES.md b/fusion_accounting_core/UPGRADE_NOTES.md new file mode 100644 index 00000000..d761c8ec --- /dev/null +++ b/fusion_accounting_core/UPGRADE_NOTES.md @@ -0,0 +1,28 @@ +# UPGRADE_NOTES — fusion_accounting_core + +## V19.0.1.0.0 (initial — Phase 0) + +### Reference sources +- `RePackaged-Odoo/accounting/account_accountant/models/account_move.py` (Enterprise extension fields read for schema match) +- `RePackaged-Odoo/accounting/account_accountant/models/account_reconcile_model.py` (same) + +### Mirror-zone files (none in _core — _core has no Mirror zone) + +### Abstract-zone files (all of _core is abstract) +- `models/account_move.py` +- `models/account_reconcile_model.py` +- `models/ir_module_module.py` + +### Intentional deltas from Odoo +- Shared-field declarations have NO compute methods, NO @api decorators beyond + basic field types. Enterprise's account_move.py adds compute methods and + business logic; we deliberately do not duplicate them. When Enterprise is + installed, its compute methods run; when it's not, the fields are simply + unused (until a fusion sub-module decides to own that behavior). + +### Migrations +- `migrations/19.0.1.0.0/pre-migration.py` — rehome fusion security xml-ids + from module='fusion_accounting' to module='fusion_accounting_core' BEFORE + data-load (avoids unique-constraint crash on upgrade from pre-Phase-0) +- `migrations/19.0.1.0.0/post-migration.py` — idempotent safety-net for the + same rehome (zero-op if pre-migration already ran) diff --git a/fusion_accounting_core/__init__.py b/fusion_accounting_core/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/fusion_accounting_core/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fusion_accounting_core/__manifest__.py b/fusion_accounting_core/__manifest__.py new file mode 100644 index 00000000..95479c45 --- /dev/null +++ b/fusion_accounting_core/__manifest__.py @@ -0,0 +1,33 @@ +{ + 'name': 'Fusion Accounting Core', + 'version': '19.0.1.0.0', + 'category': 'Accounting/Accounting', + 'sequence': 24, + 'summary': 'Shared base for the Fusion Accounting sub-module suite (security, shared schema, runtime helpers).', + 'description': """ +Fusion Accounting Core +====================== +Foundation for the Fusion Accounting sub-modules. Owns: +- Three security groups (User, Manager, Admin) shared across all fusion sub-modules +- Shared-field declarations on Community account models so deferred-revenue, + signing-user, and similar Enterprise-extension fields survive Enterprise uninstall +- Runtime helper for detecting Odoo Enterprise accounting modules + +This module never works alone. Install fusion_accounting (the meta-module) +or one of fusion_accounting_ai, fusion_accounting_bank_rec, etc. + +Built by Nexa Systems Inc. + """, + 'author': 'Nexa Systems Inc.', + 'website': 'https://nexasystems.ca', + 'support': 'support@nexasystems.ca', + 'maintainer': 'Nexa Systems Inc.', + 'depends': ['account', 'mail'], + 'data': [ + 'security/fusion_accounting_security.xml', + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': False, + 'license': 'OPL-1', +} diff --git a/fusion_accounting_core/migrations/19.0.1.0.0/post-migration.py b/fusion_accounting_core/migrations/19.0.1.0.0/post-migration.py new file mode 100644 index 00000000..9240b7c0 --- /dev/null +++ b/fusion_accounting_core/migrations/19.0.1.0.0/post-migration.py @@ -0,0 +1,47 @@ +"""Safety-net reassignment of security xml-ids to fusion_accounting_core. + +The actual rename lives in pre-migration.py — it MUST run before data-load +to avoid creating duplicate res.groups records and hitting the (module, +name) unique constraint on ir_model_data. This post-migration is a +belt-and-suspenders no-op for the common case: if pre-migration already +ran, this UPDATE matches zero rows. + +It also catches a rare edge case: fusion_accounting_ai.post-migration.py +runs an identical UPDATE to cover cross-module upgrade ordering, so both +modules redundantly ensure the rows land in the right module regardless +of which upgrade runs first. + +Idempotent: running it a second time matches zero rows. +""" + +import logging + +_logger = logging.getLogger(__name__) + + +SECURITY_NAMES = ( + 'module_category_fusion_accounting', + 'res_groups_privilege_fusion_accounting', + 'group_fusion_accounting_user', + 'group_fusion_accounting_manager', + 'group_fusion_accounting_admin', +) + + +def migrate(cr, version): + cr.execute( + """ + UPDATE ir_model_data + SET module = 'fusion_accounting_core' + WHERE module = 'fusion_accounting' + AND name = ANY(%s) + """, + (list(SECURITY_NAMES),), + ) + moved = cr.rowcount + _logger.info( + "fusion_accounting_core post-migration: reassigned %d security rows " + "from module='fusion_accounting' to module='fusion_accounting_core' " + "(usually zero; pre-migration already handled the rename)", + moved, + ) diff --git a/fusion_accounting_core/migrations/19.0.1.0.0/pre-migration.py b/fusion_accounting_core/migrations/19.0.1.0.0/pre-migration.py new file mode 100644 index 00000000..9970b7ae --- /dev/null +++ b/fusion_accounting_core/migrations/19.0.1.0.0/pre-migration.py @@ -0,0 +1,62 @@ +"""Rehome the fusion security xml-ids to fusion_accounting_core BEFORE data-load. + +Pre-Phase-0, the three fusion security groups (user, manager, admin), the +module category and the privilege all lived in module='fusion_accounting'. +Post-Phase-0 (Task 16) they moved into module='fusion_accounting_core'. + +Running this rename in pre-migration (rather than post-migration) is +essential: Odoo's XML data-load looks up records by (module, name) in +ir_model_data. If the old row still has module='fusion_accounting' when +data-load runs, Odoo will not find a match for +'fusion_accounting_core.group_fusion_accounting_user' and will CREATE a +brand-new res.groups record plus a new ir_model_data row. That leaves the +database with two groups per name: + +1. The ORIGINAL group (still tagged module='fusion_accounting') that every + existing user is linked to via res_groups_users_rel. +2. A FRESH empty group (newly tagged module='fusion_accounting_core'). + +The subsequent post-migration UPDATE...SET module='fusion_accounting_core' +would then violate the (module, name) unique constraint on ir_model_data +and the upgrade transaction would roll back. + +By renaming ir_model_data rows BEFORE data-load, Odoo finds the existing +row (now tagged fusion_accounting_core.*), UPDATEs the res.groups record +in place with the XML-defined values, and the user-group links are +preserved untouched. + +Idempotent: running this a second time matches zero rows. +""" + +import logging + +_logger = logging.getLogger(__name__) + + +SECURITY_NAMES = ( + 'module_category_fusion_accounting', + 'res_groups_privilege_fusion_accounting', + 'group_fusion_accounting_user', + 'group_fusion_accounting_manager', + 'group_fusion_accounting_admin', +) + + +def migrate(cr, version): + cr.execute( + """ + UPDATE ir_model_data + SET module = 'fusion_accounting_core' + WHERE module = 'fusion_accounting' + AND name = ANY(%s) + """, + (list(SECURITY_NAMES),), + ) + moved = cr.rowcount + _logger.info( + "fusion_accounting_core pre-migration: renamed %d security rows " + "from module='fusion_accounting' to module='fusion_accounting_core' " + "before data-load (idempotent; non-zero only on first upgrade from " + "pre-Phase-0)", + moved, + ) diff --git a/fusion_accounting_core/models/__init__.py b/fusion_accounting_core/models/__init__.py new file mode 100644 index 00000000..bcc7bba4 --- /dev/null +++ b/fusion_accounting_core/models/__init__.py @@ -0,0 +1,3 @@ +from . import ir_module_module +from . import account_move +from . import account_reconcile_model diff --git a/fusion_accounting_core/models/account_move.py b/fusion_accounting_core/models/account_move.py new file mode 100644 index 00000000..e4dd1c94 --- /dev/null +++ b/fusion_accounting_core/models/account_move.py @@ -0,0 +1,56 @@ +"""Shared-field-ownership declarations for account.move. + +Per the roadmap (Section 3.3), these fields exist in Odoo Enterprise's +account_accountant module. By declaring them here with identical schemas +and identical relation tables, fusion_accounting_core becomes a co-owner. +When Enterprise uninstalls, Odoo's module registry sees fusion still owns +the fields and preserves the columns / relation tables, so the data +(deferred revenue links, signing user, etc.) survives uninstall. + +The fields here have NO compute methods, NO defaults beyond what Enterprise +provides, NO views. They're pure schema-preservation declarations. Any +business logic that operates on these fields lives in Enterprise (when +present) or in a future fusion sub-module that opts to own that behavior. +""" + +from odoo import fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + deferred_move_ids = fields.Many2many( + comodel_name='account.move', + relation='account_move_deferred_rel', + column1='original_move_id', + column2='deferred_move_id', + copy=False, + string="Deferred Entries", + ) + deferred_original_move_ids = fields.Many2many( + comodel_name='account.move', + relation='account_move_deferred_rel', + column1='deferred_move_id', + column2='original_move_id', + copy=False, + string="Original Invoices", + ) + deferred_entry_type = fields.Selection( + selection=[ + ('expense', 'Deferred Expense'), + ('revenue', 'Deferred Revenue'), + ], + copy=False, + string="Deferred Entry Type", + ) + + signing_user = fields.Many2one( + comodel_name='res.users', + copy=False, + string="Signing User", + ) + + payment_state_before_switch = fields.Char( + copy=False, + string="Payment State Before Switch", + ) diff --git a/fusion_accounting_core/models/account_reconcile_model.py b/fusion_accounting_core/models/account_reconcile_model.py new file mode 100644 index 00000000..4f35c837 --- /dev/null +++ b/fusion_accounting_core/models/account_reconcile_model.py @@ -0,0 +1,17 @@ +"""Shared-field-ownership for account.reconcile.model. + +Mirrors the single field Enterprise's account_accountant adds to the +Community account.reconcile.model: created_automatically. +""" + +from odoo import fields, models + + +class AccountReconcileModel(models.Model): + _inherit = "account.reconcile.model" + + created_automatically = fields.Boolean( + default=False, + copy=False, + string="Created Automatically", + ) diff --git a/fusion_accounting_core/models/ir_module_module.py b/fusion_accounting_core/models/ir_module_module.py new file mode 100644 index 00000000..27e02076 --- /dev/null +++ b/fusion_accounting_core/models/ir_module_module.py @@ -0,0 +1,32 @@ +from odoo import api, models + + +# Modules considered "Odoo Enterprise accounting" for the purpose of feature gating. +# A client is "on Enterprise" if any of these are installed; fusion_accounting_* +# replacement modules will hide their menus when Enterprise is present (replace mode +# vs. augment mode is configurable in Settings). +ENTERPRISE_ACCOUNTING_MODULES = ( + 'account_accountant', + 'account_reports', + 'accountant', +) + + +class IrModuleModule(models.Model): + _inherit = "ir.module.module" + + @api.model + def _fusion_is_enterprise_accounting_installed(self): + """True if any Odoo Enterprise accounting module is installed in this DB.""" + return bool(self.sudo().search_count([ + ('name', 'in', list(ENTERPRISE_ACCOUNTING_MODULES)), + ('state', '=', 'installed'), + ])) + + @api.model + def _fusion_is_module_installed(self, module_name): + """True if a specific module is installed.""" + return bool(self.sudo().search_count([ + ('name', '=', module_name), + ('state', '=', 'installed'), + ])) diff --git a/fusion_accounting_core/security/fusion_accounting_security.xml b/fusion_accounting_core/security/fusion_accounting_security.xml new file mode 100644 index 00000000..59953d9c --- /dev/null +++ b/fusion_accounting_core/security/fusion_accounting_security.xml @@ -0,0 +1,46 @@ + + + + + Fusion Accounting + 25 + + + + + Fusion Accounting + + + + + + User + 10 + + + + + + + Manager + 20 + + + + + + + Administrator + 30 + + + + + + + + + + + + diff --git a/fusion_accounting_core/security/ir.model.access.csv b/fusion_accounting_core/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/fusion_accounting_core/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/fusion_accounting_core/tests/__init__.py b/fusion_accounting_core/tests/__init__.py new file mode 100644 index 00000000..10b90050 --- /dev/null +++ b/fusion_accounting_core/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_enterprise_detection +from . import test_shared_field_ownership diff --git a/fusion_accounting_core/tests/test_enterprise_detection.py b/fusion_accounting_core/tests/test_enterprise_detection.py new file mode 100644 index 00000000..4f2a72c7 --- /dev/null +++ b/fusion_accounting_core/tests/test_enterprise_detection.py @@ -0,0 +1,20 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestEnterpriseDetection(TransactionCase): + """Verify the helper that detects Odoo Enterprise accounting installs.""" + + def test_helper_returns_bool(self): + result = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed() + self.assertIsInstance(result, bool) + + def test_helper_matches_actual_state(self): + """Helper should return True iff one of the known Enterprise modules is installed.""" + installed = self.env['ir.module.module'].sudo().search_count([ + ('name', 'in', ['account_accountant', 'account_reports', 'accountant']), + ('state', '=', 'installed'), + ]) + expected = bool(installed) + actual = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed() + self.assertEqual(actual, expected) diff --git a/fusion_accounting_core/tests/test_shared_field_ownership.py b/fusion_accounting_core/tests/test_shared_field_ownership.py new file mode 100644 index 00000000..82fb24e1 --- /dev/null +++ b/fusion_accounting_core/tests/test_shared_field_ownership.py @@ -0,0 +1,32 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestSharedFieldOwnership(TransactionCase): + """Verify fusion_accounting_core declares the Enterprise extension fields + on account.move and account.reconcile.model, so they survive Enterprise uninstall.""" + + def test_account_move_deferred_fields_exist(self): + Move = self.env['account.move'] + for fname in ('deferred_move_ids', 'deferred_original_move_ids', 'deferred_entry_type'): + self.assertIn(fname, Move._fields, f"{fname!r} must exist on account.move") + + def test_account_move_signing_user_exists(self): + Move = self.env['account.move'] + self.assertIn('signing_user', Move._fields) + + def test_account_move_payment_state_before_switch_exists(self): + Move = self.env['account.move'] + self.assertIn('payment_state_before_switch', Move._fields) + + def test_account_reconcile_model_created_automatically_exists(self): + Model = self.env['account.reconcile.model'] + self.assertIn('created_automatically', Model._fields) + + def test_deferred_relation_table_name_matches_enterprise(self): + """The shared M2M relation table must be named identically to Enterprise's + so dual ownership works (Enterprise drops field => fusion preserves table).""" + f = self.env['account.move']._fields['deferred_move_ids'] + self.assertEqual(f.relation, 'account_move_deferred_rel') + self.assertEqual(f.column1, 'original_move_id') + self.assertEqual(f.column2, 'deferred_move_id') diff --git a/fusion_accounting_migration/CLAUDE.md b/fusion_accounting_migration/CLAUDE.md new file mode 100644 index 00000000..069e3b04 --- /dev/null +++ b/fusion_accounting_migration/CLAUDE.md @@ -0,0 +1,20 @@ +# fusion_accounting_migration — Cursor / Claude Context + +## Purpose +Transitional sub-module that helps clients move from Odoo Enterprise accounting +to Odoo Community + Fusion Accounting without losing data. + +## What it does +- Safety guard: blocks uninstall of Enterprise accounting modules until the + migration wizard has run (per-module flag in ir.config_parameter) +- Migration wizard: shell that other fusion sub-modules extend with per-feature + migration logic (Phase 0 ships only the shell) + +## Critical +- The safety guard overrides `button_immediate_uninstall` AND `module_uninstall` + on `ir.module.module`. Both paths must be guarded — UI uninstall, CLI uninstall, + and API uninstall all go through one or the other. +- Each fusion feature sub-module that replaces an Enterprise feature MUST extend + the migration wizard's `action_run_migration` to add its own migration step + AND set the corresponding `fusion_accounting.migration..completed` + flag to True after running. diff --git a/fusion_accounting_migration/README.md b/fusion_accounting_migration/README.md new file mode 100644 index 00000000..3cd02237 --- /dev/null +++ b/fusion_accounting_migration/README.md @@ -0,0 +1,22 @@ +# Fusion Accounting Migration + +Transitional helper for moving clients from Odoo Enterprise to Community + Fusion. + +## When to use + +Install this module ONCE per client during the Enterprise->Fusion switchover. +After the switchover is complete and the client is comfortable on Community, +this module can be uninstalled. + +## How it works + +1. Install fusion_accounting (the meta-module) — pulls in this module +2. Open Fusion Accounting -> Migrate from Enterprise (top-level menu) +3. Wizard shows which Enterprise modules are detected and what migrations are available +4. Run migration; wizard reports counts and warnings +5. Uninstall Enterprise modules in dep-safe order (the safety guard prevents premature uninstall) + +## Override the safety guard + +If you need to uninstall an Enterprise module WITHOUT migrating (data will be lost), +set `fusion_accounting.migration..completed` to True in System Parameters. diff --git a/fusion_accounting_migration/UPGRADE_NOTES.md b/fusion_accounting_migration/UPGRADE_NOTES.md new file mode 100644 index 00000000..eafcafa7 --- /dev/null +++ b/fusion_accounting_migration/UPGRADE_NOTES.md @@ -0,0 +1,10 @@ +# UPGRADE_NOTES — fusion_accounting_migration + +## V19.0.1.0.0 (initial — Phase 0) + +Skeleton: safety guard + wizard shell. No per-feature migration logic registered yet. + +Added by future phases: +- Phase 1: bank-rec migration (verifies account.partial.reconcile rows are intact; sets `account_accountant.completed` flag) +- Phase 5: account_followup migration +- Phase 6: account_asset, account_budget migration diff --git a/fusion_accounting_migration/__init__.py b/fusion_accounting_migration/__init__.py new file mode 100644 index 00000000..aee8895e --- /dev/null +++ b/fusion_accounting_migration/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/fusion_accounting_migration/__manifest__.py b/fusion_accounting_migration/__manifest__.py new file mode 100644 index 00000000..e6086d8d --- /dev/null +++ b/fusion_accounting_migration/__manifest__.py @@ -0,0 +1,36 @@ +{ + 'name': 'Fusion Accounting Migration', + 'version': '19.0.1.0.0', + 'category': 'Accounting/Accounting', + 'sequence': 27, + 'summary': 'Transitional module: migrates Odoo Enterprise accounting data to Fusion Accounting tables before Enterprise uninstall.', + 'description': """ +Fusion Accounting Migration +=========================== +Transitional helper that lives only during Enterprise-to-Fusion switchover. + +Provides: +- A safety guard that blocks uninstall of Odoo Enterprise accounting modules + (account_accountant, account_reports, account_followup, account_asset, + account_budget, account_loans) until the Fusion migration wizard has run +- A guided migration wizard accessible at Settings -> Fusion Accounting -> + Migrate from Enterprise (the wizard's per-feature migrations are added + by each Fusion sub-module that replaces an Enterprise feature) + +Once the switchover is complete, this module can safely be uninstalled. + +Built by Nexa Systems Inc. + """, + 'author': 'Nexa Systems Inc.', + 'website': 'https://nexasystems.ca', + 'support': 'support@nexasystems.ca', + 'maintainer': 'Nexa Systems Inc.', + 'depends': ['fusion_accounting_core'], + 'data': [ + 'security/ir.model.access.csv', + 'wizards/migration_wizard_views.xml', + ], + 'installable': True, + 'application': False, + 'license': 'OPL-1', +} diff --git a/fusion_accounting_migration/models/__init__.py b/fusion_accounting_migration/models/__init__.py new file mode 100644 index 00000000..8e2d2d1b --- /dev/null +++ b/fusion_accounting_migration/models/__init__.py @@ -0,0 +1 @@ +from . import ir_module_module diff --git a/fusion_accounting_migration/models/ir_module_module.py b/fusion_accounting_migration/models/ir_module_module.py new file mode 100644 index 00000000..28968435 --- /dev/null +++ b/fusion_accounting_migration/models/ir_module_module.py @@ -0,0 +1,84 @@ +"""Safety guard: blocks Odoo Enterprise accounting uninstall until migration runs. + +For each Enterprise accounting module the user attempts to uninstall, the +guard checks an ir.config_parameter flag named: + + fusion_accounting.migration..completed + +If the flag is False/unset and the module is currently installed, the guard +raises UserError pointing the user to the top-level +Fusion Accounting -> Migrate from Enterprise menu. + +The migration wizard sets the flag to True after a successful migration run +for that module. +""" + +from odoo import _, api, models +from odoo.exceptions import UserError + + +GUARDED_MODULES = ( + 'account_accountant', + 'account_reports', + 'accountant', + 'account_followup', + 'account_asset', + 'account_budget', + 'account_loans', +) + + +class IrModuleModule(models.Model): + _inherit = "ir.module.module" + + @api.model + def _fusion_check_uninstall_guard(self, module_names): + """Verify it's safe to uninstall the given modules. + + Returns True if all checks pass; raises UserError otherwise. + """ + Param = self.env['ir.config_parameter'].sudo() + for name in module_names: + if name not in GUARDED_MODULES: + continue + installed = self.sudo().search_count([ + ('name', '=', name), ('state', '=', 'installed'), + ]) + if not installed: + continue + flag_key = f'fusion_accounting.migration.{name}.completed' + if Param.get_param(flag_key, default='False').lower() != 'true': + raise UserError(_( + "Cannot uninstall %s: the Fusion Accounting migration " + "for this module has not run yet. Please open\n" + " Fusion Accounting -> Migrate from Enterprise\n" + "and run the migration before uninstalling. Once the " + "migration has completed, the safety guard will allow " + "uninstall.\n\n" + "If you genuinely want to uninstall WITHOUT migrating " + "(data will be lost), set the parameter %s to True manually.", + name, flag_key, + )) + return True + + def button_immediate_uninstall(self): + """Override to invoke the safety guard before allowing uninstall. + + Both this and ``module_uninstall`` below can fire in a single UI + uninstall call (button_immediate_uninstall -> module_uninstall). The + guard is a pure read + raise, so re-running it is idempotent: on the + happy path it just re-confirms; on the blocked path the first call + already raised and the second is never reached. + """ + self._fusion_check_uninstall_guard(self.mapped('name')) + return super().button_immediate_uninstall() + + def module_uninstall(self): + """Override the lower-level uninstall path too (CLI / API uninstall). + + See ``button_immediate_uninstall`` above -- both overrides may run in + the same UI uninstall; the guard is idempotent so double-invocation + is safe. + """ + self._fusion_check_uninstall_guard(self.mapped('name')) + return super().module_uninstall() diff --git a/fusion_accounting_migration/security/ir.model.access.csv b/fusion_accounting_migration/security/ir.model.access.csv new file mode 100644 index 00000000..420f6bb6 --- /dev/null +++ b/fusion_accounting_migration/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_migration_wizard_admin,fusion.migration.wizard admin,model_fusion_migration_wizard,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting_migration/tests/__init__.py b/fusion_accounting_migration/tests/__init__.py new file mode 100644 index 00000000..184d2b90 --- /dev/null +++ b/fusion_accounting_migration/tests/__init__.py @@ -0,0 +1 @@ +from . import test_safety_guard diff --git a/fusion_accounting_migration/tests/test_safety_guard.py b/fusion_accounting_migration/tests/test_safety_guard.py new file mode 100644 index 00000000..5f301664 --- /dev/null +++ b/fusion_accounting_migration/tests/test_safety_guard.py @@ -0,0 +1,42 @@ +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestSafetyGuard(TransactionCase): + """Verify the safety guard blocks Enterprise uninstall when migration hasn't run.""" + + def test_uninstall_not_blocked_when_migration_completed(self): + """If the per-module migration flag is set, uninstall is allowed. + + Skip if account_accountant isn't installed -- otherwise the guard's + `if not installed: continue` short-circuit would make this test pass + vacuously without exercising the flag-check branch. + """ + Module = self.env['ir.module.module'].sudo() + if not Module.search_count([ + ('name', '=', 'account_accountant'), + ('state', '=', 'installed'), + ]): + self.skipTest("account_accountant not installed in this DB") + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_accounting.migration.account_accountant.completed', 'True' + ) + guard = Module._fusion_check_uninstall_guard(['account_accountant']) + self.assertTrue(guard, "Guard should pass when migration flag is set") + + def test_uninstall_blocked_when_migration_pending(self): + """If account_accountant is installed and migration not run, raise.""" + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_accounting.migration.account_accountant.completed', 'False' + ) + Module = self.env['ir.module.module'].sudo() + installed = Module.search_count([ + ('name', '=', 'account_accountant'), + ('state', '=', 'installed'), + ]) + if not installed: + self.skipTest("account_accountant not installed in this DB") + with self.assertRaises(UserError) as ctx: + Module._fusion_check_uninstall_guard(['account_accountant']) + self.assertIn('migration', str(ctx.exception).lower()) diff --git a/fusion_accounting_migration/wizards/__init__.py b/fusion_accounting_migration/wizards/__init__.py new file mode 100644 index 00000000..0e67a0b3 --- /dev/null +++ b/fusion_accounting_migration/wizards/__init__.py @@ -0,0 +1 @@ +from . import migration_wizard diff --git a/fusion_accounting_migration/wizards/migration_wizard.py b/fusion_accounting_migration/wizards/migration_wizard.py new file mode 100644 index 00000000..7ca98363 --- /dev/null +++ b/fusion_accounting_migration/wizards/migration_wizard.py @@ -0,0 +1,66 @@ +"""Migration wizard skeleton. + +Per-feature migration logic (account.asset -> fusion.asset, etc.) is added +by each fusion sub-module that replaces an Enterprise feature, by extending +this wizard via _inherit. + +Phase 0 ships the wizard with no migrations registered. Phase 1 will add +the bank-rec verification check. Phase 6 will add asset migration, etc. +""" + +from odoo import _, api, fields, models + +from ..models.ir_module_module import GUARDED_MODULES + + +class FusionMigrationWizard(models.TransientModel): + _name = "fusion.migration.wizard" + _description = "Migrate from Odoo Enterprise to Fusion Accounting" + + enterprise_modules_detected = fields.Char( + compute='_compute_detected', + string="Enterprise Modules Detected", + ) + notes = fields.Text(default=lambda self: self._default_notes()) + + def _default_notes(self): + return _( + "This wizard migrates data from Odoo Enterprise accounting modules " + "to Fusion Accounting tables. Run before uninstalling Enterprise. " + "After a successful run, each migrated module is marked complete " + "and the Enterprise uninstall safety guard will allow uninstall.\n\n" + "Phase 0 of the roadmap ships this wizard as a shell. As Phase 1, " + "Phase 5, Phase 6, etc. ship, each adds its own migration step here." + ) + + @api.depends_context('uid') + def _compute_detected(self): + Mod = self.env['ir.module.module'].sudo() + installed = Mod.search([ + ('name', 'in', list(GUARDED_MODULES)), + ('state', '=', 'installed'), + ]) + for w in self: + w.enterprise_modules_detected = ', '.join(installed.mapped('name')) or _("None") + + def action_run_migration(self): + """Stub: Phase 0 has no migrations to run. + + Sub-modules extend this method to perform their per-module migration, + then set the corresponding fusion_accounting.migration..completed + config param to True. + """ + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'info', + 'title': _("Nothing to migrate (yet)"), + 'message': _( + "Phase 0 ships the migration framework but no per-feature " + "migrations are registered yet. Each fusion sub-module that " + "replaces an Enterprise feature (Phase 1+) will register its " + "own migration step here." + ), + }, + } diff --git a/fusion_accounting_migration/wizards/migration_wizard_views.xml b/fusion_accounting_migration/wizards/migration_wizard_views.xml new file mode 100644 index 00000000..17446e9e --- /dev/null +++ b/fusion_accounting_migration/wizards/migration_wizard_views.xml @@ -0,0 +1,46 @@ + + + + fusion.migration.wizard.form + fusion.migration.wizard + +
+ + + + + + +
+
+
+
+
+ + + Migrate from Enterprise + fusion.migration.wizard + form + new + + + + + +