changes
This commit is contained in:
BIN
fusion_accounting/fusion_accounting_ai/.DS_Store
vendored
Normal file
BIN
fusion_accounting/fusion_accounting_ai/.DS_Store
vendored
Normal file
Binary file not shown.
272
fusion_accounting/fusion_accounting_ai/CLAUDE.md
Normal file
272
fusion_accounting/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/fusion_accounting_ai/README.md
Normal file
31
fusion_accounting/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/fusion_accounting_ai/UPGRADE_NOTES.md
Normal file
22
fusion_accounting/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/fusion_accounting_ai/__init__.py
Normal file
4
fusion_accounting/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/fusion_accounting_ai/__manifest__.py
Normal file
58
fusion_accounting/fusion_accounting_ai/__manifest__.py
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
'name': 'Fusion Accounting AI',
|
||||
'version': '19.0.1.1.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',
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import chat_controller
|
||||
@@ -0,0 +1,243 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingChatController(http.Controller):
|
||||
|
||||
def _check_session_ownership(self, session):
|
||||
"""S1-S3: Verify the current user owns the session."""
|
||||
if session.user_id.id != request.env.user.id:
|
||||
# Allow managers to access any session
|
||||
if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'):
|
||||
return {'error': 'Access denied: you do not own this session'}
|
||||
return None
|
||||
|
||||
@http.route('/fusion_accounting/session/create', type='jsonrpc', auth='user')
|
||||
def create_session(self, context_domain=None, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].create({
|
||||
'user_id': request.env.user.id,
|
||||
'company_id': request.env.company.id,
|
||||
'context_domain': context_domain,
|
||||
})
|
||||
return {'session_id': session.id, 'name': session.name}
|
||||
|
||||
@http.route('/fusion_accounting/session/close', type='jsonrpc', auth='user')
|
||||
def close_session(self, session_id, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if not session.exists():
|
||||
return {'status': 'closed'}
|
||||
# S2: Ownership check
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
if session.state == 'active':
|
||||
session.action_close_session()
|
||||
return {'status': 'closed'}
|
||||
|
||||
@http.route('/fusion_accounting/chat', type='jsonrpc', auth='user')
|
||||
def chat(self, session_id, message, context=None, image=None, **kwargs):
|
||||
if not message and not image:
|
||||
return {'error': 'Message or image is required'}
|
||||
# S3: Ownership check
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if session.exists():
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.chat(int(session_id), message or '', context=context, image=image)
|
||||
return result
|
||||
|
||||
@http.route('/fusion_accounting/approve', type='jsonrpc', auth='user')
|
||||
def approve_action(self, match_history_id, **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to approve actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.approve_action(int(match_history_id))
|
||||
return result
|
||||
|
||||
@http.route('/fusion_accounting/reject', type='jsonrpc', auth='user')
|
||||
def reject_action(self, match_history_id, reason='', **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to reject actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.reject_action(int(match_history_id), reason)
|
||||
return result
|
||||
|
||||
@http.route('/fusion_accounting/dashboard/data', type='jsonrpc', auth='user')
|
||||
def dashboard_data(self, **kwargs):
|
||||
# E2: Wrap in try/except so dashboard doesn't return 500
|
||||
try:
|
||||
dashboard = request.env['fusion.accounting.dashboard'].new({
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {
|
||||
'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount},
|
||||
'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count},
|
||||
'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week},
|
||||
'hst': {'balance': dashboard.hst_balance},
|
||||
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
|
||||
'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items},
|
||||
# E1: Include needs_attention and recent_activity
|
||||
'needs_attention': json.loads(dashboard.needs_attention_json or '[]'),
|
||||
'recent_activity': json.loads(dashboard.recent_activity_json or '[]'),
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.exception("Dashboard data computation failed")
|
||||
return {
|
||||
'error': 'Dashboard data could not be computed',
|
||||
'bank_recon': {'count': 0, 'amount': 0},
|
||||
'ar': {'total': 0, 'overdue_count': 0},
|
||||
'ap': {'total': 0, 'due_this_week': 0},
|
||||
'hst': {'balance': 0},
|
||||
'audit': {'score': 0, 'flags': 0},
|
||||
'month_end': {'status': 'Unknown', 'open_items': 0},
|
||||
'needs_attention': [],
|
||||
'recent_activity': [],
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user')
|
||||
def approve_all(self, match_history_ids, **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to approve actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
results = []
|
||||
for mid in match_history_ids:
|
||||
try:
|
||||
result = agent.approve_action(int(mid))
|
||||
results.append({'id': mid, 'status': 'approved', 'result': result})
|
||||
except Exception as e:
|
||||
# S4: Sanitize exception — log full error, return generic message
|
||||
_logger.exception("Error approving match history %s", mid)
|
||||
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be approved. Check server logs for details.'})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
|
||||
def reject_all(self, match_history_ids, reason='', **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to reject actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
results = []
|
||||
for mid in match_history_ids:
|
||||
try:
|
||||
result = agent.reject_action(int(mid), reason)
|
||||
# E3: Consistent return shape with approve_all
|
||||
results.append({'id': mid, 'status': 'rejected', 'result': result})
|
||||
except Exception as e:
|
||||
# S4: Sanitize exception
|
||||
_logger.exception("Error rejecting match history %s", mid)
|
||||
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be rejected. Check server logs for details.'})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_accounting/chat/status', type='jsonrpc', auth='user')
|
||||
def chat_status(self, session_id, **kwargs):
|
||||
"""Poll the live execution state of a running chat — returns thinking text,
|
||||
tool calls in progress, and current status. Called every 500ms by the frontend
|
||||
while a chat request is in flight."""
|
||||
from ..services.agent import get_execution_state
|
||||
state = get_execution_state(int(session_id))
|
||||
return state
|
||||
|
||||
@http.route('/fusion_accounting/search_matches', type='jsonrpc', auth='user')
|
||||
def search_matches(self, statement_line_id, query='', **kwargs):
|
||||
"""Live search for matching journal items — called directly by the
|
||||
reconciliation table search bar (no AI round-trip)."""
|
||||
from ..services.tools.bank_reconciliation import search_matching_entries
|
||||
try:
|
||||
result = search_matching_entries(request.env, {
|
||||
'statement_line_id': int(statement_line_id),
|
||||
'query': query,
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
_logger.exception("Search matches failed")
|
||||
return {'candidates': [], 'error': str(e)}
|
||||
|
||||
@http.route('/fusion_accounting/session/list', type='jsonrpc', auth='user')
|
||||
def session_list(self, limit=20, **kwargs):
|
||||
"""List recent sessions for the session picker dropdown."""
|
||||
sessions = request.env['fusion.accounting.session'].search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
], order='write_date desc', limit=int(limit))
|
||||
return {
|
||||
'sessions': [{
|
||||
'id': s.id,
|
||||
'name': s.name,
|
||||
'state': s.state,
|
||||
'date': s.write_date.isoformat() if s.write_date else '',
|
||||
'message_count': len(json.loads(s.message_ids_json or '[]')),
|
||||
'ai_model': s.ai_model or '',
|
||||
} for s in sessions],
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user')
|
||||
def session_latest(self, **kwargs):
|
||||
# Find the most recent active session that has messages first,
|
||||
# fall back to any active session (including empty ones)
|
||||
sessions = request.env['fusion.accounting.session'].search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
('state', '=', 'active'),
|
||||
], order='write_date desc', limit=10)
|
||||
if not sessions:
|
||||
return {'session_id': None, 'messages': [], 'name': None}
|
||||
|
||||
# Prefer a session with actual messages
|
||||
session = None
|
||||
for s in sessions:
|
||||
msg_json = s.message_ids_json or '[]'
|
||||
if msg_json != '[]' and len(msg_json) > 5:
|
||||
session = s
|
||||
break
|
||||
# If no session has messages, use the newest one
|
||||
if not session:
|
||||
session = sessions[0]
|
||||
|
||||
# Clean up empty stale sessions (created but never used)
|
||||
for s in sessions:
|
||||
if s.id != session.id and (s.message_ids_json or '[]') == '[]':
|
||||
s.write({'state': 'closed'})
|
||||
|
||||
messages = json.loads(session.message_ids_json or '[]')
|
||||
display_messages = []
|
||||
for msg in messages:
|
||||
if isinstance(msg.get('content'), str) and msg['content'].strip():
|
||||
display_messages.append(msg)
|
||||
elif isinstance(msg.get('content'), list):
|
||||
for block in msg['content']:
|
||||
if isinstance(block, dict) and block.get('type') == 'text' and block['text'].strip():
|
||||
display_messages.append({'role': msg['role'], 'content': block['text']})
|
||||
|
||||
# Include any pending approvals so they show on page load
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
pending = request.env['fusion.accounting.match.history'].search([
|
||||
('session_id', '=', session.id),
|
||||
('decision', '=', 'pending'),
|
||||
])
|
||||
pending_approvals = [agent._format_pending_approval(p) for p in pending]
|
||||
|
||||
return {
|
||||
'session_id': session.id,
|
||||
'messages': display_messages,
|
||||
'name': session.name,
|
||||
'pending_approvals': pending_approvals,
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user')
|
||||
def session_history(self, session_id, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if not session.exists():
|
||||
return {'error': 'Session not found'}
|
||||
# S1: Ownership check
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
return {
|
||||
'messages': json.loads(session.message_ids_json or '[]'),
|
||||
'session_id': session.id,
|
||||
'state': session.state,
|
||||
}
|
||||
83
fusion_accounting/fusion_accounting_ai/data/cron.xml
Normal file
83
fusion_accounting/fusion_accounting_ai/data/cron.xml
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- Session name sequence -->
|
||||
<record id="seq_fusion_accounting_session" model="ir.sequence">
|
||||
<field name="name">Fusion AI Session</field>
|
||||
<field name="code">fusion.accounting.session</field>
|
||||
<field name="prefix">FAS/%(year)s/</field>
|
||||
<field name="padding">5</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily audit scan: expire stale pending approvals -->
|
||||
<record id="cron_fusion_audit_scan" model="ir.cron">
|
||||
<field name="name">Fusion AI: Periodic Audit Scan</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_match_history"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
cutoff = datetime.datetime.now() - datetime.timedelta(days=30)
|
||||
stale = model.search([('decision', '=', 'pending'), ('proposed_at', '<', cutoff.strftime('%Y-%m-%d %H:%M:%S'))])
|
||||
stale.write({'decision': 'rejected', 'rejection_reason': 'Auto-expired after 30 days'})
|
||||
</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Weekly tier promotion check -->
|
||||
<record id="cron_fusion_tier_promotion" model="ir.cron">
|
||||
<field name="name">Fusion AI: Tier Promotion Check</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_rule"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_approval')]):
|
||||
rule._check_promotion()
|
||||
</field>
|
||||
<field name="interval_number">7</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Weekly recurring pattern rebuild -->
|
||||
<record id="cron_fusion_recurring_patterns" model="ir.cron">
|
||||
<field name="name">Fusion AI: Rebuild Recurring Patterns</field>
|
||||
<field name="model_id" ref="model_fusion_recurring_pattern"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._rebuild_all_patterns(min_occurrences=3)</field>
|
||||
<field name="interval_number">7</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily auto-reconcile inter-account transfers (CC payments) -->
|
||||
<record id="cron_fusion_transfer_reconcile" model="ir.cron">
|
||||
<field name="name">Fusion AI: Auto-Reconcile Inter-Account Transfers</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_agent"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_reconcile_transfers()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily auto-reconcile payroll cheques against open liability entries -->
|
||||
<record id="cron_fusion_payroll_cheque_reconcile" model="ir.cron">
|
||||
<field name="name">Fusion AI: Reconcile Payroll Cheques</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_agent"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._reconcile_payroll_cheques()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Weekly vendor tax profile rebuild -->
|
||||
<record id="cron_fusion_vendor_profiles" model="ir.cron">
|
||||
<field name="name">Fusion AI: Rebuild Vendor Tax Profiles</field>
|
||||
<field name="model_id" ref="model_fusion_vendor_tax_profile"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._rebuild_all_profiles(min_bills=3)</field>
|
||||
<field name="interval_number">7</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="rule_elavon_fee" model="fusion.accounting.rule">
|
||||
<field name="name">Elavon Card Processing Fee</field>
|
||||
<field name="rule_type">fee</field>
|
||||
<field name="description">Elavon merchant service charges typically show as a fee deducted from card payment batches. The fee is approximately 1.5-1.8% of the gross batch amount and should be allocated to the Elavon Fee expense account.</field>
|
||||
<field name="match_logic">When a bank statement line contains "elavon" or "mrch svc" and the amount is less than the sum of matching card payments, allocate the difference to the fee account as a processing fee.</field>
|
||||
<field name="created_by">admin</field>
|
||||
<field name="approval_tier">needs_approval</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_weekend_batch" model="fusion.accounting.rule">
|
||||
<field name="name">Weekend Card Batch Combination</field>
|
||||
<field name="rule_type">match</field>
|
||||
<field name="description">Card payment batches deposited on Monday often combine Friday, Saturday, and Sunday transactions. When matching Monday bank deposits to card payments, look across the preceding weekend.</field>
|
||||
<field name="match_logic">For bank lines dated Monday with card-related labels, sum card payments from the preceding Friday through Sunday to find a match.</field>
|
||||
<field name="created_by">admin</field>
|
||||
<field name="approval_tier">needs_approval</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
</odoo>
|
||||
837
fusion_accounting/fusion_accounting_ai/data/tool_definitions.xml
Normal file
837
fusion_accounting/fusion_accounting_ai/data/tool_definitions.xml
Normal file
@@ -0,0 +1,837 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- Domain 1: Bank Reconciliation -->
|
||||
<record id="tool_get_unreconciled_bank_lines" model="fusion.accounting.tool">
|
||||
<field name="name">get_unreconciled_bank_lines</field>
|
||||
<field name="display_name_field">Get Unreconciled Bank Lines</field>
|
||||
<field name="description">List unreconciled bank statement lines with optional filters for journal, date range, and minimum amount.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Journal ID to filter by"}, "date_from": {"type": "string", "description": "Start date (YYYY-MM-DD)"}, "date_to": {"type": "string", "description": "End date (YYYY-MM-DD)"}, "min_amount": {"type": "number", "description": "Minimum absolute amount"}, "limit": {"type": "integer", "description": "Max records to return", "default": 50}}}</field>
|
||||
<field name="odoo_method">account.bank.statement.line.search_read</field>
|
||||
</record>
|
||||
<record id="tool_get_unreconciled_receipts" model="fusion.accounting.tool">
|
||||
<field name="name">get_unreconciled_receipts</field>
|
||||
<field name="display_name_field">Get Unreconciled Receipts</field>
|
||||
<field name="description">List unreconciled Outstanding Receipts entries on the specified account (default 1122).</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"account_code": {"type": "string", "description": "Account code prefix", "default": "1122"}}}</field>
|
||||
</record>
|
||||
<record id="tool_match_bank_line_to_payments" model="fusion.accounting.tool">
|
||||
<field name="name">match_bank_line_to_payments</field>
|
||||
<field name="display_name_field">Match Bank Line to Payments</field>
|
||||
<field name="description">Match a bank statement line to one or more payment journal items for reconciliation.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer", "description": "Bank statement line ID"}, "move_line_ids": {"type": "array", "items": {"type": "integer"}, "description": "Journal item IDs to match"}}, "required": ["statement_line_id", "move_line_ids"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_auto_reconcile_bank_lines" model="fusion.accounting.tool">
|
||||
<field name="name">auto_reconcile_bank_lines</field>
|
||||
<field name="display_name_field">Auto-Reconcile Bank Lines</field>
|
||||
<field name="description">Run Odoo's built-in auto-reconciliation engine on all unreconciled bank statement lines.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"company_id": {"type": "integer"}}}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_apply_reconcile_model" model="fusion.accounting.tool">
|
||||
<field name="name">apply_reconcile_model</field>
|
||||
<field name="display_name_field">Apply Reconciliation Model</field>
|
||||
<field name="description">Apply a specific reconciliation model to a bank statement line.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"model_id": {"type": "integer"}, "statement_line_id": {"type": "integer"}}, "required": ["model_id", "statement_line_id"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_unmatch_bank_line" model="fusion.accounting.tool">
|
||||
<field name="name">unmatch_bank_line</field>
|
||||
<field name="display_name_field">Unmatch Bank Line</field>
|
||||
<field name="description">Undo a bank statement line reconciliation.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer"}}, "required": ["statement_line_id"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_get_reconcile_suggestions" model="fusion.accounting.tool">
|
||||
<field name="name">get_reconcile_suggestions</field>
|
||||
<field name="display_name_field">Get Reconciliation Suggestions</field>
|
||||
<field name="description">Get available reconciliation models for a bank statement line.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer"}}, "required": ["statement_line_id"]}</field>
|
||||
</record>
|
||||
<record id="tool_sum_payments_by_date" model="fusion.accounting.tool">
|
||||
<field name="name">sum_payments_by_date</field>
|
||||
<field name="display_name_field">Sum Payments by Date</field>
|
||||
<field name="description">Sum payment journal items for a date range. IMPORTANT: You MUST pass journal_ids to filter to specific journals (e.g., the card/POS journal). Without journal_ids, returns totals across ALL company journals which will be misleadingly large. Use this to verify card batch deposit amounts against the card payment journal for the prior business day.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "journal_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["date_from", "date_to"]}</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 2: HST/GST Management -->
|
||||
<record id="tool_calculate_hst_balance" model="fusion.accounting.tool">
|
||||
<field name="name">calculate_hst_balance</field>
|
||||
<field name="display_name_field">Calculate HST Balance</field>
|
||||
<field name="description">Calculate net HST position (collected minus ITCs) for a period.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_tax_report" model="fusion.accounting.tool">
|
||||
<field name="name">get_tax_report</field>
|
||||
<field name="display_name_field">Get Tax Report</field>
|
||||
<field name="description">Generate a tax report for a period using Odoo's reporting engine.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "report_ref": {"type": "string", "default": "account.generic_tax_report"}}}</field>
|
||||
</record>
|
||||
<record id="tool_find_missing_tax_invoices" model="fusion.accounting.tool">
|
||||
<field name="name">find_missing_tax_invoices</field>
|
||||
<field name="display_name_field">Find Missing Tax Invoices</field>
|
||||
<field name="description">Find customer invoices with taxable products but no tax applied.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_find_missing_itc_bills" model="fusion.accounting.tool">
|
||||
<field name="name">find_missing_itc_bills</field>
|
||||
<field name="display_name_field">Find Missing ITC Bills</field>
|
||||
<field name="description">Find vendor bills without input tax credits.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_tax_return_status" model="fusion.accounting.tool">
|
||||
<field name="name">get_tax_return_status</field>
|
||||
<field name="display_name_field">Get Tax Return Status</field>
|
||||
<field name="description">Check the status of periodic tax returns.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_generate_tax_return" model="fusion.accounting.tool">
|
||||
<field name="name">generate_tax_return</field>
|
||||
<field name="display_name_field">Generate Tax Return</field>
|
||||
<field name="description">Refresh all tax return data.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">2</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_validate_tax_return" model="fusion.accounting.tool">
|
||||
<field name="name">validate_tax_return</field>
|
||||
<field name="display_name_field">Validate Tax Return</field>
|
||||
<field name="description">Mark a tax return as validated.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"return_id": {"type": "integer"}}, "required": ["return_id"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 3: Accounts Receivable -->
|
||||
<record id="tool_get_ar_aging" model="fusion.accounting.tool">
|
||||
<field name="name">get_ar_aging</field>
|
||||
<field name="display_name_field">Get AR Aging</field>
|
||||
<field name="description">Get accounts receivable aging buckets (current, 30, 60, 90+ days).</field>
|
||||
<field name="domain">accounts_receivable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_get_overdue_invoices" model="fusion.accounting.tool">
|
||||
<field name="name">get_overdue_invoices</field>
|
||||
<field name="display_name_field">Get Overdue Invoices</field>
|
||||
<field name="description">List invoices past due with partner contact information.</field>
|
||||
<field name="domain">accounts_receivable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"min_days_overdue": {"type": "integer", "default": 1}, "limit": {"type": "integer", "default": 50}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_partner_balance" model="fusion.accounting.tool">
|
||||
<field name="name">get_partner_balance</field>
|
||||
<field name="display_name_field">Get Partner Balance</field>
|
||||
<field name="description">[Tier 1: Read-only] Get a partner's AR and AP balance with open items. Shows: how much they owe us (receivable), how much we owe them (payable), and net balance. Use for "how much do we owe Pride Mobility?", "what's the balance for ADP?".</field>
|
||||
<field name="domain">accounts_receivable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer", "description": "Partner ID (optional if partner_name provided)"}, "partner_name": {"type": "string", "description": "Partner name to search for (e.g. 'Pride Mobility')"}}}</field>
|
||||
</record>
|
||||
<record id="tool_send_followup" model="fusion.accounting.tool">
|
||||
<field name="name">send_followup</field>
|
||||
<field name="display_name_field">Send Follow-Up</field>
|
||||
<field name="description">Draft and send a follow-up email to a partner about overdue invoices.</field>
|
||||
<field name="domain">accounts_receivable</field>
|
||||
<field name="tier">2</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer"}, "send_email": {"type": "boolean"}, "print_letter": {"type": "boolean"}, "email_subject": {"type": "string"}, "body": {"type": "string"}}, "required": ["partner_id"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_get_followup_report" model="fusion.accounting.tool">
|
||||
<field name="name">get_followup_report</field>
|
||||
<field name="display_name_field">Get Follow-Up Report</field>
|
||||
<field name="description">Get the HTML follow-up report for a partner.</field>
|
||||
<field name="domain">accounts_receivable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer"}}, "required": ["partner_id"]}</field>
|
||||
</record>
|
||||
<record id="tool_reconcile_payment_to_invoice" model="fusion.accounting.tool">
|
||||
<field name="name">reconcile_payment_to_invoice</field>
|
||||
<field name="display_name_field">Reconcile Payment to Invoice</field>
|
||||
<field name="description">Match a payment to an invoice by reconciling journal items.</field>
|
||||
<field name="domain">accounts_receivable</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_get_unmatched_payments" model="fusion.accounting.tool">
|
||||
<field name="name">get_unmatched_payments</field>
|
||||
<field name="display_name_field">Get Unmatched Payments</field>
|
||||
<field name="description">List payments not matched to invoices.</field>
|
||||
<field name="domain">accounts_receivable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 4: Accounts Payable -->
|
||||
<record id="tool_get_ap_aging" model="fusion.accounting.tool">
|
||||
<field name="name">get_ap_aging</field>
|
||||
<field name="display_name_field">Get AP Aging</field>
|
||||
<field name="description">Get accounts payable aging buckets.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_find_duplicate_bills" model="fusion.accounting.tool">
|
||||
<field name="name">find_duplicate_bills</field>
|
||||
<field name="display_name_field">Find Duplicate Bills</field>
|
||||
<field name="description">Detect potential duplicate vendor bills (same vendor + amount + date within window).</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"window_days": {"type": "integer", "default": 7}}}</field>
|
||||
</record>
|
||||
<record id="tool_match_bill_to_po" model="fusion.accounting.tool">
|
||||
<field name="name">match_bill_to_po</field>
|
||||
<field name="display_name_field">Match Bill to PO</field>
|
||||
<field name="description">Cross-reference bill lines to purchase order lines.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"bill_id": {"type": "integer"}}, "required": ["bill_id"]}</field>
|
||||
</record>
|
||||
<record id="tool_get_unpaid_bills" model="fusion.accounting.tool">
|
||||
<field name="name">get_unpaid_bills</field>
|
||||
<field name="display_name_field">Get Unpaid Bills</field>
|
||||
<field name="description">List vendor bills with outstanding balance.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer"}, "limit": {"type": "integer", "default": 50}}}</field>
|
||||
</record>
|
||||
<record id="tool_verify_bill_taxes" model="fusion.accounting.tool">
|
||||
<field name="name">verify_bill_taxes</field>
|
||||
<field name="display_name_field">Verify Bill Taxes</field>
|
||||
<field name="description">Check that bill tax matches fiscal position expectation.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"bill_id": {"type": "integer"}}, "required": ["bill_id"]}</field>
|
||||
</record>
|
||||
<record id="tool_get_payment_schedule" model="fusion.accounting.tool">
|
||||
<field name="name">get_payment_schedule</field>
|
||||
<field name="display_name_field">Get Payment Schedule</field>
|
||||
<field name="description">Bills sorted by due date for cash planning.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"days_ahead": {"type": "integer", "default": 30}}}</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 5: Journal Review -->
|
||||
<record id="tool_find_wrong_direction_balances" model="fusion.accounting.tool">
|
||||
<field name="name">find_wrong_direction_balances</field>
|
||||
<field name="display_name_field">Find Wrong Direction Balances</field>
|
||||
<field name="description">Find accounts where balance direction contradicts account type.</field>
|
||||
<field name="domain">journal_review</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_find_duplicate_entries" model="fusion.accounting.tool">
|
||||
<field name="name">find_duplicate_entries</field>
|
||||
<field name="display_name_field">Find Duplicate Entries</field>
|
||||
<field name="description">Detect entries with matching partner + amount + date + journal.</field>
|
||||
<field name="domain">journal_review</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_find_wrong_account_entries" model="fusion.accounting.tool">
|
||||
<field name="name">find_wrong_account_entries</field>
|
||||
<field name="display_name_field">Find Wrong Account Entries</field>
|
||||
<field name="description">Product lines on unlikely accounts (e.g., revenue on tax account).</field>
|
||||
<field name="domain">journal_review</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_find_sequence_gaps" model="fusion.accounting.tool">
|
||||
<field name="name">find_sequence_gaps</field>
|
||||
<field name="display_name_field">Find Sequence Gaps</field>
|
||||
<field name="description">Find journal entries where made_sequence_gap is true.</field>
|
||||
<field name="domain">journal_review</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_find_draft_entries" model="fusion.accounting.tool">
|
||||
<field name="name">find_draft_entries</field>
|
||||
<field name="display_name_field">Find Draft Entries</field>
|
||||
<field name="description">Draft entries older than specified days that should be posted or deleted.</field>
|
||||
<field name="domain">journal_review</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"min_age_days": {"type": "integer", "default": 30}}}</field>
|
||||
</record>
|
||||
<record id="tool_find_unreconciled_suspense" model="fusion.accounting.tool">
|
||||
<field name="name">find_unreconciled_suspense</field>
|
||||
<field name="display_name_field">Find Unreconciled Suspense</field>
|
||||
<field name="description">Suspense/clearing accounts with non-zero balance.</field>
|
||||
<field name="domain">journal_review</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_verify_reconciliation_integrity" model="fusion.accounting.tool">
|
||||
<field name="name">verify_reconciliation_integrity</field>
|
||||
<field name="display_name_field">Verify Reconciliation Integrity</field>
|
||||
<field name="description">Check account.partial.reconcile consistency.</field>
|
||||
<field name="domain">journal_review</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 6: Month-End -->
|
||||
<record id="tool_get_close_checklist" model="fusion.accounting.tool">
|
||||
<field name="name">get_close_checklist</field>
|
||||
<field name="display_name_field">Get Close Checklist</field>
|
||||
<field name="description">Aggregate all domain checks into a period close checklist.</field>
|
||||
<field name="domain">month_end</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"period": {"type": "string", "description": "YYYY-MM format"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_unreconciled_counts" model="fusion.accounting.tool">
|
||||
<field name="name">get_unreconciled_counts</field>
|
||||
<field name="display_name_field">Get Unreconciled Counts</field>
|
||||
<field name="description">Per-account count of unreconciled items.</field>
|
||||
<field name="domain">month_end</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_find_entries_in_locked_period" model="fusion.accounting.tool">
|
||||
<field name="name">find_entries_in_locked_period</field>
|
||||
<field name="display_name_field">Find Entries in Locked Period</field>
|
||||
<field name="description">Find entries after lock dates.</field>
|
||||
<field name="domain">month_end</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_get_accrual_status" model="fusion.accounting.tool">
|
||||
<field name="name">get_accrual_status</field>
|
||||
<field name="display_name_field">Get Accrual Status</field>
|
||||
<field name="description">Balance on accrual accounts (vacation, sick, etc.).</field>
|
||||
<field name="domain">month_end</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"account_codes": {"type": "array", "items": {"type": "string"}}}}</field>
|
||||
</record>
|
||||
<record id="tool_run_hash_integrity_check" model="fusion.accounting.tool">
|
||||
<field name="name">run_hash_integrity_check</field>
|
||||
<field name="display_name_field">Run Hash Integrity Check</field>
|
||||
<field name="description">Verify journal entry hash chain integrity.</field>
|
||||
<field name="domain">month_end</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_get_period_summary" model="fusion.accounting.tool">
|
||||
<field name="name">get_period_summary</field>
|
||||
<field name="display_name_field">Get Period Summary</field>
|
||||
<field name="description">Trial balance for the closing period.</field>
|
||||
<field name="domain">month_end</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["date_from", "date_to"]}</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 7: Payroll Verification -->
|
||||
<record id="tool_get_payroll_entries" model="fusion.accounting.tool">
|
||||
<field name="name">get_payroll_entries</field>
|
||||
<field name="display_name_field">Get Payroll Entries</field>
|
||||
<field name="description">Journal entries in payroll-related journals.</field>
|
||||
<field name="domain">payroll_verification</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "journal_id": {"type": "integer"}}}</field>
|
||||
</record>
|
||||
<record id="tool_compare_payroll_to_bank" model="fusion.accounting.tool">
|
||||
<field name="name">compare_payroll_to_bank</field>
|
||||
<field name="display_name_field">Compare Payroll to Bank</field>
|
||||
<field name="description">Cross-reference payroll cheques to bank statement lines.</field>
|
||||
<field name="domain">payroll_verification</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["date_from", "date_to"]}</field>
|
||||
</record>
|
||||
<record id="tool_verify_source_deductions" model="fusion.accounting.tool">
|
||||
<field name="name">verify_source_deductions</field>
|
||||
<field name="display_name_field">Verify Source Deductions</field>
|
||||
<field name="description">CPP + EI + tax calculation verification against CRA tables.</field>
|
||||
<field name="domain">payroll_verification</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_get_cra_remittance_status" model="fusion.accounting.tool">
|
||||
<field name="name">get_cra_remittance_status</field>
|
||||
<field name="display_name_field">Get CRA Remittance Status</field>
|
||||
<field name="description">CRA payable balance vs payments made.</field>
|
||||
<field name="domain">payroll_verification</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_find_unmatched_payroll_cheques" model="fusion.accounting.tool">
|
||||
<field name="name">find_unmatched_payroll_cheques</field>
|
||||
<field name="display_name_field">Find Unmatched Payroll Cheques</field>
|
||||
<field name="description">Bank cheques without matching payroll entry.</field>
|
||||
<field name="domain">payroll_verification</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 8: Inventory -->
|
||||
<record id="tool_get_stock_valuation" model="fusion.accounting.tool">
|
||||
<field name="name">get_stock_valuation</field>
|
||||
<field name="display_name_field">Get Stock Valuation</field>
|
||||
<field name="description">Stock In Hand balance and layers.</field>
|
||||
<field name="domain">inventory</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_get_price_differences" model="fusion.accounting.tool">
|
||||
<field name="name">get_price_differences</field>
|
||||
<field name="display_name_field">Get Price Differences</field>
|
||||
<field name="description">Entries on price difference account (PO price vs bill price).</field>
|
||||
<field name="domain">inventory</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_cogs_ratio_by_category" model="fusion.accounting.tool">
|
||||
<field name="name">get_cogs_ratio_by_category</field>
|
||||
<field name="display_name_field">Get COGS Ratio</field>
|
||||
<field name="description">COGS vs revenue per product category.</field>
|
||||
<field name="domain">inventory</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_find_unusual_adjustments" model="fusion.accounting.tool">
|
||||
<field name="name">find_unusual_adjustments</field>
|
||||
<field name="display_name_field">Find Unusual Adjustments</field>
|
||||
<field name="description">Large inventory adjustment entries.</field>
|
||||
<field name="domain">inventory</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"threshold": {"type": "number", "default": 1000}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_inventory_turnover" model="fusion.accounting.tool">
|
||||
<field name="name">get_inventory_turnover</field>
|
||||
<field name="display_name_field">Get Inventory Turnover</field>
|
||||
<field name="description">Sales vs average inventory calculation.</field>
|
||||
<field name="domain">inventory</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 9: ADP -->
|
||||
<record id="tool_get_adp_receivable_aging" model="fusion.accounting.tool">
|
||||
<field name="name">get_adp_receivable_aging</field>
|
||||
<field name="display_name_field">Get ADP Receivable Aging</field>
|
||||
<field name="description">Aging on ADP Receivable account (1101).</field>
|
||||
<field name="domain">adp</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_match_adp_payment_to_invoice" model="fusion.accounting.tool">
|
||||
<field name="name">match_adp_payment_to_invoice</field>
|
||||
<field name="display_name_field">Match ADP Payment to Invoice</field>
|
||||
<field name="description">Match ADP deposit to ADP invoices.</field>
|
||||
<field name="domain">adp</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_verify_adp_split" model="fusion.accounting.tool">
|
||||
<field name="name">verify_adp_split</field>
|
||||
<field name="display_name_field">Verify ADP Split</field>
|
||||
<field name="description">Check customer + ADP portion = invoice total.</field>
|
||||
<field name="domain">adp</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"invoice_id": {"type": "integer"}}, "required": ["invoice_id"]}</field>
|
||||
</record>
|
||||
<record id="tool_find_adp_without_payment" model="fusion.accounting.tool">
|
||||
<field name="name">find_adp_without_payment</field>
|
||||
<field name="display_name_field">Find ADP Without Payment</field>
|
||||
<field name="description">ADP invoices without matching government deposit.</field>
|
||||
<field name="domain">adp</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_get_adp_summary" model="fusion.accounting.tool">
|
||||
<field name="name">get_adp_summary</field>
|
||||
<field name="display_name_field">Get ADP Summary</field>
|
||||
<field name="description">Period summary of ADP billing vs collection.</field>
|
||||
<field name="domain">adp</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_register_adp_batch_payment" model="fusion.accounting.tool">
|
||||
<field name="name">register_adp_batch_payment</field>
|
||||
<field name="display_name_field">Register ADP Batch Payment</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Register payments for a batch of ADP invoices from a remittance advice. Takes a list of invoice numbers with payment amounts and a payment date. Registers each payment via Odoo's payment wizard, creating outstanding receipt entries (PBNK2) on account 1050. After this, use suggest_bank_line_matches on the bank deposit to match the outstanding receipts. Use this when the user uploads an ADP remittance advice screenshot and says "mark these paid".</field>
|
||||
<field name="domain">adp</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"invoices": {"type": "array", "items": {"type": "object", "properties": {"invoice_number": {"type": "string"}, "amount": {"type": "number"}}, "required": ["invoice_number", "amount"]}, "description": "List of invoices with number and payment amount"}, "payment_date": {"type": "string", "description": "Payment date from remittance (YYYY-MM-DD)"}, "journal_id": {"type": "integer", "description": "Bank journal ID (default 50 = Scotia Current)"}}, "required": ["invoices", "payment_date"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 10: Reporting -->
|
||||
<record id="tool_get_profit_loss" model="fusion.accounting.tool">
|
||||
<field name="name">get_profit_loss</field>
|
||||
<field name="display_name_field">Get Profit & Loss</field>
|
||||
<field name="description">Generate P&L report for a period.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_balance_sheet" model="fusion.accounting.tool">
|
||||
<field name="name">get_balance_sheet</field>
|
||||
<field name="display_name_field">Get Balance Sheet</field>
|
||||
<field name="description">Generate balance sheet report.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_trial_balance" model="fusion.accounting.tool">
|
||||
<field name="name">get_trial_balance</field>
|
||||
<field name="display_name_field">Get Trial Balance</field>
|
||||
<field name="description">Generate trial balance report.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_cash_flow" model="fusion.accounting.tool">
|
||||
<field name="name">get_cash_flow</field>
|
||||
<field name="display_name_field">Get Cash Flow</field>
|
||||
<field name="description">Generate cash flow statement.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_compare_periods" model="fusion.accounting.tool">
|
||||
<field name="name">compare_periods</field>
|
||||
<field name="display_name_field">Compare Periods</field>
|
||||
<field name="description">Two period reports side by side for comparison.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"report_ref": {"type": "string"}, "period1_from": {"type": "string"}, "period1_to": {"type": "string"}, "period2_from": {"type": "string"}, "period2_to": {"type": "string"}}, "required": ["period1_from", "period1_to", "period2_from", "period2_to"]}</field>
|
||||
</record>
|
||||
<record id="tool_answer_financial_question" model="fusion.accounting.tool">
|
||||
<field name="name">answer_financial_question</field>
|
||||
<field name="display_name_field">Answer Financial Question</field>
|
||||
<field name="description">Natural language to report query for financial questions.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"question": {"type": "string"}}, "required": ["question"]}</field>
|
||||
</record>
|
||||
<record id="tool_export_report" model="fusion.accounting.tool">
|
||||
<field name="name">export_report</field>
|
||||
<field name="display_name_field">Export Report</field>
|
||||
<field name="description">Export a report to PDF or XLSX.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">2</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"report_ref": {"type": "string"}, "format": {"type": "string", "enum": ["pdf", "xlsx"]}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["report_ref"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_get_invoicing_summary" model="fusion.accounting.tool">
|
||||
<field name="name">get_invoicing_summary</field>
|
||||
<field name="display_name_field">Get Invoicing Summary</field>
|
||||
<field name="description">[Tier 1: Read-only] Get customer invoicing summary — monthly breakdown for a year, date range totals, or filtered by partner. Use this for questions like "how much did we invoice this year?", "show me invoicing by month", "how much did we bill ADP this quarter?".</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"year": {"type": "integer", "description": "Year for monthly breakdown (default: current year)"}, "partner_name": {"type": "string", "description": "Filter by partner name (optional)"}, "date_from": {"type": "string", "description": "Start date for date range (YYYY-MM-DD)"}, "date_to": {"type": "string", "description": "End date for date range (YYYY-MM-DD)"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_billing_summary" model="fusion.accounting.tool">
|
||||
<field name="name">get_billing_summary</field>
|
||||
<field name="display_name_field">Get Billing Summary</field>
|
||||
<field name="description">[Tier 1: Read-only] Get vendor billing (purchases) summary — monthly breakdown for a year or date range. Use for "how much are our bills this month?", "show me vendor bills by month".</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"year": {"type": "integer"}, "partner_name": {"type": "string"}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_collections_summary" model="fusion.accounting.tool">
|
||||
<field name="name">get_collections_summary</field>
|
||||
<field name="display_name_field">Get Collections Summary</field>
|
||||
<field name="description">[Tier 1: Read-only] Get payment collections summary — how much was collected (customer payments received) in a period, broken down by partner. Use for "how much are we collecting this month?", "show me collections for March".</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 11: Audit -->
|
||||
<record id="tool_audit_posted_entry" model="fusion.accounting.tool">
|
||||
<field name="name">audit_posted_entry</field>
|
||||
<field name="display_name_field">Audit Posted Entry</field>
|
||||
<field name="description">Run all entry-level checks on a single journal entry.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"move_id": {"type": "integer"}}, "required": ["move_id"]}</field>
|
||||
</record>
|
||||
<record id="tool_audit_account_balances" model="fusion.accounting.tool">
|
||||
<field name="name">audit_account_balances</field>
|
||||
<field name="display_name_field">Audit Account Balances</field>
|
||||
<field name="description">Run all account-level checks (wrong direction, stale items).</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_audit_tax_compliance" model="fusion.accounting.tool">
|
||||
<field name="name">audit_tax_compliance</field>
|
||||
<field name="display_name_field">Audit Tax Compliance</field>
|
||||
<field name="description">All tax checks (missing tax, wrong rate, exempt verification).</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_audit_reconciliation_integrity" model="fusion.accounting.tool">
|
||||
<field name="name">audit_reconciliation_integrity</field>
|
||||
<field name="display_name_field">Audit Reconciliation Integrity</field>
|
||||
<field name="description">Verify partial/full reconcile consistency.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_check_hash_chain" model="fusion.accounting.tool">
|
||||
<field name="name">check_hash_chain</field>
|
||||
<field name="display_name_field">Check Hash Chain</field>
|
||||
<field name="description">Verify journal entry hash chain integrity.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_check_sequence_gaps" model="fusion.accounting.tool">
|
||||
<field name="name">check_sequence_gaps</field>
|
||||
<field name="display_name_field">Check Sequence Gaps</field>
|
||||
<field name="description">Check for sequence gaps in journals.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_flag_entry" model="fusion.accounting.tool">
|
||||
<field name="name">flag_entry</field>
|
||||
<field name="display_name_field">Flag Entry</field>
|
||||
<field name="description">Create a chatter note on a journal entry with flag and recommendation.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">2</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"move_id": {"type": "integer"}, "flag": {"type": "string"}, "recommendation": {"type": "string"}}, "required": ["move_id"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_get_audit_status" model="fusion.accounting.tool">
|
||||
<field name="name">get_audit_status</field>
|
||||
<field name="display_name_field">Get Audit Status</field>
|
||||
<field name="description">Account audit status per tax return.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_set_audit_status" model="fusion.accounting.tool">
|
||||
<field name="name">set_audit_status</field>
|
||||
<field name="display_name_field">Set Audit Status</field>
|
||||
<field name="description">Update review status (todo / reviewed / supervised / anomaly).</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">2</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"status_id": {"type": "integer"}, "status": {"type": "string", "enum": ["todo", "reviewed", "supervised", "anomaly"]}}, "required": ["status_id", "status"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_get_audit_trail" model="fusion.accounting.tool">
|
||||
<field name="name">get_audit_trail</field>
|
||||
<field name="display_name_field">Get Audit Trail</field>
|
||||
<field name="description">Get mail.message history for a journal entry.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"move_id": {"type": "integer"}}, "required": ["move_id"]}</field>
|
||||
</record>
|
||||
<record id="tool_run_full_audit" model="fusion.accounting.tool">
|
||||
<field name="name">run_full_audit</field>
|
||||
<field name="display_name_field">Run Full Audit</field>
|
||||
<field name="description">All checks across all domains for a period.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_audit_report" model="fusion.accounting.tool">
|
||||
<field name="name">get_audit_report</field>
|
||||
<field name="display_name_field">Get Audit Report</field>
|
||||
<field name="description">Summary of all audit findings with severity ratings.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 12: Payroll Management -->
|
||||
<record id="tool_parse_payroll_summary" model="fusion.accounting.tool">
|
||||
<field name="name">parse_payroll_summary</field>
|
||||
<field name="display_name_field">Parse Payroll Summary</field>
|
||||
<field name="description">Read pasted/uploaded payroll data from QBO or fusion_payroll.</field>
|
||||
<field name="domain">payroll_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"data": {"type": "string"}}, "required": ["data"]}</field>
|
||||
</record>
|
||||
<record id="tool_create_payroll_journal_entry" model="fusion.accounting.tool">
|
||||
<field name="name">create_payroll_journal_entry</field>
|
||||
<field name="display_name_field">Create Payroll Journal Entry</field>
|
||||
<field name="description">Create a payroll journal entry with debit/credit lines.</field>
|
||||
<field name="domain">payroll_management</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer"}, "date": {"type": "string"}, "ref": {"type": "string"}, "lines": {"type": "array", "items": {"type": "object", "properties": {"account_id": {"type": "integer"}, "name": {"type": "string"}, "debit": {"type": "number"}, "credit": {"type": "number"}, "partner_id": {"type": "integer"}}}}}, "required": ["journal_id", "date", "lines"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_match_payroll_cheques" model="fusion.accounting.tool">
|
||||
<field name="name">match_payroll_cheques</field>
|
||||
<field name="display_name_field">Match Payroll Cheques</field>
|
||||
<field name="description">Match bank cheques to payroll liabilities.</field>
|
||||
<field name="domain">payroll_management</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer"}, "move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["statement_line_id", "move_line_ids"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_prepare_cra_payment" model="fusion.accounting.tool">
|
||||
<field name="name">prepare_cra_payment</field>
|
||||
<field name="display_name_field">Prepare CRA Payment</field>
|
||||
<field name="description">Create CRA remittance payment entry.</field>
|
||||
<field name="domain">payroll_management</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer"}, "date": {"type": "string"}, "lines": {"type": "array"}}, "required": ["journal_id", "date", "lines"]}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_generate_t4" model="fusion.accounting.tool">
|
||||
<field name="name">generate_t4</field>
|
||||
<field name="display_name_field">Generate T4</field>
|
||||
<field name="description">Trigger T4 generation via fusion_payroll.</field>
|
||||
<field name="domain">payroll_management</field>
|
||||
<field name="tier">2</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_generate_roe" model="fusion.accounting.tool">
|
||||
<field name="name">generate_roe</field>
|
||||
<field name="display_name_field">Generate ROE</field>
|
||||
<field name="description">Trigger ROE generation via fusion_payroll.</field>
|
||||
<field name="domain">payroll_management</field>
|
||||
<field name="tier">2</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
<record id="tool_get_payroll_cost_report" model="fusion.accounting.tool">
|
||||
<field name="name">get_payroll_cost_report</field>
|
||||
<field name="display_name_field">Get Payroll Cost Report</field>
|
||||
<field name="description">Period summary by employee/department.</field>
|
||||
<field name="domain">payroll_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
|
||||
<!-- HST Filing Workflow Tools (added 2026-04-03) -->
|
||||
|
||||
<record id="tool_search_partners" model="fusion.accounting.tool">
|
||||
<field name="name">search_partners</field>
|
||||
<field name="display_name_field">Search Partners</field>
|
||||
<field name="description">Search for vendors/contacts by name keyword. Use this to resolve bank line descriptions (e.g., "AMAZON") to the correct Odoo partner record before creating bills. Pass supplier_only=true to filter to vendors only.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"keyword": {"type": "string", "description": "Name keyword to search (min 2 chars)"}, "supplier_only": {"type": "boolean", "description": "Only return suppliers/vendors"}, "limit": {"type": "integer"}}, "required": ["keyword"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_find_similar_bank_lines" model="fusion.accounting.tool">
|
||||
<field name="name">find_similar_bank_lines</field>
|
||||
<field name="display_name_field">Find Similar Bank Lines</field>
|
||||
<field name="description">Search past RECONCILED bank lines with similar payment_ref descriptions. Returns the expense account, tax treatment, and partner used for each historical match. Use this to check how similar expenses were coded in the past before proposing a new bill.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"keyword": {"type": "string", "description": "Keyword from payment_ref to search (min 3 chars)"}, "limit": {"type": "integer"}}, "required": ["keyword"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_get_bank_line_details" model="fusion.accounting.tool">
|
||||
<field name="name">get_bank_line_details</field>
|
||||
<field name="display_name_field">Get Bank Line Details</field>
|
||||
<field name="description">Get full details of a single unreconciled bank statement line. Also searches for existing vendor bills matching the amount and date, and suggests a partner based on the payment description. Use this to check if a bill already exists before creating a new one.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"line_id": {"type": "integer", "description": "Bank statement line ID"}}, "required": ["line_id"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_create_vendor_bill" model="fusion.accounting.tool">
|
||||
<field name="name">create_vendor_bill</field>
|
||||
<field name="display_name_field">Create Vendor Bill</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Create a vendor bill (account.move in_invoice) with expense lines and tax. Use after confirming the expense details with the user. Pass post=true to auto-post the bill after creation.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer", "description": "Vendor partner ID"}, "invoice_date": {"type": "string", "description": "Bill date (YYYY-MM-DD)"}, "lines": {"type": "array", "items": {"type": "object", "properties": {"description": {"type": "string"}, "account_id": {"type": "integer"}, "price_unit": {"type": "number"}, "quantity": {"type": "number"}, "tax_ids": {"type": "array", "items": {"type": "integer"}}}}, "description": "Invoice line items"}, "post": {"type": "boolean", "description": "Auto-post the bill after creation"}}, "required": ["partner_id", "invoice_date", "lines"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_register_bill_payment" model="fusion.accounting.tool">
|
||||
<field name="name">register_bill_payment</field>
|
||||
<field name="display_name_field">Register Bill Payment</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Register a payment on a posted vendor bill from a specific bank journal. Optionally reconcile the payment to a bank statement line. Use after create_vendor_bill to complete the full bill+payment+reconciliation flow.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"bill_id": {"type": "integer", "description": "Posted bill ID (account.move)"}, "journal_id": {"type": "integer", "description": "Bank journal ID for payment"}, "payment_date": {"type": "string", "description": "Payment date (YYYY-MM-DD)"}, "amount": {"type": "number", "description": "Payment amount (defaults to bill total)"}, "statement_line_id": {"type": "integer", "description": "Bank statement line ID to reconcile with"}}, "required": ["bill_id", "journal_id"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_check_recurring_pattern" model="fusion.accounting.tool">
|
||||
<field name="name">check_recurring_pattern</field>
|
||||
<field name="display_name_field">Check Recurring Pattern</field>
|
||||
<field name="description">Check if a bank line matches a known recurring payment pattern. Returns the historical account coding, HST treatment, partner, and reconciliation model if one exists. ALWAYS call this FIRST for every unreconciled bank line — if a recurring pattern exists, follow its instructions instead of asking the user. Pass line_id to auto-extract ref and amount.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"line_id": {"type": "integer", "description": "Bank statement line ID"}, "payment_ref": {"type": "string", "description": "Payment reference text (auto-extracted if line_id provided)"}, "amount": {"type": "number", "description": "Transaction amount (auto-extracted if line_id provided)"}}, "required": []}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_match_internal_transfers" model="fusion.accounting.tool">
|
||||
<field name="name">match_internal_transfers</field>
|
||||
<field name="display_name_field">Match Internal Transfers</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Find and match inter-account transfers between two bank journals (e.g., Scotia Current ↔ Scotia Visa). Matches EXACT amounts within 2 days. ONLY matches when there is exactly one candidate — skips ambiguous cases. First call with execute=false to preview pairs, then execute=true to reconcile. Scotia Current=50, Scotia Visa=51, RBC Chequing=53, RBC Visa=28.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"journal_a_id": {"type": "integer", "description": "First bank journal ID"}, "journal_b_id": {"type": "integer", "description": "Second bank journal ID"}, "date_from": {"type": "string"}, "date_to": {"type": "string"}, "max_days_apart": {"type": "integer", "description": "Max days between matching lines (default 2)"}, "execute": {"type": "boolean", "description": "false=preview pairs only, true=actually reconcile"}}, "required": ["journal_a_id", "journal_b_id"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_suggest_bank_line_matches" model="fusion.accounting.tool">
|
||||
<field name="name">suggest_bank_line_matches</field>
|
||||
<field name="display_name_field">Suggest Bank Line Matches</field>
|
||||
<field name="description">[Tier 1: Read-only] Find candidate invoices/bills that could match a bank statement line. Extracts partner from the bank line reference, searches open receivables (for incoming payments) or payables (for outgoing payments), scores candidates by amount/partner/date proximity, and finds the best combination of entries that sum to the bank amount. Returns data for a reconciliation-mode fusion-table with editable amounts and search. The user reviews matches, adjusts amounts for partial payments, searches and adds more entries, then clicks Apply Match.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer", "description": "Bank statement line ID to find matches for"}}, "required": ["statement_line_id"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_find_unreconciled_cheques" model="fusion.accounting.tool">
|
||||
<field name="name">find_unreconciled_cheques</field>
|
||||
<field name="display_name_field">Find Unreconciled Cheques</field>
|
||||
<field name="description">[Tier 1: Read-only] Find unreconciled cheque bank lines and classify them as payroll or non-payroll. Payroll cheques have a matching credit amount on 2201 Payroll Liabilities. Non-payroll cheques (vendor payments, rent, etc.) don't. Default journal: Scotia Current (50).</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Bank journal ID (default 50 = Scotia Current)"}, "limit": {"type": "integer", "description": "Max results (default 50)"}}}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_reconcile_payroll_cheques" model="fusion.accounting.tool">
|
||||
<field name="name">reconcile_payroll_cheques</field>
|
||||
<field name="display_name_field">Reconcile Payroll Cheques</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Reconcile payroll cheque bank lines by applying the Payroll Cheque Clearing model. ONLY processes cheques whose amount matches an existing payroll liability entry on 2201. Non-payroll cheques (vendor/rent) are skipped automatically. Uses the pre-configured "Payroll Cheque Clearing" reconcile model (writeoff to Dr 2201).</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Bank journal ID (default 50)"}, "line_ids": {"type": "array", "items": {"type": "integer"}, "description": "Optional: specific bank line IDs to reconcile. If empty, reconciles all matching payroll cheques."}}}</field>
|
||||
<field name="required_groups">fusion_accounting_core.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_create_expense_entry" model="fusion.accounting.tool">
|
||||
<field name="name">create_expense_entry</field>
|
||||
<field name="display_name_field">Create Direct GL Expense</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Create a direct GL expense entry in the Miscellaneous Operations journal. Alternative to creating a vendor bill — posts immediately. If has_hst=true, automatically splits the amount into net expense + 13% HST ITC on the 2006 account. Use this for small expenses where a formal vendor bill is not needed.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date": {"type": "string", "description": "Entry date (YYYY-MM-DD)"}, "description": {"type": "string", "description": "Expense description"}, "expense_account_id": {"type": "integer", "description": "GL expense account ID"}, "amount": {"type": "number", "description": "Total amount including HST if applicable"}, "has_hst": {"type": "boolean", "description": "Whether HST (13%) is included in the amount"}, "bank_journal_id": {"type": "integer", "description": "Bank journal for the credit side"}}, "required": ["date", "description", "expense_account_id", "amount"]}</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,294 @@
|
||||
# Graph Report - /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai (2026-04-22)
|
||||
|
||||
## Corpus Check
|
||||
- 61 files · ~40,430 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 602 nodes · 894 edges · 40 communities detected
|
||||
- Extraction: 79% EXTRACTED · 21% INFERRED · 0% AMBIGUOUS · INFERRED: 192 edges (avg confidence: 0.71)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- [[_COMMUNITY_Community 0|Community 0]]
|
||||
- [[_COMMUNITY_Community 1|Community 1]]
|
||||
- [[_COMMUNITY_Community 2|Community 2]]
|
||||
- [[_COMMUNITY_Community 3|Community 3]]
|
||||
- [[_COMMUNITY_Community 4|Community 4]]
|
||||
- [[_COMMUNITY_Community 5|Community 5]]
|
||||
- [[_COMMUNITY_Community 6|Community 6]]
|
||||
- [[_COMMUNITY_Community 7|Community 7]]
|
||||
- [[_COMMUNITY_Community 8|Community 8]]
|
||||
- [[_COMMUNITY_Community 9|Community 9]]
|
||||
- [[_COMMUNITY_Community 10|Community 10]]
|
||||
- [[_COMMUNITY_Community 11|Community 11]]
|
||||
- [[_COMMUNITY_Community 12|Community 12]]
|
||||
- [[_COMMUNITY_Community 13|Community 13]]
|
||||
- [[_COMMUNITY_Community 14|Community 14]]
|
||||
- [[_COMMUNITY_Community 15|Community 15]]
|
||||
- [[_COMMUNITY_Community 16|Community 16]]
|
||||
- [[_COMMUNITY_Community 17|Community 17]]
|
||||
- [[_COMMUNITY_Community 18|Community 18]]
|
||||
- [[_COMMUNITY_Community 19|Community 19]]
|
||||
- [[_COMMUNITY_Community 20|Community 20]]
|
||||
- [[_COMMUNITY_Community 21|Community 21]]
|
||||
- [[_COMMUNITY_Community 22|Community 22]]
|
||||
- [[_COMMUNITY_Community 23|Community 23]]
|
||||
- [[_COMMUNITY_Community 24|Community 24]]
|
||||
- [[_COMMUNITY_Community 25|Community 25]]
|
||||
- [[_COMMUNITY_Community 26|Community 26]]
|
||||
- [[_COMMUNITY_Community 27|Community 27]]
|
||||
- [[_COMMUNITY_Community 28|Community 28]]
|
||||
- [[_COMMUNITY_Community 29|Community 29]]
|
||||
- [[_COMMUNITY_Community 30|Community 30]]
|
||||
- [[_COMMUNITY_Community 31|Community 31]]
|
||||
- [[_COMMUNITY_Community 32|Community 32]]
|
||||
- [[_COMMUNITY_Community 33|Community 33]]
|
||||
- [[_COMMUNITY_Community 34|Community 34]]
|
||||
- [[_COMMUNITY_Community 35|Community 35]]
|
||||
- [[_COMMUNITY_Community 36|Community 36]]
|
||||
- [[_COMMUNITY_Community 37|Community 37]]
|
||||
- [[_COMMUNITY_Community 38|Community 38]]
|
||||
- [[_COMMUNITY_Community 39|Community 39]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `FusionChatPanel` - 38 edges
|
||||
2. `DataAdapter` - 38 edges
|
||||
3. `get_adapter()` - 37 edges
|
||||
4. `FollowupAdapter` - 28 edges
|
||||
5. `ReportsAdapter` - 27 edges
|
||||
6. `FusionAccountingAgent` - 22 edges
|
||||
7. `FusionInteractiveTable` - 21 edges
|
||||
8. `BankRecAdapter` - 20 edges
|
||||
9. `AssetsAdapter` - 15 edges
|
||||
10. `AdapterMode` - 14 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `Community shape + fusion AI fields (top suggestion, band, attachments).` --uses--> `DataAdapter` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/bank_rec.py → /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/base.py
|
||||
- `Return AI suggestions per bank line. Shape: ``{line_id: [{'id', 'rank',` --uses--> `DataAdapter` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/bank_rec.py → /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/base.py
|
||||
- `Accept a fusion AI suggestion and reconcile against its proposal. Retur` --uses--> `DataAdapter` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/bank_rec.py → /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/base.py
|
||||
- `Reverse a reconciliation by partial IDs. Returns ``{'unreconciled_line_` --uses--> `DataAdapter` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/bank_rec.py → /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/base.py
|
||||
- `Engine-free unreconcile for installs without fusion_accounting_bank_rec.` --uses--> `DataAdapter` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/bank_rec.py → /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/base.py
|
||||
|
||||
## Communities
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.04
|
||||
Nodes (6): FusionApprovalCard, FusionChatPanel, inlineFormat(), mdToHtml(), parseFusionTableBlock(), FusionInteractiveTable
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.05
|
||||
Nodes (21): _compute_display_label(), _compute_tool_display_name(), FusionAccountingMatchHistory, FusionAccountingRule, FusionAccountingAgent, Build a one-line summary of what a tool call did, for the collapsed tool log., Build a rich approval payload so the UI can show exactly what's being approved., Smart model routing: Haiku for routine tool calling, Sonnet for complex analysis (+13 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.04
|
||||
Nodes (39): BankRecAdapter, Return AI suggestions per bank line. Shape: ``{line_id: [{'id', 'rank',, Accept a fusion AI suggestion and reconcile against its proposal. Retur, Reverse a reconciliation by partial IDs. Returns ``{'unreconciled_line_, Engine-free unreconcile for installs without fusion_accounting_bank_rec., Community shape + fusion AI fields (top suggestion, band, attachments)., check_recurring_pattern(), _extract_partner_from_ref() (+31 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.05
|
||||
Nodes (37): get_ap_aging(), Return AP aging buckets. Routed through FollowupAdapter for tri-mode consistency, get_ar_aging(), get_followup_report(), get_overdue_invoices(), get_partner_balance(), Return the follow-up report HTML for a partner. Routed through FollowupAdapter., Return overdue customer invoices. Routed through FollowupAdapter. (+29 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.07
|
||||
Nodes (30): Assets data adapter — routes asset queries through fusion engine if installed., Bank reconciliation data adapter. Routes bank-rec data lookups across: - FUSION, Return unreconciled bank statement lines. All filter params are optiona, AdapterMode, DataAdapter, Base class. Subclasses set FUSION_MODEL and ENTERPRISE_MODULE class attrs an, Pick FUSION if the model is loaded, else ENTERPRISE if the module is ins, Follow-up data adapter. Routes follow-up / aged-balance / collections data look (+22 more)
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.05
|
||||
Nodes (27): audit_account_balances(), audit_reconciliation_integrity(), audit_tax_compliance(), check_hash_chain(), check_sequence_gaps(), get_audit_report(), run_full_audit(), fusion_generate_followup_text() (+19 more)
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.07
|
||||
Nodes (14): Look up <method_name>_via_<mode> on self and call it. E.g. method_name=, _company_id(), fusion_compare_periods(), fusion_drill_down_report_line(), fusion_generate_commentary(), fusion_get_anomalies(), fusion_run_report(), Fusion-engine-routed AI tools for financial reports. These 5 tools route throug (+6 more)
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.08
|
||||
Nodes (14): LLMProvider, LLMProvider contract - every adapter must conform. Phase 1 generalisation: make, Contract every LLM backend must satisfy. Adapters declare capabilities as cl, Plain text completion. Required for ALL providers. Returns: {'content':, Tool-calling completion. Optional - caller checks supports_tool_calling first., Embeddings. Optional - caller checks supports_embeddings first. Returns, ClaudeAdapter, FusionAccountingAdapterClaude (+6 more)
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.11
|
||||
Nodes (13): FusionAccountingSession, get_execution_state(), Get the current execution state for a session (called by polling endpoint)., approve_action(), approve_all(), chat(), chat_status(), close_session() (+5 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.1
|
||||
Nodes (13): AccountMoveAuditHook, create_vendor_bill(), find_similar_bank_lines(), Search for partners/vendors by name keyword., Find past reconciled bank lines with similar description to suggest coding patte, [Tier 3] Create a vendor bill (account.move with move_type='in_invoice'). Re, [Tier 3] Register payment on a posted vendor bill and optionally reconcile to ba, register_bill_payment() (+5 more)
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 0.13
|
||||
Nodes (3): _bucket_for_days(), FollowupAdapter, Shared aging-bucket implementation for receivable/payable accounts. Ret
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.12
|
||||
Nodes (5): fusion_dispose_asset(), fusion_suggest_asset_useful_life(), Fusion-engine-routed AI tools for asset management., AssetsAdapter, DataAdapter
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.14
|
||||
Nodes (10): create_payroll_journal_entry(), get_cra_remittance_due(), get_cra_remittance_status(), get_payroll_cost_report(), get_payroll_entries(), prepare_cra_payment(), Resolve an account code or ID to a valid account ID. Accepts: integer ID, st, _resolve_account_id() (+2 more)
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 0.2
|
||||
Nodes (1): FusionAccountingDashboard
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.25
|
||||
Nodes (2): Register payments for a batch of ADP invoices from a remittance advice. Tak, register_adp_batch_payment()
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.33
|
||||
Nodes (1): FusionDashboard
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.4
|
||||
Nodes (2): get_inventory_turnover(), get_stock_valuation()
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.5
|
||||
Nodes (2): FusionVendorTaxProfile, Rebuild all vendor tax profiles from posted bill history. Called by cron
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 0.5
|
||||
Nodes (2): FusionRecurringPattern, Scan reconciled bank lines for recurring patterns and cache how they were coded.
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 0.5
|
||||
Nodes (1): FusionHealthCard
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.5
|
||||
Nodes (3): build_prompt(), Bank reconciliation AI re-rank prompt. Used by fusion_accounting_bank_rec/servi, Build (system_prompt, user_prompt) for AI re-rank. Args: statement_
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.67
|
||||
Nodes (1): Reassign ir_model_data ownership from fusion_accounting to fusion_accounting_ai.
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 0.67
|
||||
Nodes (1): FusionRuleWizard
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 24 - "Community 24"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): FusionAccountingTool
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): ResConfigSettings
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 29 - "Community 29"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 30 - "Community 30"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 33 - "Community 33"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 34 - "Community 34"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Poll the live execution state of a running chat — returns thinking text,
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Live search for matching journal items — called directly by the reconcil
|
||||
|
||||
### Community 37 - "Community 37"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): List recent sessions for the session picker dropdown.
|
||||
|
||||
### Community 38 - "Community 38"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Automatically reconcile inter-account credit card payments. When a paym
|
||||
|
||||
### Community 39 - "Community 39"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Reconcile payroll cheque bank lines using writeoff to Payroll Liabilities (2201)
|
||||
|
||||
## Knowledge Gaps
|
||||
- **86 isolated node(s):** `Reassign ir_model_data ownership from fusion_accounting to fusion_accounting_ai.`, `Verify ir_model_data ownership transferred from fusion_accounting to fusion_acco`, `No fusion-related model/view/data record should still claim module='fusion_accou`, `Spot-check that key xml-ids are reachable under the new module name.`, `FusionAccountingTool` (+81 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Community 23`** (2 nodes): `get_db_param()`, `test_claude_api.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 24`** (2 nodes): `FusionAccountingTool`, `accounting_tool.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 25`** (2 nodes): `ResConfigSettings`, `accounting_config.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 26`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 27`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 28`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 29`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 30`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 31`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 32`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 33`** (1 nodes): `__manifest__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 34`** (1 nodes): `test_api_live.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 35`** (1 nodes): `Poll the live execution state of a running chat — returns thinking text,`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 36`** (1 nodes): `Live search for matching journal items — called directly by the reconcil`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 37`** (1 nodes): `List recent sessions for the session picker dropdown.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 38`** (1 nodes): `Automatically reconcile inter-account credit card payments. When a paym`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 39`** (1 nodes): `Reconcile payroll cheque bank lines using writeoff to Payroll Liabilities (2201)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `get_audit_report()` connect `Community 5` to `Community 1`?**
|
||||
_High betweenness centrality (0.205) - this node is a cross-community bridge._
|
||||
- **Why does `FusionInteractiveTable` connect `Community 0` to `Community 1`?**
|
||||
_High betweenness centrality (0.184) - this node is a cross-community bridge._
|
||||
- **Why does `get_adapter()` connect `Community 3` to `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 11`?**
|
||||
_High betweenness centrality (0.168) - this node is a cross-community bridge._
|
||||
- **Are the 33 inferred relationships involving `DataAdapter` (e.g. with `TestDataAdapterBase` and `TestBankRecAdapter`) actually correct?**
|
||||
_`DataAdapter` has 33 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 35 inferred relationships involving `get_adapter()` (e.g. with `.test_list_unreconciled_returns_our_test_line()` and `.test_trial_balance_returns_rows_in_pure_community()`) actually correct?**
|
||||
_`get_adapter()` has 35 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `Reassign ir_model_data ownership from fusion_accounting to fusion_accounting_ai.`, `Verify ir_model_data ownership transferred from fusion_accounting to fusion_acco`, `No fusion-related model/view/data record should still claim module='fusion_accou` to the rest of the system?**
|
||||
_86 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `Community 0` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.04 - nodes in this community are weakly interconnected._
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_test_claude_api_py", "label": "test_claude_api.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_claude_api.py", "source_location": "L1"}, {"id": "test_claude_api_get_db_param", "label": "get_db_param()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_claude_api.py", "source_location": "L6"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_test_claude_api_py", "target": "anthropic", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_claude_api.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_test_claude_api_py", "target": "json", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_claude_api.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_test_claude_api_py", "target": "subprocess", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_claude_api.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_test_claude_api_py", "target": "sys", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_claude_api.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_test_claude_api_py", "target": "test_claude_api_get_db_param", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_claude_api.py", "source_location": "L6", "weight": 1.0}], "raw_calls": [{"caller_nid": "test_claude_api_get_db_param", "callee": "run", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_claude_api.py", "source_location": "L7"}, {"caller_nid": "test_claude_api_get_db_param", "callee": "strip", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_claude_api.py", "source_location": "L12"}]}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/__init__.py", "source_location": "L5", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/__init__.py", "source_location": "L3", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_manifest_py", "label": "__manifest__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/__manifest__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_tool_py", "label": "accounting_tool.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_tool.py", "source_location": "L1"}, {"id": "accounting_tool_fusionaccountingtool", "label": "FusionAccountingTool", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_tool.py", "source_location": "L7"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_tool_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_tool.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_tool_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_tool.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_tool_py", "target": "accounting_tool_fusionaccountingtool", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_tool.py", "source_location": "L7", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_prompts_domain_prompts_py", "label": "domain_prompts.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/prompts/domain_prompts.py", "source_location": "L1"}, {"id": "domain_prompts_get_domain_prompt", "label": "get_domain_prompt()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/prompts/domain_prompts.py", "source_location": "L229"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_prompts_domain_prompts_py", "target": "domain_prompts_get_domain_prompt", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/prompts/domain_prompts.py", "source_location": "L229", "weight": 1.0}], "raw_calls": [{"caller_nid": "domain_prompts_get_domain_prompt", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/prompts/domain_prompts.py", "source_location": "L233"}, {"caller_nid": "domain_prompts_get_domain_prompt", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/prompts/domain_prompts.py", "source_location": "L235"}, {"caller_nid": "domain_prompts_get_domain_prompt", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/prompts/domain_prompts.py", "source_location": "L236"}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_wizards_rule_wizard_py", "label": "rule_wizard.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/wizards/rule_wizard.py", "source_location": "L1"}, {"id": "rule_wizard_fusionrulewizard", "label": "FusionRuleWizard", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/wizards/rule_wizard.py", "source_location": "L4"}, {"id": "rule_wizard_fusionrulewizard_action_create_rule", "label": ".action_create_rule()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/wizards/rule_wizard.py", "source_location": "L23"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_wizards_rule_wizard_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/wizards/rule_wizard.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_wizards_rule_wizard_py", "target": "rule_wizard_fusionrulewizard", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/wizards/rule_wizard.py", "source_location": "L4", "weight": 1.0}, {"source": "rule_wizard_fusionrulewizard", "target": "rule_wizard_fusionrulewizard_action_create_rule", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/wizards/rule_wizard.py", "source_location": "L23", "weight": 1.0}], "raw_calls": [{"caller_nid": "rule_wizard_fusionrulewizard_action_create_rule", "callee": "ensure_one", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/wizards/rule_wizard.py", "source_location": "L24"}, {"caller_nid": "rule_wizard_fusionrulewizard_action_create_rule", "callee": "create", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/wizards/rule_wizard.py", "source_location": "L25"}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_static_src_components_chat_approval_card_js", "label": "approval_card.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L1"}, {"id": "approval_card_fusionapprovalcard", "label": "FusionApprovalCard", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L5"}, {"id": "approval_card_fusionapprovalcard_toollabel", "label": ".toolLabel()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L9"}, {"id": "approval_card_fusionapprovalcard_formatamount", "label": ".formatAmount()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L25"}, {"id": "approval_card_fusionapprovalcard_approve", "label": ".approve()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L30"}, {"id": "approval_card_fusionapprovalcard_reject", "label": ".reject()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L34"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_static_src_components_chat_approval_card_js", "target": "owl", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_static_src_components_chat_approval_card_js", "target": "approval_card_fusionapprovalcard", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L5", "weight": 1.0}, {"source": "approval_card_fusionapprovalcard", "target": "approval_card_fusionapprovalcard_toollabel", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L9", "weight": 1.0}, {"source": "approval_card_fusionapprovalcard", "target": "approval_card_fusionapprovalcard_formatamount", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L25", "weight": 1.0}, {"source": "approval_card_fusionapprovalcard", "target": "approval_card_fusionapprovalcard_approve", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L30", "weight": 1.0}, {"source": "approval_card_fusionapprovalcard", "target": "approval_card_fusionapprovalcard_reject", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L34", "weight": 1.0}], "raw_calls": [{"caller_nid": "approval_card_fusionapprovalcard_toollabel", "callee": "replace", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L22"}, {"caller_nid": "approval_card_fusionapprovalcard_toollabel", "callee": "replace", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L22"}, {"caller_nid": "approval_card_fusionapprovalcard_formatamount", "callee": "toLocaleString", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L27"}, {"caller_nid": "approval_card_fusionapprovalcard_formatamount", "callee": "Number", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L27"}, {"caller_nid": "approval_card_fusionapprovalcard_approve", "callee": "onApprove", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L31"}, {"caller_nid": "approval_card_fusionapprovalcard_reject", "callee": "onReject", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/chat/approval_card.js", "source_location": "L35"}]}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_wizards_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/wizards/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_wizards_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_wizards_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/wizards/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/__init__.py", "source_location": "L6", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/__init__.py", "source_location": "L7", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/__init__.py", "source_location": "L8", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/__init__.py", "source_location": "L9", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_base_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_registry_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/__init__.py", "source_location": "L6", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/__init__.py", "source_location": "L7", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_static_src_components_dashboard_health_card_js", "label": "health_card.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/dashboard/health_card.js", "source_location": "L1"}, {"id": "health_card_fusionhealthcard", "label": "FusionHealthCard", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/dashboard/health_card.js", "source_location": "L5"}, {"id": "health_card_fusionhealthcard_icon", "label": ".icon()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/dashboard/health_card.js", "source_location": "L9"}, {"id": "health_card_fusionhealthcard_onclick", "label": ".onClick()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/dashboard/health_card.js", "source_location": "L21"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_static_src_components_dashboard_health_card_js", "target": "owl", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/dashboard/health_card.js", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_static_src_components_dashboard_health_card_js", "target": "health_card_fusionhealthcard", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/dashboard/health_card.js", "source_location": "L5", "weight": 1.0}, {"source": "health_card_fusionhealthcard", "target": "health_card_fusionhealthcard_icon", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/dashboard/health_card.js", "source_location": "L9", "weight": 1.0}, {"source": "health_card_fusionhealthcard", "target": "health_card_fusionhealthcard_onclick", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/dashboard/health_card.js", "source_location": "L21", "weight": 1.0}], "raw_calls": [{"caller_nid": "health_card_fusionhealthcard_onclick", "callee": "onCardClick", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/src/components/dashboard/health_card.js", "source_location": "L22"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_session_py", "label": "accounting_session.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_session.py", "source_location": "L1"}, {"id": "accounting_session_fusionaccountingsession", "label": "FusionAccountingSession", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_session.py", "source_location": "L7"}, {"id": "accounting_session_fusionaccountingsession_action_close_session", "label": ".action_close_session()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_session.py", "source_location": "L59"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_session_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_session.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_session_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_session.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_session_py", "target": "accounting_session_fusionaccountingsession", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_session.py", "source_location": "L7", "weight": 1.0}, {"source": "accounting_session_fusionaccountingsession", "target": "accounting_session_fusionaccountingsession_action_close_session", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_session.py", "source_location": "L59", "weight": 1.0}], "raw_calls": [{"caller_nid": "accounting_session_fusionaccountingsession_action_close_session", "callee": "write", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_session.py", "source_location": "L60"}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_controllers_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/controllers/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/controllers/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/__init__.py", "source_location": "L4", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_config_py", "label": "accounting_config.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_config.py", "source_location": "L1"}, {"id": "accounting_config_resconfigsettings", "label": "ResConfigSettings", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_config.py", "source_location": "L7"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_config_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_config.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_config_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_config.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_models_accounting_config_py", "target": "accounting_config_resconfigsettings", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/models/accounting_config.py", "source_location": "L7", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_test_api_live_py", "label": "test_api_live.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_api_live.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_test_api_live_py", "target": "anthropic", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_api_live.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_test_api_live_py", "target": "json", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_api_live.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_tests_test_api_live_py", "target": "sys", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/tests/test_api_live.py", "source_location": "L3", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_adapters_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/adapters/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_adapters_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_adapters_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/adapters/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_adapters_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_adapters_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/adapters/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_adapters_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_adapters_base_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/adapters/__init__.py", "source_location": "L3", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_registry_py", "label": "_registry.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L1"}, {"id": "registry_get_adapter", "label": "get_adapter()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L6"}, {"id": "registry_register_adapter", "label": "register_adapter()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L23"}, {"id": "registry_rationale_1", "label": "Registry: lazy-loads data adapter instances per env.", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L1"}, {"id": "registry_rationale_7", "label": "Return a data adapter by short name. Cached per request via env.context.", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L7"}, {"id": "registry_rationale_24", "label": "Register an adapter class. Call from each adapter module at import time.", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L24"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_registry_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_base_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_registry_py", "target": "registry_get_adapter", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L6", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_registry_py", "target": "registry_register_adapter", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L23", "weight": 1.0}, {"source": "registry_rationale_1", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_data_adapters_registry_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L1", "weight": 1.0}, {"source": "registry_rationale_7", "target": "registry_get_adapter", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L7", "weight": 1.0}, {"source": "registry_rationale_24", "target": "registry_register_adapter", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L24", "weight": 1.0}], "raw_calls": [{"caller_nid": "registry_get_adapter", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L8"}, {"caller_nid": "registry_get_adapter", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L12"}, {"caller_nid": "registry_get_adapter", "callee": "KeyError", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L14"}, {"caller_nid": "registry_get_adapter", "callee": "list", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L14"}, {"caller_nid": "registry_get_adapter", "callee": "cls", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/data_adapters/_registry.py", "source_location": "L15"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_prompts_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/prompts/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_prompts_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_prompts_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/prompts/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_prompts_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_prompts_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/prompts/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_prompts_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_services_prompts_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/services/prompts/__init__.py", "source_location": "L3", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_migrations_19_0_1_0_0_post_migration_py", "label": "post-migration.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L1"}, {"id": "post_migration_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L79"}, {"id": "post_migration_rationale_1", "label": "Reassign ir_model_data ownership from fusion_accounting to fusion_accounting_ai.", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_migrations_19_0_1_0_0_post_migration_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L21", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_migrations_19_0_1_0_0_post_migration_py", "target": "post_migration_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L79", "weight": 1.0}, {"source": "post_migration_rationale_1", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_ai_migrations_19_0_1_0_0_post_migration_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L1", "weight": 1.0}], "raw_calls": [{"caller_nid": "post_migration_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L81"}, {"caller_nid": "post_migration_migrate", "callee": "list", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L86"}, {"caller_nid": "post_migration_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L91"}, {"caller_nid": "post_migration_migrate", "callee": "list", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L100"}, {"caller_nid": "post_migration_migrate", "callee": "list", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L100"}, {"caller_nid": "post_migration_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L104"}, {"caller_nid": "post_migration_migrate", "callee": "list", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L112"}, {"caller_nid": "post_migration_migrate", "callee": "list", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L112"}, {"caller_nid": "post_migration_migrate", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py", "source_location": "L115"}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
257
fusion_accounting/fusion_accounting_ai/graphify-out/graph.html
Normal file
257
fusion_accounting/fusion_accounting_ai/graphify-out/graph.html
Normal file
File diff suppressed because one or more lines are too long
16156
fusion_accounting/fusion_accounting_ai/graphify-out/graph.json
Normal file
16156
fusion_accounting/fusion_accounting_ai/graphify-out/graph.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,9 @@
|
||||
from . import accounting_config
|
||||
from . import accounting_tool
|
||||
from . import accounting_session
|
||||
from . import accounting_match_history
|
||||
from . import accounting_rule
|
||||
from . import accounting_dashboard
|
||||
from . import account_move_hook
|
||||
from . import vendor_tax_profile
|
||||
from . import recurring_pattern
|
||||
@@ -0,0 +1,58 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountMoveAuditHook(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
def action_post(self):
|
||||
res = super().action_post()
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_accounting.enable_post_audit', 'False') != 'True':
|
||||
return res
|
||||
|
||||
for move in self:
|
||||
try:
|
||||
self._fusion_audit_posted_entry(move)
|
||||
except Exception as e:
|
||||
_logger.warning("Fusion post-audit hook failed for %s: %s", move.name, e)
|
||||
|
||||
return res
|
||||
|
||||
def _fusion_audit_posted_entry(self, move):
|
||||
issues = []
|
||||
|
||||
total_debit = sum(l.debit for l in move.line_ids)
|
||||
total_credit = sum(l.credit for l in move.line_ids)
|
||||
if abs(total_debit - total_credit) > 0.01:
|
||||
issues.append(f'Unbalanced: debit={total_debit:.2f}, credit={total_credit:.2f}')
|
||||
|
||||
for line in move.line_ids:
|
||||
if not line.account_id:
|
||||
issues.append(f'Line missing account: {line.name}')
|
||||
# M6: Only flag missing tax when the product has taxes configured
|
||||
# (avoids false positives for HST-exempt healthcare services)
|
||||
if (line.product_id and not line.tax_ids
|
||||
and move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')):
|
||||
# Check if the product has default taxes configured
|
||||
product_taxes = line.product_id.taxes_id if move.move_type in ('out_invoice', 'out_refund') else line.product_id.supplier_taxes_id
|
||||
if product_taxes:
|
||||
issues.append(f'Missing tax on product line: {line.product_id.name} (product has taxes configured but line has none)')
|
||||
|
||||
if not move.line_ids:
|
||||
issues.append('Entry has no lines')
|
||||
|
||||
if issues:
|
||||
body_parts = ['<strong>Fusion AI Auto-Audit</strong><ul>']
|
||||
for issue in issues:
|
||||
body_parts.append(f'<li>{issue}</li>')
|
||||
body_parts.append('</ul>')
|
||||
move.message_post(
|
||||
body=''.join(body_parts),
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
fusion_ai_provider = fields.Selection(
|
||||
selection=[('claude', 'Anthropic Claude'), ('openai', 'OpenAI GPT')],
|
||||
string='AI Provider',
|
||||
default='claude',
|
||||
config_parameter='fusion_accounting.ai_provider',
|
||||
)
|
||||
fusion_anthropic_api_key = fields.Char(
|
||||
string='Anthropic API Key (Fusion AI)',
|
||||
config_parameter='fusion_accounting.anthropic_api_key',
|
||||
)
|
||||
fusion_openai_api_key = fields.Char(
|
||||
string='OpenAI API Key (Fusion AI)',
|
||||
config_parameter='fusion_accounting.openai_api_key',
|
||||
)
|
||||
fusion_claude_model = fields.Selection(
|
||||
selection=[
|
||||
('claude-opus-4-6', 'Claude Opus 4.6 (Most Intelligent)'),
|
||||
('claude-sonnet-4-6', 'Claude Sonnet 4.6 (Best Balance)'),
|
||||
('claude-haiku-4-5', 'Claude Haiku 4.5 (Fastest)'),
|
||||
('claude-sonnet-4-5', 'Claude Sonnet 4.5'),
|
||||
('claude-opus-4-5', 'Claude Opus 4.5'),
|
||||
('claude-sonnet-4-0', 'Claude Sonnet 4'),
|
||||
('claude-opus-4-0', 'Claude Opus 4'),
|
||||
],
|
||||
string='Claude Model',
|
||||
default='claude-sonnet-4-6',
|
||||
config_parameter='fusion_accounting.claude_model',
|
||||
)
|
||||
fusion_openai_model = fields.Selection(
|
||||
selection=[
|
||||
('gpt-5.4', 'GPT-5.4 (Flagship)'),
|
||||
('gpt-5.4-mini', 'GPT-5.4 Mini (Fast)'),
|
||||
('gpt-5.4-nano', 'GPT-5.4 Nano (Cheapest)'),
|
||||
('o3', 'o3 (Best Reasoning)'),
|
||||
('o4-mini', 'o4-mini (Fast Reasoning)'),
|
||||
('gpt-4o', 'GPT-4o (Legacy)'),
|
||||
('gpt-4o-mini', 'GPT-4o Mini (Legacy)'),
|
||||
],
|
||||
string='OpenAI Model',
|
||||
default='gpt-5.4-mini',
|
||||
config_parameter='fusion_accounting.openai_model',
|
||||
)
|
||||
fusion_tier3_threshold = fields.Float(
|
||||
string='Tier 3 Promotion Threshold',
|
||||
default=0.95,
|
||||
config_parameter='fusion_accounting.tier3_threshold',
|
||||
help='Accuracy threshold for promoting Tier 3 tools to auto-approved.',
|
||||
)
|
||||
fusion_tier3_min_sample = fields.Integer(
|
||||
string='Tier 3 Minimum Sample Size',
|
||||
default=30,
|
||||
config_parameter='fusion_accounting.tier3_min_sample',
|
||||
)
|
||||
fusion_audit_cron_frequency = fields.Selection(
|
||||
selection=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')],
|
||||
string='Audit Scan Frequency',
|
||||
default='daily',
|
||||
config_parameter='fusion_accounting.audit_cron_frequency',
|
||||
)
|
||||
fusion_history_in_prompt = fields.Integer(
|
||||
string='Match History in Prompt',
|
||||
default=50,
|
||||
config_parameter='fusion_accounting.history_in_prompt',
|
||||
help='Number of recent match history records to include in AI prompt.',
|
||||
)
|
||||
fusion_max_tool_calls = fields.Integer(
|
||||
string='Max Tool Calls Per Turn',
|
||||
default=20,
|
||||
config_parameter='fusion_accounting.max_tool_calls',
|
||||
)
|
||||
fusion_enable_post_audit = fields.Boolean(
|
||||
string='Enable Post-Action Audit Hook',
|
||||
default=False,
|
||||
config_parameter='fusion_accounting.enable_post_audit',
|
||||
)
|
||||
@@ -0,0 +1,334 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingDashboard(models.TransientModel):
|
||||
_name = 'fusion.accounting.dashboard'
|
||||
_description = 'Fusion Accounting Dashboard'
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
bank_recon_count = fields.Integer(compute='_compute_bank_recon')
|
||||
bank_recon_amount = fields.Monetary(
|
||||
compute='_compute_bank_recon', currency_field='currency_id',
|
||||
)
|
||||
ar_total = fields.Monetary(
|
||||
compute='_compute_ar', currency_field='currency_id',
|
||||
)
|
||||
ar_overdue_count = fields.Integer(compute='_compute_ar')
|
||||
ap_total = fields.Monetary(
|
||||
compute='_compute_ap', currency_field='currency_id',
|
||||
)
|
||||
ap_due_this_week = fields.Integer(compute='_compute_ap')
|
||||
hst_balance = fields.Monetary(
|
||||
compute='_compute_hst', currency_field='currency_id',
|
||||
)
|
||||
audit_score = fields.Integer(compute='_compute_audit')
|
||||
audit_flag_count = fields.Integer(compute='_compute_audit')
|
||||
month_end_status = fields.Char(compute='_compute_month_end')
|
||||
month_end_open_items = fields.Integer(compute='_compute_month_end')
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
needs_attention_json = fields.Text(compute='_compute_action_centre')
|
||||
recent_activity_json = fields.Text(compute='_compute_action_centre')
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_bank_recon(self):
|
||||
for rec in self:
|
||||
data = self.env['account.bank.statement.line'].read_group(
|
||||
[('is_reconciled', '=', False), ('company_id', '=', rec.company_id.id)],
|
||||
['amount:sum'], [],
|
||||
)
|
||||
row = data[0] if data else {}
|
||||
rec.bank_recon_count = row.get('__count', 0)
|
||||
rec.bank_recon_amount = abs(row.get('amount', 0) or 0)
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_ar(self):
|
||||
for rec in self:
|
||||
data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['amount_residual:sum'], [],
|
||||
)
|
||||
row = data[0] if data else {}
|
||||
rec.ar_total = row.get('amount_residual', 0) or 0
|
||||
|
||||
rec.ar_overdue_count = self.env['account.move.line'].search_count([
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('date_maturity', '<', fields.Date.today()),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_ap(self):
|
||||
for rec in self:
|
||||
data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.account_type', '=', 'liability_payable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['amount_residual:sum'], [],
|
||||
)
|
||||
row = data[0] if data else {}
|
||||
rec.ap_total = abs(row.get('amount_residual', 0) or 0)
|
||||
|
||||
week_end = fields.Date.today() + timedelta(days=7)
|
||||
rec.ap_due_this_week = self.env['account.move.line'].search_count([
|
||||
('account_id.account_type', '=', 'liability_payable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('date_maturity', '<=', week_end),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_hst(self):
|
||||
for rec in self:
|
||||
collected_data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.code', '=like', '2005%'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['balance:sum'], [],
|
||||
)
|
||||
itc_data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.code', '=like', '2006%'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['balance:sum'], [],
|
||||
)
|
||||
collected = abs((collected_data[0] if collected_data else {}).get('balance', 0) or 0)
|
||||
itcs = abs((itc_data[0] if itc_data else {}).get('balance', 0) or 0)
|
||||
rec.hst_balance = collected - itcs
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_audit(self):
|
||||
for rec in self:
|
||||
issues = 0
|
||||
|
||||
# Wrong-direction balances via read_group
|
||||
balance_data = self.env['account.move.line'].read_group(
|
||||
[('parent_state', '=', 'posted'), ('company_id', '=', rec.company_id.id)],
|
||||
['balance:sum'], ['account_id'],
|
||||
)
|
||||
acct_cache = {}
|
||||
acct_ids = [row['account_id'][0] for row in balance_data if row.get('account_id')]
|
||||
if acct_ids:
|
||||
for acct in self.env['account.account'].browse(acct_ids):
|
||||
acct_cache[acct.id] = acct.account_type
|
||||
for row in balance_data:
|
||||
if not row.get('account_id'):
|
||||
continue
|
||||
acct_type = acct_cache.get(row['account_id'][0], '')
|
||||
balance = row.get('balance', 0) or 0
|
||||
if acct_type in ('asset_receivable', 'asset_cash', 'asset_current',
|
||||
'asset_non_current', 'asset_fixed', 'expense',
|
||||
'expense_depreciation', 'expense_direct_cost'):
|
||||
if balance < -0.01:
|
||||
issues += 1
|
||||
elif acct_type in ('liability_payable', 'liability_current',
|
||||
'liability_non_current', 'equity', 'income',
|
||||
'income_other'):
|
||||
if balance > 0.01:
|
||||
issues += 1
|
||||
|
||||
# M4: Guard against made_sequence_gap field not existing
|
||||
try:
|
||||
gaps = self.env['account.move'].search_count([
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
('made_sequence_gap', '=', True),
|
||||
])
|
||||
except (ValueError, KeyError):
|
||||
gaps = 0
|
||||
issues += gaps
|
||||
|
||||
pending_approvals = self.env['fusion.accounting.match.history'].search_count([
|
||||
('decision', '=', 'pending'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
rec.audit_score = max(0, min(100, 100 - issues * 3))
|
||||
rec.audit_flag_count = issues + pending_approvals
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_month_end(self):
|
||||
for rec in self:
|
||||
open_items = 0
|
||||
open_items += self.env['account.bank.statement.line'].search_count([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
open_items += self.env['account.move'].search_count([
|
||||
('state', '=', 'draft'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
suspense_data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.code', '=like', '999%'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['balance:sum'], ['account_id'],
|
||||
)
|
||||
for row in suspense_data:
|
||||
if abs(row.get('balance', 0) or 0) > 0.01:
|
||||
open_items += 1
|
||||
|
||||
rec.month_end_open_items = open_items
|
||||
if open_items == 0:
|
||||
rec.month_end_status = 'Ready to Close'
|
||||
elif open_items < 5:
|
||||
rec.month_end_status = 'Almost Ready'
|
||||
else:
|
||||
rec.month_end_status = 'Open'
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_action_centre(self):
|
||||
for rec in self:
|
||||
attention = []
|
||||
today = fields.Date.today()
|
||||
|
||||
# Pending AI approvals (highest priority)
|
||||
pending = self.env['fusion.accounting.match.history'].search_count([
|
||||
('decision', '=', 'pending'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if pending > 0:
|
||||
attention.append({
|
||||
'priority': 0, 'severity': 'danger',
|
||||
'title': f'{pending} AI actions awaiting your approval',
|
||||
'domain': 'audit',
|
||||
'action': 'Review and approve or reject pending actions',
|
||||
'prompt': 'Show me all pending approval actions',
|
||||
})
|
||||
|
||||
# Unreconciled bank lines
|
||||
unrecon = self.env['account.bank.statement.line'].search_count([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if unrecon > 0:
|
||||
attention.append({
|
||||
'priority': 1, 'severity': 'warning',
|
||||
'title': f'{unrecon} unreconciled bank lines',
|
||||
'domain': 'bank_reconciliation',
|
||||
'action': 'Review and reconcile bank statement lines',
|
||||
'prompt': 'Show me unreconciled bank lines across all journals with a breakdown by journal',
|
||||
})
|
||||
|
||||
# Overdue customer invoices
|
||||
overdue = self.env['account.move'].search_count([
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<', today),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if overdue > 0:
|
||||
attention.append({
|
||||
'priority': 2, 'severity': 'warning',
|
||||
'title': f'{overdue} overdue customer invoices',
|
||||
'domain': 'accounts_receivable',
|
||||
'action': 'Send follow-up reminders',
|
||||
'prompt': 'Show me overdue invoices sorted by amount',
|
||||
})
|
||||
|
||||
# Unpaid vendor bills due this week
|
||||
week_end = today + timedelta(days=7)
|
||||
due_bills = self.env['account.move'].search_count([
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<=', week_end),
|
||||
('invoice_date_due', '>=', today),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if due_bills > 0:
|
||||
attention.append({
|
||||
'priority': 3, 'severity': 'info',
|
||||
'title': f'{due_bills} vendor bills due this week',
|
||||
'domain': 'accounts_payable',
|
||||
'action': 'Review upcoming payments',
|
||||
'prompt': f'Show me vendor bills due between {today} and {week_end}',
|
||||
})
|
||||
|
||||
# Stale draft entries
|
||||
drafts = self.env['account.move'].search_count([
|
||||
('state', '=', 'draft'),
|
||||
('date', '<=', today - timedelta(days=30)),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if drafts > 0:
|
||||
attention.append({
|
||||
'priority': 4, 'severity': 'muted',
|
||||
'title': f'{drafts} stale draft entries (30+ days)',
|
||||
'domain': 'journal_review',
|
||||
'action': 'Post or delete stale draft entries',
|
||||
'prompt': 'Find all stale draft entries older than 30 days',
|
||||
})
|
||||
|
||||
# Unmatched customer payments (on outstanding receipts accounts)
|
||||
try:
|
||||
outstanding_accts = self.env['account.account'].search([
|
||||
('name', 'ilike', 'outstanding receipt'),
|
||||
('company_ids', 'in', rec.company_id.id),
|
||||
])
|
||||
if outstanding_accts:
|
||||
unmatched_payments = self.env['account.move.line'].search_count([
|
||||
('account_id', 'in', outstanding_accts.ids),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if unmatched_payments > 0:
|
||||
attention.append({
|
||||
'priority': 5, 'severity': 'info',
|
||||
'title': f'{unmatched_payments} unmatched customer payments',
|
||||
'domain': 'accounts_receivable',
|
||||
'action': 'Match payments to invoices',
|
||||
'prompt': 'Show me unmatched customer payments that need to be applied to invoices',
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
attention.sort(key=lambda x: x['priority'])
|
||||
rec.needs_attention_json = json.dumps(attention)
|
||||
|
||||
recent = self.env['fusion.accounting.match.history'].search([
|
||||
('company_id', '=', rec.company_id.id),
|
||||
], limit=10, order='proposed_at desc')
|
||||
rec.recent_activity_json = json.dumps([{
|
||||
'tool': r.tool_name,
|
||||
'decision': r.decision,
|
||||
'date': r.proposed_at.isoformat() if r.proposed_at else '',
|
||||
'amount': r.amount,
|
||||
} for r in recent])
|
||||
|
||||
def action_refresh(self):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_accounting.dashboard',
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
TOOL_LABELS = {
|
||||
'get_unreconciled_bank_lines': 'Get Unreconciled Bank Lines',
|
||||
'get_unreconciled_receipts': 'Get Unreconciled Receipts',
|
||||
'match_bank_line_to_payments': 'Match Bank Line to Payments',
|
||||
'auto_reconcile_bank_lines': 'Auto-Reconcile Bank Lines',
|
||||
'apply_reconcile_model': 'Apply Reconcile Model',
|
||||
'unmatch_bank_line': 'Unmatch Bank Line',
|
||||
'get_reconcile_suggestions': 'Get Reconcile Suggestions',
|
||||
'sum_payments_by_date': 'Sum Payments by Date',
|
||||
'get_bank_line_details': 'Get Bank Line Details',
|
||||
'check_recurring_pattern': 'Check Recurring Pattern',
|
||||
'match_internal_transfers': 'Match Internal Transfers',
|
||||
'find_unreconciled_cheques': 'Find Unreconciled Cheques',
|
||||
'reconcile_payroll_cheques': 'Reconcile Payroll Cheques',
|
||||
'suggest_bank_line_matches': 'Suggest Bank Line Matches',
|
||||
'search_matching_entries': 'Search Matching Entries',
|
||||
'calculate_hst_balance': 'Calculate HST Balance',
|
||||
'create_expense_entry': 'Create Expense Entry',
|
||||
'find_missing_itc_bills': 'Find Missing ITC Bills',
|
||||
'find_missing_tax_invoices': 'Find Missing Tax Invoices',
|
||||
'get_tax_report': 'Get Tax Report',
|
||||
'get_ar_aging': 'Get AR Aging',
|
||||
'get_overdue_invoices': 'Get Overdue Invoices',
|
||||
'get_partner_balance': 'Get Partner Balance',
|
||||
'get_ap_aging': 'Get AP Aging',
|
||||
'get_unpaid_bills': 'Get Unpaid Bills',
|
||||
'find_duplicate_bills': 'Find Duplicate Bills',
|
||||
'create_vendor_bill': 'Create Vendor Bill',
|
||||
'register_bill_payment': 'Register Bill Payment',
|
||||
'get_profit_loss': 'Get Profit & Loss',
|
||||
'get_balance_sheet': 'Get Balance Sheet',
|
||||
'get_trial_balance': 'Get Trial Balance',
|
||||
'get_cash_flow': 'Get Cash Flow',
|
||||
'compare_periods': 'Compare Periods',
|
||||
'get_invoicing_summary': 'Get Invoicing Summary',
|
||||
'get_billing_summary': 'Get Billing Summary',
|
||||
'get_collections_summary': 'Get Collections Summary',
|
||||
'create_payroll_journal_entry': 'Create Payroll Journal Entry',
|
||||
'find_adp_without_payment': 'Find ADP Without Payment',
|
||||
'get_adp_receivable_aging': 'Get ADP Receivable Aging',
|
||||
'register_adp_batch_payment': 'Register ADP Batch Payment',
|
||||
'get_close_checklist': 'Get Month-End Checklist',
|
||||
'find_draft_entries': 'Find Draft Entries',
|
||||
'find_wrong_direction_balances': 'Find Wrong Direction Balances',
|
||||
'find_duplicate_entries': 'Find Duplicate Entries',
|
||||
'get_payroll_entries': 'Get Payroll Entries',
|
||||
'get_cra_remittance_status': 'Get CRA Remittance Status',
|
||||
}
|
||||
|
||||
|
||||
class FusionAccountingMatchHistory(models.Model):
|
||||
_name = 'fusion.accounting.match.history'
|
||||
_description = 'Fusion Accounting Match History'
|
||||
_order = 'proposed_at desc'
|
||||
_rec_name = 'display_label'
|
||||
|
||||
display_label = fields.Char(
|
||||
string='Label', compute='_compute_display_label', store=True,
|
||||
)
|
||||
session_id = fields.Many2one(
|
||||
'fusion.accounting.session', string='Session',
|
||||
index=True, ondelete='cascade',
|
||||
)
|
||||
tool_name = fields.Char(string='Tool Name', required=True, index=True)
|
||||
tool_display_name = fields.Char(
|
||||
string='Tool', compute='_compute_tool_display_name', store=True,
|
||||
)
|
||||
tool_params_pretty = fields.Text(
|
||||
string='Parameters', compute='_compute_pretty_json',
|
||||
)
|
||||
tool_result_pretty = fields.Text(
|
||||
string='Result', compute='_compute_pretty_json',
|
||||
)
|
||||
tool_params = fields.Text(string='Tool Parameters (JSON)')
|
||||
tool_result = fields.Text(string='Tool Result (JSON)')
|
||||
ai_reasoning = fields.Text(string='AI Reasoning')
|
||||
ai_confidence = fields.Float(string='AI Confidence', digits=(3, 2))
|
||||
rule_id = fields.Many2one(
|
||||
'fusion.accounting.rule', string='Applied Rule',
|
||||
ondelete='set null',
|
||||
)
|
||||
proposed_at = fields.Datetime(
|
||||
string='Proposed At',
|
||||
default=fields.Datetime.now,
|
||||
required=True,
|
||||
)
|
||||
decision = fields.Selection(
|
||||
selection=[
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
('pending', 'Pending'),
|
||||
('auto', 'Auto-Executed'),
|
||||
],
|
||||
string='Decision',
|
||||
default='pending',
|
||||
index=True,
|
||||
)
|
||||
decided_at = fields.Datetime(string='Decided At')
|
||||
decided_by = fields.Many2one('res.users', string='Decided By')
|
||||
rejection_reason = fields.Text(string='Rejection Reason')
|
||||
correct_action = fields.Text(string='Correct Action (JSON)')
|
||||
bank_statement_line_id = fields.Many2one(
|
||||
'account.bank.statement.line', string='Bank Statement Line',
|
||||
ondelete='set null',
|
||||
)
|
||||
move_line_ids = fields.Many2many(
|
||||
'account.move.line', string='Journal Items',
|
||||
)
|
||||
amount = fields.Monetary(string='Amount', currency_field='currency_id')
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
partner_id = fields.Many2one('res.partner', string='Partner')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
@api.depends('tool_name')
|
||||
def _compute_tool_display_name(self):
|
||||
for rec in self:
|
||||
rec.tool_display_name = TOOL_LABELS.get(rec.tool_name, (rec.tool_name or '').replace('_', ' ').title())
|
||||
|
||||
@api.depends('tool_params', 'tool_result')
|
||||
def _compute_pretty_json(self):
|
||||
for rec in self:
|
||||
for src, dst in [('tool_params', 'tool_params_pretty'), ('tool_result', 'tool_result_pretty')]:
|
||||
raw = getattr(rec, src) or '{}'
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
setattr(rec, dst, json.dumps(parsed, indent=2, default=str, ensure_ascii=False))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
setattr(rec, dst, raw)
|
||||
|
||||
@api.depends('tool_name', 'proposed_at', 'decision')
|
||||
def _compute_display_label(self):
|
||||
for rec in self:
|
||||
label = TOOL_LABELS.get(rec.tool_name, (rec.tool_name or '').replace('_', ' ').title())
|
||||
date_str = rec.proposed_at.strftime('%b %d %H:%M') if rec.proposed_at else ''
|
||||
decision_str = dict(rec._fields['decision'].selection).get(rec.decision, '')
|
||||
rec.display_label = f"{label} — {decision_str} ({date_str})" if date_str else label
|
||||
|
||||
def action_approve(self):
|
||||
self.write({
|
||||
'decision': 'approved',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
})
|
||||
for rec in self:
|
||||
if rec.rule_id:
|
||||
rec.rule_id._record_decision(approved=True)
|
||||
|
||||
def action_reject(self):
|
||||
self.write({
|
||||
'decision': 'rejected',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
})
|
||||
for rec in self:
|
||||
if rec.rule_id:
|
||||
rec.rule_id._record_decision(approved=False)
|
||||
121
fusion_accounting/fusion_accounting_ai/models/accounting_rule.py
Normal file
121
fusion_accounting/fusion_accounting_ai/models/accounting_rule.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingRule(models.Model):
|
||||
_name = 'fusion.accounting.rule'
|
||||
_description = 'Fusion Accounting Rule'
|
||||
_order = 'sequence, id'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char(string='Name', required=True, tracking=True)
|
||||
rule_type = fields.Selection(
|
||||
selection=[
|
||||
('match', 'Match'),
|
||||
('classify', 'Classify'),
|
||||
('audit', 'Audit'),
|
||||
('fee', 'Fee'),
|
||||
('routing', 'Routing'),
|
||||
('followup', 'Follow-Up'),
|
||||
],
|
||||
string='Type',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
help='Natural language description read by the AI.',
|
||||
)
|
||||
trigger_domain = fields.Text(
|
||||
string='Trigger Domain (JSON)',
|
||||
help='Odoo domain filter for matching records.',
|
||||
)
|
||||
match_logic = fields.Text(
|
||||
string='Match Logic',
|
||||
help='Natural language matching instructions for the AI.',
|
||||
)
|
||||
match_code = fields.Text(
|
||||
string='Match Code (Python)',
|
||||
help='Optional deterministic Python matching code.',
|
||||
)
|
||||
fee_account_id = fields.Many2one(
|
||||
'account.account', string='Fee Account',
|
||||
)
|
||||
write_off_account_id = fields.Many2one(
|
||||
'account.account', string='Write-Off Account',
|
||||
)
|
||||
approval_tier = fields.Selection(
|
||||
selection=[('auto', 'Auto-Approved'), ('needs_approval', 'Needs Approval')],
|
||||
string='Approval Tier',
|
||||
default='needs_approval',
|
||||
tracking=True,
|
||||
)
|
||||
created_by = fields.Selection(
|
||||
selection=[('admin', 'Admin'), ('ai', 'AI')],
|
||||
string='Created By',
|
||||
default='admin',
|
||||
)
|
||||
confidence_score = fields.Float(
|
||||
string='Confidence Score', digits=(3, 2), default=0.0,
|
||||
)
|
||||
total_uses = fields.Integer(string='Total Uses', default=0)
|
||||
total_approved = fields.Integer(string='Total Approved', default=0)
|
||||
total_rejected = fields.Integer(string='Total Rejected', default=0)
|
||||
promotion_threshold = fields.Float(
|
||||
string='Promotion Threshold', default=0.95,
|
||||
)
|
||||
min_sample_size = fields.Integer(string='Min Sample Size', default=30)
|
||||
active = fields.Boolean(string='Active', default=True, tracking=True)
|
||||
version = fields.Integer(string='Version', default=1)
|
||||
parent_rule_id = fields.Many2one(
|
||||
'fusion.accounting.rule', string='Previous Version',
|
||||
ondelete='set null',
|
||||
)
|
||||
journal_ids = fields.Many2many(
|
||||
'account.journal', string='Journals',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
def _record_decision(self, approved=True):
|
||||
for rec in self:
|
||||
self.env.cr.execute("""
|
||||
UPDATE fusion_accounting_rule
|
||||
SET total_uses = total_uses + 1,
|
||||
total_approved = total_approved + %s,
|
||||
total_rejected = total_rejected + %s
|
||||
WHERE id = %s
|
||||
RETURNING total_uses, total_approved
|
||||
""", (int(approved), int(not approved), rec.id))
|
||||
row = self.env.cr.fetchone()
|
||||
rec.invalidate_recordset(['total_uses', 'total_approved', 'total_rejected'])
|
||||
if row and row[0] > 0:
|
||||
rec.confidence_score = row[1] / row[0]
|
||||
rec._check_promotion()
|
||||
|
||||
def _check_promotion(self):
|
||||
for rec in self:
|
||||
if (rec.approval_tier == 'needs_approval'
|
||||
and rec.total_uses >= rec.min_sample_size
|
||||
and rec.confidence_score >= rec.promotion_threshold):
|
||||
rec.write({'approval_tier': 'auto'})
|
||||
_logger.info(
|
||||
"Rule '%s' promoted to auto-approved (confidence=%.2f, uses=%d)",
|
||||
rec.name, rec.confidence_score, rec.total_uses,
|
||||
)
|
||||
|
||||
def action_demote(self):
|
||||
self.write({'approval_tier': 'needs_approval'})
|
||||
|
||||
def action_rollback(self):
|
||||
for rec in self:
|
||||
if rec.parent_rule_id:
|
||||
# M5: Use write() to trigger tracking on tracked fields
|
||||
rec.write({'active': False})
|
||||
rec.parent_rule_id.write({'active': True})
|
||||
@@ -0,0 +1,60 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingSession(models.Model):
|
||||
_name = 'fusion.accounting.session'
|
||||
_description = 'Fusion Accounting AI Session'
|
||||
_order = 'create_date desc'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char(
|
||||
string='Session',
|
||||
required=True,
|
||||
default=lambda self: self.env['ir.sequence'].next_by_code('fusion.accounting.session') or 'New',
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
'res.users', string='User',
|
||||
required=True, default=lambda self: self.env.user,
|
||||
index=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
required=True, default=lambda self: self.env.company,
|
||||
)
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('active', 'Active'),
|
||||
('closed', 'Closed'),
|
||||
],
|
||||
string='Status',
|
||||
default='active',
|
||||
index=True,
|
||||
)
|
||||
message_ids_json = fields.Text(
|
||||
string='Messages (JSON)',
|
||||
default='[]',
|
||||
help='Stored conversation messages as JSON array.',
|
||||
)
|
||||
context_domain = fields.Char(
|
||||
string='Context Domain',
|
||||
help='Active accounting domain when session started.',
|
||||
)
|
||||
context_data = fields.Text(
|
||||
string='Context Data (JSON)',
|
||||
help='Additional Odoo context captured at session start.',
|
||||
)
|
||||
match_history_ids = fields.One2many(
|
||||
'fusion.accounting.match.history', 'session_id',
|
||||
string='Match History',
|
||||
)
|
||||
token_count_in = fields.Integer(string='Tokens In', default=0)
|
||||
token_count_out = fields.Integer(string='Tokens Out', default=0)
|
||||
tool_call_count = fields.Integer(string='Tool Calls', default=0)
|
||||
ai_provider = fields.Char(string='AI Provider')
|
||||
ai_model = fields.Char(string='AI Model')
|
||||
|
||||
def action_close_session(self):
|
||||
self.write({'state': 'closed'})
|
||||
@@ -0,0 +1,60 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingTool(models.Model):
|
||||
_name = 'fusion.accounting.tool'
|
||||
_description = 'Fusion Accounting AI Tool'
|
||||
_order = 'domain, sequence, name'
|
||||
|
||||
name = fields.Char(string='Technical Name', required=True, index=True)
|
||||
display_name_field = fields.Char(string='Tool Label', required=True)
|
||||
description = fields.Text(string='Description', required=True)
|
||||
domain = fields.Selection(
|
||||
selection=[
|
||||
('bank_reconciliation', 'Bank Reconciliation'),
|
||||
('hst_management', 'HST/GST Management'),
|
||||
('accounts_receivable', 'Accounts Receivable'),
|
||||
('accounts_payable', 'Accounts Payable'),
|
||||
('journal_review', 'Journal Review'),
|
||||
('month_end', 'Month-End / Year-End'),
|
||||
('payroll_verification', 'Payroll Verification'),
|
||||
('inventory', 'Inventory & COGS'),
|
||||
('adp', 'ADP Reconciliation'),
|
||||
('reporting', 'Financial Reporting'),
|
||||
('audit', 'Audit & Integrity'),
|
||||
('payroll_management', 'Payroll Management'),
|
||||
],
|
||||
string='Domain',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
tier = fields.Selection(
|
||||
selection=[
|
||||
('1', 'Tier 1 - Free (Read-Only)'),
|
||||
('2', 'Tier 2 - Auto-Approved'),
|
||||
('3', 'Tier 3 - Requires Approval'),
|
||||
],
|
||||
string='Tier',
|
||||
required=True,
|
||||
default='1',
|
||||
)
|
||||
parameters_schema = fields.Text(string='Parameters (JSON Schema)')
|
||||
required_groups = fields.Char(
|
||||
string='Required Groups',
|
||||
help='Comma-separated XML IDs of required groups.',
|
||||
)
|
||||
odoo_method = fields.Char(string='Odoo Method Reference')
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_company_uniq', 'UNIQUE(name, company_id)',
|
||||
'Tool name must be unique per company.'),
|
||||
]
|
||||
@@ -0,0 +1,216 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionRecurringPattern(models.Model):
|
||||
_name = 'fusion.recurring.pattern'
|
||||
_description = 'Recurring Bank Transaction Pattern (AI Cache)'
|
||||
_order = 'occurrences desc'
|
||||
|
||||
name = fields.Char(string='Pattern Name', required=True)
|
||||
ref_keyword = fields.Char(
|
||||
string='Reference Keyword',
|
||||
help='The payment_ref substring that identifies this pattern.',
|
||||
index=True,
|
||||
)
|
||||
amount = fields.Float(string='Amount', digits=(12, 2))
|
||||
amount_is_fixed = fields.Boolean(
|
||||
string='Fixed Amount',
|
||||
help='True if the amount is always the same. False if it varies.',
|
||||
)
|
||||
journal_id = fields.Many2one('account.journal', string='Bank Journal')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# How this was coded historically
|
||||
expense_account_id = fields.Many2one(
|
||||
'account.account', string='Expense Account',
|
||||
)
|
||||
expense_account_code = fields.Char(
|
||||
related='expense_account_id.code', string='Account Code', store=True,
|
||||
)
|
||||
has_hst = fields.Boolean(string='Has HST')
|
||||
partner_id = fields.Many2one('res.partner', string='Partner')
|
||||
reconcile_model_id = fields.Many2one(
|
||||
'account.reconcile.model', string='Reconciliation Model',
|
||||
help='If this pattern was handled by a reconciliation model.',
|
||||
)
|
||||
|
||||
# AI-readable instructions
|
||||
action_note = fields.Text(
|
||||
string='Action (AI-Readable)',
|
||||
help='Plain English instructions for the AI on how to handle this pattern.',
|
||||
)
|
||||
|
||||
# Stats
|
||||
occurrences = fields.Integer(string='Times Seen')
|
||||
first_seen = fields.Date(string='First Seen')
|
||||
last_seen = fields.Date(string='Last Seen')
|
||||
last_computed = fields.Datetime(string='Last Computed')
|
||||
|
||||
_sql_constraints = [
|
||||
('pattern_uniq', 'unique(ref_keyword, amount, company_id)',
|
||||
'One pattern per keyword+amount per company'),
|
||||
]
|
||||
|
||||
def _rebuild_all_patterns(self, min_occurrences=3, since='2024-01-01'):
|
||||
"""Scan reconciled bank lines for recurring patterns and cache how they were coded."""
|
||||
_logger.info("Rebuilding recurring patterns (min=%d, since=%s)...", min_occurrences, since)
|
||||
companies = self.env['res.company'].search([])
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
|
||||
for company in companies:
|
||||
# Step 1: Find recurring ref+amount combinations
|
||||
self.env.cr.execute("""
|
||||
SELECT LEFT(bsl.payment_ref, 60) as ref_pattern,
|
||||
bsl.amount,
|
||||
count(*) as occurrences,
|
||||
MIN(am.date) as first_seen,
|
||||
MAX(am.date) as last_seen,
|
||||
MODE() WITHIN GROUP (ORDER BY am.journal_id) as journal_id
|
||||
FROM account_bank_statement_line bsl
|
||||
JOIN account_move am ON bsl.move_id = am.id
|
||||
WHERE bsl.is_reconciled = true
|
||||
AND am.company_id = %s
|
||||
AND am.date >= %s
|
||||
AND bsl.payment_ref IS NOT NULL
|
||||
AND bsl.payment_ref != ''
|
||||
GROUP BY LEFT(bsl.payment_ref, 60), bsl.amount
|
||||
HAVING count(*) >= %s
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 200
|
||||
""", (company.id, since, min_occurrences))
|
||||
patterns = self.env.cr.dictfetchall()
|
||||
|
||||
for pat in patterns:
|
||||
ref = pat['ref_pattern'].strip()
|
||||
if not ref or len(ref) < 3:
|
||||
continue
|
||||
|
||||
# Step 2: Trace how one instance was coded
|
||||
self.env.cr.execute("""
|
||||
SELECT aml.account_id, aml.tax_line_id, aml.partner_id
|
||||
FROM account_bank_statement_line bsl
|
||||
JOIN account_move am ON bsl.move_id = am.id
|
||||
JOIN account_move_line aml ON aml.move_id = am.id
|
||||
WHERE bsl.is_reconciled = true
|
||||
AND bsl.payment_ref ILIKE %s
|
||||
AND bsl.amount = %s
|
||||
AND am.company_id = %s
|
||||
AND aml.display_type NOT IN ('line_section', 'line_note')
|
||||
AND aml.account_id NOT IN (
|
||||
SELECT default_account_id FROM account_journal
|
||||
WHERE company_id = %s AND default_account_id IS NOT NULL
|
||||
)
|
||||
ORDER BY bsl.id DESC
|
||||
LIMIT 5
|
||||
""", (f'%{ref[:40]}%', pat['amount'], company.id, company.id))
|
||||
coded_lines = self.env.cr.dictfetchall()
|
||||
|
||||
expense_account_id = None
|
||||
has_hst = False
|
||||
partner_id = None
|
||||
|
||||
for cl in coded_lines:
|
||||
if cl['tax_line_id']:
|
||||
has_hst = True
|
||||
elif cl['account_id'] and not expense_account_id:
|
||||
acct = self.env['account.account'].browse(cl['account_id'])
|
||||
if acct.exists() and acct.account_type in (
|
||||
'expense', 'expense_direct_cost', 'expense_depreciation',
|
||||
'asset_non_current', 'liability_non_current',
|
||||
):
|
||||
expense_account_id = cl['account_id']
|
||||
if cl['partner_id'] and not partner_id:
|
||||
partner_id = cl['partner_id']
|
||||
|
||||
# Build a friendly name
|
||||
clean_ref = re.sub(r'[X*]{3,}[\w-]*', '', ref).strip()
|
||||
clean_ref = re.sub(r'\s{2,}', ' ', clean_ref)[:50]
|
||||
|
||||
# Build AI action note
|
||||
acct_name = ''
|
||||
if expense_account_id:
|
||||
acct = self.env['account.account'].browse(expense_account_id)
|
||||
acct_name = f'{acct.code} {acct.name}' if acct.exists() else ''
|
||||
|
||||
partner_name = ''
|
||||
if partner_id:
|
||||
p = self.env['res.partner'].browse(partner_id)
|
||||
partner_name = p.name if p.exists() else ''
|
||||
|
||||
action_parts = [f'RECURRING PAYMENT (seen {pat["occurrences"]} times).']
|
||||
if expense_account_id:
|
||||
action_parts.append(f'Post to account: {acct_name}.')
|
||||
if has_hst:
|
||||
action_parts.append('HST applies — split with 13% ITC.')
|
||||
else:
|
||||
action_parts.append('No HST — post without tax.')
|
||||
if partner_name:
|
||||
action_parts.append(f'Partner: {partner_name}.')
|
||||
action_parts.append('Apply same coding as previous occurrences — no user input needed.')
|
||||
|
||||
action_note = ' '.join(action_parts)
|
||||
|
||||
# Step 3: Check if a reconciliation model already handles this pattern
|
||||
reco_model_id = None
|
||||
try:
|
||||
reco_models = self.env['account.reconcile.model'].search([
|
||||
('company_id', '=', company.id),
|
||||
('active', '=', True),
|
||||
('match_label_param', '!=', False),
|
||||
])
|
||||
ref_lower = ref.lower()
|
||||
for rm in reco_models:
|
||||
if rm.match_label_param and rm.match_label_param.lower() in ref_lower:
|
||||
reco_model_id = rm.id
|
||||
action_parts.append(
|
||||
f'Reconciliation model "{rm.name}" (ID:{rm.id}) already handles this — '
|
||||
f'use apply_reconcile_model to apply it automatically.'
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Upsert
|
||||
existing = self.search([
|
||||
('ref_keyword', '=', ref),
|
||||
('amount', '=', pat['amount']),
|
||||
('company_id', '=', company.id),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'name': clean_ref,
|
||||
'ref_keyword': ref,
|
||||
'amount': pat['amount'],
|
||||
'amount_is_fixed': True,
|
||||
'journal_id': pat['journal_id'],
|
||||
'company_id': company.id,
|
||||
'expense_account_id': expense_account_id,
|
||||
'has_hst': has_hst,
|
||||
'partner_id': partner_id,
|
||||
'reconcile_model_id': reco_model_id,
|
||||
'action_note': action_note,
|
||||
'occurrences': pat['occurrences'],
|
||||
'first_seen': pat['first_seen'],
|
||||
'last_seen': pat['last_seen'],
|
||||
'last_computed': fields.Datetime.now(),
|
||||
}
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
total_updated += 1
|
||||
else:
|
||||
self.create(vals)
|
||||
total_created += 1
|
||||
|
||||
_logger.info("Recurring patterns rebuilt: %d created, %d updated", total_created, total_updated)
|
||||
return {'created': total_created, 'updated': total_updated}
|
||||
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionVendorTaxProfile(models.Model):
|
||||
_name = 'fusion.vendor.tax.profile'
|
||||
_description = 'Vendor Tax Profile (AI Cache)'
|
||||
_order = 'total_bills desc'
|
||||
_rec_name = 'partner_id'
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Vendor', required=True, index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
total_bills = fields.Integer(string='Total Bills')
|
||||
bills_with_hst = fields.Integer(string='Bills with HST')
|
||||
bills_zero_rated = fields.Integer(string='Bills Zero-Rated')
|
||||
avg_tax_pct = fields.Float(string='Avg Tax %', digits=(5, 2))
|
||||
|
||||
# Classification
|
||||
tax_classification = fields.Selection([
|
||||
('always_hst', 'Always HST (13%)'),
|
||||
('mostly_hst', 'Mostly HST (>10%)'),
|
||||
('shipping_only', 'HST on Shipping Only (<2%)'),
|
||||
('never_hst', 'Never HST (0%)'),
|
||||
('mixed', 'Mixed / Inconsistent'),
|
||||
], string='Tax Classification')
|
||||
|
||||
# Most common expense account
|
||||
primary_account_id = fields.Many2one(
|
||||
'account.account', string='Primary Expense Account',
|
||||
)
|
||||
primary_account_code = fields.Char(
|
||||
related='primary_account_id.code', string='Account Code', store=True,
|
||||
)
|
||||
|
||||
# AI-readable note
|
||||
tax_note = fields.Text(
|
||||
string='Tax Note (AI-Readable)',
|
||||
help='Plain English note the AI reads to understand tax treatment.',
|
||||
)
|
||||
|
||||
# PO-tracked vendor — bills come from purchase orders, never from bank recon
|
||||
is_po_vendor = fields.Boolean(
|
||||
string='PO-Tracked Vendor',
|
||||
help='Bills for this vendor are created from Purchase Orders. '
|
||||
'Do NOT create bills during bank reconciliation — just match to existing bills.',
|
||||
)
|
||||
po_count = fields.Integer(string='Purchase Orders')
|
||||
|
||||
# Vendor details for matching
|
||||
is_foreign = fields.Boolean(string='Foreign Vendor')
|
||||
vendor_country = fields.Char(string='Vendor Country')
|
||||
|
||||
# Timestamps
|
||||
last_computed = fields.Datetime(string='Last Computed')
|
||||
|
||||
_sql_constraints = [
|
||||
('partner_company_uniq', 'unique(partner_id, company_id)',
|
||||
'One tax profile per vendor per company'),
|
||||
]
|
||||
|
||||
def _rebuild_all_profiles(self, min_bills=3):
|
||||
"""Rebuild all vendor tax profiles from posted bill history.
|
||||
Called by cron or manually."""
|
||||
_logger.info("Rebuilding vendor tax profiles (min_bills=%d)...", min_bills)
|
||||
companies = self.env['res.company'].search([])
|
||||
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
|
||||
for company in companies:
|
||||
# Find all vendors with enough bills
|
||||
self.env.cr.execute("""
|
||||
SELECT m.partner_id, count(*) as bill_count,
|
||||
SUM(CASE WHEN m.amount_tax > 0.01 THEN 1 ELSE 0 END) as with_tax,
|
||||
SUM(CASE WHEN m.amount_tax <= 0.01 THEN 1 ELSE 0 END) as no_tax,
|
||||
COALESCE(AVG(CASE WHEN m.amount_untaxed > 0
|
||||
THEN m.amount_tax / m.amount_untaxed * 100
|
||||
ELSE 0 END), 0) as avg_tax_pct
|
||||
FROM account_move m
|
||||
WHERE m.move_type = 'in_invoice'
|
||||
AND m.state = 'posted'
|
||||
AND m.company_id = %s
|
||||
AND m.partner_id IS NOT NULL
|
||||
GROUP BY m.partner_id
|
||||
HAVING count(*) >= %s
|
||||
""", (company.id, min_bills))
|
||||
vendor_stats = self.env.cr.dictfetchall()
|
||||
|
||||
for vs in vendor_stats:
|
||||
partner = self.env['res.partner'].browse(vs['partner_id'])
|
||||
if not partner.exists():
|
||||
continue
|
||||
|
||||
# Classify
|
||||
avg_pct = round(vs['avg_tax_pct'], 2)
|
||||
total = vs['bill_count']
|
||||
with_tax = vs['with_tax']
|
||||
no_tax = vs['no_tax']
|
||||
|
||||
if no_tax == total:
|
||||
classification = 'never_hst'
|
||||
note = f'{partner.name} NEVER charges HST. All {total} bills are zero-rated. Do NOT apply HST.'
|
||||
elif avg_pct >= 12.0:
|
||||
classification = 'always_hst'
|
||||
note = f'{partner.name} consistently charges HST at ~{avg_pct}%. Apply HST PURCHASE (13%) to all product lines.'
|
||||
elif avg_pct >= 10.0:
|
||||
classification = 'mostly_hst'
|
||||
note = f'{partner.name} usually charges HST (~{avg_pct}%). {no_tax} of {total} bills had no tax. Apply HST by default but verify zero-rated items.'
|
||||
elif avg_pct < 2.0 and with_tax > 0:
|
||||
classification = 'shipping_only'
|
||||
note = (
|
||||
f'{partner.name} products are zero-rated (avg tax only {avg_pct}% of subtotal). '
|
||||
f'HST applies ONLY to shipping/freight charges, NOT to product lines. '
|
||||
f'When creating a bill, use NO TAX PURCHASE on product lines and HST PURCHASE (13%) only on shipping lines.'
|
||||
)
|
||||
else:
|
||||
classification = 'mixed'
|
||||
note = (
|
||||
f'{partner.name} has mixed tax treatment ({with_tax} bills with HST, {no_tax} without, avg {avg_pct}%). '
|
||||
f'Check each bill individually — some items may be zero-rated while others have HST.'
|
||||
)
|
||||
|
||||
# Find primary expense account
|
||||
self.env.cr.execute("""
|
||||
SELECT aml.account_id, count(*) as cnt
|
||||
FROM account_move_line aml
|
||||
JOIN account_move m ON aml.move_id = m.id
|
||||
WHERE m.partner_id = %s
|
||||
AND m.move_type = 'in_invoice'
|
||||
AND m.state = 'posted'
|
||||
AND m.company_id = %s
|
||||
AND aml.display_type = 'product'
|
||||
GROUP BY aml.account_id
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 1
|
||||
""", (vs['partner_id'], company.id))
|
||||
acct_row = self.env.cr.fetchone()
|
||||
primary_account_id = acct_row[0] if acct_row else False
|
||||
|
||||
# Check if foreign vendor
|
||||
is_foreign = False
|
||||
country = ''
|
||||
if partner.country_id:
|
||||
country = partner.country_id.name
|
||||
is_foreign = partner.country_id.code != 'CA'
|
||||
elif partner.vat and not partner.vat.startswith('CA'):
|
||||
is_foreign = True
|
||||
|
||||
# Only override to never_hst if foreign AND bills actually confirm no tax
|
||||
# (Don't override if bill data shows they DO charge HST — e.g., Amazon Canada)
|
||||
if is_foreign and avg_pct < 1.0 and no_tax > with_tax:
|
||||
classification = 'never_hst'
|
||||
note = f'{partner.name} is a FOREIGN vendor ({country or "non-Canadian"}) and bills confirm no HST. Do NOT apply any Canadian tax.'
|
||||
|
||||
# Check if this is a PO-tracked vendor (has confirmed purchase orders)
|
||||
is_po_vendor = False
|
||||
vendor_po_count = 0
|
||||
try:
|
||||
self.env.cr.execute("""
|
||||
SELECT count(*) FROM purchase_order
|
||||
WHERE partner_id = %s AND state IN ('purchase', 'done')
|
||||
AND company_id = %s
|
||||
""", (vs['partner_id'], company.id))
|
||||
po_row = self.env.cr.fetchone()
|
||||
vendor_po_count = po_row[0] if po_row else 0
|
||||
is_po_vendor = vendor_po_count >= 3
|
||||
except Exception:
|
||||
pass # purchase module may not be installed
|
||||
|
||||
if is_po_vendor:
|
||||
note = (
|
||||
f'PO-TRACKED VENDOR ({vendor_po_count} purchase orders). '
|
||||
f'Bills are created from Purchase Orders — do NOT create bills during bank reconciliation. '
|
||||
f'Instead, find the existing unpaid bill and match the bank payment to it. '
|
||||
f'Tax treatment: {note}'
|
||||
)
|
||||
|
||||
# Upsert
|
||||
existing = self.search([
|
||||
('partner_id', '=', vs['partner_id']),
|
||||
('company_id', '=', company.id),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'partner_id': vs['partner_id'],
|
||||
'company_id': company.id,
|
||||
'total_bills': total,
|
||||
'bills_with_hst': with_tax,
|
||||
'bills_zero_rated': no_tax,
|
||||
'avg_tax_pct': avg_pct,
|
||||
'tax_classification': classification,
|
||||
'primary_account_id': primary_account_id,
|
||||
'tax_note': note,
|
||||
'is_po_vendor': is_po_vendor,
|
||||
'po_count': vendor_po_count,
|
||||
'is_foreign': is_foreign,
|
||||
'vendor_country': country,
|
||||
'last_computed': fields.Datetime.now(),
|
||||
}
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
total_updated += 1
|
||||
else:
|
||||
self.create(vals)
|
||||
total_created += 1
|
||||
|
||||
_logger.info(
|
||||
"Vendor tax profiles rebuilt: %d created, %d updated",
|
||||
total_created, total_updated,
|
||||
)
|
||||
return {'created': total_created, 'updated': total_updated}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Report action -->
|
||||
<record id="action_report_fusion_audit" model="ir.actions.report">
|
||||
<field name="name">Fusion AI Audit Report</field>
|
||||
<field name="model">fusion.accounting.dashboard</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_accounting.audit_report_document</field>
|
||||
<field name="report_file">fusion_accounting.audit_report_document</field>
|
||||
<field name="binding_model_id" ref="model_fusion_accounting_dashboard"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
|
||||
<!-- Report template -->
|
||||
<template id="audit_report_document">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="o">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2>Fusion AI Audit Report</h2>
|
||||
<p>Company: <span t-field="o.company_id.name"/></p>
|
||||
<p>Generated: <span t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/></p>
|
||||
<hr/>
|
||||
|
||||
<h3>Health Summary</h3>
|
||||
<table class="table table-bordered table-sm">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Metric</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Bank Reconciliation</td>
|
||||
<td><t t-esc="o.bank_recon_count"/> unmatched lines ($<t t-esc="'%.2f' % o.bank_recon_amount"/>)</td>
|
||||
<td t-att-class="'text-success' if o.bank_recon_count == 0 else 'text-danger'">
|
||||
<t t-if="o.bank_recon_count == 0">OK</t>
|
||||
<t t-else="">Attention</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Accounts Receivable</td>
|
||||
<td>$<t t-esc="'%.2f' % o.ar_total"/> outstanding, <t t-esc="o.ar_overdue_count"/> overdue</td>
|
||||
<td t-att-class="'text-success' if o.ar_overdue_count == 0 else 'text-warning'">
|
||||
<t t-if="o.ar_overdue_count == 0">OK</t>
|
||||
<t t-else="">Overdue Items</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Accounts Payable</td>
|
||||
<td>$<t t-esc="'%.2f' % o.ap_total"/> total, <t t-esc="o.ap_due_this_week"/> due this week</td>
|
||||
<td>Info</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>HST Balance</td>
|
||||
<td>$<t t-esc="'%.2f' % o.hst_balance"/></td>
|
||||
<td><t t-if="o.hst_balance > 0">Owing to CRA</t><t t-else="">Refund Expected</t></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Audit Score</td>
|
||||
<td><t t-esc="o.audit_score"/>/100 (<t t-esc="o.audit_flag_count"/> flags)</td>
|
||||
<td t-att-class="'text-success' if o.audit_score >= 80 else ('text-warning' if o.audit_score >= 60 else 'text-danger')">
|
||||
<t t-if="o.audit_score >= 80">Good</t>
|
||||
<t t-elif="o.audit_score >= 60">Fair</t>
|
||||
<t t-else="">Needs Attention</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Month-End Status</td>
|
||||
<td><t t-esc="o.month_end_status"/> (<t t-esc="o.month_end_open_items"/> open items)</td>
|
||||
<td t-att-class="'text-success' if o.month_end_open_items == 0 else 'text-warning'">
|
||||
<t t-esc="o.month_end_status"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
BIN
fusion_accounting/fusion_accounting_ai/services/.DS_Store
vendored
Normal file
BIN
fusion_accounting/fusion_accounting_ai/services/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
from . import adapters
|
||||
from . import tools
|
||||
from . import prompts
|
||||
from . import agent
|
||||
from . import scoring
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import claude
|
||||
from . import openai_adapter
|
||||
from ._base import LLMProvider
|
||||
@@ -0,0 +1,44 @@
|
||||
"""LLMProvider contract - every adapter must conform.
|
||||
|
||||
Phase 1 generalisation: makes local LLM (Ollama, LM Studio, vLLM, llamafile,
|
||||
llama.cpp HTTP server) a one-config-line drop-in via the OpenAI-compatible
|
||||
HTTP API surface that all of them expose.
|
||||
"""
|
||||
|
||||
|
||||
class LLMProvider:
|
||||
"""Contract every LLM backend must satisfy. Adapters declare capabilities
|
||||
as class attributes; the engine inspects them before calling optional methods."""
|
||||
|
||||
supports_tool_calling: bool = False
|
||||
supports_streaming: bool = False
|
||||
max_context_tokens: int = 4096
|
||||
supports_embeddings: bool = False
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
"""Plain text completion. Required for ALL providers.
|
||||
|
||||
Returns: {'content': str, 'tokens_used': int, 'model': str}
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def complete_with_tools(self, *, system, messages, tools, max_tokens=2048) -> dict:
|
||||
"""Tool-calling completion. Optional - caller checks supports_tool_calling first.
|
||||
|
||||
Returns: {'content': str, 'tool_calls': [{'name': str, 'arguments': dict}], ...}
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support tool-calling. "
|
||||
f"Check supports_tool_calling before calling.")
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Embeddings. Optional - caller checks supports_embeddings first.
|
||||
|
||||
Returns: list of float vectors, one per input text.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support embeddings. "
|
||||
f"Check supports_embeddings before calling.")
|
||||
@@ -0,0 +1,201 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ._base import LLMProvider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import anthropic as anthropic_sdk
|
||||
except ImportError:
|
||||
anthropic_sdk = None
|
||||
|
||||
|
||||
class ClaudeAdapter(LLMProvider):
|
||||
"""Plain-Python LLMProvider implementation for Anthropic Claude.
|
||||
|
||||
Preserves all existing functionality (extended thinking, native tool_use
|
||||
blocks) used by the Odoo AbstractModel-based adapter -- this class is
|
||||
additive for the Phase 1 LLMProvider contract.
|
||||
"""
|
||||
|
||||
supports_tool_calling = True
|
||||
supports_streaming = True
|
||||
max_context_tokens = 200000
|
||||
supports_embeddings = False
|
||||
|
||||
def __init__(self, env):
|
||||
super().__init__(env)
|
||||
if anthropic_sdk is None:
|
||||
raise UserError(_("The 'anthropic' Python package is not installed."))
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
try:
|
||||
api_key = env['fusion.api.service'].get_api_key(
|
||||
provider_type='anthropic',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
api_key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
|
||||
if not api_key:
|
||||
api_key = 'not-needed'
|
||||
self.client = anthropic_sdk.Anthropic(api_key=api_key)
|
||||
self.model = ICP.get_param(
|
||||
'fusion_accounting.claude_model', 'claude-sonnet-4-6')
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
api_messages = [
|
||||
m for m in messages if m.get('role') in ('user', 'assistant')
|
||||
]
|
||||
try:
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
system=system,
|
||||
messages=api_messages,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("Claude complete error: %s", e)
|
||||
raise UserError(_("Claude API error: %s", str(e)))
|
||||
text_parts = [b.text for b in response.content if getattr(b, 'type', None) == 'text']
|
||||
return {
|
||||
'content': '\n'.join(text_parts),
|
||||
'tokens_used': (
|
||||
getattr(response.usage, 'input_tokens', 0)
|
||||
+ getattr(response.usage, 'output_tokens', 0)
|
||||
),
|
||||
'model': self.model,
|
||||
}
|
||||
|
||||
|
||||
class FusionAccountingAdapterClaude(models.AbstractModel):
|
||||
_name = 'fusion.accounting.adapter.claude'
|
||||
_description = 'Claude AI Adapter'
|
||||
|
||||
def _get_client(self):
|
||||
if anthropic_sdk is None:
|
||||
raise UserError(_("The 'anthropic' Python package is not installed."))
|
||||
try:
|
||||
key = self.env['fusion.api.service'].get_api_key(
|
||||
provider_type='anthropic',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
|
||||
if not key:
|
||||
raise UserError(_("No Anthropic API key configured."))
|
||||
return anthropic_sdk.Anthropic(api_key=key)
|
||||
|
||||
def _get_model_name(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
return ICP.get_param('fusion_accounting.claude_model', 'claude-sonnet-4-6')
|
||||
|
||||
def _format_tools(self, tools):
|
||||
formatted = []
|
||||
for tool in tools:
|
||||
t = {
|
||||
'name': tool['name'],
|
||||
'description': tool['description'],
|
||||
'input_schema': tool.get('parameters', {'type': 'object', 'properties': {}}),
|
||||
}
|
||||
formatted.append(t)
|
||||
return formatted
|
||||
|
||||
def _supports_extended_thinking(self, model):
|
||||
return '4-6' in model or '4-5' in model or '4-1' in model or '4-0' in model
|
||||
|
||||
def call_with_tools(self, system_prompt, messages, tools=None, model_override=None):
|
||||
client = self._get_client()
|
||||
model = model_override or self._get_model_name()
|
||||
|
||||
api_messages = []
|
||||
for msg in messages:
|
||||
if msg['role'] in ('user', 'assistant'):
|
||||
api_messages.append(msg)
|
||||
|
||||
kwargs = {
|
||||
'model': model,
|
||||
'max_tokens': 16384,
|
||||
'system': system_prompt,
|
||||
'messages': api_messages,
|
||||
}
|
||||
if tools:
|
||||
kwargs['tools'] = self._format_tools(tools)
|
||||
|
||||
if self._supports_extended_thinking(model) and tools:
|
||||
kwargs['thinking'] = {
|
||||
'type': 'enabled',
|
||||
'budget_tokens': 8192,
|
||||
}
|
||||
|
||||
try:
|
||||
response = client.messages.create(**kwargs)
|
||||
except anthropic_sdk.BadRequestError as e:
|
||||
if 'thinking' in str(e).lower():
|
||||
kwargs.pop('thinking', None)
|
||||
response = client.messages.create(**kwargs)
|
||||
else:
|
||||
raise UserError(_("Claude API error: %s", str(e)))
|
||||
except Exception as e:
|
||||
_logger.error("Claude API error: %s", e)
|
||||
raise UserError(_("Claude API error: %s", str(e)))
|
||||
|
||||
text_parts = []
|
||||
tool_calls = []
|
||||
thinking_text = []
|
||||
for block in response.content:
|
||||
if block.type == 'text':
|
||||
text_parts.append(block.text)
|
||||
elif block.type == 'tool_use':
|
||||
tool_calls.append({
|
||||
'id': block.id,
|
||||
'name': block.name,
|
||||
'arguments': block.input,
|
||||
})
|
||||
elif block.type == 'thinking':
|
||||
thinking_text.append(block.thinking)
|
||||
|
||||
if thinking_text:
|
||||
_logger.debug("Claude thinking: %s", thinking_text[0][:500])
|
||||
|
||||
return {
|
||||
'text': '\n'.join(text_parts),
|
||||
'tool_calls': tool_calls if tool_calls else None,
|
||||
'tokens_in': response.usage.input_tokens,
|
||||
'tokens_out': response.usage.output_tokens,
|
||||
'stop_reason': response.stop_reason,
|
||||
'raw_content': response.content,
|
||||
}
|
||||
|
||||
def append_tool_results(self, messages, ai_response, tool_results):
|
||||
assistant_content = []
|
||||
for block in ai_response.get('raw_content', []):
|
||||
if hasattr(block, 'type'):
|
||||
if block.type == 'text':
|
||||
assistant_content.append({'type': 'text', 'text': block.text})
|
||||
elif block.type == 'tool_use':
|
||||
assistant_content.append({
|
||||
'type': 'tool_use',
|
||||
'id': block.id,
|
||||
'name': block.name,
|
||||
'input': block.input,
|
||||
})
|
||||
|
||||
messages.append({'role': 'assistant', 'content': assistant_content})
|
||||
|
||||
tool_result_content = []
|
||||
for tr in tool_results:
|
||||
tool_result_content.append({
|
||||
'type': 'tool_result',
|
||||
'tool_use_id': tr['tool_call_id'],
|
||||
'content': tr['result'],
|
||||
})
|
||||
messages.append({'role': 'user', 'content': tool_result_content})
|
||||
|
||||
return messages
|
||||
@@ -0,0 +1,204 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ._base import LLMProvider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
OpenAI = None
|
||||
|
||||
|
||||
DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
||||
|
||||
|
||||
class OpenAIAdapter(LLMProvider):
|
||||
"""Plain-Python LLMProvider implementation backed by an OpenAI-compatible
|
||||
HTTP endpoint.
|
||||
|
||||
The OpenAI Python SDK speaks to any server that exposes the OpenAI
|
||||
Chat Completions surface: OpenAI itself, Ollama, LM Studio, vLLM,
|
||||
llamafile, llama.cpp HTTP server, etc. Configure the endpoint via
|
||||
the ``fusion_accounting.openai_base_url`` ir.config_parameter.
|
||||
"""
|
||||
|
||||
supports_tool_calling = True
|
||||
supports_streaming = True
|
||||
max_context_tokens = 128000
|
||||
supports_embeddings = True
|
||||
|
||||
def __init__(self, env):
|
||||
super().__init__(env)
|
||||
if OpenAI is None:
|
||||
raise UserError(_("The 'openai' Python package is not installed."))
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
base_url = ICP.get_param(
|
||||
'fusion_accounting.openai_base_url', DEFAULT_OPENAI_BASE_URL,
|
||||
) or DEFAULT_OPENAI_BASE_URL
|
||||
try:
|
||||
api_key = env['fusion.api.service'].get_api_key(
|
||||
provider_type='openai',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
api_key = ICP.get_param('fusion_accounting.openai_api_key', '')
|
||||
if not api_key:
|
||||
# Local LLM servers (Ollama, LM Studio, llama.cpp) usually do not
|
||||
# require a real key but the SDK insists on a non-empty string.
|
||||
api_key = 'not-needed'
|
||||
self.base_url = base_url
|
||||
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
self.model = ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
api_messages = [{'role': 'system', 'content': system}]
|
||||
for msg in messages:
|
||||
if msg.get('role') in ('user', 'assistant', 'tool'):
|
||||
api_messages.append(msg)
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=api_messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("OpenAI complete error: %s", e)
|
||||
raise UserError(_("OpenAI API error: %s", str(e)))
|
||||
choice = response.choices[0]
|
||||
return {
|
||||
'content': choice.message.content or '',
|
||||
'tokens_used': getattr(response.usage, 'total_tokens', 0),
|
||||
'model': self.model,
|
||||
}
|
||||
|
||||
|
||||
class FusionAccountingAdapterOpenAI(models.AbstractModel):
|
||||
_name = 'fusion.accounting.adapter.openai'
|
||||
_description = 'OpenAI AI Adapter'
|
||||
|
||||
def _get_client(self):
|
||||
if OpenAI is None:
|
||||
raise UserError(_("The 'openai' Python package is not installed."))
|
||||
try:
|
||||
key = self.env['fusion.api.service'].get_api_key(
|
||||
provider_type='openai',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
key = ICP.get_param('fusion_accounting.openai_api_key', '')
|
||||
if not key:
|
||||
raise UserError(_("No OpenAI API key configured."))
|
||||
return OpenAI(api_key=key)
|
||||
|
||||
def _get_model_name(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
return ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
|
||||
|
||||
def _format_tools(self, tools):
|
||||
formatted = []
|
||||
for tool in tools:
|
||||
formatted.append({
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tool['name'],
|
||||
'description': tool['description'],
|
||||
'parameters': tool.get('parameters', {'type': 'object', 'properties': {}}),
|
||||
},
|
||||
})
|
||||
return formatted
|
||||
|
||||
def _is_reasoning_model(self, model):
|
||||
return model.startswith('o1') or model.startswith('o3') or model.startswith('o4')
|
||||
|
||||
def call_with_tools(self, system_prompt, messages, tools=None, model_override=None):
|
||||
client = self._get_client()
|
||||
model = model_override or self._get_model_name()
|
||||
is_reasoning = self._is_reasoning_model(model)
|
||||
|
||||
if is_reasoning:
|
||||
api_messages = [{'role': 'developer', 'content': system_prompt}]
|
||||
else:
|
||||
api_messages = [{'role': 'system', 'content': system_prompt}]
|
||||
for msg in messages:
|
||||
if msg['role'] in ('user', 'assistant', 'tool'):
|
||||
api_messages.append(msg)
|
||||
|
||||
kwargs = {
|
||||
'model': model,
|
||||
'messages': api_messages,
|
||||
}
|
||||
if is_reasoning:
|
||||
kwargs['max_completion_tokens'] = 16384
|
||||
kwargs['reasoning_effort'] = 'medium'
|
||||
else:
|
||||
kwargs['max_tokens'] = 4096
|
||||
|
||||
if tools:
|
||||
kwargs['tools'] = self._format_tools(tools)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(**kwargs)
|
||||
except Exception as e:
|
||||
_logger.error("OpenAI API error: %s", e)
|
||||
raise UserError(_("OpenAI API error: %s", str(e)))
|
||||
|
||||
choice = response.choices[0]
|
||||
message = choice.message
|
||||
|
||||
tool_calls = []
|
||||
if message.tool_calls:
|
||||
for tc in message.tool_calls:
|
||||
try:
|
||||
args = json.loads(tc.function.arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
_logger.warning("Malformed tool args from OpenAI: %s", tc.function.arguments)
|
||||
args = {}
|
||||
tool_calls.append({
|
||||
'id': tc.id,
|
||||
'name': tc.function.name,
|
||||
'arguments': args,
|
||||
})
|
||||
|
||||
return {
|
||||
'text': message.content or '',
|
||||
'tool_calls': tool_calls if tool_calls else None,
|
||||
'tokens_in': response.usage.prompt_tokens,
|
||||
'tokens_out': response.usage.completion_tokens,
|
||||
'stop_reason': choice.finish_reason,
|
||||
'raw_message': message,
|
||||
}
|
||||
|
||||
def append_tool_results(self, messages, ai_response, tool_results):
|
||||
raw_msg = ai_response.get('raw_message')
|
||||
assistant_msg = {'role': 'assistant', 'content': raw_msg.content or ''}
|
||||
if raw_msg.tool_calls:
|
||||
assistant_msg['tool_calls'] = [
|
||||
{
|
||||
'id': tc.id,
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tc.function.name,
|
||||
'arguments': tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in raw_msg.tool_calls
|
||||
]
|
||||
messages.append(assistant_msg)
|
||||
|
||||
for tr in tool_results:
|
||||
messages.append({
|
||||
'role': 'tool',
|
||||
'tool_call_id': tr['tool_call_id'],
|
||||
'content': tr['result'],
|
||||
})
|
||||
|
||||
return messages
|
||||
947
fusion_accounting/fusion_accounting_ai/services/agent.py
Normal file
947
fusion_accounting/fusion_accounting_ai/services/agent.py
Normal file
@@ -0,0 +1,947 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# In-memory execution state for live status polling.
|
||||
# Key: session_id, Value: {thinking, tool_calls, status}
|
||||
# Cleared after each chat() call completes.
|
||||
_execution_state = {}
|
||||
|
||||
|
||||
def get_execution_state(session_id):
|
||||
"""Get the current execution state for a session (called by polling endpoint)."""
|
||||
return _execution_state.get(session_id, {'status': 'idle', 'thinking': '', 'tool_calls': []})
|
||||
|
||||
|
||||
# Inter-account transfer pairs: (source_journal, cc_journal, cc_account_pattern)
|
||||
# Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming)
|
||||
TRANSFER_PAIRS = [
|
||||
# (source_journal_id, cc_journal_id, outstanding_account_id)
|
||||
(50, 51, 493), # Scotia Current → Passport Visa, Outstanding Receipts - All Banks
|
||||
(53, 28, 493), # RBC Chequing → RBC Visa, Outstanding Receipts - All Banks
|
||||
]
|
||||
|
||||
|
||||
class FusionAccountingAgent(models.AbstractModel):
|
||||
_name = 'fusion.accounting.agent'
|
||||
_description = 'Fusion Accounting AI Agent Orchestrator'
|
||||
|
||||
def _get_config(self, key, default=None):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
return ICP.get_param(f'fusion_accounting.{key}', default)
|
||||
|
||||
# Domains that need deeper reasoning → use Sonnet
|
||||
COMPLEX_DOMAINS = {'audit', 'month_end', 'hst_management', 'payroll_management'}
|
||||
# Keywords in user messages that suggest complex analysis → use Sonnet
|
||||
COMPLEX_KEYWORDS = {
|
||||
'audit', 'analyze', 'analyse', 'review all', 'full report', 'investigate',
|
||||
'month-end', 'month end', 'close the books', 'hst filing', 'tax return',
|
||||
'what went wrong', 'why is', 'explain the difference', 'compare',
|
||||
}
|
||||
|
||||
def _get_adapter(self):
|
||||
provider = self._get_config('ai_provider', 'claude')
|
||||
if provider == 'claude':
|
||||
return self.env['fusion.accounting.adapter.claude']
|
||||
return self.env['fusion.accounting.adapter.openai']
|
||||
|
||||
def _route_model(self, session, user_message, has_image=False):
|
||||
"""Smart model routing: Haiku for routine tool calling, Sonnet for complex analysis.
|
||||
Returns (model_name, can_escalate) — can_escalate=True means Haiku is trying first
|
||||
and we should check if it needs help."""
|
||||
provider = session.ai_provider or self._get_config('ai_provider', 'claude')
|
||||
if provider != 'claude':
|
||||
return None, False
|
||||
|
||||
# Always use Sonnet for images (vision quality matters for OCR)
|
||||
if has_image:
|
||||
return 'claude-sonnet-4-6', False
|
||||
|
||||
# Use Sonnet for complex domains
|
||||
if session.context_domain in self.COMPLEX_DOMAINS:
|
||||
return 'claude-sonnet-4-6', False
|
||||
|
||||
# Use Sonnet if the message contains complex analysis keywords
|
||||
msg_lower = (user_message or '').lower()
|
||||
if any(kw in msg_lower for kw in self.COMPLEX_KEYWORDS):
|
||||
return 'claude-sonnet-4-6', False
|
||||
|
||||
# Default: Haiku with escalation enabled
|
||||
return 'claude-haiku-4-5', True
|
||||
|
||||
def _should_escalate(self, response, tool_calls_log, turn):
|
||||
"""Check if Haiku's response suggests it needs Sonnet's help."""
|
||||
text = (response.get('text') or '').lower()
|
||||
|
||||
# Haiku said it can't do something
|
||||
uncertainty_phrases = [
|
||||
"i'm not sure", "i cannot determine", "i don't have enough",
|
||||
"unable to", "i'm unable", "this is complex", "beyond my",
|
||||
"i need more context", "difficult to assess", "i apologize",
|
||||
"i'm having trouble", "let me think about this differently",
|
||||
]
|
||||
if any(phrase in text for phrase in uncertainty_phrases):
|
||||
return True
|
||||
|
||||
# Haiku made no tool calls on first turn when it probably should have
|
||||
# (user asked a question but Haiku just gave text without using tools)
|
||||
if turn == 0 and not response.get('tool_calls') and not text:
|
||||
return True
|
||||
|
||||
# Haiku had multiple tool errors
|
||||
error_count = sum(1 for tc in tool_calls_log if tc.get('status') == 'error')
|
||||
if error_count >= 2:
|
||||
return True
|
||||
|
||||
# Response is very short for a data question (Haiku might be confused)
|
||||
if turn == 0 and not response.get('tool_calls') and len(text) < 50:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_tool_registry(self):
|
||||
return self.env['fusion.accounting.tool'].search([('active', '=', True)])
|
||||
|
||||
def _get_tools_for_user(self, user=None):
|
||||
user = user or self.env.user
|
||||
tools = self._get_tool_registry()
|
||||
filtered = self.env['fusion.accounting.tool']
|
||||
for tool in tools:
|
||||
if not tool.required_groups:
|
||||
filtered |= tool
|
||||
continue
|
||||
group_xmlids = [g.strip() for g in tool.required_groups.split(',') if g.strip()]
|
||||
if all(user.has_group(g) for g in group_xmlids):
|
||||
filtered |= tool
|
||||
return filtered
|
||||
|
||||
def _build_tool_definitions(self, tools):
|
||||
definitions = []
|
||||
for tool in tools:
|
||||
# A2: Include tier info in description so AI knows which tools need approval
|
||||
tier_label = {'1': 'Read-only', '2': 'Auto-approved', '3': 'Requires user approval'}.get(tool.tier, '')
|
||||
desc = tool.description or ''
|
||||
if tier_label:
|
||||
desc = f"[Tier {tool.tier}: {tier_label}] {desc}"
|
||||
defn = {
|
||||
'name': tool.name,
|
||||
'description': desc,
|
||||
}
|
||||
if tool.parameters_schema:
|
||||
try:
|
||||
defn['parameters'] = json.loads(tool.parameters_schema)
|
||||
except json.JSONDecodeError:
|
||||
defn['parameters'] = {'type': 'object', 'properties': {}}
|
||||
else:
|
||||
defn['parameters'] = {'type': 'object', 'properties': {}}
|
||||
definitions.append(defn)
|
||||
return definitions
|
||||
|
||||
def _load_rules(self, domain=None):
|
||||
rule_domain = [('active', '=', True), ('company_id', '=', self.env.company.id)]
|
||||
if domain:
|
||||
rule_domain.append(('rule_type', '=', domain))
|
||||
rules = self.env['fusion.accounting.rule'].search(rule_domain, order='sequence')
|
||||
admin_rules = rules.filtered(lambda r: r.created_by == 'admin')
|
||||
ai_auto = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'auto')
|
||||
ai_pending = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'needs_approval')
|
||||
return admin_rules | ai_auto | ai_pending
|
||||
|
||||
def _load_match_history(self, domain=None, limit=None):
|
||||
limit = limit or int(self._get_config('history_in_prompt', '50'))
|
||||
history_domain = [('company_id', '=', self.env.company.id)]
|
||||
if domain:
|
||||
history_domain.append(('tool_name', 'ilike', domain))
|
||||
return self.env['fusion.accounting.match.history'].search(
|
||||
history_domain, limit=limit, order='proposed_at desc',
|
||||
)
|
||||
|
||||
def _build_system_prompt(self, rules, history, context=None, domain=None):
|
||||
from .prompts.system_prompt import build_system_prompt
|
||||
from .prompts.domain_prompts import get_domain_prompt
|
||||
base = build_system_prompt(rules, history, context)
|
||||
if domain:
|
||||
domain_prompt = get_domain_prompt(domain)
|
||||
if domain_prompt:
|
||||
base = f"{base}\n\n{domain_prompt}"
|
||||
return base
|
||||
|
||||
def _execute_tool(self, tool_name, params, session_id=None):
|
||||
from .tools import TOOL_DISPATCH
|
||||
if tool_name not in TOOL_DISPATCH:
|
||||
return {'error': f'Unknown tool: {tool_name}'}
|
||||
try:
|
||||
result = TOOL_DISPATCH[tool_name](self.env, params)
|
||||
return result
|
||||
except Exception as e:
|
||||
_logger.error("Tool execution error (%s): %s", tool_name, e)
|
||||
return {'error': str(e)}
|
||||
|
||||
def _log_match_history(self, session, tool_name, params, result, reasoning='',
|
||||
confidence=0.0, rule=None, tier='1'):
|
||||
vals = {
|
||||
'session_id': session.id if session else False,
|
||||
'tool_name': tool_name,
|
||||
'tool_params': json.dumps(params, indent=2, default=str) if params else '{}',
|
||||
'tool_result': json.dumps(result, indent=2, default=str) if result else '{}',
|
||||
'ai_reasoning': reasoning,
|
||||
'ai_confidence': confidence,
|
||||
'rule_id': rule.id if rule else False,
|
||||
'proposed_at': fields.Datetime.now(),
|
||||
'decision': 'auto' if tier in ('1', '2') else 'pending',
|
||||
'company_id': self.env.company.id,
|
||||
}
|
||||
return self.env['fusion.accounting.match.history'].sudo().create(vals)
|
||||
|
||||
def chat(self, session_id, user_message, context=None, image=None):
|
||||
session = self.env['fusion.accounting.session'].browse(session_id)
|
||||
if not session.exists():
|
||||
raise UserError(_("Session not found."))
|
||||
|
||||
adapter = self._get_adapter()
|
||||
provider = self._get_config('ai_provider', 'claude')
|
||||
|
||||
# Pin provider to session to prevent cross-adapter message contamination (C5)
|
||||
if session.ai_provider and session.ai_provider != provider:
|
||||
_logger.warning(
|
||||
"Session %s was started with %s but current provider is %s. "
|
||||
"Keeping original provider to avoid message format conflicts.",
|
||||
session.name, session.ai_provider, provider,
|
||||
)
|
||||
provider = session.ai_provider
|
||||
if provider == 'claude':
|
||||
adapter = self.env['fusion.accounting.adapter.claude']
|
||||
else:
|
||||
adapter = self.env['fusion.accounting.adapter.openai']
|
||||
|
||||
tools = self._get_tools_for_user()
|
||||
tool_definitions = self._build_tool_definitions(tools)
|
||||
rules = self._load_rules()
|
||||
history = self._load_match_history()
|
||||
system_prompt = self._build_system_prompt(
|
||||
rules, history, context, domain=session.context_domain,
|
||||
)
|
||||
|
||||
messages_json = json.loads(session.message_ids_json or '[]')
|
||||
|
||||
# Build user message — may include image for vision
|
||||
if image and isinstance(image, dict) and image.get('base64'):
|
||||
user_content = []
|
||||
if user_message:
|
||||
user_content.append({'type': 'text', 'text': user_message})
|
||||
user_content.append({
|
||||
'type': 'image',
|
||||
'source': {
|
||||
'type': 'base64',
|
||||
'media_type': image.get('media_type', 'image/png'),
|
||||
'data': image['base64'],
|
||||
},
|
||||
})
|
||||
messages_json.append({'role': 'user', 'content': user_content})
|
||||
else:
|
||||
messages_json.append({'role': 'user', 'content': user_message})
|
||||
|
||||
# Smart model routing: Haiku for routine, Sonnet for complex
|
||||
has_image = bool(image and isinstance(image, dict) and image.get('base64'))
|
||||
model_override, can_escalate = self._route_model(session, user_message, has_image=has_image)
|
||||
escalated = False
|
||||
if model_override:
|
||||
_logger.info("Model routing: %s → %s (escalation=%s)", session.name, model_override, can_escalate)
|
||||
|
||||
max_turns = max(int(self._get_config('max_tool_calls', '20')), 1)
|
||||
total_tokens_in = 0
|
||||
total_tokens_out = 0
|
||||
response = {'text': '', 'tool_calls': None}
|
||||
has_pending_tier3 = False
|
||||
tool_calls_log = [] # Track tool calls for frontend display
|
||||
reconciliation_data = None # Raw data from suggest_bank_line_matches
|
||||
|
||||
# Initialize live execution state for polling
|
||||
_execution_state[session.id] = {
|
||||
'status': 'thinking',
|
||||
'thinking': '',
|
||||
'tool_calls': [],
|
||||
'turn': 0,
|
||||
}
|
||||
|
||||
for turn in range(max_turns):
|
||||
_execution_state[session.id]['status'] = 'calling_ai'
|
||||
_execution_state[session.id]['turn'] = turn + 1
|
||||
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=tool_definitions,
|
||||
model_override=model_override,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
|
||||
# Check if Haiku needs to escalate to Sonnet
|
||||
if can_escalate and not escalated and self._should_escalate(response, tool_calls_log, turn):
|
||||
_logger.info("Escalating %s from Haiku → Sonnet (turn %d)", session.name, turn)
|
||||
model_override = 'claude-sonnet-4-6'
|
||||
escalated = True
|
||||
can_escalate = False
|
||||
_execution_state[session.id]['status'] = 'escalating'
|
||||
# Re-call with Sonnet
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=tool_definitions,
|
||||
model_override=model_override,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
|
||||
# Capture thinking text for live display
|
||||
thinking = ''
|
||||
for block in (response.get('raw_content') or []):
|
||||
if hasattr(block, 'type') and block.type == 'thinking':
|
||||
thinking = block.thinking
|
||||
break
|
||||
if thinking:
|
||||
_execution_state[session.id]['thinking'] = thinking[:500] # Truncated for live display
|
||||
|
||||
if response.get('tool_calls'):
|
||||
tool_results = []
|
||||
_execution_state[session.id]['status'] = 'calling_tools'
|
||||
|
||||
for tc in response['tool_calls']:
|
||||
tool_name = tc['name']
|
||||
tool_params = tc.get('arguments', {})
|
||||
tool_rec = tools.filtered(lambda t: t.name == tool_name)
|
||||
tier = tool_rec.tier if tool_rec else '1'
|
||||
|
||||
# Update live state: show which tool is running
|
||||
_execution_state[session.id]['tool_calls'].append({
|
||||
'name': tool_name, 'status': 'running',
|
||||
})
|
||||
|
||||
if tier == '3':
|
||||
has_pending_tier3 = True
|
||||
history_rec = self._log_match_history(
|
||||
session, tool_name, tool_params, None,
|
||||
reasoning=thinking or '',
|
||||
confidence=tc.get('confidence', 0.0),
|
||||
tier='3',
|
||||
)
|
||||
tool_results.append({
|
||||
'tool_call_id': tc.get('id', ''),
|
||||
'result': json.dumps({
|
||||
'status': 'pending_approval',
|
||||
'match_history_id': history_rec.id,
|
||||
'message': f'Action requires user approval. Match history ID: {history_rec.id}',
|
||||
}),
|
||||
})
|
||||
tool_calls_log.append({
|
||||
'name': tool_name,
|
||||
'tier': tier,
|
||||
'status': 'pending_approval',
|
||||
'summary': self._build_tool_call_summary(tool_name, tool_params, None),
|
||||
})
|
||||
_execution_state[session.id]['tool_calls'][-1]['status'] = 'pending'
|
||||
else:
|
||||
t0 = time.time()
|
||||
result = self._execute_tool(tool_name, tool_params, session.id)
|
||||
elapsed = round((time.time() - t0) * 1000)
|
||||
self._log_match_history(
|
||||
session, tool_name, tool_params, result,
|
||||
reasoning=thinking or '',
|
||||
tier=tier,
|
||||
)
|
||||
tool_results.append({
|
||||
'tool_call_id': tc.get('id', ''),
|
||||
'result': json.dumps(result) if not isinstance(result, str) else result,
|
||||
})
|
||||
tc_status = 'error' if isinstance(result, dict) and result.get('error') else 'ok'
|
||||
tc_summary = self._build_tool_call_summary(tool_name, tool_params, result)
|
||||
|
||||
# Capture reconciliation data for direct frontend rendering
|
||||
if tool_name == 'suggest_bank_line_matches' and tc_status == 'ok':
|
||||
reconciliation_data = result
|
||||
tool_calls_log.append({
|
||||
'name': tool_name,
|
||||
'tier': tier,
|
||||
'status': tc_status,
|
||||
'summary': tc_summary,
|
||||
'duration_ms': elapsed,
|
||||
})
|
||||
# Update live state
|
||||
_execution_state[session.id]['tool_calls'][-1].update({
|
||||
'status': tc_status, 'summary': tc_summary, 'duration_ms': elapsed,
|
||||
})
|
||||
try:
|
||||
self._check_rule_proposal(tool_name, tool_params, session)
|
||||
except Exception:
|
||||
_logger.exception("Error in _check_rule_proposal for tool %s", tool_name)
|
||||
|
||||
messages_json = adapter.append_tool_results(
|
||||
messages_json, response, tool_results,
|
||||
)
|
||||
session.write({'tool_call_count': session.tool_call_count + len(tool_results)})
|
||||
|
||||
# C2: Short-circuit loop when Tier 3 actions are pending —
|
||||
# force a final text response so the AI can present approval cards
|
||||
if has_pending_tier3:
|
||||
try:
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=[],
|
||||
model_override=model_override,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': response.get('text', 'I have proposed actions that require your approval.'),
|
||||
})
|
||||
except Exception:
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': 'I have proposed actions that require your approval. Please review the pending items above.',
|
||||
})
|
||||
break
|
||||
else:
|
||||
assistant_text = response.get('text', '')
|
||||
messages_json.append({'role': 'assistant', 'content': assistant_text})
|
||||
break
|
||||
else:
|
||||
# Loop exhausted — force a final text response without tools
|
||||
try:
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=[],
|
||||
model_override=model_override,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': response.get('text', 'I reached the maximum number of steps. Please continue the conversation.'),
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning("Failed to get final summary after max tool calls")
|
||||
|
||||
session.write({
|
||||
'message_ids_json': json.dumps(messages_json),
|
||||
'token_count_in': session.token_count_in + total_tokens_in,
|
||||
'token_count_out': session.token_count_out + total_tokens_out,
|
||||
'ai_provider': provider,
|
||||
'ai_model': model_override or adapter._get_model_name(),
|
||||
})
|
||||
|
||||
pending = self.env['fusion.accounting.match.history'].search([
|
||||
('session_id', '=', session.id),
|
||||
('decision', '=', 'pending'),
|
||||
])
|
||||
|
||||
# Clear live execution state
|
||||
_execution_state.pop(session.id, None)
|
||||
|
||||
# Add escalation marker to tool calls log if it happened
|
||||
if escalated:
|
||||
tool_calls_log.insert(0, {
|
||||
'name': 'model_escalation',
|
||||
'tier': '-',
|
||||
'status': 'ok',
|
||||
'summary': 'Escalated from Haiku to Sonnet for deeper analysis',
|
||||
'duration_ms': 0,
|
||||
})
|
||||
|
||||
result_payload = {
|
||||
'text': response.get('text', ''),
|
||||
'tool_calls_log': tool_calls_log,
|
||||
'pending_approvals': [self._format_pending_approval(p) for p in pending],
|
||||
'session_id': session.id,
|
||||
'model_used': model_override or adapter._get_model_name(),
|
||||
}
|
||||
|
||||
# Attach raw reconciliation data so frontend renders it directly
|
||||
# (instead of relying on AI to format fusion-table JSON correctly)
|
||||
if reconciliation_data:
|
||||
result_payload['reconciliation_table'] = reconciliation_data
|
||||
|
||||
return result_payload
|
||||
|
||||
def _build_tool_call_summary(self, tool_name, params, result):
|
||||
"""Build a one-line summary of what a tool call did, for the collapsed tool log."""
|
||||
try:
|
||||
# Result-based summaries (when we have output)
|
||||
if result and isinstance(result, dict) and not result.get('error'):
|
||||
count = result.get('count')
|
||||
status = result.get('status')
|
||||
if status == 'created':
|
||||
name = result.get('name', '')
|
||||
return f"Created {name}" if name else "Created successfully"
|
||||
if status == 'matched':
|
||||
return "Matched successfully"
|
||||
if count is not None:
|
||||
return f"Found {count} result{'s' if count != 1 else ''}"
|
||||
if 'balance' in result:
|
||||
return f"Balance: ${result['balance']:,.2f}"
|
||||
if 'total' in result:
|
||||
return f"Total: ${result['total']:,.2f}"
|
||||
if 'entries' in result:
|
||||
return f"Found {len(result['entries'])} entries"
|
||||
if 'accounts' in result:
|
||||
return f"Found {len(result['accounts'])} accounts"
|
||||
if status:
|
||||
return str(status)
|
||||
|
||||
if result and isinstance(result, dict) and result.get('error'):
|
||||
err = str(result['error'])
|
||||
return f"Error: {err[:80]}"
|
||||
|
||||
# Params-based summaries (for pending approvals, no result yet)
|
||||
if params:
|
||||
ref = params.get('ref', params.get('reference', params.get('name', '')))
|
||||
amount = params.get('amount')
|
||||
lines = params.get('lines', [])
|
||||
if lines:
|
||||
total = sum(l.get('debit', 0) for l in lines)
|
||||
return f"{ref} — ${total:,.2f}" if ref else f"${total:,.2f} journal entry"
|
||||
if ref and amount:
|
||||
return f"{ref} — ${abs(amount):,.2f}"
|
||||
if ref:
|
||||
return str(ref)
|
||||
|
||||
return "Completed"
|
||||
except Exception:
|
||||
return "Completed"
|
||||
|
||||
def _format_pending_approval(self, history):
|
||||
"""Build a rich approval payload so the UI can show exactly what's being approved."""
|
||||
params = {}
|
||||
try:
|
||||
params = json.loads(history.tool_params or '{}')
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Extract amount from params — look in common locations
|
||||
amount = history.amount or 0.0
|
||||
if not amount:
|
||||
# Try to compute from journal entry lines
|
||||
lines = params.get('lines', [])
|
||||
if lines:
|
||||
amount = sum(l.get('debit', 0) for l in lines)
|
||||
# Or from direct amount field
|
||||
if not amount:
|
||||
amount = abs(params.get('amount', 0))
|
||||
|
||||
# Build a human-readable summary of what this action will do
|
||||
summary = self._build_approval_summary(history.tool_name, params)
|
||||
|
||||
return {
|
||||
'id': history.id,
|
||||
'tool_name': history.tool_name,
|
||||
'params': history.tool_params,
|
||||
'reasoning': history.ai_reasoning,
|
||||
'confidence': history.ai_confidence,
|
||||
'amount': amount,
|
||||
'summary': summary,
|
||||
}
|
||||
|
||||
def _resolve_account_label(self, account_id):
|
||||
"""Resolve an account ID to 'code - name' for display."""
|
||||
if not account_id:
|
||||
return '?'
|
||||
try:
|
||||
acct = self.env['account.account'].browse(int(account_id))
|
||||
if acct.exists():
|
||||
return f"{acct.code} {acct.name}"
|
||||
except Exception:
|
||||
pass
|
||||
return str(account_id)
|
||||
|
||||
def _build_approval_summary(self, tool_name, params):
|
||||
"""Generate a short human-readable description of what a Tier 3 action will do."""
|
||||
try:
|
||||
if tool_name == 'create_payroll_journal_entry':
|
||||
ref = params.get('ref', 'Payroll Entry')
|
||||
date = params.get('date', '?')
|
||||
lines = params.get('lines', [])
|
||||
total = sum(l.get('debit', 0) for l in lines)
|
||||
acct_names = []
|
||||
for l in lines:
|
||||
aid = l.get('account_id', '')
|
||||
acct_label = self._resolve_account_label(aid)
|
||||
if l.get('debit'):
|
||||
acct_names.append(f"Dr {acct_label}: ${l['debit']:,.2f}")
|
||||
elif l.get('credit'):
|
||||
acct_names.append(f"Cr {acct_label}: ${l['credit']:,.2f}")
|
||||
detail = ' / '.join(acct_names) if acct_names else ''
|
||||
return f"{ref} on {date} — ${total:,.2f}\n{detail}"
|
||||
|
||||
elif tool_name == 'create_vendor_bill':
|
||||
partner = params.get('partner_name', params.get('partner_id', '?'))
|
||||
amount = params.get('amount', 0)
|
||||
ref = params.get('ref', params.get('reference', ''))
|
||||
date = params.get('date', '?')
|
||||
return f"Vendor bill for {partner} — ${abs(amount):,.2f} on {date}" + (f" ({ref})" if ref else "")
|
||||
|
||||
elif tool_name == 'register_bill_payment':
|
||||
bill_id = params.get('bill_id', '?')
|
||||
amount = params.get('amount', 0)
|
||||
journal = params.get('journal_id', '?')
|
||||
return f"Pay bill #{bill_id} — ${abs(amount):,.2f} from journal {journal}"
|
||||
|
||||
elif tool_name == 'create_expense_entry':
|
||||
ref = params.get('ref', params.get('memo', 'Expense'))
|
||||
amount = params.get('amount', 0)
|
||||
account = params.get('expense_account_id', '?')
|
||||
return f"{ref} — ${abs(amount):,.2f} to account {account}"
|
||||
|
||||
elif tool_name == 'register_hst_payment':
|
||||
amount = params.get('amount', 0)
|
||||
date = params.get('date', '?')
|
||||
return f"HST remittance — ${abs(amount):,.2f} on {date}"
|
||||
|
||||
elif tool_name in ('apply_payment', 'send_followup', 'create_payment_reminder'):
|
||||
partner = params.get('partner_name', params.get('partner_id', '?'))
|
||||
amount = params.get('amount', 0)
|
||||
return f"{tool_name.replace('_', ' ').title()} for {partner}" + (f" — ${abs(amount):,.2f}" if amount else "")
|
||||
|
||||
elif tool_name == 'flag_entry':
|
||||
move_id = params.get('move_id', '?')
|
||||
reason = params.get('reason', '')
|
||||
return f"Flag entry #{move_id}" + (f": {reason}" if reason else "")
|
||||
|
||||
else:
|
||||
# Generic fallback: show key params
|
||||
parts = []
|
||||
for key in ('ref', 'reference', 'name', 'partner_name', 'date', 'move_id'):
|
||||
if key in params:
|
||||
parts.append(f"{key}: {params[key]}")
|
||||
if 'amount' in params:
|
||||
parts.append(f"${abs(params['amount']):,.2f}")
|
||||
return ' | '.join(parts) if parts else json.dumps(params)[:120]
|
||||
|
||||
except Exception:
|
||||
return str(params)[:120]
|
||||
|
||||
def approve_action(self, match_history_id):
|
||||
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
|
||||
if not history.exists() or history.decision != 'pending':
|
||||
raise UserError(_("Action not found or already decided."))
|
||||
|
||||
params = json.loads(history.tool_params or '{}')
|
||||
result = self._execute_tool(history.tool_name, params, history.session_id.id)
|
||||
|
||||
history.write({
|
||||
'decision': 'approved',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
'tool_result': json.dumps(result) if not isinstance(result, str) else result,
|
||||
})
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=True)
|
||||
|
||||
# C1: Update session messages_json so next chat turn has coherent history
|
||||
self._update_session_after_decision(history, result)
|
||||
|
||||
# M8: Trigger promotion check after approval
|
||||
try:
|
||||
self.env['fusion.accounting.scoring'].check_promotions()
|
||||
except Exception:
|
||||
_logger.exception("Error checking promotions after approval")
|
||||
|
||||
return result
|
||||
|
||||
def _check_rule_proposal(self, tool_name, params, session):
|
||||
"""Detect repeated patterns and propose new rules when 3+ identical matches."""
|
||||
recent = self.env['fusion.accounting.match.history'].search([
|
||||
('tool_name', '=', tool_name),
|
||||
('decision', 'in', ('approved', 'auto')),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=20, order='proposed_at desc')
|
||||
|
||||
if len(recent) < 3:
|
||||
return
|
||||
|
||||
from collections import Counter
|
||||
param_keys = []
|
||||
for h in recent:
|
||||
try:
|
||||
p = json.loads(h.tool_params or '{}')
|
||||
key_parts = []
|
||||
for k in sorted(p.keys()):
|
||||
if k not in ('limit', 'date_from', 'date_to'):
|
||||
key_parts.append(f'{k}={json.dumps(p[k], sort_keys=True)}')
|
||||
if key_parts:
|
||||
param_keys.append('|'.join(key_parts))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
counts = Counter(param_keys)
|
||||
for pattern, count in counts.most_common(3):
|
||||
if count < 3:
|
||||
break
|
||||
existing = self.env['fusion.accounting.rule'].search([
|
||||
('match_logic', 'ilike', pattern[:50]),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
self.env['fusion.accounting.rule'].create({
|
||||
'name': f'AI Pattern: {tool_name} ({pattern[:40]})',
|
||||
'rule_type': 'match',
|
||||
'description': f'Automatically detected pattern from {count} approved uses of {tool_name}.',
|
||||
'match_logic': f'When using {tool_name} with parameters matching: {pattern}',
|
||||
'created_by': 'ai',
|
||||
'approval_tier': 'needs_approval',
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
_logger.info("AI proposed rule for pattern: %s (%d matches)", tool_name, count)
|
||||
|
||||
def reject_action(self, match_history_id, reason=''):
|
||||
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
|
||||
if not history.exists() or history.decision != 'pending':
|
||||
raise UserError(_("Action not found or already decided."))
|
||||
|
||||
history.write({
|
||||
'decision': 'rejected',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
'rejection_reason': reason,
|
||||
})
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=False)
|
||||
|
||||
# C1: Update session messages_json so next chat turn has coherent history
|
||||
reject_result = {'status': 'rejected', 'reason': reason}
|
||||
self._update_session_after_decision(history, reject_result)
|
||||
|
||||
return reject_result
|
||||
|
||||
def _update_session_after_decision(self, history, result):
|
||||
"""Update session messages_json to replace pending_approval placeholder
|
||||
with actual tool result, preventing dangling tool_use blocks."""
|
||||
session = history.session_id
|
||||
if not session or not session.message_ids_json:
|
||||
return
|
||||
try:
|
||||
messages = json.loads(session.message_ids_json)
|
||||
result_str = json.dumps(result) if not isinstance(result, str) else result
|
||||
updated = False
|
||||
for msg in messages:
|
||||
if msg.get('role') != 'user':
|
||||
continue
|
||||
content = msg.get('content')
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if (isinstance(block, dict) and block.get('type') == 'tool_result'
|
||||
and 'pending_approval' in str(block.get('content', ''))):
|
||||
# Check if this is the matching tool_result block
|
||||
if str(history.id) in str(block.get('content', '')):
|
||||
block['content'] = result_str
|
||||
updated = True
|
||||
break
|
||||
if updated:
|
||||
break
|
||||
if updated:
|
||||
session.write({'message_ids_json': json.dumps(messages)})
|
||||
except Exception:
|
||||
_logger.warning("Failed to update session messages after decision for history %s", history.id)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Cron: Auto-Reconcile Inter-Account Transfers
|
||||
# ----------------------------------------------------------------
|
||||
@api.model
|
||||
def _cron_reconcile_transfers(self):
|
||||
"""Automatically reconcile inter-account credit card payments.
|
||||
|
||||
When a payment is made from a bank account (e.g. Scotia Current) to a
|
||||
credit card (e.g. Scotia Passport Visa), two bank statement lines appear:
|
||||
- Source side: "MB-CREDIT CARD" (negative) — reconciled by model 38/35
|
||||
- CC side: "PAYMENT FROM *7814" (positive) — needs matching
|
||||
|
||||
The source-side reconciliation creates outstanding entries on account 493.
|
||||
This cron matches the CC-side lines against those outstanding entries by
|
||||
exact amount and closest date (within 3 days).
|
||||
"""
|
||||
AML = self.env['account.move.line'].sudo()
|
||||
BSL = self.env['account.bank.statement.line'].sudo()
|
||||
company_partner_id = self.env.company.partner_id.id
|
||||
|
||||
total_reconciled = 0
|
||||
|
||||
for source_jid, cc_jid, outstanding_acct_id in TRANSFER_PAIRS:
|
||||
# Find all unreconciled INCOMING lines on the credit card journal
|
||||
cc_lines = BSL.search([
|
||||
('journal_id', '=', cc_jid),
|
||||
('is_reconciled', '=', False),
|
||||
('amount', '>', 0), # Incoming payments only
|
||||
('company_id', '=', self.env.company.id),
|
||||
])
|
||||
if not cc_lines:
|
||||
continue
|
||||
|
||||
journal_name = cc_lines[0].journal_id.name
|
||||
_logger.info(
|
||||
"Transfer reconcile: %s — %d incoming unreconciled lines",
|
||||
journal_name, len(cc_lines),
|
||||
)
|
||||
|
||||
reconciled = 0
|
||||
skipped = 0
|
||||
|
||||
for line in cc_lines:
|
||||
line_date = line.move_id.date
|
||||
amount = line.amount
|
||||
|
||||
# Find outstanding entries with exact matching amount
|
||||
candidates = AML.search([
|
||||
('account_id', '=', outstanding_acct_id),
|
||||
('partner_id', '=', company_partner_id),
|
||||
('reconciled', '=', False),
|
||||
('amount_residual', '=', amount),
|
||||
])
|
||||
|
||||
if not candidates:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Pick the candidate closest in date (within 3 days)
|
||||
best = None
|
||||
best_gap = 999
|
||||
for c in candidates:
|
||||
gap = abs((c.date - line_date).days)
|
||||
if gap < best_gap:
|
||||
best_gap = gap
|
||||
best = c
|
||||
|
||||
if best_gap > 7:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Set partner and reconcile
|
||||
try:
|
||||
line.partner_id = company_partner_id
|
||||
line.set_line_bank_statement_line(best.ids)
|
||||
reconciled += 1
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Transfer reconcile failed: line %s (%s, $%.2f): %s",
|
||||
line.id, line.payment_ref, amount, e,
|
||||
)
|
||||
|
||||
# Commit every 50 lines to avoid long transactions
|
||||
if reconciled % 50 == 0 and reconciled > 0:
|
||||
self.env.cr.commit()
|
||||
|
||||
self.env.cr.commit()
|
||||
total_reconciled += reconciled
|
||||
_logger.info(
|
||||
"Transfer reconcile: %s — reconciled %d, skipped %d",
|
||||
journal_name, reconciled, skipped,
|
||||
)
|
||||
|
||||
_logger.info("Transfer reconcile complete: %d total reconciled", total_reconciled)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# One-time: Match payroll cheque bank lines against open payroll liability entries
|
||||
# ----------------------------------------------------------------
|
||||
@api.model
|
||||
def _reconcile_payroll_cheques(self):
|
||||
"""Reconcile payroll cheque bank lines using writeoff to Payroll Liabilities (2201).
|
||||
|
||||
Your payroll JEs post:
|
||||
Dr Salaries / Dr ER CPP-EI / Dr CRA Taxes
|
||||
Cr 2201 Payroll Liabilities (net pay = cheque amount)
|
||||
|
||||
When the cheque clears the bank, the bank line shows:
|
||||
"Cheque 1773 : Cheque" = -$1,477.95
|
||||
|
||||
This method finds cheque bank lines that have a matching payroll liability
|
||||
entry (same amount) and applies a reconcile model that writes off to account
|
||||
433 (Payroll Liabilities). This debits 433 to clear the liability.
|
||||
|
||||
Non-payroll cheques (no matching entry on 433) are skipped.
|
||||
"""
|
||||
PAYROLL_LIABILITY_ACCT_ID = 433 # code 2201
|
||||
SCOTIA_CURRENT_JOURNAL_ID = 50
|
||||
|
||||
AML = self.env['account.move.line'].sudo()
|
||||
BSL = self.env['account.bank.statement.line'].sudo()
|
||||
RecModel = self.env['account.reconcile.model'].sudo()
|
||||
|
||||
# Find the payroll cheque reconcile model (must be pre-created via XML or manually)
|
||||
model = RecModel.search([
|
||||
('name', 'ilike', 'Payroll Cheque'),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not model:
|
||||
_logger.warning("Payroll cheque reconcile: no 'Payroll Cheque' model found — create one manually")
|
||||
return
|
||||
|
||||
# Find all unreconciled cheque lines on Scotia Current (negative = outgoing)
|
||||
# Only process lines after lock date to avoid lock date errors
|
||||
cheque_lines = BSL.search([
|
||||
('journal_id', '=', SCOTIA_CURRENT_JOURNAL_ID),
|
||||
('is_reconciled', '=', False),
|
||||
('amount', '<', 0),
|
||||
('payment_ref', 'ilike', 'cheque'),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], order='move_id asc')
|
||||
|
||||
# Filter to post-lock-date lines only
|
||||
lock_date = self.env.company.fiscalyear_lock_date
|
||||
if lock_date:
|
||||
cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock_date)
|
||||
|
||||
_logger.info("Payroll cheque reconcile: found %d unreconciled cheque lines (post lock date)", len(cheque_lines))
|
||||
|
||||
# Build set of all known payroll liability credit amounts
|
||||
payroll_credit_amounts = set()
|
||||
for aml in AML.search([
|
||||
('account_id', '=', PAYROLL_LIABILITY_ACCT_ID),
|
||||
('parent_state', '=', 'posted'),
|
||||
('credit', '>', 0),
|
||||
]):
|
||||
payroll_credit_amounts.add(round(aml.credit, 2))
|
||||
|
||||
# Filter: only reconcile cheques that have a matching payroll liability entry
|
||||
payroll_lines = cheque_lines.filtered(
|
||||
lambda l: round(abs(l.amount), 2) in payroll_credit_amounts
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Payroll cheque reconcile: %d payroll, %d non-payroll (skipped)",
|
||||
len(payroll_lines), len(cheque_lines) - len(payroll_lines),
|
||||
)
|
||||
|
||||
if not payroll_lines:
|
||||
_logger.info("Payroll cheque reconcile: nothing to reconcile")
|
||||
return
|
||||
|
||||
# Apply the reconcile model to payroll cheque lines
|
||||
try:
|
||||
model._apply_reconcile_models(payroll_lines)
|
||||
self.env.cr.commit()
|
||||
except Exception as e:
|
||||
_logger.exception("Payroll cheque reconcile batch failed: %s", e)
|
||||
self.env.cr.rollback()
|
||||
return
|
||||
|
||||
# Count results
|
||||
still_unreconciled = payroll_lines.filtered(lambda l: not l.is_reconciled)
|
||||
reconciled = len(payroll_lines) - len(still_unreconciled)
|
||||
|
||||
for line in still_unreconciled[:10]:
|
||||
_logger.info("Payroll cheque still unreconciled: %s $%.2f", line.payment_ref, abs(line.amount))
|
||||
|
||||
_logger.info(
|
||||
"Payroll cheque reconcile complete: %d reconciled, %d still unreconciled",
|
||||
reconciled, len(still_unreconciled),
|
||||
)
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Assets data adapter — routes asset queries through fusion engine if installed."""
|
||||
|
||||
from .base import DataAdapter
|
||||
from ._registry import register_adapter
|
||||
|
||||
|
||||
class AssetsAdapter(DataAdapter):
|
||||
FUSION_MODEL = 'fusion.asset.engine'
|
||||
ENTERPRISE_MODULE = 'account_asset'
|
||||
|
||||
# ============================================================
|
||||
# list_assets
|
||||
# ============================================================
|
||||
|
||||
def list_assets(self, state=None, limit=50, company_id=None):
|
||||
return self._dispatch(
|
||||
'list_assets', state=state, limit=limit, company_id=company_id,
|
||||
)
|
||||
|
||||
def list_assets_via_fusion(self, **kwargs):
|
||||
if 'fusion.asset.engine' not in self.env.registry:
|
||||
return {'assets': [], 'count': 0, 'total': 0}
|
||||
Asset = self.env['fusion.asset'].sudo()
|
||||
domain = [('company_id', '=', kwargs.get('company_id') or self.env.company.id)]
|
||||
if kwargs.get('state'):
|
||||
domain.append(('state', '=', kwargs['state']))
|
||||
total = Asset.search_count(domain)
|
||||
assets = Asset.search(
|
||||
domain, limit=int(kwargs.get('limit', 50)),
|
||||
order='acquisition_date desc',
|
||||
)
|
||||
return {
|
||||
'count': len(assets), 'total': total,
|
||||
'assets': [{
|
||||
'id': a.id, 'name': a.name, 'state': a.state,
|
||||
'cost': a.cost, 'book_value': a.book_value,
|
||||
'method': a.method,
|
||||
'category_name': a.category_id.name if a.category_id else None,
|
||||
} for a in assets],
|
||||
}
|
||||
|
||||
def list_assets_via_enterprise(self, **kwargs):
|
||||
return {
|
||||
'assets': [], 'count': 0, 'total': 0,
|
||||
'error': 'Enterprise account_asset must be queried from Enterprise UI',
|
||||
}
|
||||
|
||||
def list_assets_via_community(self, **kwargs):
|
||||
return {
|
||||
'assets': [], 'count': 0, 'total': 0,
|
||||
'error': 'No assets engine in pure Community',
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# suggest_useful_life
|
||||
# ============================================================
|
||||
|
||||
def suggest_useful_life(self, description, amount=None, partner_name=None):
|
||||
return self._dispatch(
|
||||
'suggest_useful_life',
|
||||
description=description, amount=amount, partner_name=partner_name,
|
||||
)
|
||||
|
||||
def suggest_useful_life_via_fusion(self, **kwargs):
|
||||
if 'fusion.asset.engine' not in self.env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
|
||||
predict_useful_life,
|
||||
)
|
||||
return predict_useful_life(self.env, **kwargs)
|
||||
|
||||
def suggest_useful_life_via_enterprise(self, **kwargs):
|
||||
return {'error': 'AI useful-life suggestion is fusion-only'}
|
||||
|
||||
def suggest_useful_life_via_community(self, **kwargs):
|
||||
return {'error': 'AI useful-life suggestion is fusion-only'}
|
||||
|
||||
# ============================================================
|
||||
# dispose_asset
|
||||
# ============================================================
|
||||
|
||||
def dispose_asset(self, asset_id, **kwargs):
|
||||
return self._dispatch('dispose_asset', asset_id=asset_id, **kwargs)
|
||||
|
||||
def dispose_asset_via_fusion(self, asset_id, **kwargs):
|
||||
if 'fusion.asset.engine' not in self.env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
asset = self.env['fusion.asset'].sudo().browse(int(asset_id))
|
||||
return self.env['fusion.asset.engine'].sudo().dispose_asset(asset, **kwargs)
|
||||
|
||||
def dispose_asset_via_enterprise(self, asset_id, **kwargs):
|
||||
return {'error': 'Enterprise asset disposal must use Enterprise UI'}
|
||||
|
||||
def dispose_asset_via_community(self, asset_id, **kwargs):
|
||||
return {'error': 'Community has no asset disposal flow'}
|
||||
|
||||
|
||||
register_adapter('assets', AssetsAdapter)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""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
|
||||
|
||||
In addition to ``list_unreconciled``, the adapter exposes thin wrappers
|
||||
around the engine's public API: ``suggest_matches``, ``accept_suggestion``,
|
||||
``unreconcile``. AI tools and the OWL controller go through these wrappers
|
||||
instead of touching the engine directly so install-mode routing stays in
|
||||
one place.
|
||||
"""
|
||||
|
||||
from .base import DataAdapter
|
||||
from ._registry import register_adapter
|
||||
|
||||
|
||||
class BankRecAdapter(DataAdapter):
|
||||
FUSION_MODEL = 'fusion.bank.rec.widget'
|
||||
ENTERPRISE_MODULE = 'account_accountant'
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# list_unreconciled
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def list_unreconciled(self, journal_id=None, limit=100, date_from=None,
|
||||
date_to=None, min_amount=None, company_id=None):
|
||||
"""Return unreconciled bank statement lines.
|
||||
|
||||
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):
|
||||
"""Community shape + fusion AI fields (top suggestion, band, attachments)."""
|
||||
base = self.list_unreconciled_via_community(
|
||||
journal_id=journal_id, limit=limit,
|
||||
date_from=date_from, date_to=date_to,
|
||||
min_amount=min_amount, company_id=company_id,
|
||||
)
|
||||
if not base:
|
||||
return base
|
||||
Line = self.env['account.bank.statement.line'].sudo()
|
||||
ids = [row['id'] for row in base]
|
||||
lines_by_id = {line.id: line for line in Line.browse(ids)}
|
||||
for row in base:
|
||||
line = lines_by_id.get(row['id'])
|
||||
if not line:
|
||||
row['fusion_top_suggestion_id'] = None
|
||||
row['fusion_confidence_band'] = 'none'
|
||||
row['attachment_count'] = 0
|
||||
continue
|
||||
top = line.fusion_top_suggestion_id
|
||||
row['fusion_top_suggestion_id'] = top.id if top else None
|
||||
row['fusion_confidence_band'] = line.fusion_confidence_band or 'none'
|
||||
row['attachment_count'] = len(line.bank_statement_attachment_ids)
|
||||
return base
|
||||
|
||||
def list_unreconciled_via_enterprise(self, journal_id=None, limit=100,
|
||||
date_from=None, date_to=None,
|
||||
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
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# suggest_matches
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def suggest_matches(self, statement_line_ids, *, limit_per_line=3,
|
||||
company_id=None):
|
||||
"""Return AI suggestions per bank line.
|
||||
|
||||
Shape: ``{line_id: [{'id', 'rank', 'confidence', 'reasoning',
|
||||
'candidate_id'}, ...]}``. Empty dict when AI suggestions are not
|
||||
available (Enterprise / Community).
|
||||
"""
|
||||
return self._dispatch(
|
||||
'suggest_matches',
|
||||
statement_line_ids=statement_line_ids,
|
||||
limit_per_line=limit_per_line,
|
||||
company_id=company_id,
|
||||
)
|
||||
|
||||
def suggest_matches_via_fusion(self, statement_line_ids, *,
|
||||
limit_per_line=3, company_id=None):
|
||||
Line = self.env['account.bank.statement.line'].sudo()
|
||||
lines = Line.browse(list(statement_line_ids or [])).exists()
|
||||
if not lines:
|
||||
return {}
|
||||
return self.env['fusion.reconcile.engine'].suggest_matches(
|
||||
lines, limit_per_line=limit_per_line)
|
||||
|
||||
def suggest_matches_via_enterprise(self, statement_line_ids, *,
|
||||
limit_per_line=3, company_id=None):
|
||||
# Enterprise has its own suggest mechanism inside bank_rec_widget;
|
||||
# we don't proxy it from Python.
|
||||
return {}
|
||||
|
||||
def suggest_matches_via_community(self, statement_line_ids, *,
|
||||
limit_per_line=3, company_id=None):
|
||||
return {}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# accept_suggestion
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def accept_suggestion(self, suggestion_id):
|
||||
"""Accept a fusion AI suggestion and reconcile against its proposal.
|
||||
|
||||
Returns ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
|
||||
'write_off_move_id': int|None}``. Fusion-only.
|
||||
"""
|
||||
return self._dispatch(
|
||||
'accept_suggestion', suggestion_id=suggestion_id)
|
||||
|
||||
def accept_suggestion_via_fusion(self, suggestion_id):
|
||||
return self.env['fusion.reconcile.engine'].accept_suggestion(
|
||||
int(suggestion_id))
|
||||
|
||||
def accept_suggestion_via_enterprise(self, suggestion_id):
|
||||
raise NotImplementedError("accept_suggestion is fusion-only")
|
||||
|
||||
def accept_suggestion_via_community(self, suggestion_id):
|
||||
raise NotImplementedError("accept_suggestion is fusion-only")
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# unreconcile
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def unreconcile(self, partial_reconcile_ids):
|
||||
"""Reverse a reconciliation by partial IDs.
|
||||
|
||||
Returns ``{'unreconciled_line_ids': [...]}``. Available in all modes
|
||||
(the engine delegates to V19's standard
|
||||
``account.bank.statement.line.action_undo_reconciliation``).
|
||||
"""
|
||||
return self._dispatch(
|
||||
'unreconcile', partial_reconcile_ids=partial_reconcile_ids)
|
||||
|
||||
def unreconcile_via_fusion(self, partial_reconcile_ids):
|
||||
Partial = self.env['account.partial.reconcile'].sudo()
|
||||
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
|
||||
return self.env['fusion.reconcile.engine'].unreconcile(partials)
|
||||
|
||||
def unreconcile_via_enterprise(self, partial_reconcile_ids):
|
||||
# Enterprise/community paths can't depend on fusion.reconcile.engine
|
||||
# being loaded (fusion_accounting_ai does NOT depend on
|
||||
# fusion_accounting_bank_rec). Mirror the engine's behaviour using
|
||||
# only Community-available helpers.
|
||||
return self._unreconcile_standalone(partial_reconcile_ids)
|
||||
|
||||
def unreconcile_via_community(self, partial_reconcile_ids):
|
||||
return self._unreconcile_standalone(partial_reconcile_ids)
|
||||
|
||||
def _unreconcile_standalone(self, partial_reconcile_ids):
|
||||
"""Engine-free unreconcile for installs without fusion_accounting_bank_rec.
|
||||
|
||||
Mirrors ``fusion.reconcile.engine.unreconcile``: finds bank lines whose
|
||||
moves own any of the partials' journal items, runs the standard undo
|
||||
on them, then unlinks any leftovers.
|
||||
"""
|
||||
Partial = self.env['account.partial.reconcile'].sudo()
|
||||
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
|
||||
if not partials:
|
||||
return {'unreconciled_line_ids': []}
|
||||
all_lines = (
|
||||
partials.mapped('debit_move_id')
|
||||
| partials.mapped('credit_move_id')
|
||||
)
|
||||
line_ids = all_lines.ids
|
||||
affected = self.env['account.bank.statement.line'].sudo().search([
|
||||
('move_id', 'in', all_lines.mapped('move_id').ids),
|
||||
])
|
||||
if affected:
|
||||
affected.action_undo_reconciliation()
|
||||
remaining = partials.exists()
|
||||
if remaining:
|
||||
remaining.unlink()
|
||||
return {'unreconciled_line_ids': line_ids}
|
||||
|
||||
|
||||
register_adapter('bank_rec', BankRecAdapter)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user