Merge Phase 0 Foundation into main

Phase 0 splits the fusion_accounting module into a multi-sub-module
architecture (fusion_accounting_core, fusion_accounting_ai,
fusion_accounting_migration) as the foundation for the Enterprise
Takeover Roadmap (docs/superpowers/specs/2026-04-18-fusion-accounting-
enterprise-takeover-roadmap-design.md).

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

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

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

Made-with: Cursor

# Conflicts:
#	fusion_plating/fusion_plating_receiving/models/fp_receiving.py
#	fusion_plating/fusion_plating_shopfloor/__manifest__.py
#	fusion_plating/scripts/fp_demo_stage_filler.py
This commit is contained in:
gsinghpal
2026-04-19 07:08:21 -04:00
121 changed files with 2749 additions and 651 deletions

View File

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

View File

@@ -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 `<search>` element
- NO `string` attribute on `<group>` element inside search views
- Group-by filters MUST have `domain="[]"` attribute
- Add `<separator/>` before `<group>` 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 `<div class="slot" t-att-data-idx="index"/>`, 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 <group_id>, 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 \"<SQL>\""
# 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.

View File

@@ -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 <db> -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 <db> --no-http <<EOF
env['ir.module.module'].search([
('name', 'in', [
'fusion_accounting',
'fusion_accounting_ai',
'fusion_accounting_migration',
'fusion_accounting_core',
]),
('state', '=', 'installed'),
]).button_immediate_uninstall()
EOF
## Documentation
See `docs/superpowers/specs/` for the design and `docs/superpowers/plans/` for implementation plans.

View File

@@ -1,4 +1 @@
from . import models
from . import services
from . import controllers
from . import wizards
# Meta-module: no Python code. All implementation is in sub-modules listed in __manifest__.py 'depends'.

View File

@@ -1,63 +1,41 @@
{
'name': 'Fusion Accounting AI',
'name': 'Fusion Accounting',
'version': '19.0.1.0.0',
'category': 'Accounting/Accounting',
'sequence': 25,
'summary': 'AI Accounting Co-Pilot with conversational interface and automated analysis',
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
'description': """
Fusion Accounting AI
====================
An AI-powered accounting co-pilot that embeds Claude/GPT into the Odoo Accounting
module. Features conversational bank reconciliation, HST management, AR/AP analysis,
audit scanning, and a comprehensive dashboard.
Fusion Accounting (Meta-Module)
===============================
One-click install of the entire Fusion Accounting suite.
Currently installs:
- fusion_accounting_core Shared schema, security, runtime helpers
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
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',
],
},
}

View File

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

View File

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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_session_user fusion.accounting.session.user model_fusion_accounting_session group_fusion_accounting_user 1 1 1 0
3 access_fusion_session_admin fusion.accounting.session.admin model_fusion_accounting_session group_fusion_accounting_admin 1 1 1 1
4 access_fusion_history_user fusion.accounting.match.history.user model_fusion_accounting_match_history group_fusion_accounting_user 1 0 0 0
5 access_fusion_history_manager fusion.accounting.match.history.manager model_fusion_accounting_match_history group_fusion_accounting_manager 1 1 1 0
6 access_fusion_history_admin fusion.accounting.match.history.admin model_fusion_accounting_match_history group_fusion_accounting_admin 1 1 1 1
7 access_fusion_rule_user fusion.accounting.rule.user model_fusion_accounting_rule group_fusion_accounting_user 1 0 0 0
8 access_fusion_rule_manager fusion.accounting.rule.manager model_fusion_accounting_rule group_fusion_accounting_manager 1 1 1 0
9 access_fusion_rule_admin fusion.accounting.rule.admin model_fusion_accounting_rule group_fusion_accounting_admin 1 1 1 1
10 access_fusion_tool_user fusion.accounting.tool.user model_fusion_accounting_tool group_fusion_accounting_user 1 0 0 0
11 access_fusion_tool_admin fusion.accounting.tool.admin model_fusion_accounting_tool group_fusion_accounting_admin 1 1 1 1
12 access_fusion_dashboard_user fusion.accounting.dashboard.user model_fusion_accounting_dashboard group_fusion_accounting_user 1 1 1 1
13 access_fusion_rule_wizard_manager fusion.accounting.rule.wizard.manager model_fusion_accounting_rule_wizard group_fusion_accounting_manager 1 1 1 1
14 access_fusion_recurring_pattern_user fusion.recurring.pattern.user model_fusion_recurring_pattern group_fusion_accounting_user 1 0 0 0
15 access_fusion_recurring_pattern_manager fusion.recurring.pattern.manager model_fusion_recurring_pattern group_fusion_accounting_manager 1 1 1 0
16 access_fusion_recurring_pattern_admin fusion.recurring.pattern.admin model_fusion_recurring_pattern group_fusion_accounting_admin 1 1 1 1
17 access_fusion_vendor_profile_user fusion.vendor.tax.profile.user model_fusion_vendor_tax_profile group_fusion_accounting_user 1 0 0 0
18 access_fusion_vendor_profile_manager fusion.vendor.tax.profile.manager model_fusion_vendor_tax_profile group_fusion_accounting_manager 1 1 1 0
19 access_fusion_vendor_profile_admin fusion.vendor.tax.profile.admin model_fusion_vendor_tax_profile group_fusion_accounting_admin 1 1 1 1

View File

@@ -1,94 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Module Category -->
<record id="module_category_fusion_accounting" model="ir.module.category">
<field name="name">Fusion Accounting AI</field>
<field name="sequence">25</field>
</record>
<!-- Groups Privilege -->
<record id="res_groups_privilege_fusion_accounting" model="res.groups.privilege">
<field name="name">Fusion Accounting AI</field>
<field name="category_id" ref="module_category_fusion_accounting"/>
</record>
<!-- User Group (Staff) -->
<record id="group_fusion_accounting_user" model="res.groups">
<field name="name">User</field>
<field name="sequence">10</field>
<field name="implied_ids" eval="[(4, ref('account.group_account_user'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
</record>
<!-- Manager Group -->
<record id="group_fusion_accounting_manager" model="res.groups">
<field name="name">Manager</field>
<field name="sequence">20</field>
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_user'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
</record>
<!-- Admin Group -->
<record id="group_fusion_accounting_admin" model="res.groups">
<field name="name">Administrator</field>
<field name="sequence">30</field>
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
</record>
<!-- Auto-assign: Accounting users get Fusion AI User, Advisers get Admin -->
<record id="account.group_account_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_user'))]"/>
</record>
<record id="account.group_account_manager" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_admin'))]"/>
</record>
<!-- Record Rules -->
<record id="rule_fusion_session_user" model="ir.rule">
<field name="name">Fusion Session: Own Sessions</field>
<field name="model_id" ref="model_fusion_accounting_session"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_user'))]"/>
</record>
<record id="rule_fusion_session_manager" model="ir.rule">
<field name="name">Fusion Session: All Sessions</field>
<field name="model_id" ref="model_fusion_accounting_session"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
</record>
<record id="rule_fusion_history_user" model="ir.rule">
<field name="name">Fusion History: Own History</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">[('session_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_user'))]"/>
</record>
<record id="rule_fusion_history_manager" model="ir.rule">
<field name="name">Fusion History: All History</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
</record>
<!-- Multi-company rules -->
<record id="rule_fusion_tool_company" model="ir.rule">
<field name="name">Fusion Tool: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_tool"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<record id="rule_fusion_rule_company" model="ir.rule">
<field name="name">Fusion Rule: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_rule"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<record id="rule_fusion_history_company" model="ir.rule">
<field name="name">Fusion History: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
</odoo>

View File

@@ -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 <module> <from_version> <to_version> [<output_md>]
### 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-<version>/<module>` (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

View File

@@ -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 <module> <from_version> <to_version> [<output_md>]
#
# 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 <module> <from_version> <to_version> [<output_md>]}"
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

View File

@@ -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 `<method>_via_fusion`, `<method>_via_enterprise`, `<method>_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 `<search>` element
- NO `string` attribute on `<group>` element inside search views
- Group-by filters MUST have `domain="[]"` attribute
- Add `<separator/>` before `<group>` 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 `<div class="slot" t-att-data-idx="index"/>`, 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 <group_id>, 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 \"<SQL>\""
# 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)

View File

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

View File

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

View File

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

View File

@@ -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',
],
},
}

View File

@@ -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 = []

View File

@@ -25,7 +25,7 @@
<field name="domain">bank_reconciliation</field>
<field name="tier">3</field>
<field name="parameters_schema">{"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"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_auto_reconcile_bank_lines" model="fusion.accounting.tool">
<field name="name">auto_reconcile_bank_lines</field>
@@ -34,7 +34,7 @@
<field name="domain">bank_reconciliation</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"company_id": {"type": "integer"}}}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_apply_reconcile_model" model="fusion.accounting.tool">
<field name="name">apply_reconcile_model</field>
@@ -43,7 +43,7 @@
<field name="domain">bank_reconciliation</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"model_id": {"type": "integer"}, "statement_line_id": {"type": "integer"}}, "required": ["model_id", "statement_line_id"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_unmatch_bank_line" model="fusion.accounting.tool">
<field name="name">unmatch_bank_line</field>
@@ -52,7 +52,7 @@
<field name="domain">bank_reconciliation</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer"}}, "required": ["statement_line_id"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_reconcile_suggestions" model="fusion.accounting.tool">
<field name="name">get_reconcile_suggestions</field>
@@ -119,7 +119,7 @@
<field name="domain">hst_management</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_validate_tax_return" model="fusion.accounting.tool">
<field name="name">validate_tax_return</field>
@@ -128,7 +128,7 @@
<field name="domain">hst_management</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"return_id": {"type": "integer"}}, "required": ["return_id"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<!-- Domain 3: Accounts Receivable -->
@@ -163,7 +163,7 @@
<field name="domain">accounts_receivable</field>
<field name="tier">2</field>
<field name="parameters_schema">{"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"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_followup_report" model="fusion.accounting.tool">
<field name="name">get_followup_report</field>
@@ -180,7 +180,7 @@
<field name="domain">accounts_receivable</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_unmatched_payments" model="fusion.accounting.tool">
<field name="name">get_unmatched_payments</field>
@@ -449,7 +449,7 @@
<field name="domain">adp</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_verify_adp_split" model="fusion.accounting.tool">
<field name="name">verify_adp_split</field>
@@ -483,7 +483,7 @@
<field name="domain">adp</field>
<field name="tier">3</field>
<field name="parameters_schema">{"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"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<!-- Domain 10: Reporting -->
@@ -542,7 +542,7 @@
<field name="domain">reporting</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {"report_ref": {"type": "string"}, "format": {"type": "string", "enum": ["pdf", "xlsx"]}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["report_ref"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_invoicing_summary" model="fusion.accounting.tool">
@@ -626,7 +626,7 @@
<field name="domain">audit</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {"move_id": {"type": "integer"}, "flag": {"type": "string"}, "recommendation": {"type": "string"}}, "required": ["move_id"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_audit_status" model="fusion.accounting.tool">
<field name="name">get_audit_status</field>
@@ -643,7 +643,7 @@
<field name="domain">audit</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {"status_id": {"type": "integer"}, "status": {"type": "string", "enum": ["todo", "reviewed", "supervised", "anomaly"]}}, "required": ["status_id", "status"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_audit_trail" model="fusion.accounting.tool">
<field name="name">get_audit_trail</field>
@@ -686,7 +686,7 @@
<field name="domain">payroll_management</field>
<field name="tier">3</field>
<field name="parameters_schema">{"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"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_match_payroll_cheques" model="fusion.accounting.tool">
<field name="name">match_payroll_cheques</field>
@@ -695,7 +695,7 @@
<field name="domain">payroll_management</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer"}, "move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["statement_line_id", "move_line_ids"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_prepare_cra_payment" model="fusion.accounting.tool">
<field name="name">prepare_cra_payment</field>
@@ -704,7 +704,7 @@
<field name="domain">payroll_management</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer"}, "date": {"type": "string"}, "lines": {"type": "array"}}, "required": ["journal_id", "date", "lines"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_generate_t4" model="fusion.accounting.tool">
<field name="name">generate_t4</field>
@@ -713,7 +713,7 @@
<field name="domain">payroll_management</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_generate_roe" model="fusion.accounting.tool">
<field name="name">generate_roe</field>
@@ -722,7 +722,7 @@
<field name="domain">payroll_management</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_payroll_cost_report" model="fusion.accounting.tool">
<field name="name">get_payroll_cost_report</field>
@@ -823,7 +823,7 @@
<field name="domain">bank_reconciliation</field>
<field name="tier">3</field>
<field name="parameters_schema">{"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."}}}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
</record>
<record id="tool_create_expense_entry" model="fusion.accounting.tool">

View File

@@ -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 <record id="model_..."/> 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 '<cron_name>_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,
)

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Per-user record rules (sessions visible only to the owning user; managers see all) -->
<record id="rule_fusion_session_user" model="ir.rule">
<field name="name">Fusion Session: Own Sessions</field>
<field name="model_id" ref="model_fusion_accounting_session"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('fusion_accounting_core.group_fusion_accounting_user'))]"/>
</record>
<record id="rule_fusion_session_manager" model="ir.rule">
<field name="name">Fusion Session: All Sessions</field>
<field name="model_id" ref="model_fusion_accounting_session"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('fusion_accounting_core.group_fusion_accounting_manager'))]"/>
</record>
<record id="rule_fusion_history_user" model="ir.rule">
<field name="name">Fusion History: Own History</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">[('session_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('fusion_accounting_core.group_fusion_accounting_user'))]"/>
</record>
<record id="rule_fusion_history_manager" model="ir.rule">
<field name="name">Fusion History: All History</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('fusion_accounting_core.group_fusion_accounting_manager'))]"/>
</record>
<!-- Multi-company rules -->
<record id="rule_fusion_tool_company" model="ir.rule">
<field name="name">Fusion Tool: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_tool"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<record id="rule_fusion_rule_company" model="ir.rule">
<field name="name">Fusion Rule: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_rule"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<record id="rule_fusion_history_company" model="ir.rule">
<field name="name">Fusion History: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<!-- NEW (Phase 0): Multi-company rule on session itself
(per spec Section 4.2 + existing CLAUDE.md Known Issues) -->
<record id="rule_fusion_session_company" model="ir.rule">
<field name="name">Fusion Session: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_session"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
</odoo>

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_session_user fusion.accounting.session.user model_fusion_accounting_session fusion_accounting_core.group_fusion_accounting_user 1 1 1 0
3 access_fusion_session_admin fusion.accounting.session.admin model_fusion_accounting_session fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
4 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
5 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
6 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
7 access_fusion_rule_user fusion.accounting.rule.user model_fusion_accounting_rule fusion_accounting_core.group_fusion_accounting_user 1 0 0 0
8 access_fusion_rule_manager fusion.accounting.rule.manager model_fusion_accounting_rule fusion_accounting_core.group_fusion_accounting_manager 1 1 1 0
9 access_fusion_rule_admin fusion.accounting.rule.admin model_fusion_accounting_rule fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
10 access_fusion_tool_user fusion.accounting.tool.user model_fusion_accounting_tool fusion_accounting_core.group_fusion_accounting_user 1 0 0 0
11 access_fusion_tool_admin fusion.accounting.tool.admin model_fusion_accounting_tool fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
12 access_fusion_dashboard_user fusion.accounting.dashboard.user model_fusion_accounting_dashboard fusion_accounting_core.group_fusion_accounting_user 1 1 1 1
13 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
14 access_fusion_recurring_pattern_user fusion.recurring.pattern.user model_fusion_recurring_pattern fusion_accounting_core.group_fusion_accounting_user 1 0 0 0
15 access_fusion_recurring_pattern_manager fusion.recurring.pattern.manager model_fusion_recurring_pattern fusion_accounting_core.group_fusion_accounting_manager 1 1 1 0
16 access_fusion_recurring_pattern_admin fusion.recurring.pattern.admin model_fusion_recurring_pattern fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
17 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
18 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
19 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <method_name>_via_<mode> 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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': l.get('name', ''),
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
} for l in lines[:100]],
'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': result.get('lines', []),
}

View File

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

View File

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,2 @@
from . import test_post_migration
from . import test_data_adapters

View File

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

View File

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

View File

@@ -31,10 +31,10 @@
<header>
<button name="action_approve" string="Approve" type="object"
class="btn-primary" invisible="decision != 'pending'"
groups="fusion_accounting.group_fusion_accounting_manager"/>
groups="fusion_accounting_core.group_fusion_accounting_manager"/>
<button name="action_reject" string="Reject" type="object"
class="btn-danger" invisible="decision != 'pending'"
groups="fusion_accounting.group_fusion_accounting_manager"/>
groups="fusion_accounting_core.group_fusion_accounting_manager"/>
<field name="decision" widget="statusbar"
statusbar_visible="pending,approved,rejected,auto"/>
</header>

View File

@@ -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"/>
<!-- Dashboard -->
<menuitem id="menu_fusion_dashboard"
@@ -34,7 +34,7 @@
parent="menu_fusion_accounting_root"
action="action_fusion_rule"
sequence="40"
groups="group_fusion_accounting_manager"/>
groups="fusion_accounting_core.group_fusion_accounting_manager"/>
<!-- Vendor Tax Profiles -->
<menuitem id="menu_fusion_vendor_profiles"
@@ -42,7 +42,7 @@
parent="menu_fusion_accounting_root"
action="action_vendor_tax_profiles"
sequence="50"
groups="group_fusion_accounting_manager"/>
groups="fusion_accounting_core.group_fusion_accounting_manager"/>
<!-- Recurring Patterns -->
<menuitem id="menu_fusion_recurring_patterns"
@@ -50,7 +50,7 @@
parent="menu_fusion_accounting_root"
action="action_recurring_patterns"
sequence="55"
groups="group_fusion_accounting_manager"/>
groups="fusion_accounting_core.group_fusion_accounting_manager"/>
<!-- Configuration (link to settings) -->
<menuitem id="menu_fusion_config"
@@ -58,5 +58,5 @@
parent="menu_fusion_accounting_root"
action="account.action_account_config"
sequence="90"
groups="group_fusion_accounting_admin"/>
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
</odoo>

View File

@@ -27,10 +27,10 @@
<header>
<button name="action_demote" string="Demote to Needs Approval" type="object"
class="btn-warning" invisible="approval_tier != 'auto'"
groups="fusion_accounting.group_fusion_accounting_admin"/>
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
<button name="action_rollback" string="Rollback to Previous Version" type="object"
class="btn-secondary" invisible="not parent_rule_id"
groups="fusion_accounting.group_fusion_accounting_admin"/>
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
</header>
<sheet>
<div class="oe_title">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
from . import ir_module_module
from . import account_move
from . import account_reconcile_model

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