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:
79
.gitea/workflows/fusion_accounting_ci.yml
Normal file
79
.gitea/workflows/fusion_accounting_ci.yml
Normal 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
|
||||||
@@ -1,248 +1,46 @@
|
|||||||
# fusion_accounting — AI Accounting Co-Pilot
|
# fusion_accounting (meta-module) — Cursor / Claude Context
|
||||||
|
|
||||||
## What This Module Does
|
## Purpose
|
||||||
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.
|
|
||||||
|
|
||||||
## Architecture
|
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
|
||||||
fusion_accounting/
|
that depends on the sub-modules.
|
||||||
├── 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Design Decisions
|
## Sub-modules (current)
|
||||||
|
|
||||||
### AI Provider Integration
|
| Sub-module | Phase | Purpose |
|
||||||
- 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 |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/fusion_accounting/session/create` | user | Create new chat session |
|
| `fusion_accounting_core` | 0 | Security groups, shared schema, Enterprise detection helper |
|
||||||
| `/fusion_accounting/session/close` | user (ownership check) | Close active session |
|
| `fusion_accounting_ai` | 0 | AI Co-Pilot (Claude/GPT) — was the original `fusion_accounting` code |
|
||||||
| `/fusion_accounting/session/latest` | user (own sessions only) | Load most recent active session + messages |
|
| `fusion_accounting_migration` | 0 | Transitional Enterprise->Fusion data migration |
|
||||||
| `/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'`).
|
## Sub-modules (planned)
|
||||||
|
|
||||||
## Models
|
Per the roadmap design at `docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`:
|
||||||
| 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
|
| Sub-module | Phase | Purpose |
|
||||||
**Claude** (default: claude-sonnet-4-6):
|
|---|---|---|
|
||||||
- claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5
|
| `fusion_accounting_bank_rec` | 1 | Native bank reconciliation (replaces account_accountant bank rec) |
|
||||||
- claude-sonnet-4-5, claude-opus-4-5, claude-sonnet-4-0, claude-opus-4-0
|
| `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):
|
## Roadmap and plans
|
||||||
- gpt-5.4, gpt-5.4-mini, gpt-5.4-nano
|
|
||||||
- o3, o4-mini
|
|
||||||
- gpt-4o, gpt-4o-mini (legacy)
|
|
||||||
|
|
||||||
## Theme / Styling Rules
|
- Roadmap design: `docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`
|
||||||
- NO hardcoded colours — use CSS variables (`var(--o-border-color)`, `var(--bs-body-color-rgb)`) and Bootstrap utility classes
|
- Phase 0 plan: `docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md`
|
||||||
- Must work in both light and dark mode
|
- Empirical uninstall test results: `docs/superpowers/specs/2026-04-18-empirical-uninstall-test-results.md` (produced in Task 18 of Phase 0)
|
||||||
- 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
|
|
||||||
|
|
||||||
### HST Filing Workflow (4-Phase AI-Driven)
|
## Tooling
|
||||||
- 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
|
|
||||||
|
|
||||||
## Known Issues / Future Work
|
- `tools/check_odoo_diff.sh` — annual upgrade ritual: diff Enterprise source between Odoo versions
|
||||||
- `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)
|
## Per-sub-module CLAUDE.md
|
||||||
- `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)
|
Each sub-module has its own `CLAUDE.md` with feature-specific context. Read them when working on that sub-module.
|
||||||
- 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
|
## Workspace-wide conventions
|
||||||
- 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)
|
`/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.
|
||||||
- Multi-company record rule missing on `fusion.accounting.session` — add if multi-company usage is needed
|
|
||||||
|
|||||||
38
fusion_accounting/README.md
Normal file
38
fusion_accounting/README.md
Normal 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.
|
||||||
@@ -1,4 +1 @@
|
|||||||
from . import models
|
# Meta-module: no Python code. All implementation is in sub-modules listed in __manifest__.py 'depends'.
|
||||||
from . import services
|
|
||||||
from . import controllers
|
|
||||||
from . import wizards
|
|
||||||
|
|||||||
@@ -1,63 +1,41 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting AI',
|
'name': 'Fusion Accounting',
|
||||||
'version': '19.0.1.0.0',
|
'version': '19.0.1.0.0',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 25,
|
'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': """
|
'description': """
|
||||||
Fusion Accounting AI
|
Fusion Accounting (Meta-Module)
|
||||||
====================
|
===============================
|
||||||
An AI-powered accounting co-pilot that embeds Claude/GPT into the Odoo Accounting
|
One-click install of the entire Fusion Accounting suite.
|
||||||
module. Features conversational bank reconciliation, HST management, AR/AP analysis,
|
|
||||||
audit scanning, and a comprehensive dashboard.
|
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.
|
Built by Nexa Systems Inc.
|
||||||
""",
|
""",
|
||||||
'icon': '/fusion_accounting/static/description/icon.png',
|
'icon': '/fusion_accounting_ai/static/description/icon.png',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
'website': 'https://nexasystems.ca',
|
'website': 'https://nexasystems.ca',
|
||||||
'support': 'support@nexasystems.ca',
|
'support': 'support@nexasystems.ca',
|
||||||
'maintainer': 'Nexa Systems Inc.',
|
'maintainer': 'Nexa Systems Inc.',
|
||||||
'depends': [
|
'depends': [
|
||||||
'account',
|
'fusion_accounting_core',
|
||||||
'account_accountant',
|
'fusion_accounting_ai',
|
||||||
'account_reports',
|
'fusion_accounting_migration',
|
||||||
'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',
|
|
||||||
],
|
],
|
||||||
|
'data': [],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'application': True,
|
||||||
'license': 'OPL-1',
|
'license': 'OPL-1',
|
||||||
'assets': {
|
|
||||||
'web.assets_backend': [
|
|
||||||
'fusion_accounting/static/src/**/*.js',
|
|
||||||
'fusion_accounting/static/src/**/*.xml',
|
|
||||||
'fusion_accounting/static/src/**/*.scss',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3734,3 +3734,41 @@ Expected: both tags listed (`fusion_accounting/pre-phase-0` and `fusion_accounti
|
|||||||
## What Comes After Phase 0
|
## 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 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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,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>
|
|
||||||
37
fusion_accounting/tools/README.md
Normal file
37
fusion_accounting/tools/README.md
Normal 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
|
||||||
83
fusion_accounting/tools/check_odoo_diff.sh
Executable file
83
fusion_accounting/tools/check_odoo_diff.sh
Executable 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
|
||||||
272
fusion_accounting_ai/CLAUDE.md
Normal file
272
fusion_accounting_ai/CLAUDE.md
Normal 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)
|
||||||
31
fusion_accounting_ai/README.md
Normal file
31
fusion_accounting_ai/README.md
Normal 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.
|
||||||
22
fusion_accounting_ai/UPGRADE_NOTES.md
Normal file
22
fusion_accounting_ai/UPGRADE_NOTES.md
Normal 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.
|
||||||
4
fusion_accounting_ai/__init__.py
Normal file
4
fusion_accounting_ai/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
from . import services
|
||||||
|
from . import wizards
|
||||||
58
fusion_accounting_ai/__manifest__.py
Normal file
58
fusion_accounting_ai/__manifest__.py
Normal 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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
"""S1-S3: Verify the current user owns the session."""
|
"""S1-S3: Verify the current user owns the session."""
|
||||||
if session.user_id.id != request.env.user.id:
|
if session.user_id.id != request.env.user.id:
|
||||||
# Allow managers to access any session
|
# 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 {'error': 'Access denied: you do not own this session'}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/fusion_accounting/approve', type='jsonrpc', auth='user')
|
@http.route('/fusion_accounting/approve', type='jsonrpc', auth='user')
|
||||||
def approve_action(self, match_history_id, **kwargs):
|
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'}
|
return {'error': 'Insufficient permissions to approve actions'}
|
||||||
agent = request.env['fusion.accounting.agent']
|
agent = request.env['fusion.accounting.agent']
|
||||||
result = agent.approve_action(int(match_history_id))
|
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')
|
@http.route('/fusion_accounting/reject', type='jsonrpc', auth='user')
|
||||||
def reject_action(self, match_history_id, reason='', **kwargs):
|
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'}
|
return {'error': 'Insufficient permissions to reject actions'}
|
||||||
agent = request.env['fusion.accounting.agent']
|
agent = request.env['fusion.accounting.agent']
|
||||||
result = agent.reject_action(int(match_history_id), reason)
|
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')
|
@http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user')
|
||||||
def approve_all(self, match_history_ids, **kwargs):
|
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'}
|
return {'error': 'Insufficient permissions to approve actions'}
|
||||||
agent = request.env['fusion.accounting.agent']
|
agent = request.env['fusion.accounting.agent']
|
||||||
results = []
|
results = []
|
||||||
@@ -119,7 +119,7 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
|
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
|
||||||
def reject_all(self, match_history_ids, reason='', **kwargs):
|
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'}
|
return {'error': 'Insufficient permissions to reject actions'}
|
||||||
agent = request.env['fusion.accounting.agent']
|
agent = request.env['fusion.accounting.agent']
|
||||||
results = []
|
results = []
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<field name="domain">bank_reconciliation</field>
|
<field name="domain">bank_reconciliation</field>
|
||||||
<field name="tier">3</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="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>
|
||||||
<record id="tool_auto_reconcile_bank_lines" model="fusion.accounting.tool">
|
<record id="tool_auto_reconcile_bank_lines" model="fusion.accounting.tool">
|
||||||
<field name="name">auto_reconcile_bank_lines</field>
|
<field name="name">auto_reconcile_bank_lines</field>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<field name="domain">bank_reconciliation</field>
|
<field name="domain">bank_reconciliation</field>
|
||||||
<field name="tier">3</field>
|
<field name="tier">3</field>
|
||||||
<field name="parameters_schema">{"type": "object", "properties": {"company_id": {"type": "integer"}}}</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>
|
||||||
<record id="tool_apply_reconcile_model" model="fusion.accounting.tool">
|
<record id="tool_apply_reconcile_model" model="fusion.accounting.tool">
|
||||||
<field name="name">apply_reconcile_model</field>
|
<field name="name">apply_reconcile_model</field>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<field name="domain">bank_reconciliation</field>
|
<field name="domain">bank_reconciliation</field>
|
||||||
<field name="tier">3</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="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>
|
||||||
<record id="tool_unmatch_bank_line" model="fusion.accounting.tool">
|
<record id="tool_unmatch_bank_line" model="fusion.accounting.tool">
|
||||||
<field name="name">unmatch_bank_line</field>
|
<field name="name">unmatch_bank_line</field>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<field name="domain">bank_reconciliation</field>
|
<field name="domain">bank_reconciliation</field>
|
||||||
<field name="tier">3</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="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>
|
||||||
<record id="tool_get_reconcile_suggestions" model="fusion.accounting.tool">
|
<record id="tool_get_reconcile_suggestions" model="fusion.accounting.tool">
|
||||||
<field name="name">get_reconcile_suggestions</field>
|
<field name="name">get_reconcile_suggestions</field>
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
<field name="domain">hst_management</field>
|
<field name="domain">hst_management</field>
|
||||||
<field name="tier">2</field>
|
<field name="tier">2</field>
|
||||||
<field name="parameters_schema">{"type": "object", "properties": {}}</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>
|
||||||
<record id="tool_validate_tax_return" model="fusion.accounting.tool">
|
<record id="tool_validate_tax_return" model="fusion.accounting.tool">
|
||||||
<field name="name">validate_tax_return</field>
|
<field name="name">validate_tax_return</field>
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
<field name="domain">hst_management</field>
|
<field name="domain">hst_management</field>
|
||||||
<field name="tier">3</field>
|
<field name="tier">3</field>
|
||||||
<field name="parameters_schema">{"type": "object", "properties": {"return_id": {"type": "integer"}}, "required": ["return_id"]}</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>
|
</record>
|
||||||
|
|
||||||
<!-- Domain 3: Accounts Receivable -->
|
<!-- Domain 3: Accounts Receivable -->
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
<field name="domain">accounts_receivable</field>
|
<field name="domain">accounts_receivable</field>
|
||||||
<field name="tier">2</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="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>
|
||||||
<record id="tool_get_followup_report" model="fusion.accounting.tool">
|
<record id="tool_get_followup_report" model="fusion.accounting.tool">
|
||||||
<field name="name">get_followup_report</field>
|
<field name="name">get_followup_report</field>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
<field name="domain">accounts_receivable</field>
|
<field name="domain">accounts_receivable</field>
|
||||||
<field name="tier">3</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="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>
|
||||||
<record id="tool_get_unmatched_payments" model="fusion.accounting.tool">
|
<record id="tool_get_unmatched_payments" model="fusion.accounting.tool">
|
||||||
<field name="name">get_unmatched_payments</field>
|
<field name="name">get_unmatched_payments</field>
|
||||||
@@ -449,7 +449,7 @@
|
|||||||
<field name="domain">adp</field>
|
<field name="domain">adp</field>
|
||||||
<field name="tier">3</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="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>
|
||||||
<record id="tool_verify_adp_split" model="fusion.accounting.tool">
|
<record id="tool_verify_adp_split" model="fusion.accounting.tool">
|
||||||
<field name="name">verify_adp_split</field>
|
<field name="name">verify_adp_split</field>
|
||||||
@@ -483,7 +483,7 @@
|
|||||||
<field name="domain">adp</field>
|
<field name="domain">adp</field>
|
||||||
<field name="tier">3</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="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>
|
</record>
|
||||||
|
|
||||||
<!-- Domain 10: Reporting -->
|
<!-- Domain 10: Reporting -->
|
||||||
@@ -542,7 +542,7 @@
|
|||||||
<field name="domain">reporting</field>
|
<field name="domain">reporting</field>
|
||||||
<field name="tier">2</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="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>
|
||||||
|
|
||||||
<record id="tool_get_invoicing_summary" model="fusion.accounting.tool">
|
<record id="tool_get_invoicing_summary" model="fusion.accounting.tool">
|
||||||
@@ -626,7 +626,7 @@
|
|||||||
<field name="domain">audit</field>
|
<field name="domain">audit</field>
|
||||||
<field name="tier">2</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="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>
|
||||||
<record id="tool_get_audit_status" model="fusion.accounting.tool">
|
<record id="tool_get_audit_status" model="fusion.accounting.tool">
|
||||||
<field name="name">get_audit_status</field>
|
<field name="name">get_audit_status</field>
|
||||||
@@ -643,7 +643,7 @@
|
|||||||
<field name="domain">audit</field>
|
<field name="domain">audit</field>
|
||||||
<field name="tier">2</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="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>
|
||||||
<record id="tool_get_audit_trail" model="fusion.accounting.tool">
|
<record id="tool_get_audit_trail" model="fusion.accounting.tool">
|
||||||
<field name="name">get_audit_trail</field>
|
<field name="name">get_audit_trail</field>
|
||||||
@@ -686,7 +686,7 @@
|
|||||||
<field name="domain">payroll_management</field>
|
<field name="domain">payroll_management</field>
|
||||||
<field name="tier">3</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="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>
|
||||||
<record id="tool_match_payroll_cheques" model="fusion.accounting.tool">
|
<record id="tool_match_payroll_cheques" model="fusion.accounting.tool">
|
||||||
<field name="name">match_payroll_cheques</field>
|
<field name="name">match_payroll_cheques</field>
|
||||||
@@ -695,7 +695,7 @@
|
|||||||
<field name="domain">payroll_management</field>
|
<field name="domain">payroll_management</field>
|
||||||
<field name="tier">3</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="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>
|
||||||
<record id="tool_prepare_cra_payment" model="fusion.accounting.tool">
|
<record id="tool_prepare_cra_payment" model="fusion.accounting.tool">
|
||||||
<field name="name">prepare_cra_payment</field>
|
<field name="name">prepare_cra_payment</field>
|
||||||
@@ -704,7 +704,7 @@
|
|||||||
<field name="domain">payroll_management</field>
|
<field name="domain">payroll_management</field>
|
||||||
<field name="tier">3</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="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>
|
||||||
<record id="tool_generate_t4" model="fusion.accounting.tool">
|
<record id="tool_generate_t4" model="fusion.accounting.tool">
|
||||||
<field name="name">generate_t4</field>
|
<field name="name">generate_t4</field>
|
||||||
@@ -713,7 +713,7 @@
|
|||||||
<field name="domain">payroll_management</field>
|
<field name="domain">payroll_management</field>
|
||||||
<field name="tier">2</field>
|
<field name="tier">2</field>
|
||||||
<field name="parameters_schema">{"type": "object", "properties": {}}</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>
|
||||||
<record id="tool_generate_roe" model="fusion.accounting.tool">
|
<record id="tool_generate_roe" model="fusion.accounting.tool">
|
||||||
<field name="name">generate_roe</field>
|
<field name="name">generate_roe</field>
|
||||||
@@ -722,7 +722,7 @@
|
|||||||
<field name="domain">payroll_management</field>
|
<field name="domain">payroll_management</field>
|
||||||
<field name="tier">2</field>
|
<field name="tier">2</field>
|
||||||
<field name="parameters_schema">{"type": "object", "properties": {}}</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>
|
||||||
<record id="tool_get_payroll_cost_report" model="fusion.accounting.tool">
|
<record id="tool_get_payroll_cost_report" model="fusion.accounting.tool">
|
||||||
<field name="name">get_payroll_cost_report</field>
|
<field name="name">get_payroll_cost_report</field>
|
||||||
@@ -823,7 +823,7 @@
|
|||||||
<field name="domain">bank_reconciliation</field>
|
<field name="domain">bank_reconciliation</field>
|
||||||
<field name="tier">3</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="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>
|
||||||
|
|
||||||
<record id="tool_create_expense_entry" model="fusion.accounting.tool">
|
<record id="tool_create_expense_entry" model="fusion.accounting.tool">
|
||||||
123
fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py
Normal file
123
fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
19
fusion_accounting_ai/security/ir.model.access.csv
Normal file
19
fusion_accounting_ai/security/ir.model.access.csv
Normal 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
|
||||||
|
9
fusion_accounting_ai/services/data_adapters/__init__.py
Normal file
9
fusion_accounting_ai/services/data_adapters/__init__.py
Normal 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']
|
||||||
25
fusion_accounting_ai/services/data_adapters/_registry.py
Normal file
25
fusion_accounting_ai/services/data_adapters/_registry.py
Normal 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
|
||||||
42
fusion_accounting_ai/services/data_adapters/assets.py
Normal file
42
fusion_accounting_ai/services/data_adapters/assets.py
Normal 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)
|
||||||
87
fusion_accounting_ai/services/data_adapters/bank_rec.py
Normal file
87
fusion_accounting_ai/services/data_adapters/bank_rec.py
Normal 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)
|
||||||
79
fusion_accounting_ai/services/data_adapters/base.py
Normal file
79
fusion_accounting_ai/services/data_adapters/base.py
Normal 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)
|
||||||
210
fusion_accounting_ai/services/data_adapters/followup.py
Normal file
210
fusion_accounting_ai/services/data_adapters/followup.py
Normal 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)
|
||||||
170
fusion_accounting_ai/services/data_adapters/reports.py
Normal file
170
fusion_accounting_ai/services/data_adapters/reports.py
Normal 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)
|
||||||
@@ -6,32 +6,10 @@ _logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def get_ap_aging(env, params):
|
def get_ap_aging(env, params):
|
||||||
today = fields.Date.today()
|
"""Return AP aging buckets. Routed through FollowupAdapter for tri-mode consistency."""
|
||||||
domain = [
|
from ..data_adapters import get_adapter
|
||||||
('account_id.account_type', '=', 'liability_payable'),
|
adapter = get_adapter(env, 'followup')
|
||||||
('parent_state', '=', 'posted'),
|
return adapter.aged_payables(company_id=env.company.id)
|
||||||
('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)}
|
|
||||||
|
|
||||||
|
|
||||||
def find_duplicate_bills(env, params):
|
def find_duplicate_bills(env, params):
|
||||||
@@ -1,66 +1,36 @@
|
|||||||
import logging
|
import logging
|
||||||
from odoo import fields
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_ar_aging(env, params):
|
def get_ar_aging(env, params):
|
||||||
today = fields.Date.today()
|
"""Return AR aging buckets. Routed through FollowupAdapter for tri-mode consistency."""
|
||||||
domain = [
|
from ..data_adapters import get_adapter
|
||||||
('account_id.account_type', '=', 'asset_receivable'),
|
adapter = get_adapter(env, 'followup')
|
||||||
('parent_state', '=', 'posted'),
|
return adapter.aged_receivables(company_id=env.company.id)
|
||||||
('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),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_overdue_invoices(env, params):
|
def get_overdue_invoices(env, params):
|
||||||
today = fields.Date.today()
|
"""Return overdue customer invoices. Routed through FollowupAdapter."""
|
||||||
days_overdue = int(params.get('min_days_overdue', 1))
|
from ..data_adapters import get_adapter
|
||||||
from datetime import timedelta
|
adapter = get_adapter(env, 'followup')
|
||||||
cutoff = today - timedelta(days=days_overdue)
|
rows = adapter.overdue_invoices(
|
||||||
invoices = env['account.move'].search([
|
days_overdue=int(params.get('min_days_overdue', 1)),
|
||||||
('move_type', '=', 'out_invoice'),
|
limit=int(params.get('limit', 50)),
|
||||||
('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 {
|
return {
|
||||||
'count': len(invoices),
|
'count': len(rows),
|
||||||
'invoices': [{
|
'invoices': [{
|
||||||
'id': inv.id,
|
'id': r['id'],
|
||||||
'name': inv.name,
|
'name': r['name'],
|
||||||
'partner': inv.partner_id.name if inv.partner_id else '',
|
'partner': r['partner_name'] or '',
|
||||||
'email': inv.partner_id.email or '' if inv.partner_id else '',
|
'email': r['partner_email'],
|
||||||
'phone': inv.partner_id.phone or '' if inv.partner_id else '',
|
'phone': r['partner_phone'],
|
||||||
'amount_total': inv.amount_total,
|
'amount_total': r['amount_total'],
|
||||||
'amount_residual': inv.amount_residual,
|
'amount_residual': r['amount_residual'],
|
||||||
'date_due': str(inv.invoice_date_due),
|
'date_due': str(r['invoice_date_due']) if r['invoice_date_due'] else '',
|
||||||
'days_overdue': (today - inv.invoice_date_due).days,
|
'days_overdue': r['days_overdue'],
|
||||||
} for inv in invoices],
|
} for r in rows],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -119,10 +89,10 @@ def get_partner_balance(env, params):
|
|||||||
|
|
||||||
|
|
||||||
def send_followup(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_id = int(params['partner_id'])
|
||||||
partner = env['res.partner'].browse(partner_id)
|
|
||||||
if not partner.exists():
|
|
||||||
return {'error': 'Partner not found'}
|
|
||||||
options = {
|
options = {
|
||||||
'partner_id': partner_id,
|
'partner_id': partner_id,
|
||||||
'email': params.get('send_email', False),
|
'email': params.get('send_email', False),
|
||||||
@@ -133,21 +103,16 @@ def send_followup(env, params):
|
|||||||
options['email_subject'] = params['email_subject']
|
options['email_subject'] = params['email_subject']
|
||||||
if params.get('body'):
|
if params.get('body'):
|
||||||
options['body'] = params['body']
|
options['body'] = params['body']
|
||||||
result = partner.execute_followup(options)
|
adapter = get_adapter(env, 'followup')
|
||||||
return {'status': 'sent', 'partner': partner.name, 'result': str(result) if result else 'done'}
|
return adapter.send_followup(partner_id=partner_id, options=options)
|
||||||
|
|
||||||
|
|
||||||
def get_followup_report(env, params):
|
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_id = int(params['partner_id'])
|
||||||
partner = env['res.partner'].browse(partner_id)
|
adapter = get_adapter(env, 'followup')
|
||||||
if not partner.exists():
|
return adapter.followup_report_html(partner_id=partner_id)
|
||||||
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)}
|
|
||||||
|
|
||||||
|
|
||||||
def reconcile_payment_to_invoice(env, params):
|
def reconcile_payment_to_invoice(env, params):
|
||||||
@@ -6,28 +6,32 @@ _logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def get_unreconciled_bank_lines(env, params):
|
def get_unreconciled_bank_lines(env, params):
|
||||||
domain = [('is_reconciled', '=', False), ('company_id', '=', env.company.id)]
|
"""Return unreconciled bank lines for a journal/company.
|
||||||
if params.get('journal_id'):
|
|
||||||
domain.append(('journal_id', '=', int(params['journal_id'])))
|
Routed through the bank_rec data adapter so the result shape is identical
|
||||||
if params.get('date_from'):
|
whether the install profile is fusion-native, Enterprise, or pure Community.
|
||||||
domain.append(('date', '>=', params['date_from']))
|
"""
|
||||||
if params.get('date_to'):
|
from ..data_adapters import get_adapter
|
||||||
domain.append(('date', '<=', params['date_to']))
|
adapter = get_adapter(env, 'bank_rec')
|
||||||
if params.get('min_amount'):
|
rows = adapter.list_unreconciled(
|
||||||
domain.append(('amount', '>=', float(params['min_amount'])))
|
journal_id=int(params['journal_id']) if params.get('journal_id') else None,
|
||||||
limit = int(params.get('limit', 50))
|
limit=int(params.get('limit', 50)),
|
||||||
lines = env['account.bank.statement.line'].search(domain, limit=limit, order='date desc')
|
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 {
|
return {
|
||||||
'count': len(lines),
|
'count': len(rows),
|
||||||
'total_amount': sum(abs(l.amount) for l in lines),
|
'total_amount': sum(abs(r['amount']) for r in rows),
|
||||||
'lines': [{
|
'lines': [{
|
||||||
'id': l.id,
|
'id': r['id'],
|
||||||
'date': str(l.date),
|
'date': str(r['date']) if r['date'] else '',
|
||||||
'payment_ref': l.payment_ref or '',
|
'payment_ref': r['payment_ref'] or '',
|
||||||
'partner_name': l.partner_name or (l.partner_id.name if l.partner_id else ''),
|
'partner_name': r['partner_name'] or '',
|
||||||
'amount': l.amount,
|
'amount': r['amount'],
|
||||||
'journal': l.journal_id.name,
|
'journal': r['journal_name'],
|
||||||
} for l in lines],
|
} for r in rows],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,25 +52,16 @@ def calculate_hst_balance(env, params):
|
|||||||
|
|
||||||
|
|
||||||
def get_tax_report(env, params):
|
def get_tax_report(env, params):
|
||||||
report_ref = params.get('report_ref', 'account.generic_tax_report')
|
"""Route through ReportsAdapter for tri-mode consistency. The Community
|
||||||
try:
|
fallback returns an error dict explaining the report is Enterprise-only."""
|
||||||
report = env.ref(report_ref)
|
from ..data_adapters import get_adapter
|
||||||
except Exception:
|
adapter = get_adapter(env, 'reports')
|
||||||
return {'error': f'Report not found: {report_ref}'}
|
return adapter.run_report(
|
||||||
options = report.get_options({
|
ref_id=params.get('report_ref', 'account.generic_tax_report'),
|
||||||
'date': {
|
date_from=params.get('date_from'),
|
||||||
'date_from': params.get('date_from', ''),
|
date_to=params.get('date_to'),
|
||||||
'date_to': params.get('date_to', ''),
|
limit=50,
|
||||||
}
|
)
|
||||||
})
|
|
||||||
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]],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def find_missing_tax_invoices(env, params):
|
def find_missing_tax_invoices(env, params):
|
||||||
@@ -101,22 +101,31 @@ def run_hash_integrity_check(env, params):
|
|||||||
|
|
||||||
|
|
||||||
def get_period_summary(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_from = params.get('date_from')
|
||||||
date_to = params.get('date_to')
|
date_to = params.get('date_to')
|
||||||
try:
|
result = adapter.run_report(
|
||||||
report = env.ref('account_reports.trial_balance_report')
|
ref_id='account_reports.trial_balance_report',
|
||||||
except Exception:
|
date_from=date_from, date_to=date_to,
|
||||||
report = env.ref('account.trial_balance_report', raise_if_not_found=False)
|
)
|
||||||
if not report:
|
if isinstance(result, dict) and result.get('error'):
|
||||||
return {'error': 'Trial balance report not found'}
|
rows = adapter.trial_balance(
|
||||||
options = report.get_options({'date': {'date_from': date_from, 'date_to': date_to}})
|
date_to=date_to, company_ids=[env.company.id],
|
||||||
lines = report._get_lines(options)
|
)
|
||||||
return {
|
return {
|
||||||
'period': f'{date_from} to {date_to}',
|
'period': f'{date_from} to {date_to}',
|
||||||
'lines': [{
|
'lines': [{
|
||||||
'name': l.get('name', ''),
|
'name': f"{r['account_code']} {r['account_name']}",
|
||||||
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
|
'columns': [r['debit'], r['credit'], r['balance']],
|
||||||
} for l in lines[:100]],
|
} for r in rows[:100]],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'period': f'{date_from} to {date_to}',
|
||||||
|
'lines': result.get('lines', []),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,67 +1,91 @@
|
|||||||
import logging
|
import logging
|
||||||
import base64
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _get_report(env, ref_id):
|
# ---------------------------------------------------------------------------
|
||||||
try:
|
# Enterprise account.report wrappers — all routed through ReportsAdapter.
|
||||||
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]],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_profit_loss(env, params):
|
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):
|
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):
|
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):
|
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):
|
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_ref = params.get('report_ref', 'account_reports.profit_and_loss')
|
||||||
report = _get_report(env, report_ref)
|
period1 = adapter.run_report(
|
||||||
if not report:
|
ref_id=report_ref,
|
||||||
return {'error': f'Report {report_ref} not found'}
|
date_from=params.get('period1_from'),
|
||||||
|
date_to=params.get('period1_to'),
|
||||||
period1 = _run_report(env, report_ref, {
|
)
|
||||||
'date_from': params.get('period1_from'),
|
period2 = adapter.run_report(
|
||||||
'date_to': params.get('period1_to'),
|
ref_id=report_ref,
|
||||||
})
|
date_from=params.get('period2_from'),
|
||||||
period2 = _run_report(env, report_ref, {
|
date_to=params.get('period2_to'),
|
||||||
'date_from': params.get('period2_from'),
|
)
|
||||||
'date_to': params.get('period2_to'),
|
|
||||||
})
|
|
||||||
return {'period_1': period1, 'period_2': period2}
|
return {'period_1': period1, 'period_2': period2}
|
||||||
|
|
||||||
|
|
||||||
@@ -74,42 +98,27 @@ def answer_financial_question(env, params):
|
|||||||
|
|
||||||
|
|
||||||
def export_report(env, params):
|
def export_report(env, params):
|
||||||
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
|
"""Route through ReportsAdapter for tri-mode consistency."""
|
||||||
fmt = params.get('format', 'pdf')
|
from ..data_adapters import get_adapter
|
||||||
report = _get_report(env, report_ref)
|
adapter = get_adapter(env, 'reports')
|
||||||
if not report:
|
return adapter.export_report(
|
||||||
return {'error': f'Report {report_ref} not found'}
|
ref_id=params.get('report_ref', 'account_reports.profit_and_loss'),
|
||||||
date_opts = {}
|
fmt=params.get('format', 'pdf'),
|
||||||
if params.get('date_from'):
|
date_from=params.get('date_from'),
|
||||||
date_opts['date_from'] = params['date_from']
|
date_to=params.get('date_to'),
|
||||||
if params.get('date_to'):
|
)
|
||||||
date_opts['date_to'] = params['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)}'}
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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):
|
def get_invoicing_summary(env, params):
|
||||||
"""Get invoicing summary — total invoiced by month, by partner, or for a date range.
|
"""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."""
|
Supports: monthly breakdown for a year, current month totals, or filtered by partner."""
|
||||||
from datetime import date, timedelta
|
from datetime import date
|
||||||
import calendar
|
import calendar
|
||||||
|
|
||||||
year = int(params.get('year', date.today().year))
|
year = int(params.get('year', date.today().year))
|
||||||
@@ -145,7 +154,6 @@ def get_invoicing_summary(env, params):
|
|||||||
} for inv in invoices[:30]],
|
} for inv in invoices[:30]],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Monthly breakdown for the year
|
|
||||||
months = []
|
months = []
|
||||||
grand_total = 0
|
grand_total = 0
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
@@ -209,7 +217,6 @@ def get_billing_summary(env, params):
|
|||||||
} for b in bills[:30]],
|
} for b in bills[:30]],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Monthly breakdown
|
|
||||||
months = []
|
months = []
|
||||||
grand_total = 0
|
grand_total = 0
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
2
fusion_accounting_ai/tests/__init__.py
Normal file
2
fusion_accounting_ai/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import test_post_migration
|
||||||
|
from . import test_data_adapters
|
||||||
146
fusion_accounting_ai/tests/test_data_adapters.py
Normal file
146
fusion_accounting_ai/tests/test_data_adapters.py
Normal 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)
|
||||||
34
fusion_accounting_ai/tests/test_post_migration.py
Normal file
34
fusion_accounting_ai/tests/test_post_migration.py
Normal 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)
|
||||||
@@ -31,10 +31,10 @@
|
|||||||
<header>
|
<header>
|
||||||
<button name="action_approve" string="Approve" type="object"
|
<button name="action_approve" string="Approve" type="object"
|
||||||
class="btn-primary" invisible="decision != 'pending'"
|
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"
|
<button name="action_reject" string="Reject" type="object"
|
||||||
class="btn-danger" invisible="decision != 'pending'"
|
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"
|
<field name="decision" widget="statusbar"
|
||||||
statusbar_visible="pending,approved,rejected,auto"/>
|
statusbar_visible="pending,approved,rejected,auto"/>
|
||||||
</header>
|
</header>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
name="Fusion AI"
|
name="Fusion AI"
|
||||||
parent="accountant.menu_accounting"
|
parent="accountant.menu_accounting"
|
||||||
sequence="8"
|
sequence="8"
|
||||||
groups="group_fusion_accounting_user"/>
|
groups="fusion_accounting_core.group_fusion_accounting_user"/>
|
||||||
|
|
||||||
<!-- Dashboard -->
|
<!-- Dashboard -->
|
||||||
<menuitem id="menu_fusion_dashboard"
|
<menuitem id="menu_fusion_dashboard"
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
parent="menu_fusion_accounting_root"
|
parent="menu_fusion_accounting_root"
|
||||||
action="action_fusion_rule"
|
action="action_fusion_rule"
|
||||||
sequence="40"
|
sequence="40"
|
||||||
groups="group_fusion_accounting_manager"/>
|
groups="fusion_accounting_core.group_fusion_accounting_manager"/>
|
||||||
|
|
||||||
<!-- Vendor Tax Profiles -->
|
<!-- Vendor Tax Profiles -->
|
||||||
<menuitem id="menu_fusion_vendor_profiles"
|
<menuitem id="menu_fusion_vendor_profiles"
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
parent="menu_fusion_accounting_root"
|
parent="menu_fusion_accounting_root"
|
||||||
action="action_vendor_tax_profiles"
|
action="action_vendor_tax_profiles"
|
||||||
sequence="50"
|
sequence="50"
|
||||||
groups="group_fusion_accounting_manager"/>
|
groups="fusion_accounting_core.group_fusion_accounting_manager"/>
|
||||||
|
|
||||||
<!-- Recurring Patterns -->
|
<!-- Recurring Patterns -->
|
||||||
<menuitem id="menu_fusion_recurring_patterns"
|
<menuitem id="menu_fusion_recurring_patterns"
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
parent="menu_fusion_accounting_root"
|
parent="menu_fusion_accounting_root"
|
||||||
action="action_recurring_patterns"
|
action="action_recurring_patterns"
|
||||||
sequence="55"
|
sequence="55"
|
||||||
groups="group_fusion_accounting_manager"/>
|
groups="fusion_accounting_core.group_fusion_accounting_manager"/>
|
||||||
|
|
||||||
<!-- Configuration (link to settings) -->
|
<!-- Configuration (link to settings) -->
|
||||||
<menuitem id="menu_fusion_config"
|
<menuitem id="menu_fusion_config"
|
||||||
@@ -58,5 +58,5 @@
|
|||||||
parent="menu_fusion_accounting_root"
|
parent="menu_fusion_accounting_root"
|
||||||
action="account.action_account_config"
|
action="account.action_account_config"
|
||||||
sequence="90"
|
sequence="90"
|
||||||
groups="group_fusion_accounting_admin"/>
|
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
|
||||||
</odoo>
|
</odoo>
|
||||||
@@ -27,10 +27,10 @@
|
|||||||
<header>
|
<header>
|
||||||
<button name="action_demote" string="Demote to Needs Approval" type="object"
|
<button name="action_demote" string="Demote to Needs Approval" type="object"
|
||||||
class="btn-warning" invisible="approval_tier != 'auto'"
|
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"
|
<button name="action_rollback" string="Rollback to Previous Version" type="object"
|
||||||
class="btn-secondary" invisible="not parent_rule_id"
|
class="btn-secondary" invisible="not parent_rule_id"
|
||||||
groups="fusion_accounting.group_fusion_accounting_admin"/>
|
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
|
||||||
</header>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<div class="oe_title">
|
<div class="oe_title">
|
||||||
25
fusion_accounting_core/CLAUDE.md
Normal file
25
fusion_accounting_core/CLAUDE.md
Normal 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`
|
||||||
39
fusion_accounting_core/README.md
Normal file
39
fusion_accounting_core/README.md
Normal 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;
|
||||||
28
fusion_accounting_core/UPGRADE_NOTES.md
Normal file
28
fusion_accounting_core/UPGRADE_NOTES.md
Normal 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)
|
||||||
1
fusion_accounting_core/__init__.py
Normal file
1
fusion_accounting_core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
33
fusion_accounting_core/__manifest__.py
Normal file
33
fusion_accounting_core/__manifest__.py
Normal 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',
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
3
fusion_accounting_core/models/__init__.py
Normal file
3
fusion_accounting_core/models/__init__.py
Normal 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
Reference in New Issue
Block a user